Restructure solution layout by module
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.CertIn.Internal;
|
||||
|
||||
internal sealed record CertInAdvisoryDto(
|
||||
string AdvisoryId,
|
||||
string Title,
|
||||
string Link,
|
||||
DateTimeOffset Published,
|
||||
string? Summary,
|
||||
string Content,
|
||||
string? Severity,
|
||||
ImmutableArray<string> CveIds,
|
||||
ImmutableArray<string> VendorNames,
|
||||
ImmutableArray<string> ReferenceLinks);
|
||||
@@ -0,0 +1,129 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Connector.CertIn.Configuration;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.CertIn.Internal;
|
||||
|
||||
public sealed class CertInClient
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly CertInOptions _options;
|
||||
private readonly ILogger<CertInClient> _logger;
|
||||
|
||||
public CertInClient(IHttpClientFactory httpClientFactory, IOptions<CertInOptions> options, ILogger<CertInClient> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_options.Validate();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CertInListingItem>> GetListingsAsync(int page, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(CertInOptions.HttpClientName);
|
||||
var requestUri = BuildPageUri(_options.AlertsEndpoint, page);
|
||||
|
||||
using var response = await client.GetAsync(requestUri, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var root = document.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
_logger.LogWarning("Unexpected CERT-In alert payload shape for {Uri}", requestUri);
|
||||
return Array.Empty<CertInListingItem>();
|
||||
}
|
||||
|
||||
var items = new List<CertInListingItem>(capacity: root.GetArrayLength());
|
||||
foreach (var element in root.EnumerateArray())
|
||||
{
|
||||
if (!TryParseListing(element, out var item))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
items.Add(item);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private static bool TryParseListing(JsonElement element, out CertInListingItem item)
|
||||
{
|
||||
item = null!;
|
||||
|
||||
if (!element.TryGetProperty("advisoryId", out var idElement) || idElement.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var advisoryId = idElement.GetString();
|
||||
if (string.IsNullOrWhiteSpace(advisoryId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var title = element.TryGetProperty("title", out var titleElement) && titleElement.ValueKind == JsonValueKind.String
|
||||
? titleElement.GetString()
|
||||
: advisoryId;
|
||||
|
||||
if (!element.TryGetProperty("detailUrl", out var linkElement) || linkElement.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(linkElement.GetString(), UriKind.Absolute, out var detailUri))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
DateTimeOffset published;
|
||||
if (element.TryGetProperty("publishedOn", out var publishedElement) && publishedElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
if (!DateTimeOffset.TryParse(publishedElement.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out published))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string? summary = null;
|
||||
if (element.TryGetProperty("summary", out var summaryElement) && summaryElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
summary = summaryElement.GetString();
|
||||
}
|
||||
|
||||
item = new CertInListingItem(advisoryId.Trim(), title?.Trim() ?? advisoryId.Trim(), detailUri, published.ToUniversalTime(), summary?.Trim());
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Uri BuildPageUri(Uri baseUri, int page)
|
||||
{
|
||||
if (page <= 1)
|
||||
{
|
||||
return baseUri;
|
||||
}
|
||||
|
||||
var builder = new UriBuilder(baseUri);
|
||||
var trimmed = builder.Query.TrimStart('?');
|
||||
var pageSegment = $"page={page.ToString(CultureInfo.InvariantCulture)}";
|
||||
builder.Query = string.IsNullOrEmpty(trimmed)
|
||||
? pageSegment
|
||||
: $"{trimmed}&{pageSegment}";
|
||||
return builder.Uri;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using MongoDB.Bson;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.CertIn.Internal;
|
||||
|
||||
internal sealed record CertInCursor(
|
||||
DateTimeOffset? LastPublished,
|
||||
IReadOnlyCollection<Guid> PendingDocuments,
|
||||
IReadOnlyCollection<Guid> PendingMappings)
|
||||
{
|
||||
public static CertInCursor Empty { get; } = new(null, Array.Empty<Guid>(), Array.Empty<Guid>());
|
||||
|
||||
public BsonDocument ToBsonDocument()
|
||||
{
|
||||
var document = new BsonDocument
|
||||
{
|
||||
["pendingDocuments"] = new BsonArray(PendingDocuments.Select(id => id.ToString())),
|
||||
["pendingMappings"] = new BsonArray(PendingMappings.Select(id => id.ToString())),
|
||||
};
|
||||
|
||||
if (LastPublished.HasValue)
|
||||
{
|
||||
document["lastPublished"] = LastPublished.Value.UtcDateTime;
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
public static CertInCursor FromBson(BsonDocument? document)
|
||||
{
|
||||
if (document is null || document.ElementCount == 0)
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
|
||||
var lastPublished = document.TryGetValue("lastPublished", out var dateValue)
|
||||
? ParseDate(dateValue)
|
||||
: null;
|
||||
|
||||
return new CertInCursor(
|
||||
lastPublished,
|
||||
ReadGuidArray(document, "pendingDocuments"),
|
||||
ReadGuidArray(document, "pendingMappings"));
|
||||
}
|
||||
|
||||
public CertInCursor WithLastPublished(DateTimeOffset? timestamp)
|
||||
=> this with { LastPublished = timestamp };
|
||||
|
||||
public CertInCursor WithPendingDocuments(IEnumerable<Guid> ids)
|
||||
=> this with { PendingDocuments = ids?.Distinct().ToArray() ?? Array.Empty<Guid>() };
|
||||
|
||||
public CertInCursor WithPendingMappings(IEnumerable<Guid> ids)
|
||||
=> this with { PendingMappings = ids?.Distinct().ToArray() ?? Array.Empty<Guid>() };
|
||||
|
||||
private static DateTimeOffset? ParseDate(BsonValue value)
|
||||
=> value.BsonType switch
|
||||
{
|
||||
BsonType.DateTime => DateTime.SpecifyKind(value.ToUniversalTime(), DateTimeKind.Utc),
|
||||
BsonType.String when DateTimeOffset.TryParse(value.AsString, out var parsed) => parsed.ToUniversalTime(),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static IReadOnlyCollection<Guid> ReadGuidArray(BsonDocument document, string field)
|
||||
{
|
||||
if (!document.TryGetValue(field, out var value) || value is not BsonArray array)
|
||||
{
|
||||
return Array.Empty<Guid>();
|
||||
}
|
||||
|
||||
var results = new List<Guid>(array.Count);
|
||||
foreach (var element in array)
|
||||
{
|
||||
if (element is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Guid.TryParse(element.ToString(), out var guid))
|
||||
{
|
||||
results.Add(guid);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.CertIn.Internal;
|
||||
|
||||
internal static class CertInDetailParser
|
||||
{
|
||||
private static readonly Regex CveRegex = new("CVE-\\d{4}-\\d+", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex SeverityRegex = new("Severity\\s*[:\\-]\\s*(?<value>[A-Za-z ]{1,32})", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex VendorRegex = new("(?:Vendor|Organisation|Organization|Company)\\s*[:\\-]\\s*(?<value>[^\\n\\r]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex LinkRegex = new("href=\"(https?://[^\"]+)\"", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
public static CertInAdvisoryDto Parse(CertInListingItem listing, byte[] rawHtml)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(listing);
|
||||
|
||||
var html = Encoding.UTF8.GetString(rawHtml);
|
||||
var content = HtmlToPlainText(html);
|
||||
var summary = listing.Summary ?? ExtractSummary(content);
|
||||
var severity = ExtractSeverity(content);
|
||||
var cves = ExtractCves(listing.Title, summary, content);
|
||||
var vendors = ExtractVendors(summary, content);
|
||||
var references = ExtractLinks(html);
|
||||
|
||||
return new CertInAdvisoryDto(
|
||||
listing.AdvisoryId,
|
||||
listing.Title,
|
||||
listing.DetailUri.ToString(),
|
||||
listing.Published,
|
||||
summary,
|
||||
content,
|
||||
severity,
|
||||
cves,
|
||||
vendors,
|
||||
references);
|
||||
}
|
||||
|
||||
private static string HtmlToPlainText(string html)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var withoutScripts = Regex.Replace(html, "<script[\\s\\S]*?</script>", string.Empty, RegexOptions.IgnoreCase);
|
||||
var withoutStyles = Regex.Replace(withoutScripts, "<style[\\s\\S]*?</style>", string.Empty, RegexOptions.IgnoreCase);
|
||||
var withoutComments = Regex.Replace(withoutStyles, "<!--.*?-->", string.Empty, RegexOptions.Singleline);
|
||||
var withoutTags = Regex.Replace(withoutComments, "<[^>]+>", " ");
|
||||
var decoded = System.Net.WebUtility.HtmlDecode(withoutTags);
|
||||
return string.IsNullOrWhiteSpace(decoded)
|
||||
? string.Empty
|
||||
: Regex.Replace(decoded, "\\s+", " ").Trim();
|
||||
}
|
||||
|
||||
private static string? ExtractSummary(string content)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sentenceTerminators = new[] { ".", "!", "?" };
|
||||
foreach (var terminator in sentenceTerminators)
|
||||
{
|
||||
var index = content.IndexOf(terminator, StringComparison.Ordinal);
|
||||
if (index > 0)
|
||||
{
|
||||
return content[..(index + terminator.Length)].Trim();
|
||||
}
|
||||
}
|
||||
|
||||
return content.Length > 280 ? content[..280].Trim() : content;
|
||||
}
|
||||
|
||||
private static string? ExtractSeverity(string content)
|
||||
{
|
||||
var match = SeverityRegex.Match(content);
|
||||
if (match.Success)
|
||||
{
|
||||
return match.Groups["value"].Value.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> ExtractCves(string title, string? summary, string content)
|
||||
{
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
void Capture(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (Match match in CveRegex.Matches(text))
|
||||
{
|
||||
if (match.Success)
|
||||
{
|
||||
set.Add(match.Value.ToUpperInvariant());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Capture(title);
|
||||
Capture(summary);
|
||||
Capture(content);
|
||||
|
||||
return set.OrderBy(static value => value, StringComparer.Ordinal).ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> ExtractVendors(string? summary, string content)
|
||||
{
|
||||
var vendors = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
void Add(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var cleaned = value
|
||||
.Replace("’", "'", StringComparison.Ordinal)
|
||||
.Trim();
|
||||
|
||||
if (cleaned.Length > 200)
|
||||
{
|
||||
cleaned = cleaned[..200];
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(cleaned))
|
||||
{
|
||||
vendors.Add(cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(summary))
|
||||
{
|
||||
foreach (Match match in VendorRegex.Matches(summary))
|
||||
{
|
||||
Add(match.Groups["value"].Value);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Match match in VendorRegex.Matches(content))
|
||||
{
|
||||
Add(match.Groups["value"].Value);
|
||||
}
|
||||
|
||||
if (vendors.Count == 0 && !string.IsNullOrWhiteSpace(summary))
|
||||
{
|
||||
var fallback = summary.Split('.', 2, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
|
||||
Add(fallback);
|
||||
}
|
||||
|
||||
return vendors.Count == 0
|
||||
? ImmutableArray<string>.Empty
|
||||
: vendors.OrderBy(static value => value, StringComparer.Ordinal).ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> ExtractLinks(string html)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
var links = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (Match match in LinkRegex.Matches(html))
|
||||
{
|
||||
if (match.Success)
|
||||
{
|
||||
links.Add(match.Groups[1].Value);
|
||||
}
|
||||
}
|
||||
|
||||
return links.Count == 0
|
||||
? ImmutableArray<string>.Empty
|
||||
: links.OrderBy(static value => value, StringComparer.Ordinal).ToImmutableArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.CertIn.Internal;
|
||||
|
||||
public sealed record CertInListingItem(
|
||||
string AdvisoryId,
|
||||
string Title,
|
||||
Uri DetailUri,
|
||||
DateTimeOffset Published,
|
||||
string? Summary);
|
||||
Reference in New Issue
Block a user