Rename Concelier Source modules to Connector
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Concelier.Connector.Distro.Debian.Tests")]
|
||||
@@ -0,0 +1,87 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Debian.Configuration;
|
||||
|
||||
public sealed class DebianOptions
|
||||
{
|
||||
public const string HttpClientName = "concelier.debian";
|
||||
|
||||
/// <summary>
|
||||
/// Raw advisory list published by the Debian security tracker team.
|
||||
/// Defaults to the Salsa Git raw endpoint to avoid HTML scraping.
|
||||
/// </summary>
|
||||
public Uri ListEndpoint { get; set; } = new("https://salsa.debian.org/security-tracker-team/security-tracker/-/raw/master/data/DSA/list");
|
||||
|
||||
/// <summary>
|
||||
/// Base URI for advisory detail pages. Connector appends {AdvisoryId}.
|
||||
/// </summary>
|
||||
public Uri DetailBaseUri { get; set; } = new("https://security-tracker.debian.org/tracker/");
|
||||
|
||||
/// <summary>
|
||||
/// Maximum advisories fetched per run to cap backfill effort.
|
||||
/// </summary>
|
||||
public int MaxAdvisoriesPerFetch { get; set; } = 40;
|
||||
|
||||
/// <summary>
|
||||
/// Initial history window pulled on first run.
|
||||
/// </summary>
|
||||
public TimeSpan InitialBackfill { get; set; } = TimeSpan.FromDays(30);
|
||||
|
||||
/// <summary>
|
||||
/// Resume overlap to accommodate late edits of existing advisories.
|
||||
/// </summary>
|
||||
public TimeSpan ResumeOverlap { get; set; } = TimeSpan.FromDays(2);
|
||||
|
||||
/// <summary>
|
||||
/// Request timeout used for list/detail fetches unless overridden via HTTP client.
|
||||
/// </summary>
|
||||
public TimeSpan FetchTimeout { get; set; } = TimeSpan.FromSeconds(45);
|
||||
|
||||
/// <summary>
|
||||
/// Optional pacing delay between detail fetches.
|
||||
/// </summary>
|
||||
public TimeSpan RequestDelay { get; set; } = TimeSpan.Zero;
|
||||
|
||||
/// <summary>
|
||||
/// Custom user-agent for Debian tracker courtesy.
|
||||
/// </summary>
|
||||
public string UserAgent { get; set; } = "StellaOps.Concelier.Debian/0.1 (+https://stella-ops.org)";
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (ListEndpoint is null || !ListEndpoint.IsAbsoluteUri)
|
||||
{
|
||||
throw new InvalidOperationException("Debian list endpoint must be an absolute URI.");
|
||||
}
|
||||
|
||||
if (DetailBaseUri is null || !DetailBaseUri.IsAbsoluteUri)
|
||||
{
|
||||
throw new InvalidOperationException("Debian detail base URI must be an absolute URI.");
|
||||
}
|
||||
|
||||
if (MaxAdvisoriesPerFetch <= 0 || MaxAdvisoriesPerFetch > 200)
|
||||
{
|
||||
throw new InvalidOperationException("MaxAdvisoriesPerFetch must be between 1 and 200.");
|
||||
}
|
||||
|
||||
if (InitialBackfill < TimeSpan.Zero || InitialBackfill > TimeSpan.FromDays(365))
|
||||
{
|
||||
throw new InvalidOperationException("InitialBackfill must be between 0 and 365 days.");
|
||||
}
|
||||
|
||||
if (ResumeOverlap < TimeSpan.Zero || ResumeOverlap > TimeSpan.FromDays(14))
|
||||
{
|
||||
throw new InvalidOperationException("ResumeOverlap must be between 0 and 14 days.");
|
||||
}
|
||||
|
||||
if (FetchTimeout <= TimeSpan.Zero || FetchTimeout > TimeSpan.FromMinutes(5))
|
||||
{
|
||||
throw new InvalidOperationException("FetchTimeout must be positive and less than five minutes.");
|
||||
}
|
||||
|
||||
if (RequestDelay < TimeSpan.Zero || RequestDelay > TimeSpan.FromSeconds(10))
|
||||
{
|
||||
throw new InvalidOperationException("RequestDelay must be between 0 and 10 seconds.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.IO;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Common.Fetch;
|
||||
using StellaOps.Concelier.Connector.Distro.Debian.Configuration;
|
||||
using StellaOps.Concelier.Connector.Distro.Debian.Internal;
|
||||
using StellaOps.Concelier.Storage.Mongo;
|
||||
using StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
using StellaOps.Concelier.Storage.Mongo.Dtos;
|
||||
using StellaOps.Plugin;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Debian;
|
||||
|
||||
public sealed class DebianConnector : IFeedConnector
|
||||
{
|
||||
private const string SchemaVersion = "debian.v1";
|
||||
|
||||
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 DebianOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<DebianConnector> _logger;
|
||||
|
||||
private static readonly Action<ILogger, string, int, Exception?> LogMapped =
|
||||
LoggerMessage.Define<string, int>(
|
||||
LogLevel.Information,
|
||||
new EventId(1, "DebianMapped"),
|
||||
"Debian advisory {AdvisoryId} mapped with {AffectedCount} packages");
|
||||
|
||||
public DebianConnector(
|
||||
SourceFetchService fetchService,
|
||||
RawDocumentStorage rawDocumentStorage,
|
||||
IDocumentStore documentStore,
|
||||
IDtoStore dtoStore,
|
||||
IAdvisoryStore advisoryStore,
|
||||
ISourceStateRepository stateRepository,
|
||||
IOptions<DebianOptions> options,
|
||||
TimeProvider? timeProvider,
|
||||
ILogger<DebianConnector> logger)
|
||||
{
|
||||
_fetchService = fetchService ?? throw new ArgumentNullException(nameof(fetchService));
|
||||
_rawDocumentStorage = rawDocumentStorage ?? throw new ArgumentNullException(nameof(rawDocumentStorage));
|
||||
_documentStore = documentStore ?? throw new ArgumentNullException(nameof(documentStore));
|
||||
_dtoStore = dtoStore ?? throw new ArgumentNullException(nameof(dtoStore));
|
||||
_advisoryStore = advisoryStore ?? throw new ArgumentNullException(nameof(advisoryStore));
|
||||
_stateRepository = stateRepository ?? throw new ArgumentNullException(nameof(stateRepository));
|
||||
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_options.Validate();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public string SourceName => DebianConnectorPlugin.SourceName;
|
||||
|
||||
public async Task FetchAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
var cursor = await GetCursorAsync(cancellationToken).ConfigureAwait(false);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var pendingDocuments = new HashSet<Guid>(cursor.PendingDocuments);
|
||||
var pendingMappings = new HashSet<Guid>(cursor.PendingMappings);
|
||||
var fetchCache = new Dictionary<string, DebianFetchCacheEntry>(cursor.FetchCache, StringComparer.OrdinalIgnoreCase);
|
||||
var touchedResources = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var listUri = _options.ListEndpoint;
|
||||
var listKey = listUri.ToString();
|
||||
touchedResources.Add(listKey);
|
||||
|
||||
var existingList = await _documentStore.FindBySourceAndUriAsync(SourceName, listKey, cancellationToken).ConfigureAwait(false);
|
||||
cursor.TryGetCache(listKey, out var cachedListEntry);
|
||||
|
||||
var listRequest = new SourceFetchRequest(DebianOptions.HttpClientName, SourceName, listUri)
|
||||
{
|
||||
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["type"] = "index"
|
||||
},
|
||||
AcceptHeaders = new[] { "text/plain", "text/plain; charset=utf-8" },
|
||||
TimeoutOverride = _options.FetchTimeout,
|
||||
ETag = existingList?.Etag ?? cachedListEntry?.ETag,
|
||||
LastModified = existingList?.LastModified ?? cachedListEntry?.LastModified,
|
||||
};
|
||||
|
||||
SourceFetchResult listResult;
|
||||
try
|
||||
{
|
||||
listResult = await _fetchService.FetchAsync(listRequest, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Debian list fetch failed");
|
||||
await _stateRepository.MarkFailureAsync(SourceName, now, TimeSpan.FromMinutes(5), ex.Message, cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
var lastPublished = cursor.LastPublished ?? (now - _options.InitialBackfill);
|
||||
var processedIds = new HashSet<string>(cursor.ProcessedAdvisoryIds, StringComparer.OrdinalIgnoreCase);
|
||||
var newProcessedIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var maxPublished = cursor.LastPublished ?? DateTimeOffset.MinValue;
|
||||
var processedUpdated = false;
|
||||
|
||||
if (listResult.IsNotModified)
|
||||
{
|
||||
if (existingList is not null)
|
||||
{
|
||||
fetchCache[listKey] = DebianFetchCacheEntry.FromDocument(existingList);
|
||||
}
|
||||
}
|
||||
else if (listResult.IsSuccess && listResult.Document is not null)
|
||||
{
|
||||
fetchCache[listKey] = DebianFetchCacheEntry.FromDocument(listResult.Document);
|
||||
|
||||
if (!listResult.Document.GridFsId.HasValue)
|
||||
{
|
||||
_logger.LogWarning("Debian list document {DocumentId} missing GridFS payload", listResult.Document.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
byte[] bytes;
|
||||
try
|
||||
{
|
||||
bytes = await _rawDocumentStorage.DownloadAsync(listResult.Document.GridFsId.Value, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to download Debian list document {DocumentId}", listResult.Document.Id);
|
||||
throw;
|
||||
}
|
||||
|
||||
var text = System.Text.Encoding.UTF8.GetString(bytes);
|
||||
var entries = DebianListParser.Parse(text);
|
||||
if (entries.Count > 0)
|
||||
{
|
||||
var windowStart = (cursor.LastPublished ?? (now - _options.InitialBackfill)) - _options.ResumeOverlap;
|
||||
if (windowStart < DateTimeOffset.UnixEpoch)
|
||||
{
|
||||
windowStart = DateTimeOffset.UnixEpoch;
|
||||
}
|
||||
|
||||
ProvenanceDiagnostics.ReportResumeWindow(SourceName, windowStart, _logger);
|
||||
|
||||
var candidates = entries
|
||||
.Where(entry => entry.Published >= windowStart)
|
||||
.OrderBy(entry => entry.Published)
|
||||
.ThenBy(entry => entry.AdvisoryId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
candidates = entries
|
||||
.OrderByDescending(entry => entry.Published)
|
||||
.ThenBy(entry => entry.AdvisoryId, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(_options.MaxAdvisoriesPerFetch)
|
||||
.OrderBy(entry => entry.Published)
|
||||
.ThenBy(entry => entry.AdvisoryId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
else if (candidates.Count > _options.MaxAdvisoriesPerFetch)
|
||||
{
|
||||
candidates = candidates
|
||||
.OrderByDescending(entry => entry.Published)
|
||||
.ThenBy(entry => entry.AdvisoryId, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(_options.MaxAdvisoriesPerFetch)
|
||||
.OrderBy(entry => entry.Published)
|
||||
.ThenBy(entry => entry.AdvisoryId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
foreach (var entry in candidates)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var detailUri = new Uri(_options.DetailBaseUri, entry.AdvisoryId);
|
||||
var cacheKey = detailUri.ToString();
|
||||
touchedResources.Add(cacheKey);
|
||||
|
||||
cursor.TryGetCache(cacheKey, out var cachedDetail);
|
||||
if (!fetchCache.TryGetValue(cacheKey, out var cachedInRun))
|
||||
{
|
||||
cachedInRun = cachedDetail;
|
||||
}
|
||||
|
||||
var metadata = BuildDetailMetadata(entry);
|
||||
var existingDetail = await _documentStore.FindBySourceAndUriAsync(SourceName, cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var request = new SourceFetchRequest(DebianOptions.HttpClientName, SourceName, detailUri)
|
||||
{
|
||||
Metadata = metadata,
|
||||
AcceptHeaders = new[] { "text/html", "application/xhtml+xml" },
|
||||
TimeoutOverride = _options.FetchTimeout,
|
||||
ETag = existingDetail?.Etag ?? cachedInRun?.ETag,
|
||||
LastModified = existingDetail?.LastModified ?? cachedInRun?.LastModified,
|
||||
};
|
||||
|
||||
SourceFetchResult result;
|
||||
try
|
||||
{
|
||||
result = await _fetchService.FetchAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fetch Debian advisory {AdvisoryId}", entry.AdvisoryId);
|
||||
await _stateRepository.MarkFailureAsync(SourceName, now, TimeSpan.FromMinutes(5), ex.Message, cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
if (result.IsNotModified)
|
||||
{
|
||||
if (existingDetail is not null)
|
||||
{
|
||||
fetchCache[cacheKey] = DebianFetchCacheEntry.FromDocument(existingDetail);
|
||||
if (string.Equals(existingDetail.Status, DocumentStatuses.Mapped, StringComparison.Ordinal))
|
||||
{
|
||||
pendingDocuments.Remove(existingDetail.Id);
|
||||
pendingMappings.Remove(existingDetail.Id);
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!result.IsSuccess || result.Document is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
fetchCache[cacheKey] = DebianFetchCacheEntry.FromDocument(result.Document);
|
||||
pendingDocuments.Add(result.Document.Id);
|
||||
pendingMappings.Remove(result.Document.Id);
|
||||
|
||||
if (_options.RequestDelay > TimeSpan.Zero)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(_options.RequestDelay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.Published > maxPublished)
|
||||
{
|
||||
maxPublished = entry.Published;
|
||||
newProcessedIds.Clear();
|
||||
processedUpdated = true;
|
||||
}
|
||||
|
||||
if (entry.Published == maxPublished)
|
||||
{
|
||||
newProcessedIds.Add(entry.AdvisoryId);
|
||||
processedUpdated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fetchCache.Count > 0 && touchedResources.Count > 0)
|
||||
{
|
||||
var stale = fetchCache.Keys.Where(key => !touchedResources.Contains(key)).ToArray();
|
||||
foreach (var key in stale)
|
||||
{
|
||||
fetchCache.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (!processedUpdated && cursor.LastPublished.HasValue)
|
||||
{
|
||||
maxPublished = cursor.LastPublished.Value;
|
||||
newProcessedIds = new HashSet<string>(cursor.ProcessedAdvisoryIds, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var updatedCursor = cursor
|
||||
.WithPendingDocuments(pendingDocuments)
|
||||
.WithPendingMappings(pendingMappings)
|
||||
.WithFetchCache(fetchCache);
|
||||
|
||||
if (processedUpdated && maxPublished > DateTimeOffset.MinValue)
|
||||
{
|
||||
updatedCursor = updatedCursor.WithProcessed(maxPublished, newProcessedIds);
|
||||
}
|
||||
|
||||
await UpdateCursorAsync(updatedCursor, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task ParseAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
var cursor = await GetCursorAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (cursor.PendingDocuments.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var remaining = cursor.PendingDocuments.ToList();
|
||||
var pendingMappings = cursor.PendingMappings.ToList();
|
||||
|
||||
foreach (var documentId in cursor.PendingDocuments)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var document = await _documentStore.FindAsync(documentId, cancellationToken).ConfigureAwait(false);
|
||||
if (document is null)
|
||||
{
|
||||
remaining.Remove(documentId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!document.GridFsId.HasValue)
|
||||
{
|
||||
_logger.LogWarning("Debian document {DocumentId} missing GridFS payload", document.Id);
|
||||
await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
|
||||
remaining.Remove(documentId);
|
||||
continue;
|
||||
}
|
||||
|
||||
var metadata = ExtractMetadata(document);
|
||||
if (metadata is null)
|
||||
{
|
||||
_logger.LogWarning("Debian document {DocumentId} missing required metadata", document.Id);
|
||||
await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
|
||||
remaining.Remove(documentId);
|
||||
continue;
|
||||
}
|
||||
|
||||
byte[] bytes;
|
||||
try
|
||||
{
|
||||
bytes = await _rawDocumentStorage.DownloadAsync(document.GridFsId.Value, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to download Debian document {DocumentId}", document.Id);
|
||||
throw;
|
||||
}
|
||||
|
||||
var html = System.Text.Encoding.UTF8.GetString(bytes);
|
||||
DebianAdvisoryDto dto;
|
||||
try
|
||||
{
|
||||
dto = DebianHtmlParser.Parse(html, metadata);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse Debian advisory {AdvisoryId}", metadata.AdvisoryId);
|
||||
await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
|
||||
remaining.Remove(document.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
var payload = ToBson(dto);
|
||||
var dtoRecord = new DtoRecord(Guid.NewGuid(), document.Id, SourceName, SchemaVersion, payload, _timeProvider.GetUtcNow());
|
||||
await _dtoStore.UpsertAsync(dtoRecord, cancellationToken).ConfigureAwait(false);
|
||||
await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.PendingMap, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
remaining.Remove(document.Id);
|
||||
if (!pendingMappings.Contains(document.Id))
|
||||
{
|
||||
pendingMappings.Add(document.Id);
|
||||
}
|
||||
}
|
||||
|
||||
var updatedCursor = cursor
|
||||
.WithPendingDocuments(remaining)
|
||||
.WithPendingMappings(pendingMappings);
|
||||
|
||||
await UpdateCursorAsync(updatedCursor, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task MapAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
var cursor = await GetCursorAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (cursor.PendingMappings.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pendingMappings = cursor.PendingMappings.ToList();
|
||||
|
||||
foreach (var documentId in cursor.PendingMappings)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var dtoRecord = await _dtoStore.FindByDocumentIdAsync(documentId, cancellationToken).ConfigureAwait(false);
|
||||
var document = await _documentStore.FindAsync(documentId, cancellationToken).ConfigureAwait(false);
|
||||
if (dtoRecord is null || document is null)
|
||||
{
|
||||
pendingMappings.Remove(documentId);
|
||||
continue;
|
||||
}
|
||||
|
||||
DebianAdvisoryDto dto;
|
||||
try
|
||||
{
|
||||
dto = FromBson(dtoRecord.Payload);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to deserialize Debian DTO for document {DocumentId}", documentId);
|
||||
await _documentStore.UpdateStatusAsync(documentId, DocumentStatuses.Failed, cancellationToken).ConfigureAwait(false);
|
||||
pendingMappings.Remove(documentId);
|
||||
continue;
|
||||
}
|
||||
|
||||
var advisory = DebianMapper.Map(dto, document, _timeProvider.GetUtcNow());
|
||||
await _advisoryStore.UpsertAsync(advisory, cancellationToken).ConfigureAwait(false);
|
||||
await _documentStore.UpdateStatusAsync(documentId, DocumentStatuses.Mapped, cancellationToken).ConfigureAwait(false);
|
||||
pendingMappings.Remove(documentId);
|
||||
LogMapped(_logger, dto.AdvisoryId, advisory.AffectedPackages.Length, null);
|
||||
}
|
||||
|
||||
var updatedCursor = cursor.WithPendingMappings(pendingMappings);
|
||||
await UpdateCursorAsync(updatedCursor, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<DebianCursor> GetCursorAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var state = await _stateRepository.TryGetAsync(SourceName, cancellationToken).ConfigureAwait(false);
|
||||
return state is null ? DebianCursor.Empty : DebianCursor.FromBson(state.Cursor);
|
||||
}
|
||||
|
||||
private async Task UpdateCursorAsync(DebianCursor cursor, CancellationToken cancellationToken)
|
||||
{
|
||||
var document = cursor.ToBsonDocument();
|
||||
await _stateRepository.UpdateCursorAsync(SourceName, document, _timeProvider.GetUtcNow(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildDetailMetadata(DebianListEntry entry)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["debian.id"] = entry.AdvisoryId,
|
||||
["debian.published"] = entry.Published.ToString("O", CultureInfo.InvariantCulture),
|
||||
["debian.title"] = entry.Title,
|
||||
["debian.package"] = entry.SourcePackage
|
||||
};
|
||||
|
||||
if (entry.CveIds.Count > 0)
|
||||
{
|
||||
metadata["debian.cves"] = string.Join(' ', entry.CveIds);
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private static DebianDetailMetadata? ExtractMetadata(DocumentRecord document)
|
||||
{
|
||||
if (document.Metadata is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!document.Metadata.TryGetValue("debian.id", out var id) || string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!document.Metadata.TryGetValue("debian.published", out var publishedRaw)
|
||||
|| !DateTimeOffset.TryParse(publishedRaw, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var published))
|
||||
{
|
||||
published = document.FetchedAt;
|
||||
}
|
||||
|
||||
var title = document.Metadata.TryGetValue("debian.title", out var t) ? t : id;
|
||||
var package = document.Metadata.TryGetValue("debian.package", out var pkg) && !string.IsNullOrWhiteSpace(pkg)
|
||||
? pkg
|
||||
: id;
|
||||
|
||||
IReadOnlyList<string> cveList = Array.Empty<string>();
|
||||
if (document.Metadata.TryGetValue("debian.cves", out var cvesRaw) && !string.IsNullOrWhiteSpace(cvesRaw))
|
||||
{
|
||||
cveList = cvesRaw
|
||||
.Split(' ', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
|
||||
.Where(static s => !string.IsNullOrWhiteSpace(s))
|
||||
.Select(static s => s!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
return new DebianDetailMetadata(
|
||||
id.Trim(),
|
||||
new Uri(document.Uri, UriKind.Absolute),
|
||||
published.ToUniversalTime(),
|
||||
title,
|
||||
package,
|
||||
cveList);
|
||||
}
|
||||
|
||||
private static BsonDocument ToBson(DebianAdvisoryDto dto)
|
||||
{
|
||||
var packages = new BsonArray();
|
||||
foreach (var package in dto.Packages)
|
||||
{
|
||||
var packageDoc = new BsonDocument
|
||||
{
|
||||
["package"] = package.Package,
|
||||
["release"] = package.Release,
|
||||
["status"] = package.Status,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(package.IntroducedVersion))
|
||||
{
|
||||
packageDoc["introduced"] = package.IntroducedVersion;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(package.FixedVersion))
|
||||
{
|
||||
packageDoc["fixed"] = package.FixedVersion;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(package.LastAffectedVersion))
|
||||
{
|
||||
packageDoc["last"] = package.LastAffectedVersion;
|
||||
}
|
||||
|
||||
if (package.Published.HasValue)
|
||||
{
|
||||
packageDoc["published"] = package.Published.Value.UtcDateTime;
|
||||
}
|
||||
|
||||
packages.Add(packageDoc);
|
||||
}
|
||||
|
||||
var references = new BsonArray(dto.References.Select(reference =>
|
||||
{
|
||||
var doc = new BsonDocument
|
||||
{
|
||||
["url"] = reference.Url
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(reference.Kind))
|
||||
{
|
||||
doc["kind"] = reference.Kind;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(reference.Title))
|
||||
{
|
||||
doc["title"] = reference.Title;
|
||||
}
|
||||
|
||||
return doc;
|
||||
}));
|
||||
|
||||
return new BsonDocument
|
||||
{
|
||||
["advisoryId"] = dto.AdvisoryId,
|
||||
["sourcePackage"] = dto.SourcePackage,
|
||||
["title"] = dto.Title,
|
||||
["description"] = dto.Description ?? string.Empty,
|
||||
["cves"] = new BsonArray(dto.CveIds),
|
||||
["packages"] = packages,
|
||||
["references"] = references,
|
||||
};
|
||||
}
|
||||
|
||||
private static DebianAdvisoryDto FromBson(BsonDocument document)
|
||||
{
|
||||
var advisoryId = document.GetValue("advisoryId", "").AsString;
|
||||
var sourcePackage = document.GetValue("sourcePackage", advisoryId).AsString;
|
||||
var title = document.GetValue("title", advisoryId).AsString;
|
||||
var description = document.TryGetValue("description", out var desc) ? desc.AsString : null;
|
||||
|
||||
var cves = document.TryGetValue("cves", out var cveArray) && cveArray is BsonArray cvesBson
|
||||
? cvesBson.OfType<BsonValue>()
|
||||
.Select(static value => value.ToString())
|
||||
.Where(static s => !string.IsNullOrWhiteSpace(s))
|
||||
.Select(static s => s!)
|
||||
.ToArray()
|
||||
: Array.Empty<string>();
|
||||
|
||||
var packages = new List<DebianPackageStateDto>();
|
||||
if (document.TryGetValue("packages", out var packageArray) && packageArray is BsonArray packagesBson)
|
||||
{
|
||||
foreach (var element in packagesBson.OfType<BsonDocument>())
|
||||
{
|
||||
packages.Add(new DebianPackageStateDto(
|
||||
element.GetValue("package", sourcePackage).AsString,
|
||||
element.GetValue("release", string.Empty).AsString,
|
||||
element.GetValue("status", "unknown").AsString,
|
||||
element.TryGetValue("introduced", out var introducedValue) ? introducedValue.AsString : null,
|
||||
element.TryGetValue("fixed", out var fixedValue) ? fixedValue.AsString : null,
|
||||
element.TryGetValue("last", out var lastValue) ? lastValue.AsString : null,
|
||||
element.TryGetValue("published", out var publishedValue)
|
||||
? publishedValue.BsonType switch
|
||||
{
|
||||
BsonType.DateTime => DateTime.SpecifyKind(publishedValue.ToUniversalTime(), DateTimeKind.Utc),
|
||||
BsonType.String when DateTimeOffset.TryParse(publishedValue.AsString, out var parsed) => parsed.ToUniversalTime(),
|
||||
_ => (DateTimeOffset?)null,
|
||||
}
|
||||
: null));
|
||||
}
|
||||
}
|
||||
|
||||
var references = new List<DebianReferenceDto>();
|
||||
if (document.TryGetValue("references", out var referenceArray) && referenceArray is BsonArray refBson)
|
||||
{
|
||||
foreach (var element in refBson.OfType<BsonDocument>())
|
||||
{
|
||||
references.Add(new DebianReferenceDto(
|
||||
element.GetValue("url", "").AsString,
|
||||
element.TryGetValue("kind", out var kind) ? kind.AsString : null,
|
||||
element.TryGetValue("title", out var titleValue) ? titleValue.AsString : null));
|
||||
}
|
||||
}
|
||||
|
||||
return new DebianAdvisoryDto(
|
||||
advisoryId,
|
||||
sourcePackage,
|
||||
title,
|
||||
description,
|
||||
cves,
|
||||
packages,
|
||||
references);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Plugin;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Debian;
|
||||
|
||||
public sealed class DebianConnectorPlugin : IConnectorPlugin
|
||||
{
|
||||
public const string SourceName = "distro-debian";
|
||||
|
||||
public string Name => SourceName;
|
||||
|
||||
public bool IsAvailable(IServiceProvider services) => services is not null;
|
||||
|
||||
public IFeedConnector Create(IServiceProvider services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
return ActivatorUtilities.CreateInstance<DebianConnector>(services);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.DependencyInjection;
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
using StellaOps.Concelier.Connector.Distro.Debian.Configuration;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Debian;
|
||||
|
||||
public sealed class DebianDependencyInjectionRoutine : IDependencyInjectionRoutine
|
||||
{
|
||||
private const string ConfigurationSection = "concelier:sources:debian";
|
||||
private const string FetchSchedule = "*/30 * * * *";
|
||||
private const string ParseSchedule = "7,37 * * * *";
|
||||
private const string MapSchedule = "12,42 * * * *";
|
||||
|
||||
private static readonly TimeSpan FetchTimeout = TimeSpan.FromMinutes(6);
|
||||
private static readonly TimeSpan ParseTimeout = TimeSpan.FromMinutes(10);
|
||||
private static readonly TimeSpan MapTimeout = TimeSpan.FromMinutes(10);
|
||||
private static readonly TimeSpan LeaseDuration = TimeSpan.FromMinutes(5);
|
||||
|
||||
public IServiceCollection Register(IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
services.AddDebianConnector(options =>
|
||||
{
|
||||
configuration.GetSection(ConfigurationSection).Bind(options);
|
||||
options.Validate();
|
||||
});
|
||||
|
||||
var scheduler = new JobSchedulerBuilder(services);
|
||||
scheduler
|
||||
.AddJob<DebianFetchJob>(
|
||||
DebianJobKinds.Fetch,
|
||||
cronExpression: FetchSchedule,
|
||||
timeout: FetchTimeout,
|
||||
leaseDuration: LeaseDuration)
|
||||
.AddJob<DebianParseJob>(
|
||||
DebianJobKinds.Parse,
|
||||
cronExpression: ParseSchedule,
|
||||
timeout: ParseTimeout,
|
||||
leaseDuration: LeaseDuration)
|
||||
.AddJob<DebianMapJob>(
|
||||
DebianJobKinds.Map,
|
||||
cronExpression: MapSchedule,
|
||||
timeout: MapTimeout,
|
||||
leaseDuration: LeaseDuration);
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Connector.Common.Http;
|
||||
using StellaOps.Concelier.Connector.Distro.Debian.Configuration;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Debian;
|
||||
|
||||
public static class DebianServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddDebianConnector(this IServiceCollection services, Action<DebianOptions> configure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
|
||||
services.AddOptions<DebianOptions>()
|
||||
.Configure(configure)
|
||||
.PostConfigure(static options => options.Validate());
|
||||
|
||||
services.AddSourceHttpClient(DebianOptions.HttpClientName, (sp, httpOptions) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<DebianOptions>>().Value;
|
||||
httpOptions.BaseAddress = options.DetailBaseUri.GetLeftPart(UriPartial.Authority) is { Length: > 0 } authority
|
||||
? new Uri(authority, UriKind.Absolute)
|
||||
: new Uri("https://security-tracker.debian.org/", UriKind.Absolute);
|
||||
httpOptions.Timeout = options.FetchTimeout;
|
||||
httpOptions.UserAgent = options.UserAgent;
|
||||
httpOptions.AllowedHosts.Clear();
|
||||
httpOptions.AllowedHosts.Add(options.DetailBaseUri.Host);
|
||||
httpOptions.AllowedHosts.Add(options.ListEndpoint.Host);
|
||||
httpOptions.DefaultRequestHeaders["Accept"] = "text/html,application/xhtml+xml,text/plain;q=0.9,application/json;q=0.8";
|
||||
});
|
||||
|
||||
services.AddTransient<DebianConnector>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Debian.Internal;
|
||||
|
||||
internal sealed record DebianAdvisoryDto(
|
||||
string AdvisoryId,
|
||||
string SourcePackage,
|
||||
string? Title,
|
||||
string? Description,
|
||||
IReadOnlyList<string> CveIds,
|
||||
IReadOnlyList<DebianPackageStateDto> Packages,
|
||||
IReadOnlyList<DebianReferenceDto> References);
|
||||
|
||||
internal sealed record DebianPackageStateDto(
|
||||
string Package,
|
||||
string Release,
|
||||
string Status,
|
||||
string? IntroducedVersion,
|
||||
string? FixedVersion,
|
||||
string? LastAffectedVersion,
|
||||
DateTimeOffset? Published);
|
||||
|
||||
internal sealed record DebianReferenceDto(
|
||||
string Url,
|
||||
string? Kind,
|
||||
string? Title);
|
||||
@@ -0,0 +1,177 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using MongoDB.Bson;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Debian.Internal;
|
||||
|
||||
internal sealed record DebianCursor(
|
||||
DateTimeOffset? LastPublished,
|
||||
IReadOnlyCollection<string> ProcessedAdvisoryIds,
|
||||
IReadOnlyCollection<Guid> PendingDocuments,
|
||||
IReadOnlyCollection<Guid> PendingMappings,
|
||||
IReadOnlyDictionary<string, DebianFetchCacheEntry> FetchCache)
|
||||
{
|
||||
private static readonly IReadOnlyCollection<string> EmptyIds = Array.Empty<string>();
|
||||
private static readonly IReadOnlyCollection<Guid> EmptyGuidList = Array.Empty<Guid>();
|
||||
private static readonly IReadOnlyDictionary<string, DebianFetchCacheEntry> EmptyCache =
|
||||
new Dictionary<string, DebianFetchCacheEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static DebianCursor Empty { get; } = new(null, EmptyIds, EmptyGuidList, EmptyGuidList, EmptyCache);
|
||||
|
||||
public static DebianCursor FromBson(BsonDocument? document)
|
||||
{
|
||||
if (document is null || document.ElementCount == 0)
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
|
||||
DateTimeOffset? lastPublished = null;
|
||||
if (document.TryGetValue("lastPublished", out var lastValue))
|
||||
{
|
||||
lastPublished = lastValue.BsonType switch
|
||||
{
|
||||
BsonType.String when DateTimeOffset.TryParse(lastValue.AsString, out var parsed) => parsed.ToUniversalTime(),
|
||||
BsonType.DateTime => DateTime.SpecifyKind(lastValue.ToUniversalTime(), DateTimeKind.Utc),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
var processed = ReadStringArray(document, "processedIds");
|
||||
var pendingDocuments = ReadGuidArray(document, "pendingDocuments");
|
||||
var pendingMappings = ReadGuidArray(document, "pendingMappings");
|
||||
var cache = ReadCache(document);
|
||||
|
||||
return new DebianCursor(lastPublished, processed, pendingDocuments, pendingMappings, cache);
|
||||
}
|
||||
|
||||
public BsonDocument ToBsonDocument()
|
||||
{
|
||||
var document = new BsonDocument
|
||||
{
|
||||
["pendingDocuments"] = new BsonArray(PendingDocuments.Select(static id => id.ToString())),
|
||||
["pendingMappings"] = new BsonArray(PendingMappings.Select(static id => id.ToString())),
|
||||
};
|
||||
|
||||
if (LastPublished.HasValue)
|
||||
{
|
||||
document["lastPublished"] = LastPublished.Value.UtcDateTime;
|
||||
}
|
||||
|
||||
if (ProcessedAdvisoryIds.Count > 0)
|
||||
{
|
||||
document["processedIds"] = new BsonArray(ProcessedAdvisoryIds);
|
||||
}
|
||||
|
||||
if (FetchCache.Count > 0)
|
||||
{
|
||||
var cacheDoc = new BsonDocument();
|
||||
foreach (var (key, entry) in FetchCache)
|
||||
{
|
||||
cacheDoc[key] = entry.ToBsonDocument();
|
||||
}
|
||||
|
||||
document["fetchCache"] = cacheDoc;
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
public DebianCursor WithPendingDocuments(IEnumerable<Guid> ids)
|
||||
=> this with { PendingDocuments = ids?.Distinct().ToArray() ?? EmptyGuidList };
|
||||
|
||||
public DebianCursor WithPendingMappings(IEnumerable<Guid> ids)
|
||||
=> this with { PendingMappings = ids?.Distinct().ToArray() ?? EmptyGuidList };
|
||||
|
||||
public DebianCursor WithProcessed(DateTimeOffset published, IEnumerable<string> ids)
|
||||
=> this with
|
||||
{
|
||||
LastPublished = published.ToUniversalTime(),
|
||||
ProcessedAdvisoryIds = ids?.Where(static id => !string.IsNullOrWhiteSpace(id))
|
||||
.Select(static id => id.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray() ?? EmptyIds
|
||||
};
|
||||
|
||||
public DebianCursor WithFetchCache(IDictionary<string, DebianFetchCacheEntry>? cache)
|
||||
{
|
||||
if (cache is null || cache.Count == 0)
|
||||
{
|
||||
return this with { FetchCache = EmptyCache };
|
||||
}
|
||||
|
||||
return this with { FetchCache = new Dictionary<string, DebianFetchCacheEntry>(cache, StringComparer.OrdinalIgnoreCase) };
|
||||
}
|
||||
|
||||
public bool TryGetCache(string key, out DebianFetchCacheEntry entry)
|
||||
{
|
||||
if (FetchCache.Count == 0)
|
||||
{
|
||||
entry = DebianFetchCacheEntry.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
return FetchCache.TryGetValue(key, out entry!);
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<string> ReadStringArray(BsonDocument document, string field)
|
||||
{
|
||||
if (!document.TryGetValue(field, out var value) || value is not BsonArray array)
|
||||
{
|
||||
return EmptyIds;
|
||||
}
|
||||
|
||||
var list = new List<string>(array.Count);
|
||||
foreach (var element in array)
|
||||
{
|
||||
if (element.BsonType == BsonType.String)
|
||||
{
|
||||
var str = element.AsString.Trim();
|
||||
if (!string.IsNullOrEmpty(str))
|
||||
{
|
||||
list.Add(str);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<Guid> ReadGuidArray(BsonDocument document, string field)
|
||||
{
|
||||
if (!document.TryGetValue(field, out var value) || value is not BsonArray array)
|
||||
{
|
||||
return EmptyGuidList;
|
||||
}
|
||||
|
||||
var list = new List<Guid>(array.Count);
|
||||
foreach (var element in array)
|
||||
{
|
||||
if (Guid.TryParse(element.ToString(), out var guid))
|
||||
{
|
||||
list.Add(guid);
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, DebianFetchCacheEntry> ReadCache(BsonDocument document)
|
||||
{
|
||||
if (!document.TryGetValue("fetchCache", out var value) || value is not BsonDocument cacheDocument || cacheDocument.ElementCount == 0)
|
||||
{
|
||||
return EmptyCache;
|
||||
}
|
||||
|
||||
var cache = new Dictionary<string, DebianFetchCacheEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var element in cacheDocument.Elements)
|
||||
{
|
||||
if (element.Value is BsonDocument entry)
|
||||
{
|
||||
cache[element.Name] = DebianFetchCacheEntry.FromBson(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return cache;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Debian.Internal;
|
||||
|
||||
internal sealed record DebianDetailMetadata(
|
||||
string AdvisoryId,
|
||||
Uri DetailUri,
|
||||
DateTimeOffset Published,
|
||||
string Title,
|
||||
string SourcePackage,
|
||||
IReadOnlyList<string> CveIds);
|
||||
@@ -0,0 +1,76 @@
|
||||
using System;
|
||||
using MongoDB.Bson;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Debian.Internal;
|
||||
|
||||
internal sealed record DebianFetchCacheEntry(string? ETag, DateTimeOffset? LastModified)
|
||||
{
|
||||
public static DebianFetchCacheEntry Empty { get; } = new(null, null);
|
||||
|
||||
public static DebianFetchCacheEntry FromDocument(StellaOps.Concelier.Storage.Mongo.Documents.DocumentRecord document)
|
||||
=> new(document.Etag, document.LastModified);
|
||||
|
||||
public static DebianFetchCacheEntry FromBson(BsonDocument document)
|
||||
{
|
||||
if (document is null || document.ElementCount == 0)
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
|
||||
string? etag = null;
|
||||
DateTimeOffset? lastModified = null;
|
||||
|
||||
if (document.TryGetValue("etag", out var etagValue) && etagValue.BsonType == BsonType.String)
|
||||
{
|
||||
etag = etagValue.AsString;
|
||||
}
|
||||
|
||||
if (document.TryGetValue("lastModified", out var modifiedValue))
|
||||
{
|
||||
lastModified = modifiedValue.BsonType switch
|
||||
{
|
||||
BsonType.String when DateTimeOffset.TryParse(modifiedValue.AsString, out var parsed) => parsed.ToUniversalTime(),
|
||||
BsonType.DateTime => DateTime.SpecifyKind(modifiedValue.ToUniversalTime(), DateTimeKind.Utc),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
return new DebianFetchCacheEntry(etag, lastModified);
|
||||
}
|
||||
|
||||
public BsonDocument ToBsonDocument()
|
||||
{
|
||||
var document = new BsonDocument();
|
||||
if (!string.IsNullOrWhiteSpace(ETag))
|
||||
{
|
||||
document["etag"] = ETag;
|
||||
}
|
||||
|
||||
if (LastModified.HasValue)
|
||||
{
|
||||
document["lastModified"] = LastModified.Value.UtcDateTime;
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
public bool Matches(StellaOps.Concelier.Storage.Mongo.Documents.DocumentRecord document)
|
||||
{
|
||||
if (document is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(document.Etag, ETag, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (LastModified.HasValue && document.LastModified.HasValue)
|
||||
{
|
||||
return LastModified.Value.UtcDateTime == document.LastModified.Value.UtcDateTime;
|
||||
}
|
||||
|
||||
return !LastModified.HasValue && !document.LastModified.HasValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using AngleSharp.Html.Dom;
|
||||
using AngleSharp.Html.Parser;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Debian.Internal;
|
||||
|
||||
internal static class DebianHtmlParser
|
||||
{
|
||||
public static DebianAdvisoryDto Parse(string html, DebianDetailMetadata metadata)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(html);
|
||||
ArgumentNullException.ThrowIfNull(metadata);
|
||||
|
||||
var parser = new HtmlParser();
|
||||
var document = parser.ParseDocument(html);
|
||||
|
||||
var description = ExtractDescription(document) ?? metadata.Title;
|
||||
var references = ExtractReferences(document, metadata);
|
||||
var packages = ExtractPackages(document, metadata.SourcePackage, metadata.Published);
|
||||
|
||||
return new DebianAdvisoryDto(
|
||||
metadata.AdvisoryId,
|
||||
metadata.SourcePackage,
|
||||
metadata.Title,
|
||||
description,
|
||||
metadata.CveIds,
|
||||
packages,
|
||||
references);
|
||||
}
|
||||
|
||||
private static string? ExtractDescription(IHtmlDocument document)
|
||||
{
|
||||
foreach (var table in document.QuerySelectorAll("table"))
|
||||
{
|
||||
if (table is not IHtmlTableElement tableElement)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var row in tableElement.Rows)
|
||||
{
|
||||
if (row.Cells.Length < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var header = row.Cells[0].TextContent?.Trim();
|
||||
if (string.Equals(header, "Description", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return NormalizeWhitespace(row.Cells[1].TextContent);
|
||||
}
|
||||
}
|
||||
|
||||
// Only the first table contains the metadata rows we need.
|
||||
break;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<DebianReferenceDto> ExtractReferences(IHtmlDocument document, DebianDetailMetadata metadata)
|
||||
{
|
||||
var references = new List<DebianReferenceDto>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Add canonical Debian advisory page.
|
||||
var canonical = new Uri($"https://www.debian.org/security/{metadata.AdvisoryId.ToLowerInvariant()}");
|
||||
references.Add(new DebianReferenceDto(canonical.ToString(), "advisory", metadata.Title));
|
||||
seen.Add(canonical.ToString());
|
||||
|
||||
foreach (var link in document.QuerySelectorAll("a"))
|
||||
{
|
||||
var href = link.GetAttribute("href");
|
||||
if (string.IsNullOrWhiteSpace(href))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string resolved;
|
||||
if (Uri.TryCreate(href, UriKind.Absolute, out var absolute))
|
||||
{
|
||||
resolved = absolute.ToString();
|
||||
}
|
||||
else if (Uri.TryCreate(metadata.DetailUri, href, out var relative))
|
||||
{
|
||||
resolved = relative.ToString();
|
||||
}
|
||||
else
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!seen.Add(resolved))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var text = NormalizeWhitespace(link.TextContent);
|
||||
string? kind = null;
|
||||
if (text.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
kind = "cve";
|
||||
}
|
||||
else if (resolved.Contains("debian.org/security", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
kind = "advisory";
|
||||
}
|
||||
|
||||
references.Add(new DebianReferenceDto(resolved, kind, text));
|
||||
}
|
||||
|
||||
return references;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<DebianPackageStateDto> ExtractPackages(IHtmlDocument document, string defaultPackage, DateTimeOffset published)
|
||||
{
|
||||
var table = FindPackagesTable(document);
|
||||
if (table is null)
|
||||
{
|
||||
return Array.Empty<DebianPackageStateDto>();
|
||||
}
|
||||
|
||||
var accumulators = new Dictionary<string, PackageAccumulator>(StringComparer.OrdinalIgnoreCase);
|
||||
string currentPackage = defaultPackage;
|
||||
|
||||
foreach (var body in table.Bodies)
|
||||
{
|
||||
foreach (var row in body.Rows)
|
||||
{
|
||||
if (row.Cells.Length < 4)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var packageCell = NormalizeWhitespace(row.Cells[0].TextContent);
|
||||
if (!string.IsNullOrWhiteSpace(packageCell))
|
||||
{
|
||||
currentPackage = ExtractPackageName(packageCell);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(currentPackage))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var releaseRaw = NormalizeWhitespace(row.Cells[1].TextContent);
|
||||
var versionRaw = NormalizeWhitespace(row.Cells[2].TextContent);
|
||||
var statusRaw = NormalizeWhitespace(row.Cells[3].TextContent);
|
||||
if (string.IsNullOrWhiteSpace(releaseRaw))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var release = NormalizeRelease(releaseRaw);
|
||||
var key = $"{currentPackage}|{release}";
|
||||
if (!accumulators.TryGetValue(key, out var accumulator))
|
||||
{
|
||||
accumulator = new PackageAccumulator(currentPackage, release, published);
|
||||
accumulators[key] = accumulator;
|
||||
}
|
||||
|
||||
accumulator.Apply(statusRaw, versionRaw);
|
||||
}
|
||||
}
|
||||
|
||||
return accumulators.Values
|
||||
.Where(static acc => acc.ShouldEmit)
|
||||
.Select(static acc => acc.ToDto())
|
||||
.OrderBy(static dto => dto.Release, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(static dto => dto.Package, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IHtmlTableElement? FindPackagesTable(IHtmlDocument document)
|
||||
{
|
||||
foreach (var table in document.QuerySelectorAll("table"))
|
||||
{
|
||||
if (table is not IHtmlTableElement tableElement)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var header = tableElement.Rows.FirstOrDefault();
|
||||
if (header is null || header.Cells.Length < 4)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var firstHeader = NormalizeWhitespace(header.Cells[0].TextContent);
|
||||
var secondHeader = NormalizeWhitespace(header.Cells[1].TextContent);
|
||||
var thirdHeader = NormalizeWhitespace(header.Cells[2].TextContent);
|
||||
if (string.Equals(firstHeader, "Source Package", StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(secondHeader, "Release", StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(thirdHeader, "Version", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return tableElement;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string NormalizeRelease(string release)
|
||||
{
|
||||
var trimmed = release.Trim();
|
||||
var parenthesisIndex = trimmed.IndexOf('(');
|
||||
if (parenthesisIndex > 0)
|
||||
{
|
||||
trimmed = trimmed[..parenthesisIndex].Trim();
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private static string ExtractPackageName(string value)
|
||||
{
|
||||
var trimmed = value.Split(' ', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(trimmed))
|
||||
{
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
if (trimmed.EndsWith(")", StringComparison.Ordinal) && trimmed.Contains('('))
|
||||
{
|
||||
trimmed = trimmed[..trimmed.IndexOf('(')];
|
||||
}
|
||||
|
||||
return trimmed.Trim();
|
||||
}
|
||||
|
||||
private static string NormalizeWhitespace(string value)
|
||||
=> string.IsNullOrWhiteSpace(value)
|
||||
? string.Empty
|
||||
: string.Join(' ', value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
||||
|
||||
private sealed class PackageAccumulator
|
||||
{
|
||||
private readonly DateTimeOffset _published;
|
||||
|
||||
public PackageAccumulator(string package, string release, DateTimeOffset published)
|
||||
{
|
||||
Package = package;
|
||||
Release = release;
|
||||
_published = published;
|
||||
Status = "unknown";
|
||||
}
|
||||
|
||||
public string Package { get; }
|
||||
|
||||
public string Release { get; }
|
||||
|
||||
public string Status { get; private set; }
|
||||
|
||||
public string? IntroducedVersion { get; private set; }
|
||||
|
||||
public string? FixedVersion { get; private set; }
|
||||
|
||||
public string? LastAffectedVersion { get; private set; }
|
||||
|
||||
public bool ShouldEmit =>
|
||||
!string.Equals(Status, "not_affected", StringComparison.OrdinalIgnoreCase)
|
||||
|| IntroducedVersion is not null
|
||||
|| FixedVersion is not null;
|
||||
|
||||
public void Apply(string statusRaw, string versionRaw)
|
||||
{
|
||||
var status = statusRaw.ToLowerInvariant();
|
||||
var version = string.IsNullOrWhiteSpace(versionRaw) ? null : versionRaw.Trim();
|
||||
|
||||
if (status.Contains("fixed", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
FixedVersion = version;
|
||||
if (!string.Equals(Status, "open", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Status = "resolved";
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.Contains("vulnerable", StringComparison.OrdinalIgnoreCase)
|
||||
|| status.Contains("open", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
IntroducedVersion ??= version;
|
||||
if (!string.Equals(Status, "resolved", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Status = "open";
|
||||
}
|
||||
|
||||
LastAffectedVersion = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.Contains("not affected", StringComparison.OrdinalIgnoreCase)
|
||||
|| status.Contains("not vulnerable", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Status = "not_affected";
|
||||
IntroducedVersion = null;
|
||||
FixedVersion = null;
|
||||
LastAffectedVersion = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.Contains("end-of-life", StringComparison.OrdinalIgnoreCase) || status.Contains("end of life", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Status = "end_of_life";
|
||||
return;
|
||||
}
|
||||
|
||||
Status = statusRaw;
|
||||
}
|
||||
|
||||
public DebianPackageStateDto ToDto()
|
||||
=> new(
|
||||
Package: Package,
|
||||
Release: Release,
|
||||
Status: Status,
|
||||
IntroducedVersion: IntroducedVersion,
|
||||
FixedVersion: FixedVersion,
|
||||
LastAffectedVersion: LastAffectedVersion,
|
||||
Published: _published);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Debian.Internal;
|
||||
|
||||
internal sealed record DebianListEntry(
|
||||
string AdvisoryId,
|
||||
DateTimeOffset Published,
|
||||
string Title,
|
||||
string SourcePackage,
|
||||
IReadOnlyList<string> CveIds);
|
||||
@@ -0,0 +1,107 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Debian.Internal;
|
||||
|
||||
internal static class DebianListParser
|
||||
{
|
||||
private static readonly Regex HeaderRegex = new("^\\[(?<date>[^\\]]+)\\]\\s+(?<id>DSA-\\d{4,}-\\d+)\\s+(?<title>.+)$", RegexOptions.Compiled);
|
||||
private static readonly Regex CveRegex = new("CVE-\\d{4}-\\d{3,7}", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
public static IReadOnlyList<DebianListEntry> Parse(string? content)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return Array.Empty<DebianListEntry>();
|
||||
}
|
||||
|
||||
var entries = new List<DebianListEntry>();
|
||||
var currentCves = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
DateTimeOffset currentDate = default;
|
||||
string? currentId = null;
|
||||
string? currentTitle = null;
|
||||
string? currentPackage = null;
|
||||
|
||||
foreach (var rawLine in content.Split('\n'))
|
||||
{
|
||||
var line = rawLine.TrimEnd('\r');
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line[0] == '[')
|
||||
{
|
||||
if (currentId is not null && currentTitle is not null && currentPackage is not null)
|
||||
{
|
||||
entries.Add(new DebianListEntry(
|
||||
currentId,
|
||||
currentDate,
|
||||
currentTitle,
|
||||
currentPackage,
|
||||
currentCves.Count == 0 ? Array.Empty<string>() : new List<string>(currentCves)));
|
||||
}
|
||||
|
||||
currentCves.Clear();
|
||||
currentId = null;
|
||||
currentTitle = null;
|
||||
currentPackage = null;
|
||||
|
||||
var match = HeaderRegex.Match(line);
|
||||
if (!match.Success)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!DateTimeOffset.TryParseExact(
|
||||
match.Groups["date"].Value,
|
||||
new[] { "dd MMM yyyy", "d MMM yyyy" },
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
||||
out currentDate))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
currentId = match.Groups["id"].Value.Trim();
|
||||
currentTitle = match.Groups["title"].Value.Trim();
|
||||
|
||||
var separatorIndex = currentTitle.IndexOf(" - ", StringComparison.Ordinal);
|
||||
currentPackage = separatorIndex > 0
|
||||
? currentTitle[..separatorIndex].Trim()
|
||||
: currentTitle.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(currentPackage))
|
||||
{
|
||||
currentPackage = currentId;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line[0] == '{')
|
||||
{
|
||||
foreach (Match match in CveRegex.Matches(line))
|
||||
{
|
||||
if (match.Success && !string.IsNullOrWhiteSpace(match.Value))
|
||||
{
|
||||
currentCves.Add(match.Value.ToUpperInvariant());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentId is not null && currentTitle is not null && currentPackage is not null)
|
||||
{
|
||||
entries.Add(new DebianListEntry(
|
||||
currentId,
|
||||
currentDate,
|
||||
currentTitle,
|
||||
currentPackage,
|
||||
currentCves.Count == 0 ? Array.Empty<string>() : new List<string>(currentCves)));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Normalization.Distro;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Debian.Internal;
|
||||
|
||||
internal static class DebianMapper
|
||||
{
|
||||
public static Advisory Map(
|
||||
DebianAdvisoryDto dto,
|
||||
DocumentRecord document,
|
||||
DateTimeOffset recordedAt)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dto);
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
var aliases = BuildAliases(dto);
|
||||
var references = BuildReferences(dto, recordedAt);
|
||||
var affectedPackages = BuildAffectedPackages(dto, recordedAt);
|
||||
|
||||
var fetchProvenance = new AdvisoryProvenance(
|
||||
DebianConnectorPlugin.SourceName,
|
||||
"document",
|
||||
document.Uri,
|
||||
document.FetchedAt.ToUniversalTime());
|
||||
|
||||
var mappingProvenance = new AdvisoryProvenance(
|
||||
DebianConnectorPlugin.SourceName,
|
||||
"mapping",
|
||||
dto.AdvisoryId,
|
||||
recordedAt);
|
||||
|
||||
return new Advisory(
|
||||
advisoryKey: dto.AdvisoryId,
|
||||
title: dto.Title ?? dto.AdvisoryId,
|
||||
summary: dto.Description,
|
||||
language: "en",
|
||||
published: dto.Packages.Select(p => p.Published).Where(p => p.HasValue).Select(p => p!.Value).Cast<DateTimeOffset?>().DefaultIfEmpty(null).Min(),
|
||||
modified: recordedAt,
|
||||
severity: null,
|
||||
exploitKnown: false,
|
||||
aliases: aliases,
|
||||
references: references,
|
||||
affectedPackages: affectedPackages,
|
||||
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||
provenance: new[] { fetchProvenance, mappingProvenance });
|
||||
}
|
||||
|
||||
private static string[] BuildAliases(DebianAdvisoryDto dto)
|
||||
{
|
||||
var aliases = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (!string.IsNullOrWhiteSpace(dto.AdvisoryId))
|
||||
{
|
||||
aliases.Add(dto.AdvisoryId.Trim());
|
||||
}
|
||||
|
||||
foreach (var cve in dto.CveIds ?? Array.Empty<string>())
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(cve))
|
||||
{
|
||||
aliases.Add(cve.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
return aliases.OrderBy(a => a, StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
}
|
||||
|
||||
private static AdvisoryReference[] BuildReferences(DebianAdvisoryDto dto, DateTimeOffset recordedAt)
|
||||
{
|
||||
if (dto.References is null || dto.References.Count == 0)
|
||||
{
|
||||
return Array.Empty<AdvisoryReference>();
|
||||
}
|
||||
|
||||
var references = new List<AdvisoryReference>();
|
||||
foreach (var reference in dto.References)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(reference.Url))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var provenance = new AdvisoryProvenance(
|
||||
DebianConnectorPlugin.SourceName,
|
||||
"reference",
|
||||
reference.Url,
|
||||
recordedAt);
|
||||
|
||||
references.Add(new AdvisoryReference(
|
||||
reference.Url,
|
||||
NormalizeReferenceKind(reference.Kind),
|
||||
reference.Kind,
|
||||
reference.Title,
|
||||
provenance));
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// Ignore malformed URLs while keeping the rest of the advisory intact.
|
||||
}
|
||||
}
|
||||
|
||||
return references.Count == 0
|
||||
? Array.Empty<AdvisoryReference>()
|
||||
: references
|
||||
.OrderBy(r => r.Url, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string? NormalizeReferenceKind(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"advisory" or "dsa" => "advisory",
|
||||
"cve" => "cve",
|
||||
"patch" => "patch",
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static AdvisoryProvenance BuildPackageProvenance(DebianPackageStateDto package, DateTimeOffset recordedAt)
|
||||
=> new(DebianConnectorPlugin.SourceName, "affected", $"{package.Package}:{package.Release}", recordedAt);
|
||||
|
||||
private static IReadOnlyList<AffectedPackage> BuildAffectedPackages(DebianAdvisoryDto dto, DateTimeOffset recordedAt)
|
||||
{
|
||||
if (dto.Packages is null || dto.Packages.Count == 0)
|
||||
{
|
||||
return Array.Empty<AffectedPackage>();
|
||||
}
|
||||
|
||||
var packages = new List<AffectedPackage>(dto.Packages.Count);
|
||||
foreach (var package in dto.Packages)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(package.Package))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var provenance = new[] { BuildPackageProvenance(package, recordedAt) };
|
||||
var ranges = BuildVersionRanges(package, recordedAt);
|
||||
var normalizedVersions = BuildNormalizedVersions(package, ranges);
|
||||
|
||||
packages.Add(new AffectedPackage(
|
||||
AffectedPackageTypes.Deb,
|
||||
identifier: package.Package.Trim(),
|
||||
platform: package.Release,
|
||||
versionRanges: ranges,
|
||||
statuses: Array.Empty<AffectedPackageStatus>(),
|
||||
provenance: provenance,
|
||||
normalizedVersions: normalizedVersions));
|
||||
}
|
||||
|
||||
return packages;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AffectedVersionRange> BuildVersionRanges(DebianPackageStateDto package, DateTimeOffset recordedAt)
|
||||
{
|
||||
var provenance = new AdvisoryProvenance(
|
||||
DebianConnectorPlugin.SourceName,
|
||||
"range",
|
||||
$"{package.Package}:{package.Release}",
|
||||
recordedAt);
|
||||
|
||||
var introduced = package.IntroducedVersion;
|
||||
var fixedVersion = package.FixedVersion;
|
||||
var lastAffected = package.LastAffectedVersion;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(introduced) && string.IsNullOrWhiteSpace(fixedVersion) && string.IsNullOrWhiteSpace(lastAffected))
|
||||
{
|
||||
return Array.Empty<AffectedVersionRange>();
|
||||
}
|
||||
|
||||
var extensions = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["debian.release"] = package.Release,
|
||||
["debian.status"] = package.Status
|
||||
};
|
||||
|
||||
AddExtension(extensions, "debian.introduced", introduced);
|
||||
AddExtension(extensions, "debian.fixed", fixedVersion);
|
||||
AddExtension(extensions, "debian.lastAffected", lastAffected);
|
||||
|
||||
var primitives = BuildEvrPrimitives(introduced, fixedVersion, lastAffected);
|
||||
return new[]
|
||||
{
|
||||
new AffectedVersionRange(
|
||||
rangeKind: "evr",
|
||||
introducedVersion: introduced,
|
||||
fixedVersion: fixedVersion,
|
||||
lastAffectedVersion: lastAffected,
|
||||
rangeExpression: BuildRangeExpression(introduced, fixedVersion, lastAffected),
|
||||
provenance: provenance,
|
||||
primitives: primitives is null && extensions.Count == 0
|
||||
? null
|
||||
: new RangePrimitives(
|
||||
SemVer: null,
|
||||
Nevra: null,
|
||||
Evr: primitives,
|
||||
VendorExtensions: extensions.Count == 0 ? null : extensions))
|
||||
};
|
||||
}
|
||||
|
||||
private static EvrPrimitive? BuildEvrPrimitives(string? introduced, string? fixedVersion, string? lastAffected)
|
||||
{
|
||||
var introducedComponent = ParseEvr(introduced);
|
||||
var fixedComponent = ParseEvr(fixedVersion);
|
||||
var lastAffectedComponent = ParseEvr(lastAffected);
|
||||
|
||||
if (introducedComponent is null && fixedComponent is null && lastAffectedComponent is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new EvrPrimitive(introducedComponent, fixedComponent, lastAffectedComponent);
|
||||
}
|
||||
|
||||
private static EvrComponent? ParseEvr(string? value)
|
||||
{
|
||||
if (!DebianEvr.TryParse(value, out var evr) || evr is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new EvrComponent(
|
||||
evr.Epoch,
|
||||
evr.Version,
|
||||
evr.Revision.Length == 0 ? null : evr.Revision);
|
||||
}
|
||||
|
||||
private static string? BuildRangeExpression(string? introduced, string? fixedVersion, string? lastAffected)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(introduced))
|
||||
{
|
||||
parts.Add($"introduced:{introduced.Trim()}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(fixedVersion))
|
||||
{
|
||||
parts.Add($"fixed:{fixedVersion.Trim()}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(lastAffected))
|
||||
{
|
||||
parts.Add($"last:{lastAffected.Trim()}");
|
||||
}
|
||||
|
||||
return parts.Count == 0 ? null : string.Join(" ", parts);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<NormalizedVersionRule> BuildNormalizedVersions(
|
||||
DebianPackageStateDto package,
|
||||
IReadOnlyList<AffectedVersionRange> ranges)
|
||||
{
|
||||
if (ranges.Count == 0)
|
||||
{
|
||||
return Array.Empty<NormalizedVersionRule>();
|
||||
}
|
||||
|
||||
var note = string.IsNullOrWhiteSpace(package.Release)
|
||||
? null
|
||||
: $"debian:{package.Release.Trim()}";
|
||||
|
||||
var rules = new List<NormalizedVersionRule>(ranges.Count);
|
||||
foreach (var range in ranges)
|
||||
{
|
||||
var rule = range.ToNormalizedVersionRule(note);
|
||||
if (rule is not null)
|
||||
{
|
||||
rules.Add(rule);
|
||||
}
|
||||
}
|
||||
|
||||
return rules.Count == 0 ? Array.Empty<NormalizedVersionRule>() : rules;
|
||||
}
|
||||
|
||||
private static void AddExtension(IDictionary<string, string> extensions, string key, string? value)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
extensions[key] = value.Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
46
src/StellaOps.Concelier.Connector.Distro.Debian/Jobs.cs
Normal file
46
src/StellaOps.Concelier.Connector.Distro.Debian/Jobs.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Distro.Debian;
|
||||
|
||||
internal static class DebianJobKinds
|
||||
{
|
||||
public const string Fetch = "source:debian:fetch";
|
||||
public const string Parse = "source:debian:parse";
|
||||
public const string Map = "source:debian:map";
|
||||
}
|
||||
|
||||
internal sealed class DebianFetchJob : IJob
|
||||
{
|
||||
private readonly DebianConnector _connector;
|
||||
|
||||
public DebianFetchJob(DebianConnector connector)
|
||||
=> _connector = connector ?? throw new ArgumentNullException(nameof(connector));
|
||||
|
||||
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
|
||||
=> _connector.FetchAsync(context.Services, cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class DebianParseJob : IJob
|
||||
{
|
||||
private readonly DebianConnector _connector;
|
||||
|
||||
public DebianParseJob(DebianConnector connector)
|
||||
=> _connector = connector ?? throw new ArgumentNullException(nameof(connector));
|
||||
|
||||
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
|
||||
=> _connector.ParseAsync(context.Services, cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class DebianMapJob : IJob
|
||||
{
|
||||
private readonly DebianConnector _connector;
|
||||
|
||||
public DebianMapJob(DebianConnector connector)
|
||||
=> _connector = connector ?? throw new ArgumentNullException(nameof(connector));
|
||||
|
||||
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
|
||||
=> _connector.MapAsync(context.Services, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
||||
|
||||
<ProjectReference Include="../StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Concelier.Normalization/StellaOps.Concelier.Normalization.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Concelier.Storage.Mongo/StellaOps.Concelier.Storage.Mongo.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user