up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
This commit is contained in:
@@ -1,39 +1,39 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Oracle.Configuration;
|
||||
|
||||
public sealed class OracleOptions
|
||||
{
|
||||
public const string HttpClientName = "vndr-oracle";
|
||||
|
||||
public List<Uri> AdvisoryUris { get; set; } = new();
|
||||
|
||||
public List<Uri> CalendarUris { get; set; } = new();
|
||||
|
||||
public TimeSpan RequestDelay { get; set; } = TimeSpan.FromSeconds(1);
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (AdvisoryUris.Count == 0 && CalendarUris.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Oracle connector requires at least one advisory or calendar URI.");
|
||||
}
|
||||
|
||||
if (AdvisoryUris.Any(uri => uri is null || !uri.IsAbsoluteUri))
|
||||
{
|
||||
throw new InvalidOperationException("All Oracle AdvisoryUris must be absolute URIs.");
|
||||
}
|
||||
|
||||
if (CalendarUris.Any(uri => uri is null || !uri.IsAbsoluteUri))
|
||||
{
|
||||
throw new InvalidOperationException("All Oracle CalendarUris must be absolute URIs.");
|
||||
}
|
||||
|
||||
if (RequestDelay < TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("RequestDelay cannot be negative.");
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Oracle.Configuration;
|
||||
|
||||
public sealed class OracleOptions
|
||||
{
|
||||
public const string HttpClientName = "vndr-oracle";
|
||||
|
||||
public List<Uri> AdvisoryUris { get; set; } = new();
|
||||
|
||||
public List<Uri> CalendarUris { get; set; } = new();
|
||||
|
||||
public TimeSpan RequestDelay { get; set; } = TimeSpan.FromSeconds(1);
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (AdvisoryUris.Count == 0 && CalendarUris.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Oracle connector requires at least one advisory or calendar URI.");
|
||||
}
|
||||
|
||||
if (AdvisoryUris.Any(uri => uri is null || !uri.IsAbsoluteUri))
|
||||
{
|
||||
throw new InvalidOperationException("All Oracle AdvisoryUris must be absolute URIs.");
|
||||
}
|
||||
|
||||
if (CalendarUris.Any(uri => uri is null || !uri.IsAbsoluteUri))
|
||||
{
|
||||
throw new InvalidOperationException("All Oracle CalendarUris must be absolute URIs.");
|
||||
}
|
||||
|
||||
if (RequestDelay < TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("RequestDelay cannot be negative.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Oracle.Internal;
|
||||
|
||||
internal sealed record OracleAffectedEntry(
|
||||
[property: JsonPropertyName("product")] string Product,
|
||||
[property: JsonPropertyName("component")] string? Component,
|
||||
[property: JsonPropertyName("supportedVersions")] string? SupportedVersions,
|
||||
[property: JsonPropertyName("notes")] string? Notes,
|
||||
[property: JsonPropertyName("cves")] IReadOnlyList<string> CveIds);
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Oracle.Internal;
|
||||
|
||||
internal sealed record OracleAffectedEntry(
|
||||
[property: JsonPropertyName("product")] string Product,
|
||||
[property: JsonPropertyName("component")] string? Component,
|
||||
[property: JsonPropertyName("supportedVersions")] string? SupportedVersions,
|
||||
[property: JsonPropertyName("notes")] string? Notes,
|
||||
[property: JsonPropertyName("cves")] IReadOnlyList<string> CveIds);
|
||||
|
||||
@@ -1,92 +1,92 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Connector.Vndr.Oracle.Configuration;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Oracle.Internal;
|
||||
|
||||
public sealed class OracleCalendarFetcher
|
||||
{
|
||||
private static readonly Regex AnchorRegex = new("<a[^>]+href=\"(?<url>[^\"]+)\"", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly OracleOptions _options;
|
||||
private readonly ILogger<OracleCalendarFetcher> _logger;
|
||||
|
||||
public OracleCalendarFetcher(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<OracleOptions> options,
|
||||
ILogger<OracleCalendarFetcher> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyCollection<Uri>> GetAdvisoryUrisAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_options.CalendarUris.Count == 0)
|
||||
{
|
||||
return Array.Empty<Uri>();
|
||||
}
|
||||
|
||||
var discovered = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var client = _httpClientFactory.CreateClient(OracleOptions.HttpClientName);
|
||||
|
||||
foreach (var calendarUri in _options.CalendarUris)
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = await client.GetStringAsync(calendarUri, cancellationToken).ConfigureAwait(false);
|
||||
foreach (var link in ExtractLinks(calendarUri, content))
|
||||
{
|
||||
discovered.Add(link.AbsoluteUri);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Oracle calendar fetch failed for {Uri}", calendarUri);
|
||||
}
|
||||
}
|
||||
|
||||
return discovered
|
||||
.Select(static uri => new Uri(uri, UriKind.Absolute))
|
||||
.OrderBy(static uri => uri.AbsoluteUri, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IEnumerable<Uri> ExtractLinks(Uri baseUri, string html)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (Match match in AnchorRegex.Matches(html))
|
||||
{
|
||||
if (!match.Success)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var href = match.Groups["url"].Value?.Trim();
|
||||
if (string.IsNullOrEmpty(href))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(baseUri, href, out var uri) || !uri.IsAbsoluteUri)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return uri;
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Connector.Vndr.Oracle.Configuration;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Oracle.Internal;
|
||||
|
||||
public sealed class OracleCalendarFetcher
|
||||
{
|
||||
private static readonly Regex AnchorRegex = new("<a[^>]+href=\"(?<url>[^\"]+)\"", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly OracleOptions _options;
|
||||
private readonly ILogger<OracleCalendarFetcher> _logger;
|
||||
|
||||
public OracleCalendarFetcher(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<OracleOptions> options,
|
||||
ILogger<OracleCalendarFetcher> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyCollection<Uri>> GetAdvisoryUrisAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_options.CalendarUris.Count == 0)
|
||||
{
|
||||
return Array.Empty<Uri>();
|
||||
}
|
||||
|
||||
var discovered = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var client = _httpClientFactory.CreateClient(OracleOptions.HttpClientName);
|
||||
|
||||
foreach (var calendarUri in _options.CalendarUris)
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = await client.GetStringAsync(calendarUri, cancellationToken).ConfigureAwait(false);
|
||||
foreach (var link in ExtractLinks(calendarUri, content))
|
||||
{
|
||||
discovered.Add(link.AbsoluteUri);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Oracle calendar fetch failed for {Uri}", calendarUri);
|
||||
}
|
||||
}
|
||||
|
||||
return discovered
|
||||
.Select(static uri => new Uri(uri, UriKind.Absolute))
|
||||
.OrderBy(static uri => uri.AbsoluteUri, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IEnumerable<Uri> ExtractLinks(Uri baseUri, string html)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (Match match in AnchorRegex.Matches(html))
|
||||
{
|
||||
if (!match.Success)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var href = match.Groups["url"].Value?.Trim();
|
||||
if (string.IsNullOrEmpty(href))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(baseUri, href, out var uri) || !uri.IsAbsoluteUri)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return uri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,227 +1,227 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StellaOps.Concelier.Bson;
|
||||
using StellaOps.Concelier.Storage;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Oracle.Internal;
|
||||
|
||||
internal sealed record OracleCursor(
|
||||
DateTimeOffset? LastProcessed,
|
||||
IReadOnlyCollection<Guid> PendingDocuments,
|
||||
IReadOnlyCollection<Guid> PendingMappings,
|
||||
IReadOnlyDictionary<string, OracleFetchCacheEntry> FetchCache)
|
||||
{
|
||||
private static readonly IReadOnlyCollection<Guid> EmptyGuidCollection = Array.Empty<Guid>();
|
||||
private static readonly IReadOnlyDictionary<string, OracleFetchCacheEntry> EmptyFetchCache =
|
||||
new Dictionary<string, OracleFetchCacheEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static OracleCursor Empty { get; } = new(null, EmptyGuidCollection, EmptyGuidCollection, EmptyFetchCache);
|
||||
|
||||
public BsonDocument ToBsonDocument()
|
||||
{
|
||||
var document = new BsonDocument
|
||||
{
|
||||
["pendingDocuments"] = new BsonArray(PendingDocuments.Select(id => id.ToString())),
|
||||
["pendingMappings"] = new BsonArray(PendingMappings.Select(id => id.ToString())),
|
||||
};
|
||||
|
||||
if (LastProcessed.HasValue)
|
||||
{
|
||||
document["lastProcessed"] = LastProcessed.Value.UtcDateTime;
|
||||
}
|
||||
|
||||
if (FetchCache.Count > 0)
|
||||
{
|
||||
var cacheDocument = new BsonDocument();
|
||||
foreach (var (key, entry) in FetchCache)
|
||||
{
|
||||
cacheDocument[key] = entry.ToBsonDocument();
|
||||
}
|
||||
|
||||
document["fetchCache"] = cacheDocument;
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
public static OracleCursor FromBson(BsonDocument? document)
|
||||
{
|
||||
if (document is null || document.ElementCount == 0)
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
|
||||
var lastProcessed = document.TryGetValue("lastProcessed", out var value)
|
||||
? ParseDate(value)
|
||||
: null;
|
||||
|
||||
return new OracleCursor(
|
||||
lastProcessed,
|
||||
ReadGuidArray(document, "pendingDocuments"),
|
||||
ReadGuidArray(document, "pendingMappings"),
|
||||
ReadFetchCache(document));
|
||||
}
|
||||
|
||||
public OracleCursor WithLastProcessed(DateTimeOffset? timestamp)
|
||||
=> this with { LastProcessed = timestamp };
|
||||
|
||||
public OracleCursor WithPendingDocuments(IEnumerable<Guid> ids)
|
||||
=> this with { PendingDocuments = ids?.Distinct().ToArray() ?? EmptyGuidCollection };
|
||||
|
||||
public OracleCursor WithPendingMappings(IEnumerable<Guid> ids)
|
||||
=> this with { PendingMappings = ids?.Distinct().ToArray() ?? EmptyGuidCollection };
|
||||
|
||||
public OracleCursor WithFetchCache(IDictionary<string, OracleFetchCacheEntry> cache)
|
||||
{
|
||||
if (cache is null || cache.Count == 0)
|
||||
{
|
||||
return this with { FetchCache = EmptyFetchCache };
|
||||
}
|
||||
|
||||
return this with { FetchCache = new Dictionary<string, OracleFetchCacheEntry>(cache, StringComparer.OrdinalIgnoreCase) };
|
||||
}
|
||||
|
||||
public bool TryGetFetchCache(string key, out OracleFetchCacheEntry entry)
|
||||
{
|
||||
if (FetchCache.Count == 0)
|
||||
{
|
||||
entry = OracleFetchCacheEntry.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
return FetchCache.TryGetValue(key, out entry!);
|
||||
}
|
||||
|
||||
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 raw) || raw is not BsonArray array)
|
||||
{
|
||||
return Array.Empty<Guid>();
|
||||
}
|
||||
|
||||
var result = new List<Guid>(array.Count);
|
||||
foreach (var element in array)
|
||||
{
|
||||
if (element is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Guid.TryParse(element.ToString(), out var guid))
|
||||
{
|
||||
result.Add(guid);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, OracleFetchCacheEntry> ReadFetchCache(BsonDocument document)
|
||||
{
|
||||
if (!document.TryGetValue("fetchCache", out var raw) || raw is not BsonDocument cacheDocument || cacheDocument.ElementCount == 0)
|
||||
{
|
||||
return EmptyFetchCache;
|
||||
}
|
||||
|
||||
var cache = new Dictionary<string, OracleFetchCacheEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var element in cacheDocument.Elements)
|
||||
{
|
||||
if (element.Value is not BsonDocument entryDocument)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
cache[element.Name] = OracleFetchCacheEntry.FromBson(entryDocument);
|
||||
}
|
||||
|
||||
return cache;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record OracleFetchCacheEntry(string? Sha256, string? ETag, DateTimeOffset? LastModified)
|
||||
{
|
||||
public static OracleFetchCacheEntry Empty { get; } = new(string.Empty, null, null);
|
||||
|
||||
public BsonDocument ToBsonDocument()
|
||||
{
|
||||
var document = new BsonDocument
|
||||
{
|
||||
["sha256"] = Sha256 ?? string.Empty,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ETag))
|
||||
{
|
||||
document["etag"] = ETag;
|
||||
}
|
||||
|
||||
if (LastModified.HasValue)
|
||||
{
|
||||
document["lastModified"] = LastModified.Value.UtcDateTime;
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
public static OracleFetchCacheEntry FromBson(BsonDocument document)
|
||||
{
|
||||
var sha = document.TryGetValue("sha256", out var shaValue) ? shaValue.ToString() : string.Empty;
|
||||
string? etag = null;
|
||||
if (document.TryGetValue("etag", out var etagValue) && !etagValue.IsBsonNull)
|
||||
{
|
||||
etag = etagValue.ToString();
|
||||
}
|
||||
|
||||
DateTimeOffset? lastModified = null;
|
||||
if (document.TryGetValue("lastModified", out var lastModifiedValue))
|
||||
{
|
||||
lastModified = lastModifiedValue.BsonType switch
|
||||
{
|
||||
BsonType.DateTime => DateTime.SpecifyKind(lastModifiedValue.ToUniversalTime(), DateTimeKind.Utc),
|
||||
BsonType.String when DateTimeOffset.TryParse(lastModifiedValue.AsString, out var parsed) => parsed.ToUniversalTime(),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
return new OracleFetchCacheEntry(sha, etag, lastModified);
|
||||
}
|
||||
|
||||
public static OracleFetchCacheEntry FromDocument(DocumentRecord document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
return new OracleFetchCacheEntry(
|
||||
document.Sha256 ?? string.Empty,
|
||||
document.Etag,
|
||||
document.LastModified?.ToUniversalTime());
|
||||
}
|
||||
|
||||
public bool Matches(DocumentRecord document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
if (!string.IsNullOrEmpty(Sha256) && !string.IsNullOrEmpty(document.Sha256))
|
||||
{
|
||||
return string.Equals(Sha256, document.Sha256, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(ETag) && !string.IsNullOrEmpty(document.Etag))
|
||||
{
|
||||
return string.Equals(ETag, document.Etag, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
if (LastModified.HasValue && document.LastModified.HasValue)
|
||||
{
|
||||
return LastModified.Value.ToUniversalTime() == document.LastModified.Value.ToUniversalTime();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StellaOps.Concelier.Documents;
|
||||
using StellaOps.Concelier.Storage;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Oracle.Internal;
|
||||
|
||||
internal sealed record OracleCursor(
|
||||
DateTimeOffset? LastProcessed,
|
||||
IReadOnlyCollection<Guid> PendingDocuments,
|
||||
IReadOnlyCollection<Guid> PendingMappings,
|
||||
IReadOnlyDictionary<string, OracleFetchCacheEntry> FetchCache)
|
||||
{
|
||||
private static readonly IReadOnlyCollection<Guid> EmptyGuidCollection = Array.Empty<Guid>();
|
||||
private static readonly IReadOnlyDictionary<string, OracleFetchCacheEntry> EmptyFetchCache =
|
||||
new Dictionary<string, OracleFetchCacheEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static OracleCursor Empty { get; } = new(null, EmptyGuidCollection, EmptyGuidCollection, EmptyFetchCache);
|
||||
|
||||
public DocumentObject ToDocumentObject()
|
||||
{
|
||||
var document = new DocumentObject
|
||||
{
|
||||
["pendingDocuments"] = new DocumentArray(PendingDocuments.Select(id => id.ToString())),
|
||||
["pendingMappings"] = new DocumentArray(PendingMappings.Select(id => id.ToString())),
|
||||
};
|
||||
|
||||
if (LastProcessed.HasValue)
|
||||
{
|
||||
document["lastProcessed"] = LastProcessed.Value.UtcDateTime;
|
||||
}
|
||||
|
||||
if (FetchCache.Count > 0)
|
||||
{
|
||||
var cacheDocument = new DocumentObject();
|
||||
foreach (var (key, entry) in FetchCache)
|
||||
{
|
||||
cacheDocument[key] = entry.ToDocumentObject();
|
||||
}
|
||||
|
||||
document["fetchCache"] = cacheDocument;
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
public static OracleCursor FromBson(DocumentObject? document)
|
||||
{
|
||||
if (document is null || document.ElementCount == 0)
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
|
||||
var lastProcessed = document.TryGetValue("lastProcessed", out var value)
|
||||
? ParseDate(value)
|
||||
: null;
|
||||
|
||||
return new OracleCursor(
|
||||
lastProcessed,
|
||||
ReadGuidArray(document, "pendingDocuments"),
|
||||
ReadGuidArray(document, "pendingMappings"),
|
||||
ReadFetchCache(document));
|
||||
}
|
||||
|
||||
public OracleCursor WithLastProcessed(DateTimeOffset? timestamp)
|
||||
=> this with { LastProcessed = timestamp };
|
||||
|
||||
public OracleCursor WithPendingDocuments(IEnumerable<Guid> ids)
|
||||
=> this with { PendingDocuments = ids?.Distinct().ToArray() ?? EmptyGuidCollection };
|
||||
|
||||
public OracleCursor WithPendingMappings(IEnumerable<Guid> ids)
|
||||
=> this with { PendingMappings = ids?.Distinct().ToArray() ?? EmptyGuidCollection };
|
||||
|
||||
public OracleCursor WithFetchCache(IDictionary<string, OracleFetchCacheEntry> cache)
|
||||
{
|
||||
if (cache is null || cache.Count == 0)
|
||||
{
|
||||
return this with { FetchCache = EmptyFetchCache };
|
||||
}
|
||||
|
||||
return this with { FetchCache = new Dictionary<string, OracleFetchCacheEntry>(cache, StringComparer.OrdinalIgnoreCase) };
|
||||
}
|
||||
|
||||
public bool TryGetFetchCache(string key, out OracleFetchCacheEntry entry)
|
||||
{
|
||||
if (FetchCache.Count == 0)
|
||||
{
|
||||
entry = OracleFetchCacheEntry.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
return FetchCache.TryGetValue(key, out entry!);
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ParseDate(DocumentValue value)
|
||||
=> value.DocumentType switch
|
||||
{
|
||||
DocumentType.DateTime => DateTime.SpecifyKind(value.ToUniversalTime(), DateTimeKind.Utc),
|
||||
DocumentType.String when DateTimeOffset.TryParse(value.AsString, out var parsed) => parsed.ToUniversalTime(),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static IReadOnlyCollection<Guid> ReadGuidArray(DocumentObject document, string field)
|
||||
{
|
||||
if (!document.TryGetValue(field, out var raw) || raw is not DocumentArray array)
|
||||
{
|
||||
return Array.Empty<Guid>();
|
||||
}
|
||||
|
||||
var result = new List<Guid>(array.Count);
|
||||
foreach (var element in array)
|
||||
{
|
||||
if (element is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Guid.TryParse(element.ToString(), out var guid))
|
||||
{
|
||||
result.Add(guid);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, OracleFetchCacheEntry> ReadFetchCache(DocumentObject document)
|
||||
{
|
||||
if (!document.TryGetValue("fetchCache", out var raw) || raw is not DocumentObject cacheDocument || cacheDocument.ElementCount == 0)
|
||||
{
|
||||
return EmptyFetchCache;
|
||||
}
|
||||
|
||||
var cache = new Dictionary<string, OracleFetchCacheEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var element in cacheDocument.Elements)
|
||||
{
|
||||
if (element.Value is not DocumentObject entryDocument)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
cache[element.Name] = OracleFetchCacheEntry.FromBson(entryDocument);
|
||||
}
|
||||
|
||||
return cache;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record OracleFetchCacheEntry(string? Sha256, string? ETag, DateTimeOffset? LastModified)
|
||||
{
|
||||
public static OracleFetchCacheEntry Empty { get; } = new(string.Empty, null, null);
|
||||
|
||||
public DocumentObject ToDocumentObject()
|
||||
{
|
||||
var document = new DocumentObject
|
||||
{
|
||||
["sha256"] = Sha256 ?? string.Empty,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ETag))
|
||||
{
|
||||
document["etag"] = ETag;
|
||||
}
|
||||
|
||||
if (LastModified.HasValue)
|
||||
{
|
||||
document["lastModified"] = LastModified.Value.UtcDateTime;
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
public static OracleFetchCacheEntry FromBson(DocumentObject document)
|
||||
{
|
||||
var sha = document.TryGetValue("sha256", out var shaValue) ? shaValue.ToString() : string.Empty;
|
||||
string? etag = null;
|
||||
if (document.TryGetValue("etag", out var etagValue) && !etagValue.IsDocumentNull)
|
||||
{
|
||||
etag = etagValue.ToString();
|
||||
}
|
||||
|
||||
DateTimeOffset? lastModified = null;
|
||||
if (document.TryGetValue("lastModified", out var lastModifiedValue))
|
||||
{
|
||||
lastModified = lastModifiedValue.DocumentType switch
|
||||
{
|
||||
DocumentType.DateTime => DateTime.SpecifyKind(lastModifiedValue.ToUniversalTime(), DateTimeKind.Utc),
|
||||
DocumentType.String when DateTimeOffset.TryParse(lastModifiedValue.AsString, out var parsed) => parsed.ToUniversalTime(),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
return new OracleFetchCacheEntry(sha, etag, lastModified);
|
||||
}
|
||||
|
||||
public static OracleFetchCacheEntry FromDocument(DocumentRecord document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
return new OracleFetchCacheEntry(
|
||||
document.Sha256 ?? string.Empty,
|
||||
document.Etag,
|
||||
document.LastModified?.ToUniversalTime());
|
||||
}
|
||||
|
||||
public bool Matches(DocumentRecord document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
if (!string.IsNullOrEmpty(Sha256) && !string.IsNullOrEmpty(document.Sha256))
|
||||
{
|
||||
return string.Equals(Sha256, document.Sha256, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(ETag) && !string.IsNullOrEmpty(document.Etag))
|
||||
{
|
||||
return string.Equals(ETag, document.Etag, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
if (LastModified.HasValue && document.LastModified.HasValue)
|
||||
{
|
||||
return LastModified.Value.ToUniversalTime() == document.LastModified.Value.ToUniversalTime();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +1,56 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Concelier.Storage;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Oracle.Internal;
|
||||
|
||||
internal sealed record OracleDocumentMetadata(
|
||||
string AdvisoryId,
|
||||
string Title,
|
||||
DateTimeOffset Published,
|
||||
Uri DetailUri)
|
||||
{
|
||||
private const string AdvisoryIdKey = "oracle.advisoryId";
|
||||
private const string TitleKey = "oracle.title";
|
||||
private const string PublishedKey = "oracle.published";
|
||||
|
||||
public static IReadOnlyDictionary<string, string> CreateMetadata(string advisoryId, string title, DateTimeOffset published)
|
||||
=> new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
[AdvisoryIdKey] = advisoryId,
|
||||
[TitleKey] = title,
|
||||
[PublishedKey] = published.ToString("O"),
|
||||
};
|
||||
|
||||
public static OracleDocumentMetadata FromDocument(DocumentRecord document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
if (document.Metadata is null)
|
||||
{
|
||||
throw new InvalidOperationException("Oracle document metadata missing.");
|
||||
}
|
||||
|
||||
var metadata = document.Metadata;
|
||||
if (!metadata.TryGetValue(AdvisoryIdKey, out var advisoryId) || string.IsNullOrWhiteSpace(advisoryId))
|
||||
{
|
||||
throw new InvalidOperationException("Oracle advisory id metadata missing.");
|
||||
}
|
||||
|
||||
if (!metadata.TryGetValue(TitleKey, out var title) || string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
throw new InvalidOperationException("Oracle title metadata missing.");
|
||||
}
|
||||
|
||||
if (!metadata.TryGetValue(PublishedKey, out var publishedRaw) || !DateTimeOffset.TryParse(publishedRaw, out var published))
|
||||
{
|
||||
throw new InvalidOperationException("Oracle published metadata invalid.");
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(document.Uri, UriKind.Absolute, out var detailUri))
|
||||
{
|
||||
throw new InvalidOperationException("Oracle document URI invalid.");
|
||||
}
|
||||
|
||||
return new OracleDocumentMetadata(advisoryId.Trim(), title.Trim(), published.ToUniversalTime(), detailUri);
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Concelier.Storage;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Oracle.Internal;
|
||||
|
||||
internal sealed record OracleDocumentMetadata(
|
||||
string AdvisoryId,
|
||||
string Title,
|
||||
DateTimeOffset Published,
|
||||
Uri DetailUri)
|
||||
{
|
||||
private const string AdvisoryIdKey = "oracle.advisoryId";
|
||||
private const string TitleKey = "oracle.title";
|
||||
private const string PublishedKey = "oracle.published";
|
||||
|
||||
public static IReadOnlyDictionary<string, string> CreateMetadata(string advisoryId, string title, DateTimeOffset published)
|
||||
=> new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
[AdvisoryIdKey] = advisoryId,
|
||||
[TitleKey] = title,
|
||||
[PublishedKey] = published.ToString("O"),
|
||||
};
|
||||
|
||||
public static OracleDocumentMetadata FromDocument(DocumentRecord document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
if (document.Metadata is null)
|
||||
{
|
||||
throw new InvalidOperationException("Oracle document metadata missing.");
|
||||
}
|
||||
|
||||
var metadata = document.Metadata;
|
||||
if (!metadata.TryGetValue(AdvisoryIdKey, out var advisoryId) || string.IsNullOrWhiteSpace(advisoryId))
|
||||
{
|
||||
throw new InvalidOperationException("Oracle advisory id metadata missing.");
|
||||
}
|
||||
|
||||
if (!metadata.TryGetValue(TitleKey, out var title) || string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
throw new InvalidOperationException("Oracle title metadata missing.");
|
||||
}
|
||||
|
||||
if (!metadata.TryGetValue(PublishedKey, out var publishedRaw) || !DateTimeOffset.TryParse(publishedRaw, out var published))
|
||||
{
|
||||
throw new InvalidOperationException("Oracle published metadata invalid.");
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(document.Uri, UriKind.Absolute, out var detailUri))
|
||||
{
|
||||
throw new InvalidOperationException("Oracle document URI invalid.");
|
||||
}
|
||||
|
||||
return new OracleDocumentMetadata(advisoryId.Trim(), title.Trim(), published.ToUniversalTime(), detailUri);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Oracle.Internal;
|
||||
|
||||
internal sealed record OracleDto(
|
||||
[property: JsonPropertyName("advisoryId")] string AdvisoryId,
|
||||
[property: JsonPropertyName("title")] string Title,
|
||||
[property: JsonPropertyName("detailUrl")] string DetailUrl,
|
||||
[property: JsonPropertyName("published")] DateTimeOffset Published,
|
||||
[property: JsonPropertyName("content")] string Content,
|
||||
[property: JsonPropertyName("references")] IReadOnlyList<string> References,
|
||||
[property: JsonPropertyName("cveIds")] IReadOnlyList<string> CveIds,
|
||||
[property: JsonPropertyName("affected")] IReadOnlyList<OracleAffectedEntry> Affected,
|
||||
[property: JsonPropertyName("patchDocuments")] IReadOnlyList<OraclePatchDocument> PatchDocuments);
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Oracle.Internal;
|
||||
|
||||
internal sealed record OracleDto(
|
||||
[property: JsonPropertyName("advisoryId")] string AdvisoryId,
|
||||
[property: JsonPropertyName("title")] string Title,
|
||||
[property: JsonPropertyName("detailUrl")] string DetailUrl,
|
||||
[property: JsonPropertyName("published")] DateTimeOffset Published,
|
||||
[property: JsonPropertyName("content")] string Content,
|
||||
[property: JsonPropertyName("references")] IReadOnlyList<string> References,
|
||||
[property: JsonPropertyName("cveIds")] IReadOnlyList<string> CveIds,
|
||||
[property: JsonPropertyName("affected")] IReadOnlyList<OracleAffectedEntry> Affected,
|
||||
[property: JsonPropertyName("patchDocuments")] IReadOnlyList<OraclePatchDocument> PatchDocuments);
|
||||
|
||||
@@ -1,276 +1,276 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Oracle.Internal;
|
||||
|
||||
internal static class OracleDtoValidator
|
||||
{
|
||||
private const int MaxAdvisoryIdLength = 128;
|
||||
private const int MaxTitleLength = 512;
|
||||
private const int MaxContentLength = 200_000;
|
||||
private const int MaxReferenceCount = 100;
|
||||
private const int MaxCveCount = 1_024;
|
||||
private const int MaxAffectedCount = 2_048;
|
||||
private const int MaxPatchDocumentCount = 512;
|
||||
private const int MaxProductLength = 512;
|
||||
private const int MaxComponentLength = 512;
|
||||
private const int MaxSupportedVersionsLength = 4_096;
|
||||
private const int MaxNotesLength = 1_024;
|
||||
private const int MaxPatchTitleLength = 512;
|
||||
private const int MaxPatchUrlLength = 1_024;
|
||||
private static readonly Regex CveRegex = new("CVE-\\d{4}-\\d{3,7}", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
public static bool TryNormalize(OracleDto dto, out OracleDto normalized, out string? failureReason)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dto);
|
||||
|
||||
failureReason = null;
|
||||
normalized = dto;
|
||||
|
||||
var advisoryId = dto.AdvisoryId?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(advisoryId))
|
||||
{
|
||||
failureReason = "AdvisoryId is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (advisoryId.Length > MaxAdvisoryIdLength)
|
||||
{
|
||||
failureReason = $"AdvisoryId exceeds {MaxAdvisoryIdLength} characters.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var title = string.IsNullOrWhiteSpace(dto.Title) ? advisoryId : dto.Title.Trim();
|
||||
if (title.Length > MaxTitleLength)
|
||||
{
|
||||
title = title.Substring(0, MaxTitleLength);
|
||||
}
|
||||
|
||||
var detailUrlRaw = dto.DetailUrl?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(detailUrlRaw) || !Uri.TryCreate(detailUrlRaw, UriKind.Absolute, out var detailUri))
|
||||
{
|
||||
failureReason = "DetailUrl must be an absolute URI.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(detailUri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(detailUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
failureReason = "DetailUrl must use HTTP or HTTPS.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (dto.Published == default)
|
||||
{
|
||||
failureReason = "Published timestamp is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var published = dto.Published.ToUniversalTime();
|
||||
var content = dto.Content?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
failureReason = "Advisory content is empty.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (content.Length > MaxContentLength)
|
||||
{
|
||||
content = content.Substring(0, MaxContentLength);
|
||||
}
|
||||
|
||||
var references = NormalizeReferences(dto.References);
|
||||
var cveIds = NormalizeCveIds(dto.CveIds);
|
||||
var affected = NormalizeAffected(dto.Affected);
|
||||
var patchDocuments = NormalizePatchDocuments(dto.PatchDocuments);
|
||||
|
||||
normalized = dto with
|
||||
{
|
||||
AdvisoryId = advisoryId,
|
||||
Title = title,
|
||||
DetailUrl = detailUri.ToString(),
|
||||
Published = published,
|
||||
Content = content,
|
||||
References = references,
|
||||
CveIds = cveIds,
|
||||
Affected = affected,
|
||||
PatchDocuments = patchDocuments,
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> NormalizeReferences(IReadOnlyList<string>? references)
|
||||
{
|
||||
if (references is null || references.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var normalized = new List<string>(Math.Min(references.Count, MaxReferenceCount));
|
||||
foreach (var reference in references.Where(static reference => !string.IsNullOrWhiteSpace(reference)))
|
||||
{
|
||||
var trimmed = reference.Trim();
|
||||
if (Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)
|
||||
&& (string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
normalized.Add(uri.ToString());
|
||||
}
|
||||
|
||||
if (normalized.Count >= MaxReferenceCount)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return normalized
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static url => url, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> NormalizeCveIds(IReadOnlyList<string>? cveIds)
|
||||
{
|
||||
if (cveIds is null || cveIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var normalized = new List<string>(Math.Min(cveIds.Count, MaxCveCount));
|
||||
foreach (var cve in cveIds.Where(static value => !string.IsNullOrWhiteSpace(value)))
|
||||
{
|
||||
var candidate = cve.Trim().ToUpperInvariant();
|
||||
if (!CveRegex.IsMatch(candidate))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized.Add(candidate);
|
||||
if (normalized.Count >= MaxCveCount)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return normalized
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<OracleAffectedEntry> NormalizeAffected(IReadOnlyList<OracleAffectedEntry>? entries)
|
||||
{
|
||||
if (entries is null || entries.Count == 0)
|
||||
{
|
||||
return Array.Empty<OracleAffectedEntry>();
|
||||
}
|
||||
|
||||
var normalized = new List<OracleAffectedEntry>(Math.Min(entries.Count, MaxAffectedCount));
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
if (entry is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var product = TrimToLength(entry.Product, MaxProductLength);
|
||||
if (string.IsNullOrWhiteSpace(product))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var component = TrimToNull(entry.Component, MaxComponentLength);
|
||||
var versions = TrimToNull(entry.SupportedVersions, MaxSupportedVersionsLength);
|
||||
var notes = TrimToNull(entry.Notes, MaxNotesLength);
|
||||
var cves = NormalizeCveIds(entry.CveIds);
|
||||
|
||||
normalized.Add(new OracleAffectedEntry(product, component, versions, notes, cves));
|
||||
if (normalized.Count >= MaxAffectedCount)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized.Count == 0 ? Array.Empty<OracleAffectedEntry>() : normalized;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<OraclePatchDocument> NormalizePatchDocuments(IReadOnlyList<OraclePatchDocument>? documents)
|
||||
{
|
||||
if (documents is null || documents.Count == 0)
|
||||
{
|
||||
return Array.Empty<OraclePatchDocument>();
|
||||
}
|
||||
|
||||
var normalized = new List<OraclePatchDocument>(Math.Min(documents.Count, MaxPatchDocumentCount));
|
||||
foreach (var document in documents)
|
||||
{
|
||||
if (document is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var product = TrimToLength(document.Product, MaxProductLength);
|
||||
if (string.IsNullOrWhiteSpace(product))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var title = TrimToNull(document.Title, MaxPatchTitleLength);
|
||||
var urlRaw = TrimToLength(document.Url, MaxPatchUrlLength);
|
||||
if (string.IsNullOrWhiteSpace(urlRaw))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(urlRaw, UriKind.Absolute, out var uri)
|
||||
|| (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized.Add(new OraclePatchDocument(product, title, uri.ToString()));
|
||||
if (normalized.Count >= MaxPatchDocumentCount)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized.Count == 0 ? Array.Empty<OraclePatchDocument>() : normalized;
|
||||
}
|
||||
|
||||
private static string TrimToLength(string? value, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (trimmed.Length <= maxLength)
|
||||
{
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return trimmed[..maxLength];
|
||||
}
|
||||
|
||||
private static string? TrimToNull(string? value, int maxLength)
|
||||
{
|
||||
var trimmed = TrimToLength(value, maxLength);
|
||||
return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed;
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Oracle.Internal;
|
||||
|
||||
internal static class OracleDtoValidator
|
||||
{
|
||||
private const int MaxAdvisoryIdLength = 128;
|
||||
private const int MaxTitleLength = 512;
|
||||
private const int MaxContentLength = 200_000;
|
||||
private const int MaxReferenceCount = 100;
|
||||
private const int MaxCveCount = 1_024;
|
||||
private const int MaxAffectedCount = 2_048;
|
||||
private const int MaxPatchDocumentCount = 512;
|
||||
private const int MaxProductLength = 512;
|
||||
private const int MaxComponentLength = 512;
|
||||
private const int MaxSupportedVersionsLength = 4_096;
|
||||
private const int MaxNotesLength = 1_024;
|
||||
private const int MaxPatchTitleLength = 512;
|
||||
private const int MaxPatchUrlLength = 1_024;
|
||||
private static readonly Regex CveRegex = new("CVE-\\d{4}-\\d{3,7}", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
public static bool TryNormalize(OracleDto dto, out OracleDto normalized, out string? failureReason)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dto);
|
||||
|
||||
failureReason = null;
|
||||
normalized = dto;
|
||||
|
||||
var advisoryId = dto.AdvisoryId?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(advisoryId))
|
||||
{
|
||||
failureReason = "AdvisoryId is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (advisoryId.Length > MaxAdvisoryIdLength)
|
||||
{
|
||||
failureReason = $"AdvisoryId exceeds {MaxAdvisoryIdLength} characters.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var title = string.IsNullOrWhiteSpace(dto.Title) ? advisoryId : dto.Title.Trim();
|
||||
if (title.Length > MaxTitleLength)
|
||||
{
|
||||
title = title.Substring(0, MaxTitleLength);
|
||||
}
|
||||
|
||||
var detailUrlRaw = dto.DetailUrl?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(detailUrlRaw) || !Uri.TryCreate(detailUrlRaw, UriKind.Absolute, out var detailUri))
|
||||
{
|
||||
failureReason = "DetailUrl must be an absolute URI.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(detailUri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(detailUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
failureReason = "DetailUrl must use HTTP or HTTPS.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (dto.Published == default)
|
||||
{
|
||||
failureReason = "Published timestamp is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var published = dto.Published.ToUniversalTime();
|
||||
var content = dto.Content?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
failureReason = "Advisory content is empty.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (content.Length > MaxContentLength)
|
||||
{
|
||||
content = content.Substring(0, MaxContentLength);
|
||||
}
|
||||
|
||||
var references = NormalizeReferences(dto.References);
|
||||
var cveIds = NormalizeCveIds(dto.CveIds);
|
||||
var affected = NormalizeAffected(dto.Affected);
|
||||
var patchDocuments = NormalizePatchDocuments(dto.PatchDocuments);
|
||||
|
||||
normalized = dto with
|
||||
{
|
||||
AdvisoryId = advisoryId,
|
||||
Title = title,
|
||||
DetailUrl = detailUri.ToString(),
|
||||
Published = published,
|
||||
Content = content,
|
||||
References = references,
|
||||
CveIds = cveIds,
|
||||
Affected = affected,
|
||||
PatchDocuments = patchDocuments,
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> NormalizeReferences(IReadOnlyList<string>? references)
|
||||
{
|
||||
if (references is null || references.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var normalized = new List<string>(Math.Min(references.Count, MaxReferenceCount));
|
||||
foreach (var reference in references.Where(static reference => !string.IsNullOrWhiteSpace(reference)))
|
||||
{
|
||||
var trimmed = reference.Trim();
|
||||
if (Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)
|
||||
&& (string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
normalized.Add(uri.ToString());
|
||||
}
|
||||
|
||||
if (normalized.Count >= MaxReferenceCount)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return normalized
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static url => url, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> NormalizeCveIds(IReadOnlyList<string>? cveIds)
|
||||
{
|
||||
if (cveIds is null || cveIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var normalized = new List<string>(Math.Min(cveIds.Count, MaxCveCount));
|
||||
foreach (var cve in cveIds.Where(static value => !string.IsNullOrWhiteSpace(value)))
|
||||
{
|
||||
var candidate = cve.Trim().ToUpperInvariant();
|
||||
if (!CveRegex.IsMatch(candidate))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized.Add(candidate);
|
||||
if (normalized.Count >= MaxCveCount)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return normalized
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<OracleAffectedEntry> NormalizeAffected(IReadOnlyList<OracleAffectedEntry>? entries)
|
||||
{
|
||||
if (entries is null || entries.Count == 0)
|
||||
{
|
||||
return Array.Empty<OracleAffectedEntry>();
|
||||
}
|
||||
|
||||
var normalized = new List<OracleAffectedEntry>(Math.Min(entries.Count, MaxAffectedCount));
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
if (entry is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var product = TrimToLength(entry.Product, MaxProductLength);
|
||||
if (string.IsNullOrWhiteSpace(product))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var component = TrimToNull(entry.Component, MaxComponentLength);
|
||||
var versions = TrimToNull(entry.SupportedVersions, MaxSupportedVersionsLength);
|
||||
var notes = TrimToNull(entry.Notes, MaxNotesLength);
|
||||
var cves = NormalizeCveIds(entry.CveIds);
|
||||
|
||||
normalized.Add(new OracleAffectedEntry(product, component, versions, notes, cves));
|
||||
if (normalized.Count >= MaxAffectedCount)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized.Count == 0 ? Array.Empty<OracleAffectedEntry>() : normalized;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<OraclePatchDocument> NormalizePatchDocuments(IReadOnlyList<OraclePatchDocument>? documents)
|
||||
{
|
||||
if (documents is null || documents.Count == 0)
|
||||
{
|
||||
return Array.Empty<OraclePatchDocument>();
|
||||
}
|
||||
|
||||
var normalized = new List<OraclePatchDocument>(Math.Min(documents.Count, MaxPatchDocumentCount));
|
||||
foreach (var document in documents)
|
||||
{
|
||||
if (document is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var product = TrimToLength(document.Product, MaxProductLength);
|
||||
if (string.IsNullOrWhiteSpace(product))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var title = TrimToNull(document.Title, MaxPatchTitleLength);
|
||||
var urlRaw = TrimToLength(document.Url, MaxPatchUrlLength);
|
||||
if (string.IsNullOrWhiteSpace(urlRaw))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(urlRaw, UriKind.Absolute, out var uri)
|
||||
|| (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized.Add(new OraclePatchDocument(product, title, uri.ToString()));
|
||||
if (normalized.Count >= MaxPatchDocumentCount)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized.Count == 0 ? Array.Empty<OraclePatchDocument>() : normalized;
|
||||
}
|
||||
|
||||
private static string TrimToLength(string? value, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (trimmed.Length <= maxLength)
|
||||
{
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return trimmed[..maxLength];
|
||||
}
|
||||
|
||||
private static string? TrimToNull(string? value, int maxLength)
|
||||
{
|
||||
var trimmed = TrimToLength(value, maxLength);
|
||||
return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,426 +1,426 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Connector.Common.Packages;
|
||||
using StellaOps.Concelier.Storage;
|
||||
using StellaOps.Concelier.Storage;
|
||||
using StellaOps.Concelier.Storage.PsirtFlags;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Oracle.Internal;
|
||||
|
||||
internal static class OracleMapper
|
||||
{
|
||||
private static readonly Regex FixedVersionRegex = new("(?:Fixed|Fix)\\s+(?:in|available in|for)\\s+(?<value>[A-Za-z0-9._-]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex PatchNumberRegex = new("Patch\\s+(?<value>\\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
public static (Advisory Advisory, PsirtFlagRecord Flag) Map(
|
||||
OracleDto dto,
|
||||
DocumentRecord document,
|
||||
DtoRecord dtoRecord,
|
||||
string sourceName,
|
||||
DateTimeOffset mappedAt)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dto);
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
ArgumentNullException.ThrowIfNull(dtoRecord);
|
||||
ArgumentException.ThrowIfNullOrEmpty(sourceName);
|
||||
|
||||
var advisoryKey = $"oracle/{dto.AdvisoryId}";
|
||||
var fetchProvenance = new AdvisoryProvenance(sourceName, "document", document.Uri, document.FetchedAt.ToUniversalTime());
|
||||
var mappingProvenance = new AdvisoryProvenance(sourceName, "mapping", dto.AdvisoryId, mappedAt.ToUniversalTime());
|
||||
|
||||
var aliases = BuildAliases(dto);
|
||||
var references = BuildReferences(dto, sourceName, mappedAt);
|
||||
var affectedPackages = BuildAffectedPackages(dto, sourceName, mappedAt);
|
||||
|
||||
var advisory = new Advisory(
|
||||
advisoryKey,
|
||||
dto.Title,
|
||||
dto.Content,
|
||||
language: "en",
|
||||
published: dto.Published.ToUniversalTime(),
|
||||
modified: null,
|
||||
severity: null,
|
||||
exploitKnown: false,
|
||||
aliases,
|
||||
references,
|
||||
affectedPackages,
|
||||
Array.Empty<CvssMetric>(),
|
||||
new[] { fetchProvenance, mappingProvenance });
|
||||
|
||||
var flag = new PsirtFlagRecord(
|
||||
advisoryKey,
|
||||
"Oracle",
|
||||
sourceName,
|
||||
dto.AdvisoryId,
|
||||
mappedAt.ToUniversalTime());
|
||||
|
||||
return (advisory, flag);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> BuildAliases(OracleDto dto)
|
||||
{
|
||||
var aliases = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
$"ORACLE:{dto.AdvisoryId}".ToUpperInvariant(),
|
||||
};
|
||||
|
||||
foreach (var cve in dto.CveIds)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(cve))
|
||||
{
|
||||
aliases.Add(cve.Trim().ToUpperInvariant());
|
||||
}
|
||||
}
|
||||
|
||||
return aliases
|
||||
.OrderBy(static alias => alias, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AdvisoryReference> BuildReferences(OracleDto dto, string sourceName, DateTimeOffset recordedAt)
|
||||
{
|
||||
var comparer = StringComparer.OrdinalIgnoreCase;
|
||||
var entries = new List<(AdvisoryReference Reference, int Priority)>
|
||||
{
|
||||
(new AdvisoryReference(
|
||||
dto.DetailUrl,
|
||||
"advisory",
|
||||
"oracle",
|
||||
dto.Title,
|
||||
new AdvisoryProvenance(sourceName, "reference", dto.DetailUrl, recordedAt.ToUniversalTime())), 0),
|
||||
};
|
||||
|
||||
foreach (var document in dto.PatchDocuments)
|
||||
{
|
||||
var summary = document.Title ?? document.Product;
|
||||
entries.Add((new AdvisoryReference(
|
||||
document.Url,
|
||||
"patch",
|
||||
"oracle",
|
||||
summary,
|
||||
new AdvisoryProvenance(sourceName, "reference", document.Url, recordedAt.ToUniversalTime())), 1));
|
||||
}
|
||||
|
||||
foreach (var url in dto.References)
|
||||
{
|
||||
entries.Add((new AdvisoryReference(
|
||||
url,
|
||||
"reference",
|
||||
null,
|
||||
null,
|
||||
new AdvisoryProvenance(sourceName, "reference", url, recordedAt.ToUniversalTime())), 2));
|
||||
}
|
||||
|
||||
foreach (var cve in dto.CveIds)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cve))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var cveUrl = $"https://www.cve.org/CVERecord?id={cve}";
|
||||
entries.Add((new AdvisoryReference(
|
||||
cveUrl,
|
||||
"advisory",
|
||||
cve,
|
||||
null,
|
||||
new AdvisoryProvenance(sourceName, "reference", cveUrl, recordedAt.ToUniversalTime())), 3));
|
||||
}
|
||||
|
||||
return entries
|
||||
.GroupBy(tuple => tuple.Reference.Url, comparer)
|
||||
.Select(group => group
|
||||
.OrderBy(t => t.Priority)
|
||||
.ThenBy(t => t.Reference.Kind ?? string.Empty, comparer)
|
||||
.ThenBy(t => t.Reference.Url, comparer)
|
||||
.First())
|
||||
.OrderBy(t => t.Priority)
|
||||
.ThenBy(t => t.Reference.Kind ?? string.Empty, comparer)
|
||||
.ThenBy(t => t.Reference.Url, comparer)
|
||||
.Select(t => t.Reference)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AffectedPackage> BuildAffectedPackages(OracleDto dto, string sourceName, DateTimeOffset recordedAt)
|
||||
{
|
||||
if (dto.Affected.Count == 0)
|
||||
{
|
||||
return Array.Empty<AffectedPackage>();
|
||||
}
|
||||
|
||||
var packages = new List<AffectedPackage>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var entry in dto.Affected)
|
||||
{
|
||||
if (entry is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var component = NormalizeComponent(entry.Component);
|
||||
var notes = entry.Notes;
|
||||
|
||||
foreach (var segment in SplitSupportedVersions(entry.Product, entry.SupportedVersions))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(segment.Product))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var identifier = CreateIdentifier(segment.Product, component);
|
||||
var baseExpression = segment.Versions ?? entry.SupportedVersions ?? string.Empty;
|
||||
var composedExpression = baseExpression;
|
||||
|
||||
if (!string.IsNullOrEmpty(notes))
|
||||
{
|
||||
composedExpression = string.IsNullOrEmpty(composedExpression)
|
||||
? $"notes: {notes}"
|
||||
: $"{composedExpression} (notes: {notes})";
|
||||
}
|
||||
|
||||
var rangeExpression = string.IsNullOrWhiteSpace(composedExpression) ? null : composedExpression;
|
||||
var (fixedVersion, patchNumber) = ExtractFixMetadata(notes);
|
||||
var rangeProvenance = new AdvisoryProvenance(sourceName, "range", identifier, recordedAt.ToUniversalTime());
|
||||
var rangePrimitives = BuildVendorRangePrimitives(entry, segment, component, baseExpression, rangeExpression, notes, fixedVersion, patchNumber);
|
||||
|
||||
var ranges = rangeExpression is null && string.IsNullOrEmpty(fixedVersion)
|
||||
? Array.Empty<AffectedVersionRange>()
|
||||
: new[]
|
||||
{
|
||||
new AffectedVersionRange(
|
||||
rangeKind: "vendor",
|
||||
introducedVersion: null,
|
||||
fixedVersion: fixedVersion,
|
||||
lastAffectedVersion: null,
|
||||
rangeExpression: rangeExpression,
|
||||
provenance: rangeProvenance,
|
||||
primitives: rangePrimitives),
|
||||
};
|
||||
|
||||
var provenance = new[]
|
||||
{
|
||||
new AdvisoryProvenance(sourceName, "affected", identifier, recordedAt.ToUniversalTime()),
|
||||
};
|
||||
|
||||
var package = new AffectedPackage(
|
||||
AffectedPackageTypes.Vendor,
|
||||
identifier,
|
||||
component,
|
||||
ranges,
|
||||
statuses: Array.Empty<AffectedPackageStatus>(),
|
||||
provenance: provenance);
|
||||
|
||||
var key = $"{identifier}::{component}::{ranges.FirstOrDefault()?.CreateDeterministicKey()}";
|
||||
if (seen.Add(key))
|
||||
{
|
||||
packages.Add(package);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return packages.Count == 0 ? Array.Empty<AffectedPackage>() : packages;
|
||||
}
|
||||
|
||||
private static IEnumerable<(string Product, string? Versions)> SplitSupportedVersions(string product, string? supportedVersions)
|
||||
{
|
||||
var normalizedProduct = string.IsNullOrWhiteSpace(product) ? "Oracle Product" : product.Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(supportedVersions))
|
||||
{
|
||||
yield return (normalizedProduct, null);
|
||||
yield break;
|
||||
}
|
||||
|
||||
var segments = supportedVersions.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (segments.Length <= 1)
|
||||
{
|
||||
yield return (normalizedProduct, supportedVersions.Trim());
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
var text = segment.Trim();
|
||||
if (text.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var colonIndex = text.IndexOf(':');
|
||||
if (colonIndex > 0)
|
||||
{
|
||||
var name = text[..colonIndex].Trim();
|
||||
var versions = text[(colonIndex + 1)..].Trim();
|
||||
yield return (string.IsNullOrEmpty(name) ? normalizedProduct : name, versions);
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return (normalizedProduct, text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static RangePrimitives? BuildVendorRangePrimitives(
|
||||
OracleAffectedEntry entry,
|
||||
(string Product, string? Versions) segment,
|
||||
string? component,
|
||||
string? baseExpression,
|
||||
string? rangeExpression,
|
||||
string? notes,
|
||||
string? fixedVersion,
|
||||
string? patchNumber)
|
||||
{
|
||||
var extensions = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
AddExtension(extensions, "oracle.product", segment.Product);
|
||||
AddExtension(extensions, "oracle.productRaw", entry.Product);
|
||||
AddExtension(extensions, "oracle.component", component);
|
||||
AddExtension(extensions, "oracle.componentRaw", entry.Component);
|
||||
AddExtension(extensions, "oracle.segmentVersions", segment.Versions);
|
||||
AddExtension(extensions, "oracle.supportedVersions", entry.SupportedVersions);
|
||||
AddExtension(extensions, "oracle.rangeExpression", rangeExpression);
|
||||
AddExtension(extensions, "oracle.baseExpression", baseExpression);
|
||||
AddExtension(extensions, "oracle.notes", notes);
|
||||
AddExtension(extensions, "oracle.fixedVersion", fixedVersion);
|
||||
AddExtension(extensions, "oracle.patchNumber", patchNumber);
|
||||
|
||||
var versionTokens = ExtractVersionTokens(baseExpression);
|
||||
if (versionTokens.Count > 0)
|
||||
{
|
||||
extensions["oracle.versionTokens"] = string.Join('|', versionTokens);
|
||||
|
||||
var normalizedTokens = versionTokens
|
||||
.Select(NormalizeSemVerToken)
|
||||
.Where(static token => !string.IsNullOrEmpty(token))
|
||||
.Cast<string>()
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
if (normalizedTokens.Length > 0)
|
||||
{
|
||||
extensions["oracle.versionTokens.normalized"] = string.Join('|', normalizedTokens);
|
||||
}
|
||||
}
|
||||
|
||||
if (extensions.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new RangePrimitives(null, null, null, extensions);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractVersionTokens(string? baseExpression)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseExpression))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var tokens = new List<string>();
|
||||
foreach (var token in baseExpression.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var value = token.Trim();
|
||||
if (value.Length == 0 || !value.Any(char.IsDigit))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
tokens.Add(value);
|
||||
}
|
||||
|
||||
return tokens.Count == 0 ? Array.Empty<string>() : tokens;
|
||||
}
|
||||
|
||||
private static string? NormalizeSemVerToken(string token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (PackageCoordinateHelper.TryParseSemVer(token, out _, out var normalized) && !string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (Version.TryParse(token, out var parsed))
|
||||
{
|
||||
if (parsed.Build >= 0 && parsed.Revision >= 0)
|
||||
{
|
||||
return $"{parsed.Major}.{parsed.Minor}.{parsed.Build}.{parsed.Revision}";
|
||||
}
|
||||
|
||||
if (parsed.Build >= 0)
|
||||
{
|
||||
return $"{parsed.Major}.{parsed.Minor}.{parsed.Build}";
|
||||
}
|
||||
|
||||
return $"{parsed.Major}.{parsed.Minor}";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void AddExtension(Dictionary<string, string> extensions, string key, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
extensions[key] = value.Trim();
|
||||
}
|
||||
|
||||
private static string? NormalizeComponent(string? component)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(component))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = component.Trim();
|
||||
return trimmed.Length == 0 ? null : trimmed;
|
||||
}
|
||||
|
||||
private static string CreateIdentifier(string product, string? component)
|
||||
{
|
||||
var normalizedProduct = product.Trim();
|
||||
if (string.IsNullOrEmpty(component))
|
||||
{
|
||||
return normalizedProduct;
|
||||
}
|
||||
|
||||
return $"{normalizedProduct}::{component}";
|
||||
}
|
||||
|
||||
private static (string? FixedVersion, string? PatchNumber) ExtractFixMetadata(string? notes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(notes))
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
string? fixedVersion = null;
|
||||
string? patchNumber = null;
|
||||
|
||||
var match = FixedVersionRegex.Match(notes);
|
||||
if (match.Success)
|
||||
{
|
||||
fixedVersion = match.Groups["value"].Value.Trim();
|
||||
}
|
||||
|
||||
match = PatchNumberRegex.Match(notes);
|
||||
if (match.Success)
|
||||
{
|
||||
patchNumber = match.Groups["value"].Value.Trim();
|
||||
fixedVersion ??= patchNumber;
|
||||
}
|
||||
|
||||
return (fixedVersion, patchNumber);
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Connector.Common.Packages;
|
||||
using StellaOps.Concelier.Storage;
|
||||
using StellaOps.Concelier.Storage;
|
||||
using StellaOps.Concelier.Storage.PsirtFlags;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Oracle.Internal;
|
||||
|
||||
internal static class OracleMapper
|
||||
{
|
||||
private static readonly Regex FixedVersionRegex = new("(?:Fixed|Fix)\\s+(?:in|available in|for)\\s+(?<value>[A-Za-z0-9._-]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex PatchNumberRegex = new("Patch\\s+(?<value>\\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
public static (Advisory Advisory, PsirtFlagRecord Flag) Map(
|
||||
OracleDto dto,
|
||||
DocumentRecord document,
|
||||
DtoRecord dtoRecord,
|
||||
string sourceName,
|
||||
DateTimeOffset mappedAt)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dto);
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
ArgumentNullException.ThrowIfNull(dtoRecord);
|
||||
ArgumentException.ThrowIfNullOrEmpty(sourceName);
|
||||
|
||||
var advisoryKey = $"oracle/{dto.AdvisoryId}";
|
||||
var fetchProvenance = new AdvisoryProvenance(sourceName, "document", document.Uri, document.FetchedAt.ToUniversalTime());
|
||||
var mappingProvenance = new AdvisoryProvenance(sourceName, "mapping", dto.AdvisoryId, mappedAt.ToUniversalTime());
|
||||
|
||||
var aliases = BuildAliases(dto);
|
||||
var references = BuildReferences(dto, sourceName, mappedAt);
|
||||
var affectedPackages = BuildAffectedPackages(dto, sourceName, mappedAt);
|
||||
|
||||
var advisory = new Advisory(
|
||||
advisoryKey,
|
||||
dto.Title,
|
||||
dto.Content,
|
||||
language: "en",
|
||||
published: dto.Published.ToUniversalTime(),
|
||||
modified: null,
|
||||
severity: null,
|
||||
exploitKnown: false,
|
||||
aliases,
|
||||
references,
|
||||
affectedPackages,
|
||||
Array.Empty<CvssMetric>(),
|
||||
new[] { fetchProvenance, mappingProvenance });
|
||||
|
||||
var flag = new PsirtFlagRecord(
|
||||
advisoryKey,
|
||||
"Oracle",
|
||||
sourceName,
|
||||
dto.AdvisoryId,
|
||||
mappedAt.ToUniversalTime());
|
||||
|
||||
return (advisory, flag);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> BuildAliases(OracleDto dto)
|
||||
{
|
||||
var aliases = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
$"ORACLE:{dto.AdvisoryId}".ToUpperInvariant(),
|
||||
};
|
||||
|
||||
foreach (var cve in dto.CveIds)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(cve))
|
||||
{
|
||||
aliases.Add(cve.Trim().ToUpperInvariant());
|
||||
}
|
||||
}
|
||||
|
||||
return aliases
|
||||
.OrderBy(static alias => alias, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AdvisoryReference> BuildReferences(OracleDto dto, string sourceName, DateTimeOffset recordedAt)
|
||||
{
|
||||
var comparer = StringComparer.OrdinalIgnoreCase;
|
||||
var entries = new List<(AdvisoryReference Reference, int Priority)>
|
||||
{
|
||||
(new AdvisoryReference(
|
||||
dto.DetailUrl,
|
||||
"advisory",
|
||||
"oracle",
|
||||
dto.Title,
|
||||
new AdvisoryProvenance(sourceName, "reference", dto.DetailUrl, recordedAt.ToUniversalTime())), 0),
|
||||
};
|
||||
|
||||
foreach (var document in dto.PatchDocuments)
|
||||
{
|
||||
var summary = document.Title ?? document.Product;
|
||||
entries.Add((new AdvisoryReference(
|
||||
document.Url,
|
||||
"patch",
|
||||
"oracle",
|
||||
summary,
|
||||
new AdvisoryProvenance(sourceName, "reference", document.Url, recordedAt.ToUniversalTime())), 1));
|
||||
}
|
||||
|
||||
foreach (var url in dto.References)
|
||||
{
|
||||
entries.Add((new AdvisoryReference(
|
||||
url,
|
||||
"reference",
|
||||
null,
|
||||
null,
|
||||
new AdvisoryProvenance(sourceName, "reference", url, recordedAt.ToUniversalTime())), 2));
|
||||
}
|
||||
|
||||
foreach (var cve in dto.CveIds)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cve))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var cveUrl = $"https://www.cve.org/CVERecord?id={cve}";
|
||||
entries.Add((new AdvisoryReference(
|
||||
cveUrl,
|
||||
"advisory",
|
||||
cve,
|
||||
null,
|
||||
new AdvisoryProvenance(sourceName, "reference", cveUrl, recordedAt.ToUniversalTime())), 3));
|
||||
}
|
||||
|
||||
return entries
|
||||
.GroupBy(tuple => tuple.Reference.Url, comparer)
|
||||
.Select(group => group
|
||||
.OrderBy(t => t.Priority)
|
||||
.ThenBy(t => t.Reference.Kind ?? string.Empty, comparer)
|
||||
.ThenBy(t => t.Reference.Url, comparer)
|
||||
.First())
|
||||
.OrderBy(t => t.Priority)
|
||||
.ThenBy(t => t.Reference.Kind ?? string.Empty, comparer)
|
||||
.ThenBy(t => t.Reference.Url, comparer)
|
||||
.Select(t => t.Reference)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AffectedPackage> BuildAffectedPackages(OracleDto dto, string sourceName, DateTimeOffset recordedAt)
|
||||
{
|
||||
if (dto.Affected.Count == 0)
|
||||
{
|
||||
return Array.Empty<AffectedPackage>();
|
||||
}
|
||||
|
||||
var packages = new List<AffectedPackage>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var entry in dto.Affected)
|
||||
{
|
||||
if (entry is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var component = NormalizeComponent(entry.Component);
|
||||
var notes = entry.Notes;
|
||||
|
||||
foreach (var segment in SplitSupportedVersions(entry.Product, entry.SupportedVersions))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(segment.Product))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var identifier = CreateIdentifier(segment.Product, component);
|
||||
var baseExpression = segment.Versions ?? entry.SupportedVersions ?? string.Empty;
|
||||
var composedExpression = baseExpression;
|
||||
|
||||
if (!string.IsNullOrEmpty(notes))
|
||||
{
|
||||
composedExpression = string.IsNullOrEmpty(composedExpression)
|
||||
? $"notes: {notes}"
|
||||
: $"{composedExpression} (notes: {notes})";
|
||||
}
|
||||
|
||||
var rangeExpression = string.IsNullOrWhiteSpace(composedExpression) ? null : composedExpression;
|
||||
var (fixedVersion, patchNumber) = ExtractFixMetadata(notes);
|
||||
var rangeProvenance = new AdvisoryProvenance(sourceName, "range", identifier, recordedAt.ToUniversalTime());
|
||||
var rangePrimitives = BuildVendorRangePrimitives(entry, segment, component, baseExpression, rangeExpression, notes, fixedVersion, patchNumber);
|
||||
|
||||
var ranges = rangeExpression is null && string.IsNullOrEmpty(fixedVersion)
|
||||
? Array.Empty<AffectedVersionRange>()
|
||||
: new[]
|
||||
{
|
||||
new AffectedVersionRange(
|
||||
rangeKind: "vendor",
|
||||
introducedVersion: null,
|
||||
fixedVersion: fixedVersion,
|
||||
lastAffectedVersion: null,
|
||||
rangeExpression: rangeExpression,
|
||||
provenance: rangeProvenance,
|
||||
primitives: rangePrimitives),
|
||||
};
|
||||
|
||||
var provenance = new[]
|
||||
{
|
||||
new AdvisoryProvenance(sourceName, "affected", identifier, recordedAt.ToUniversalTime()),
|
||||
};
|
||||
|
||||
var package = new AffectedPackage(
|
||||
AffectedPackageTypes.Vendor,
|
||||
identifier,
|
||||
component,
|
||||
ranges,
|
||||
statuses: Array.Empty<AffectedPackageStatus>(),
|
||||
provenance: provenance);
|
||||
|
||||
var key = $"{identifier}::{component}::{ranges.FirstOrDefault()?.CreateDeterministicKey()}";
|
||||
if (seen.Add(key))
|
||||
{
|
||||
packages.Add(package);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return packages.Count == 0 ? Array.Empty<AffectedPackage>() : packages;
|
||||
}
|
||||
|
||||
private static IEnumerable<(string Product, string? Versions)> SplitSupportedVersions(string product, string? supportedVersions)
|
||||
{
|
||||
var normalizedProduct = string.IsNullOrWhiteSpace(product) ? "Oracle Product" : product.Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(supportedVersions))
|
||||
{
|
||||
yield return (normalizedProduct, null);
|
||||
yield break;
|
||||
}
|
||||
|
||||
var segments = supportedVersions.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (segments.Length <= 1)
|
||||
{
|
||||
yield return (normalizedProduct, supportedVersions.Trim());
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
var text = segment.Trim();
|
||||
if (text.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var colonIndex = text.IndexOf(':');
|
||||
if (colonIndex > 0)
|
||||
{
|
||||
var name = text[..colonIndex].Trim();
|
||||
var versions = text[(colonIndex + 1)..].Trim();
|
||||
yield return (string.IsNullOrEmpty(name) ? normalizedProduct : name, versions);
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return (normalizedProduct, text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static RangePrimitives? BuildVendorRangePrimitives(
|
||||
OracleAffectedEntry entry,
|
||||
(string Product, string? Versions) segment,
|
||||
string? component,
|
||||
string? baseExpression,
|
||||
string? rangeExpression,
|
||||
string? notes,
|
||||
string? fixedVersion,
|
||||
string? patchNumber)
|
||||
{
|
||||
var extensions = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
AddExtension(extensions, "oracle.product", segment.Product);
|
||||
AddExtension(extensions, "oracle.productRaw", entry.Product);
|
||||
AddExtension(extensions, "oracle.component", component);
|
||||
AddExtension(extensions, "oracle.componentRaw", entry.Component);
|
||||
AddExtension(extensions, "oracle.segmentVersions", segment.Versions);
|
||||
AddExtension(extensions, "oracle.supportedVersions", entry.SupportedVersions);
|
||||
AddExtension(extensions, "oracle.rangeExpression", rangeExpression);
|
||||
AddExtension(extensions, "oracle.baseExpression", baseExpression);
|
||||
AddExtension(extensions, "oracle.notes", notes);
|
||||
AddExtension(extensions, "oracle.fixedVersion", fixedVersion);
|
||||
AddExtension(extensions, "oracle.patchNumber", patchNumber);
|
||||
|
||||
var versionTokens = ExtractVersionTokens(baseExpression);
|
||||
if (versionTokens.Count > 0)
|
||||
{
|
||||
extensions["oracle.versionTokens"] = string.Join('|', versionTokens);
|
||||
|
||||
var normalizedTokens = versionTokens
|
||||
.Select(NormalizeSemVerToken)
|
||||
.Where(static token => !string.IsNullOrEmpty(token))
|
||||
.Cast<string>()
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
if (normalizedTokens.Length > 0)
|
||||
{
|
||||
extensions["oracle.versionTokens.normalized"] = string.Join('|', normalizedTokens);
|
||||
}
|
||||
}
|
||||
|
||||
if (extensions.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new RangePrimitives(null, null, null, extensions);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractVersionTokens(string? baseExpression)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseExpression))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var tokens = new List<string>();
|
||||
foreach (var token in baseExpression.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var value = token.Trim();
|
||||
if (value.Length == 0 || !value.Any(char.IsDigit))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
tokens.Add(value);
|
||||
}
|
||||
|
||||
return tokens.Count == 0 ? Array.Empty<string>() : tokens;
|
||||
}
|
||||
|
||||
private static string? NormalizeSemVerToken(string token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (PackageCoordinateHelper.TryParseSemVer(token, out _, out var normalized) && !string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (Version.TryParse(token, out var parsed))
|
||||
{
|
||||
if (parsed.Build >= 0 && parsed.Revision >= 0)
|
||||
{
|
||||
return $"{parsed.Major}.{parsed.Minor}.{parsed.Build}.{parsed.Revision}";
|
||||
}
|
||||
|
||||
if (parsed.Build >= 0)
|
||||
{
|
||||
return $"{parsed.Major}.{parsed.Minor}.{parsed.Build}";
|
||||
}
|
||||
|
||||
return $"{parsed.Major}.{parsed.Minor}";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void AddExtension(Dictionary<string, string> extensions, string key, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
extensions[key] = value.Trim();
|
||||
}
|
||||
|
||||
private static string? NormalizeComponent(string? component)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(component))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = component.Trim();
|
||||
return trimmed.Length == 0 ? null : trimmed;
|
||||
}
|
||||
|
||||
private static string CreateIdentifier(string product, string? component)
|
||||
{
|
||||
var normalizedProduct = product.Trim();
|
||||
if (string.IsNullOrEmpty(component))
|
||||
{
|
||||
return normalizedProduct;
|
||||
}
|
||||
|
||||
return $"{normalizedProduct}::{component}";
|
||||
}
|
||||
|
||||
private static (string? FixedVersion, string? PatchNumber) ExtractFixMetadata(string? notes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(notes))
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
string? fixedVersion = null;
|
||||
string? patchNumber = null;
|
||||
|
||||
var match = FixedVersionRegex.Match(notes);
|
||||
if (match.Success)
|
||||
{
|
||||
fixedVersion = match.Groups["value"].Value.Trim();
|
||||
}
|
||||
|
||||
match = PatchNumberRegex.Match(notes);
|
||||
if (match.Success)
|
||||
{
|
||||
patchNumber = match.Groups["value"].Value.Trim();
|
||||
fixedVersion ??= patchNumber;
|
||||
}
|
||||
|
||||
return (fixedVersion, patchNumber);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,457 +1,457 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using AngleSharp.Html.Dom;
|
||||
using AngleSharp.Html.Parser;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Oracle.Internal;
|
||||
|
||||
internal static class OracleParser
|
||||
{
|
||||
private static readonly Regex AnchorRegex = new("<a[^>]+href=\"(?<url>https?://[^\"]+)\"", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex TagRegex = new("<[^>]+>", RegexOptions.Compiled);
|
||||
private static readonly Regex WhitespaceRegex = new("\\s+", RegexOptions.Compiled);
|
||||
private static readonly Regex CveRegex = new("CVE-\\d{4}-\\d{3,7}", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex UpdatedDateRegex = new("\"updatedDate\"\\s*:\\s*\"(?<value>[^\"]+)\"", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly string[] AllowedReferenceTokens =
|
||||
{
|
||||
"security-alerts",
|
||||
"/kb/",
|
||||
"/patches",
|
||||
"/rs",
|
||||
"/support/",
|
||||
"/mos/",
|
||||
"/technicalresources/",
|
||||
"/technetwork/"
|
||||
};
|
||||
|
||||
public static OracleDto Parse(string html, OracleDocumentMetadata metadata)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(html);
|
||||
ArgumentNullException.ThrowIfNull(metadata);
|
||||
|
||||
var parser = new HtmlParser();
|
||||
var document = parser.ParseDocument(html);
|
||||
|
||||
var published = ExtractPublishedDate(document) ?? metadata.Published;
|
||||
var content = Sanitize(html);
|
||||
var affected = ExtractAffectedEntries(document);
|
||||
var references = ExtractReferences(html);
|
||||
var patchDocuments = ExtractPatchDocuments(document, metadata.DetailUri);
|
||||
var cveIds = ExtractCveIds(document, content, affected);
|
||||
|
||||
return new OracleDto(
|
||||
metadata.AdvisoryId,
|
||||
metadata.Title,
|
||||
metadata.DetailUri.ToString(),
|
||||
published,
|
||||
content,
|
||||
references,
|
||||
cveIds,
|
||||
affected,
|
||||
patchDocuments);
|
||||
}
|
||||
|
||||
private static string Sanitize(string html)
|
||||
{
|
||||
var withoutTags = TagRegex.Replace(html, " ");
|
||||
var decoded = System.Net.WebUtility.HtmlDecode(withoutTags) ?? string.Empty;
|
||||
return WhitespaceRegex.Replace(decoded, " ").Trim();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractReferences(string html)
|
||||
{
|
||||
var references = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (Match match in AnchorRegex.Matches(html))
|
||||
{
|
||||
if (!match.Success)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var raw = match.Groups["url"].Value?.Trim();
|
||||
if (string.IsNullOrEmpty(raw))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var decoded = System.Net.WebUtility.HtmlDecode(raw) ?? raw;
|
||||
|
||||
if (!Uri.TryCreate(decoded, UriKind.Absolute, out var uri))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ShouldIncludeReference(uri))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
references.Add(uri.ToString());
|
||||
}
|
||||
|
||||
return references.Count == 0
|
||||
? Array.Empty<string>()
|
||||
: references.OrderBy(url => url, StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
}
|
||||
|
||||
private static bool ShouldIncludeReference(Uri uri)
|
||||
{
|
||||
if (uri.Host.EndsWith("cve.org", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!uri.Host.EndsWith("oracle.com", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (uri.Query.Contains("type=doc", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var path = uri.AbsolutePath ?? string.Empty;
|
||||
return AllowedReferenceTokens.Any(token => path.Contains(token, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ExtractPublishedDate(IHtmlDocument document)
|
||||
{
|
||||
var meta = document.QuerySelectorAll("meta")
|
||||
.FirstOrDefault(static element => string.Equals(element.GetAttribute("name"), "Updated Date", StringComparison.OrdinalIgnoreCase));
|
||||
if (meta is not null && TryParseOracleDate(meta.GetAttribute("content"), out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
foreach (var script in document.Scripts)
|
||||
{
|
||||
var text = script.TextContent;
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var match = UpdatedDateRegex.Match(text);
|
||||
if (!match.Success)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryParseOracleDate(match.Groups["value"].Value, out var embedded))
|
||||
{
|
||||
return embedded;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryParseOracleDate(string? value, out DateTimeOffset result)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value)
|
||||
&& DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out result))
|
||||
{
|
||||
result = result.ToUniversalTime();
|
||||
return true;
|
||||
}
|
||||
|
||||
result = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<OracleAffectedEntry> ExtractAffectedEntries(IHtmlDocument document)
|
||||
{
|
||||
var entries = new List<OracleAffectedEntry>();
|
||||
|
||||
foreach (var table in document.QuerySelectorAll("table"))
|
||||
{
|
||||
if (table is not IHtmlTableElement tableElement)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!IsRiskMatrixTable(tableElement))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var lastProduct = string.Empty;
|
||||
var lastComponent = string.Empty;
|
||||
var lastVersions = string.Empty;
|
||||
var lastNotes = string.Empty;
|
||||
IReadOnlyList<string> lastCves = Array.Empty<string>();
|
||||
|
||||
foreach (var body in tableElement.Bodies)
|
||||
{
|
||||
foreach (var row in body.Rows)
|
||||
{
|
||||
if (row is not IHtmlTableRowElement tableRow || tableRow.Cells.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var cveText = NormalizeCellText(GetCellText(tableRow, 0));
|
||||
var cves = ExtractCvesFromText(cveText);
|
||||
if (cves.Count == 0 && lastCves.Count > 0)
|
||||
{
|
||||
cves = lastCves;
|
||||
}
|
||||
else if (cves.Count > 0)
|
||||
{
|
||||
lastCves = cves;
|
||||
}
|
||||
|
||||
var product = NormalizeCellText(GetCellText(tableRow, 1));
|
||||
if (string.IsNullOrEmpty(product))
|
||||
{
|
||||
product = lastProduct;
|
||||
}
|
||||
else
|
||||
{
|
||||
lastProduct = product;
|
||||
}
|
||||
|
||||
var component = NormalizeCellText(GetCellText(tableRow, 2));
|
||||
if (string.IsNullOrEmpty(component))
|
||||
{
|
||||
component = lastComponent;
|
||||
}
|
||||
else
|
||||
{
|
||||
lastComponent = component;
|
||||
}
|
||||
|
||||
var supportedVersions = NormalizeCellText(GetCellTextFromEnd(tableRow, 2));
|
||||
if (string.IsNullOrEmpty(supportedVersions))
|
||||
{
|
||||
supportedVersions = lastVersions;
|
||||
}
|
||||
else
|
||||
{
|
||||
lastVersions = supportedVersions;
|
||||
}
|
||||
|
||||
var notes = NormalizeCellText(GetCellTextFromEnd(tableRow, 1));
|
||||
if (string.IsNullOrEmpty(notes))
|
||||
{
|
||||
notes = lastNotes;
|
||||
}
|
||||
else
|
||||
{
|
||||
lastNotes = notes;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(product) || cves.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.Add(new OracleAffectedEntry(
|
||||
product,
|
||||
string.IsNullOrEmpty(component) ? null : component,
|
||||
string.IsNullOrEmpty(supportedVersions) ? null : supportedVersions,
|
||||
string.IsNullOrEmpty(notes) ? null : notes,
|
||||
cves));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entries.Count == 0 ? Array.Empty<OracleAffectedEntry>() : entries;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractCveIds(IHtmlDocument document, string content, IReadOnlyList<OracleAffectedEntry> affectedEntries)
|
||||
{
|
||||
var cves = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
foreach (Match match in CveRegex.Matches(content))
|
||||
{
|
||||
cves.Add(match.Value.ToUpperInvariant());
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var entry in affectedEntries)
|
||||
{
|
||||
foreach (var cve in entry.CveIds)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(cve))
|
||||
{
|
||||
cves.Add(cve.ToUpperInvariant());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var bodyText = document.Body?.TextContent;
|
||||
if (!string.IsNullOrWhiteSpace(bodyText))
|
||||
{
|
||||
foreach (Match match in CveRegex.Matches(bodyText))
|
||||
{
|
||||
cves.Add(match.Value.ToUpperInvariant());
|
||||
}
|
||||
}
|
||||
|
||||
return cves.Count == 0
|
||||
? Array.Empty<string>()
|
||||
: cves.OrderBy(static id => id, StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<OraclePatchDocument> ExtractPatchDocuments(IHtmlDocument document, Uri detailUri)
|
||||
{
|
||||
var results = new List<OraclePatchDocument>();
|
||||
|
||||
foreach (var table in document.QuerySelectorAll("table"))
|
||||
{
|
||||
if (table is not IHtmlTableElement tableElement)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TableHasPatchHeader(tableElement))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var body in tableElement.Bodies)
|
||||
{
|
||||
foreach (var row in body.Rows)
|
||||
{
|
||||
if (row is not IHtmlTableRowElement tableRow || tableRow.Cells.Length < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var product = NormalizeCellText(tableRow.Cells[0]?.TextContent);
|
||||
if (string.IsNullOrEmpty(product))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var anchor = tableRow.Cells[1]?.QuerySelector("a");
|
||||
if (anchor is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var href = anchor.GetAttribute("href");
|
||||
if (string.IsNullOrWhiteSpace(href))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var decoded = System.Net.WebUtility.HtmlDecode(href) ?? href;
|
||||
|
||||
if (!Uri.TryCreate(detailUri, decoded, out var uri) || !uri.IsAbsoluteUri)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var title = NormalizeCellText(anchor.TextContent);
|
||||
results.Add(new OraclePatchDocument(product, string.IsNullOrEmpty(title) ? null : title, uri.ToString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results.Count == 0 ? Array.Empty<OraclePatchDocument>() : results;
|
||||
}
|
||||
|
||||
private static bool IsRiskMatrixTable(IHtmlTableElement table)
|
||||
{
|
||||
var headerText = table.Head?.TextContent;
|
||||
if (string.IsNullOrWhiteSpace(headerText))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return headerText.Contains("CVE ID", StringComparison.OrdinalIgnoreCase)
|
||||
&& headerText.Contains("Supported Versions", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool TableHasPatchHeader(IHtmlTableElement table)
|
||||
{
|
||||
var headerText = table.Head?.TextContent;
|
||||
if (string.IsNullOrWhiteSpace(headerText))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return headerText.Contains("Affected Products and Versions", StringComparison.OrdinalIgnoreCase)
|
||||
&& headerText.Contains("Patch Availability Document", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string? GetCellText(IHtmlTableRowElement row, int index)
|
||||
{
|
||||
if (index < 0 || index >= row.Cells.Length)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return row.Cells[index]?.TextContent;
|
||||
}
|
||||
|
||||
private static string? GetCellTextFromEnd(IHtmlTableRowElement row, int offsetFromEnd)
|
||||
{
|
||||
if (offsetFromEnd <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var index = row.Cells.Length - offsetFromEnd;
|
||||
return index >= 0 ? row.Cells[index]?.TextContent : null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractCvesFromText(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var matches = CveRegex.Matches(text);
|
||||
if (matches.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
if (match.Success)
|
||||
{
|
||||
set.Add(match.Value.ToUpperInvariant());
|
||||
}
|
||||
}
|
||||
|
||||
return set.Count == 0
|
||||
? Array.Empty<string>()
|
||||
: set.OrderBy(static id => id, StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
|
||||
private static string NormalizeCellText(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var cleaned = value.Replace('\u00A0', ' ');
|
||||
cleaned = WhitespaceRegex.Replace(cleaned, " ");
|
||||
return cleaned.Trim();
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using AngleSharp.Html.Dom;
|
||||
using AngleSharp.Html.Parser;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Oracle.Internal;
|
||||
|
||||
internal static class OracleParser
|
||||
{
|
||||
private static readonly Regex AnchorRegex = new("<a[^>]+href=\"(?<url>https?://[^\"]+)\"", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex TagRegex = new("<[^>]+>", RegexOptions.Compiled);
|
||||
private static readonly Regex WhitespaceRegex = new("\\s+", RegexOptions.Compiled);
|
||||
private static readonly Regex CveRegex = new("CVE-\\d{4}-\\d{3,7}", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex UpdatedDateRegex = new("\"updatedDate\"\\s*:\\s*\"(?<value>[^\"]+)\"", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly string[] AllowedReferenceTokens =
|
||||
{
|
||||
"security-alerts",
|
||||
"/kb/",
|
||||
"/patches",
|
||||
"/rs",
|
||||
"/support/",
|
||||
"/mos/",
|
||||
"/technicalresources/",
|
||||
"/technetwork/"
|
||||
};
|
||||
|
||||
public static OracleDto Parse(string html, OracleDocumentMetadata metadata)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(html);
|
||||
ArgumentNullException.ThrowIfNull(metadata);
|
||||
|
||||
var parser = new HtmlParser();
|
||||
var document = parser.ParseDocument(html);
|
||||
|
||||
var published = ExtractPublishedDate(document) ?? metadata.Published;
|
||||
var content = Sanitize(html);
|
||||
var affected = ExtractAffectedEntries(document);
|
||||
var references = ExtractReferences(html);
|
||||
var patchDocuments = ExtractPatchDocuments(document, metadata.DetailUri);
|
||||
var cveIds = ExtractCveIds(document, content, affected);
|
||||
|
||||
return new OracleDto(
|
||||
metadata.AdvisoryId,
|
||||
metadata.Title,
|
||||
metadata.DetailUri.ToString(),
|
||||
published,
|
||||
content,
|
||||
references,
|
||||
cveIds,
|
||||
affected,
|
||||
patchDocuments);
|
||||
}
|
||||
|
||||
private static string Sanitize(string html)
|
||||
{
|
||||
var withoutTags = TagRegex.Replace(html, " ");
|
||||
var decoded = System.Net.WebUtility.HtmlDecode(withoutTags) ?? string.Empty;
|
||||
return WhitespaceRegex.Replace(decoded, " ").Trim();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractReferences(string html)
|
||||
{
|
||||
var references = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (Match match in AnchorRegex.Matches(html))
|
||||
{
|
||||
if (!match.Success)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var raw = match.Groups["url"].Value?.Trim();
|
||||
if (string.IsNullOrEmpty(raw))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var decoded = System.Net.WebUtility.HtmlDecode(raw) ?? raw;
|
||||
|
||||
if (!Uri.TryCreate(decoded, UriKind.Absolute, out var uri))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ShouldIncludeReference(uri))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
references.Add(uri.ToString());
|
||||
}
|
||||
|
||||
return references.Count == 0
|
||||
? Array.Empty<string>()
|
||||
: references.OrderBy(url => url, StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
}
|
||||
|
||||
private static bool ShouldIncludeReference(Uri uri)
|
||||
{
|
||||
if (uri.Host.EndsWith("cve.org", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!uri.Host.EndsWith("oracle.com", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (uri.Query.Contains("type=doc", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var path = uri.AbsolutePath ?? string.Empty;
|
||||
return AllowedReferenceTokens.Any(token => path.Contains(token, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ExtractPublishedDate(IHtmlDocument document)
|
||||
{
|
||||
var meta = document.QuerySelectorAll("meta")
|
||||
.FirstOrDefault(static element => string.Equals(element.GetAttribute("name"), "Updated Date", StringComparison.OrdinalIgnoreCase));
|
||||
if (meta is not null && TryParseOracleDate(meta.GetAttribute("content"), out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
foreach (var script in document.Scripts)
|
||||
{
|
||||
var text = script.TextContent;
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var match = UpdatedDateRegex.Match(text);
|
||||
if (!match.Success)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryParseOracleDate(match.Groups["value"].Value, out var embedded))
|
||||
{
|
||||
return embedded;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryParseOracleDate(string? value, out DateTimeOffset result)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value)
|
||||
&& DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out result))
|
||||
{
|
||||
result = result.ToUniversalTime();
|
||||
return true;
|
||||
}
|
||||
|
||||
result = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<OracleAffectedEntry> ExtractAffectedEntries(IHtmlDocument document)
|
||||
{
|
||||
var entries = new List<OracleAffectedEntry>();
|
||||
|
||||
foreach (var table in document.QuerySelectorAll("table"))
|
||||
{
|
||||
if (table is not IHtmlTableElement tableElement)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!IsRiskMatrixTable(tableElement))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var lastProduct = string.Empty;
|
||||
var lastComponent = string.Empty;
|
||||
var lastVersions = string.Empty;
|
||||
var lastNotes = string.Empty;
|
||||
IReadOnlyList<string> lastCves = Array.Empty<string>();
|
||||
|
||||
foreach (var body in tableElement.Bodies)
|
||||
{
|
||||
foreach (var row in body.Rows)
|
||||
{
|
||||
if (row is not IHtmlTableRowElement tableRow || tableRow.Cells.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var cveText = NormalizeCellText(GetCellText(tableRow, 0));
|
||||
var cves = ExtractCvesFromText(cveText);
|
||||
if (cves.Count == 0 && lastCves.Count > 0)
|
||||
{
|
||||
cves = lastCves;
|
||||
}
|
||||
else if (cves.Count > 0)
|
||||
{
|
||||
lastCves = cves;
|
||||
}
|
||||
|
||||
var product = NormalizeCellText(GetCellText(tableRow, 1));
|
||||
if (string.IsNullOrEmpty(product))
|
||||
{
|
||||
product = lastProduct;
|
||||
}
|
||||
else
|
||||
{
|
||||
lastProduct = product;
|
||||
}
|
||||
|
||||
var component = NormalizeCellText(GetCellText(tableRow, 2));
|
||||
if (string.IsNullOrEmpty(component))
|
||||
{
|
||||
component = lastComponent;
|
||||
}
|
||||
else
|
||||
{
|
||||
lastComponent = component;
|
||||
}
|
||||
|
||||
var supportedVersions = NormalizeCellText(GetCellTextFromEnd(tableRow, 2));
|
||||
if (string.IsNullOrEmpty(supportedVersions))
|
||||
{
|
||||
supportedVersions = lastVersions;
|
||||
}
|
||||
else
|
||||
{
|
||||
lastVersions = supportedVersions;
|
||||
}
|
||||
|
||||
var notes = NormalizeCellText(GetCellTextFromEnd(tableRow, 1));
|
||||
if (string.IsNullOrEmpty(notes))
|
||||
{
|
||||
notes = lastNotes;
|
||||
}
|
||||
else
|
||||
{
|
||||
lastNotes = notes;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(product) || cves.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.Add(new OracleAffectedEntry(
|
||||
product,
|
||||
string.IsNullOrEmpty(component) ? null : component,
|
||||
string.IsNullOrEmpty(supportedVersions) ? null : supportedVersions,
|
||||
string.IsNullOrEmpty(notes) ? null : notes,
|
||||
cves));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entries.Count == 0 ? Array.Empty<OracleAffectedEntry>() : entries;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractCveIds(IHtmlDocument document, string content, IReadOnlyList<OracleAffectedEntry> affectedEntries)
|
||||
{
|
||||
var cves = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
foreach (Match match in CveRegex.Matches(content))
|
||||
{
|
||||
cves.Add(match.Value.ToUpperInvariant());
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var entry in affectedEntries)
|
||||
{
|
||||
foreach (var cve in entry.CveIds)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(cve))
|
||||
{
|
||||
cves.Add(cve.ToUpperInvariant());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var bodyText = document.Body?.TextContent;
|
||||
if (!string.IsNullOrWhiteSpace(bodyText))
|
||||
{
|
||||
foreach (Match match in CveRegex.Matches(bodyText))
|
||||
{
|
||||
cves.Add(match.Value.ToUpperInvariant());
|
||||
}
|
||||
}
|
||||
|
||||
return cves.Count == 0
|
||||
? Array.Empty<string>()
|
||||
: cves.OrderBy(static id => id, StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<OraclePatchDocument> ExtractPatchDocuments(IHtmlDocument document, Uri detailUri)
|
||||
{
|
||||
var results = new List<OraclePatchDocument>();
|
||||
|
||||
foreach (var table in document.QuerySelectorAll("table"))
|
||||
{
|
||||
if (table is not IHtmlTableElement tableElement)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TableHasPatchHeader(tableElement))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var body in tableElement.Bodies)
|
||||
{
|
||||
foreach (var row in body.Rows)
|
||||
{
|
||||
if (row is not IHtmlTableRowElement tableRow || tableRow.Cells.Length < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var product = NormalizeCellText(tableRow.Cells[0]?.TextContent);
|
||||
if (string.IsNullOrEmpty(product))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var anchor = tableRow.Cells[1]?.QuerySelector("a");
|
||||
if (anchor is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var href = anchor.GetAttribute("href");
|
||||
if (string.IsNullOrWhiteSpace(href))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var decoded = System.Net.WebUtility.HtmlDecode(href) ?? href;
|
||||
|
||||
if (!Uri.TryCreate(detailUri, decoded, out var uri) || !uri.IsAbsoluteUri)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var title = NormalizeCellText(anchor.TextContent);
|
||||
results.Add(new OraclePatchDocument(product, string.IsNullOrEmpty(title) ? null : title, uri.ToString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results.Count == 0 ? Array.Empty<OraclePatchDocument>() : results;
|
||||
}
|
||||
|
||||
private static bool IsRiskMatrixTable(IHtmlTableElement table)
|
||||
{
|
||||
var headerText = table.Head?.TextContent;
|
||||
if (string.IsNullOrWhiteSpace(headerText))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return headerText.Contains("CVE ID", StringComparison.OrdinalIgnoreCase)
|
||||
&& headerText.Contains("Supported Versions", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool TableHasPatchHeader(IHtmlTableElement table)
|
||||
{
|
||||
var headerText = table.Head?.TextContent;
|
||||
if (string.IsNullOrWhiteSpace(headerText))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return headerText.Contains("Affected Products and Versions", StringComparison.OrdinalIgnoreCase)
|
||||
&& headerText.Contains("Patch Availability Document", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string? GetCellText(IHtmlTableRowElement row, int index)
|
||||
{
|
||||
if (index < 0 || index >= row.Cells.Length)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return row.Cells[index]?.TextContent;
|
||||
}
|
||||
|
||||
private static string? GetCellTextFromEnd(IHtmlTableRowElement row, int offsetFromEnd)
|
||||
{
|
||||
if (offsetFromEnd <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var index = row.Cells.Length - offsetFromEnd;
|
||||
return index >= 0 ? row.Cells[index]?.TextContent : null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractCvesFromText(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var matches = CveRegex.Matches(text);
|
||||
if (matches.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
if (match.Success)
|
||||
{
|
||||
set.Add(match.Value.ToUpperInvariant());
|
||||
}
|
||||
}
|
||||
|
||||
return set.Count == 0
|
||||
? Array.Empty<string>()
|
||||
: set.OrderBy(static id => id, StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
|
||||
private static string NormalizeCellText(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var cleaned = value.Replace('\u00A0', ' ');
|
||||
cleaned = WhitespaceRegex.Replace(cleaned, " ");
|
||||
return cleaned.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Oracle.Internal;
|
||||
|
||||
internal sealed record OraclePatchDocument(
|
||||
[property: JsonPropertyName("product")] string Product,
|
||||
[property: JsonPropertyName("title")] string? Title,
|
||||
[property: JsonPropertyName("url")] string Url);
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Oracle.Internal;
|
||||
|
||||
internal sealed record OraclePatchDocument(
|
||||
[property: JsonPropertyName("product")] string Product,
|
||||
[property: JsonPropertyName("title")] string? Title,
|
||||
[property: JsonPropertyName("url")] string Url);
|
||||
|
||||
@@ -1,46 +1,46 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Oracle;
|
||||
|
||||
internal static class OracleJobKinds
|
||||
{
|
||||
public const string Fetch = "source:vndr-oracle:fetch";
|
||||
public const string Parse = "source:vndr-oracle:parse";
|
||||
public const string Map = "source:vndr-oracle:map";
|
||||
}
|
||||
|
||||
internal sealed class OracleFetchJob : IJob
|
||||
{
|
||||
private readonly OracleConnector _connector;
|
||||
|
||||
public OracleFetchJob(OracleConnector connector)
|
||||
=> _connector = connector ?? throw new ArgumentNullException(nameof(connector));
|
||||
|
||||
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
|
||||
=> _connector.FetchAsync(context.Services, cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class OracleParseJob : IJob
|
||||
{
|
||||
private readonly OracleConnector _connector;
|
||||
|
||||
public OracleParseJob(OracleConnector connector)
|
||||
=> _connector = connector ?? throw new ArgumentNullException(nameof(connector));
|
||||
|
||||
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
|
||||
=> _connector.ParseAsync(context.Services, cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class OracleMapJob : IJob
|
||||
{
|
||||
private readonly OracleConnector _connector;
|
||||
|
||||
public OracleMapJob(OracleConnector connector)
|
||||
=> _connector = connector ?? throw new ArgumentNullException(nameof(connector));
|
||||
|
||||
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
|
||||
=> _connector.MapAsync(context.Services, cancellationToken);
|
||||
}
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Oracle;
|
||||
|
||||
internal static class OracleJobKinds
|
||||
{
|
||||
public const string Fetch = "source:vndr-oracle:fetch";
|
||||
public const string Parse = "source:vndr-oracle:parse";
|
||||
public const string Map = "source:vndr-oracle:map";
|
||||
}
|
||||
|
||||
internal sealed class OracleFetchJob : IJob
|
||||
{
|
||||
private readonly OracleConnector _connector;
|
||||
|
||||
public OracleFetchJob(OracleConnector connector)
|
||||
=> _connector = connector ?? throw new ArgumentNullException(nameof(connector));
|
||||
|
||||
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
|
||||
=> _connector.FetchAsync(context.Services, cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class OracleParseJob : IJob
|
||||
{
|
||||
private readonly OracleConnector _connector;
|
||||
|
||||
public OracleParseJob(OracleConnector connector)
|
||||
=> _connector = connector ?? throw new ArgumentNullException(nameof(connector));
|
||||
|
||||
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
|
||||
=> _connector.ParseAsync(context.Services, cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class OracleMapJob : IJob
|
||||
{
|
||||
private readonly OracleConnector _connector;
|
||||
|
||||
public OracleMapJob(OracleConnector connector)
|
||||
=> _connector = connector ?? throw new ArgumentNullException(nameof(connector));
|
||||
|
||||
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
|
||||
=> _connector.MapAsync(context.Services, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Bson;
|
||||
using StellaOps.Concelier.Documents;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Common.Fetch;
|
||||
using StellaOps.Concelier.Connector.Vndr.Oracle.Configuration;
|
||||
@@ -223,7 +223,7 @@ public sealed class OracleConnector : IFeedConnector
|
||||
dto = normalized;
|
||||
|
||||
var json = JsonSerializer.Serialize(dto, SerializerOptions);
|
||||
var payload = BsonDocument.Parse(json);
|
||||
var payload = DocumentObject.Parse(json);
|
||||
var validatedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
var existingDto = await _dtoStore.FindByDocumentIdAsync(document.Id, cancellationToken).ConfigureAwait(false);
|
||||
@@ -320,7 +320,7 @@ public sealed class OracleConnector : IFeedConnector
|
||||
private async Task UpdateCursorAsync(OracleCursor cursor, CancellationToken cancellationToken)
|
||||
{
|
||||
var completedAt = _timeProvider.GetUtcNow();
|
||||
await _stateRepository.UpdateCursorAsync(SourceName, cursor.ToBsonDocument(), completedAt, cancellationToken).ConfigureAwait(false);
|
||||
await _stateRepository.UpdateCursorAsync(SourceName, cursor.ToDocumentObject(), completedAt, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyCollection<Uri>> ResolveAdvisoryUrisAsync(CancellationToken cancellationToken)
|
||||
|
||||
@@ -1,54 +1,54 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.DependencyInjection;
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
using StellaOps.Concelier.Connector.Vndr.Oracle.Configuration;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Oracle;
|
||||
|
||||
public sealed class OracleDependencyInjectionRoutine : IDependencyInjectionRoutine
|
||||
{
|
||||
private const string ConfigurationSection = "concelier:sources:oracle";
|
||||
|
||||
public IServiceCollection Register(IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
services.AddOracleConnector(options =>
|
||||
{
|
||||
configuration.GetSection(ConfigurationSection).Bind(options);
|
||||
options.Validate();
|
||||
});
|
||||
|
||||
services.AddTransient<OracleFetchJob>();
|
||||
services.AddTransient<OracleParseJob>();
|
||||
services.AddTransient<OracleMapJob>();
|
||||
|
||||
services.PostConfigure<JobSchedulerOptions>(options =>
|
||||
{
|
||||
EnsureJob(options, OracleJobKinds.Fetch, typeof(OracleFetchJob));
|
||||
EnsureJob(options, OracleJobKinds.Parse, typeof(OracleParseJob));
|
||||
EnsureJob(options, OracleJobKinds.Map, typeof(OracleMapJob));
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.DependencyInjection;
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
using StellaOps.Concelier.Connector.Vndr.Oracle.Configuration;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Oracle;
|
||||
|
||||
public sealed class OracleDependencyInjectionRoutine : IDependencyInjectionRoutine
|
||||
{
|
||||
private const string ConfigurationSection = "concelier:sources:oracle";
|
||||
|
||||
public IServiceCollection Register(IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
services.AddOracleConnector(options =>
|
||||
{
|
||||
configuration.GetSection(ConfigurationSection).Bind(options);
|
||||
options.Validate();
|
||||
});
|
||||
|
||||
services.AddTransient<OracleFetchJob>();
|
||||
services.AddTransient<OracleParseJob>();
|
||||
services.AddTransient<OracleMapJob>();
|
||||
|
||||
services.PostConfigure<JobSchedulerOptions>(options =>
|
||||
{
|
||||
EnsureJob(options, OracleJobKinds.Fetch, typeof(OracleFetchJob));
|
||||
EnsureJob(options, OracleJobKinds.Parse, typeof(OracleParseJob));
|
||||
EnsureJob(options, OracleJobKinds.Map, typeof(OracleMapJob));
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,42 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Connector.Common.Http;
|
||||
using StellaOps.Concelier.Connector.Vndr.Oracle.Configuration;
|
||||
using StellaOps.Concelier.Connector.Vndr.Oracle.Internal;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Oracle;
|
||||
|
||||
public static class OracleServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddOracleConnector(this IServiceCollection services, Action<OracleOptions> configure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
|
||||
services.AddOptions<OracleOptions>()
|
||||
.Configure(configure)
|
||||
.PostConfigure(static opts => opts.Validate());
|
||||
|
||||
services.AddSourceHttpClient(OracleOptions.HttpClientName, static (sp, clientOptions) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<OracleOptions>>().Value;
|
||||
clientOptions.Timeout = TimeSpan.FromSeconds(30);
|
||||
clientOptions.UserAgent = "StellaOps.Concelier.Oracle/1.0";
|
||||
clientOptions.AllowedHosts.Clear();
|
||||
foreach (var uri in options.AdvisoryUris)
|
||||
{
|
||||
clientOptions.AllowedHosts.Add(uri.Host);
|
||||
}
|
||||
foreach (var uri in options.CalendarUris)
|
||||
{
|
||||
clientOptions.AllowedHosts.Add(uri.Host);
|
||||
}
|
||||
});
|
||||
|
||||
services.AddTransient<OracleCalendarFetcher>();
|
||||
services.AddTransient<OracleConnector>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Connector.Common.Http;
|
||||
using StellaOps.Concelier.Connector.Vndr.Oracle.Configuration;
|
||||
using StellaOps.Concelier.Connector.Vndr.Oracle.Internal;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Oracle;
|
||||
|
||||
public static class OracleServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddOracleConnector(this IServiceCollection services, Action<OracleOptions> configure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
|
||||
services.AddOptions<OracleOptions>()
|
||||
.Configure(configure)
|
||||
.PostConfigure(static opts => opts.Validate());
|
||||
|
||||
services.AddSourceHttpClient(OracleOptions.HttpClientName, static (sp, clientOptions) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<OracleOptions>>().Value;
|
||||
clientOptions.Timeout = TimeSpan.FromSeconds(30);
|
||||
clientOptions.UserAgent = "StellaOps.Concelier.Oracle/1.0";
|
||||
clientOptions.AllowedHosts.Clear();
|
||||
foreach (var uri in options.AdvisoryUris)
|
||||
{
|
||||
clientOptions.AllowedHosts.Add(uri.Host);
|
||||
}
|
||||
foreach (var uri in options.CalendarUris)
|
||||
{
|
||||
clientOptions.AllowedHosts.Add(uri.Host);
|
||||
}
|
||||
});
|
||||
|
||||
services.AddTransient<OracleCalendarFetcher>();
|
||||
services.AddTransient<OracleConnector>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Concelier.Connector.Vndr.Oracle.Tests")]
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Concelier.Connector.Vndr.Oracle.Tests")]
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Plugin;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Oracle;
|
||||
|
||||
public sealed class VndrOracleConnectorPlugin : IConnectorPlugin
|
||||
{
|
||||
public const string SourceName = "vndr-oracle";
|
||||
|
||||
public string Name => SourceName;
|
||||
|
||||
public bool IsAvailable(IServiceProvider services)
|
||||
=> services.GetService<OracleConnector>() is not null;
|
||||
|
||||
public IFeedConnector Create(IServiceProvider services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
return services.GetRequiredService<OracleConnector>();
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Plugin;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Vndr.Oracle;
|
||||
|
||||
public sealed class VndrOracleConnectorPlugin : IConnectorPlugin
|
||||
{
|
||||
public const string SourceName = "vndr-oracle";
|
||||
|
||||
public string Name => SourceName;
|
||||
|
||||
public bool IsAvailable(IServiceProvider services)
|
||||
=> services.GetService<OracleConnector>() is not null;
|
||||
|
||||
public IFeedConnector Create(IServiceProvider services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
return services.GetRequiredService<OracleConnector>();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user