save progress
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BugCveMappingRouter.cs
|
||||
// Sprint: SPRINT_20251230_001_BE_backport_resolver (BP-407)
|
||||
// Task: Implement cache layer and router for bug → CVE mapping
|
||||
// Description: Routes lookups to appropriate trackers with caching
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Frozen;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Concelier.SourceIntel.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Routes bug → CVE lookups to the appropriate tracker implementation
|
||||
/// with caching and rate limiting.
|
||||
/// </summary>
|
||||
public sealed class BugCveMappingRouter : IBugCveMappingRouter
|
||||
{
|
||||
private const string SourceName = "BugCveMappingRouter";
|
||||
private static readonly TimeSpan DefaultCacheDuration = TimeSpan.FromHours(24);
|
||||
|
||||
private readonly IReadOnlyList<IBugCveMappingService> _services;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ILogger<BugCveMappingRouter> _logger;
|
||||
|
||||
public BugCveMappingRouter(
|
||||
IEnumerable<IBugCveMappingService> services,
|
||||
IMemoryCache cache,
|
||||
ILogger<BugCveMappingRouter> logger)
|
||||
{
|
||||
_services = services.ToList();
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<BugCveMappingResult> LookupCvesAsync(
|
||||
BugReference bugReference,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Check unified cache first
|
||||
var cacheKey = GetCacheKey(bugReference);
|
||||
if (_cache.TryGetValue<BugCveMappingResult>(cacheKey, out var cached))
|
||||
{
|
||||
_logger.LogDebug("Cache hit for {Tracker} bug #{BugId}", bugReference.Tracker, bugReference.BugId);
|
||||
return cached!;
|
||||
}
|
||||
|
||||
// Find a service that supports this tracker
|
||||
var service = _services.FirstOrDefault(s => s.SupportsTracker(bugReference.Tracker));
|
||||
|
||||
if (service == null)
|
||||
{
|
||||
_logger.LogDebug("No service registered for tracker {Tracker}", bugReference.Tracker);
|
||||
return BugCveMappingResult.Failure(
|
||||
bugReference,
|
||||
SourceName,
|
||||
$"No mapping service available for tracker: {bugReference.Tracker}");
|
||||
}
|
||||
|
||||
var result = await service.LookupCvesAsync(bugReference, cancellationToken);
|
||||
|
||||
// Cache successful lookups (or confirmed "no CVEs" results)
|
||||
if (result.WasSuccessful)
|
||||
{
|
||||
_cache.Set(cacheKey, result, DefaultCacheDuration);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyDictionary<BugReference, BugCveMappingResult>> LookupCvesBatchAsync(
|
||||
IEnumerable<BugReference> bugReferences,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = new Dictionary<BugReference, BugCveMappingResult>();
|
||||
var bugList = bugReferences.ToList();
|
||||
|
||||
// Check cache first and split into cached vs uncached
|
||||
var uncached = new List<BugReference>();
|
||||
foreach (var bug in bugList)
|
||||
{
|
||||
var cacheKey = GetCacheKey(bug);
|
||||
if (_cache.TryGetValue<BugCveMappingResult>(cacheKey, out var cached))
|
||||
{
|
||||
results[bug] = cached!;
|
||||
}
|
||||
else
|
||||
{
|
||||
uncached.Add(bug);
|
||||
}
|
||||
}
|
||||
|
||||
if (uncached.Count == 0)
|
||||
{
|
||||
return results.ToFrozenDictionary();
|
||||
}
|
||||
|
||||
// Group by tracker for efficient batching
|
||||
var grouped = uncached.GroupBy(b => b.Tracker);
|
||||
|
||||
foreach (var group in grouped)
|
||||
{
|
||||
var service = _services.FirstOrDefault(s => s.SupportsTracker(group.Key));
|
||||
|
||||
if (service == null)
|
||||
{
|
||||
// No service - add failure results
|
||||
foreach (var bug in group)
|
||||
{
|
||||
results[bug] = BugCveMappingResult.Failure(
|
||||
bug,
|
||||
SourceName,
|
||||
$"No mapping service available for tracker: {group.Key}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Let the service do batch lookup if it supports it
|
||||
var batchResults = await service.LookupCvesBatchAsync(group, cancellationToken);
|
||||
|
||||
foreach (var (bug, result) in batchResults)
|
||||
{
|
||||
results[bug] = result;
|
||||
|
||||
// Cache successful lookups
|
||||
if (result.WasSuccessful)
|
||||
{
|
||||
var cacheKey = GetCacheKey(bug);
|
||||
_cache.Set(cacheKey, result, DefaultCacheDuration);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results.ToFrozenDictionary();
|
||||
}
|
||||
|
||||
private static string GetCacheKey(BugReference bug) => $"bug_cve_map_{bug.Tracker}_{bug.BugId}";
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DebianSecurityTrackerClient.cs
|
||||
// Sprint: SPRINT_20251230_001_BE_backport_resolver (BP-405)
|
||||
// Task: Implement DebianSecurityTrackerClient for bug → CVE mapping
|
||||
// Description: API client for Debian Security Tracker to resolve bug IDs to CVEs
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Frozen;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Concelier.SourceIntel.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for the Debian Security Tracker API.
|
||||
/// Resolves Debian BTS bug numbers to their associated CVE identifiers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The Debian Security Tracker provides JSON APIs at:
|
||||
/// - https://security-tracker.debian.org/tracker/source-package/[package]
|
||||
/// - https://security-tracker.debian.org/tracker/data/json (full CVE database)
|
||||
///
|
||||
/// Bug references are linked via DSA (Debian Security Advisory) entries.
|
||||
/// </remarks>
|
||||
public sealed class DebianSecurityTrackerClient : IBugCveMappingService, IDisposable
|
||||
{
|
||||
private const string TrackerBaseUrl = "https://security-tracker.debian.org/tracker/data/json";
|
||||
private const string SourceName = "Debian Security Tracker";
|
||||
private static readonly TimeSpan DefaultCacheDuration = TimeSpan.FromHours(24);
|
||||
private static readonly TimeSpan HttpTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ILogger<DebianSecurityTrackerClient> _logger;
|
||||
private readonly SemaphoreSlim _loadLock = new(1, 1);
|
||||
|
||||
// Cache key for the full CVE database
|
||||
private const string CveDbCacheKey = "debian_security_tracker_cve_db";
|
||||
|
||||
public DebianSecurityTrackerClient(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IMemoryCache cache,
|
||||
ILogger<DebianSecurityTrackerClient> logger)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient("DebianSecurityTracker");
|
||||
_httpClient.Timeout = HttpTimeout;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool SupportsTracker(BugTracker tracker) => tracker == BugTracker.Debian;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<BugCveMappingResult> LookupCvesAsync(
|
||||
BugReference bugReference,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (bugReference.Tracker != BugTracker.Debian)
|
||||
{
|
||||
return BugCveMappingResult.Failure(
|
||||
bugReference,
|
||||
SourceName,
|
||||
$"Unsupported tracker: {bugReference.Tracker}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Check individual bug cache first
|
||||
var cacheKey = $"debian_bug_{bugReference.BugId}";
|
||||
if (_cache.TryGetValue<BugCveMappingResult>(cacheKey, out var cached))
|
||||
{
|
||||
_logger.LogDebug("Cache hit for Debian bug #{BugId}", bugReference.BugId);
|
||||
return cached!;
|
||||
}
|
||||
|
||||
// Load the full CVE database (cached for 24h)
|
||||
var cveDb = await LoadCveDatabaseAsync(cancellationToken);
|
||||
|
||||
// Search for bug references in the database
|
||||
var matchingCves = SearchCvesForBug(cveDb, bugReference.BugId);
|
||||
|
||||
var result = matchingCves.Count > 0
|
||||
? BugCveMappingResult.Success(bugReference, matchingCves, SourceName, 0.85)
|
||||
: BugCveMappingResult.NoCvesFound(bugReference, SourceName);
|
||||
|
||||
// Cache the result
|
||||
_cache.Set(cacheKey, result, DefaultCacheDuration);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to query Debian Security Tracker for bug #{BugId}", bugReference.BugId);
|
||||
return BugCveMappingResult.Failure(bugReference, SourceName, $"HTTP error: {ex.Message}");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw; // Re-throw cancellation
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unexpected error looking up Debian bug #{BugId}", bugReference.BugId);
|
||||
return BugCveMappingResult.Failure(bugReference, SourceName, $"Unexpected error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyDictionary<BugReference, BugCveMappingResult>> LookupCvesBatchAsync(
|
||||
IEnumerable<BugReference> bugReferences,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var debianBugs = bugReferences.Where(b => b.Tracker == BugTracker.Debian).ToList();
|
||||
var results = new Dictionary<BugReference, BugCveMappingResult>();
|
||||
|
||||
if (debianBugs.Count == 0)
|
||||
{
|
||||
return results.ToFrozenDictionary();
|
||||
}
|
||||
|
||||
// Load database once for batch lookup
|
||||
var cveDb = await LoadCveDatabaseAsync(cancellationToken);
|
||||
|
||||
foreach (var bug in debianBugs)
|
||||
{
|
||||
var cacheKey = $"debian_bug_{bug.BugId}";
|
||||
if (_cache.TryGetValue<BugCveMappingResult>(cacheKey, out var cached))
|
||||
{
|
||||
results[bug] = cached!;
|
||||
continue;
|
||||
}
|
||||
|
||||
var matchingCves = SearchCvesForBug(cveDb, bug.BugId);
|
||||
var result = matchingCves.Count > 0
|
||||
? BugCveMappingResult.Success(bug, matchingCves, SourceName, 0.85)
|
||||
: BugCveMappingResult.NoCvesFound(bug, SourceName);
|
||||
|
||||
_cache.Set(cacheKey, result, DefaultCacheDuration);
|
||||
results[bug] = result;
|
||||
}
|
||||
|
||||
return results.ToFrozenDictionary();
|
||||
}
|
||||
|
||||
private async Task<JsonDocument?> LoadCveDatabaseAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_cache.TryGetValue<JsonDocument>(CveDbCacheKey, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
await _loadLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
// Double-check after acquiring lock
|
||||
if (_cache.TryGetValue<JsonDocument>(CveDbCacheKey, out cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Loading Debian Security Tracker CVE database");
|
||||
|
||||
var response = await _httpClient.GetAsync(TrackerBaseUrl, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
|
||||
|
||||
_cache.Set(CveDbCacheKey, doc, DefaultCacheDuration);
|
||||
|
||||
_logger.LogInformation("Loaded Debian Security Tracker CVE database");
|
||||
return doc;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loadLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> SearchCvesForBug(JsonDocument? cveDb, string bugId)
|
||||
{
|
||||
var matchingCves = new List<string>();
|
||||
|
||||
if (cveDb == null)
|
||||
{
|
||||
return matchingCves;
|
||||
}
|
||||
|
||||
// The Debian Security Tracker JSON structure is:
|
||||
// { "package_name": { "CVE-XXXX-YYYY": { "releases": {...}, "debianbug": 123456, ... } } }
|
||||
foreach (var packageProp in cveDb.RootElement.EnumerateObject())
|
||||
{
|
||||
foreach (var cveProp in packageProp.Value.EnumerateObject())
|
||||
{
|
||||
if (!cveProp.Name.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for debianbug field
|
||||
if (cveProp.Value.TryGetProperty("debianbug", out var debianBugProp))
|
||||
{
|
||||
var bugNum = debianBugProp.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number => debianBugProp.GetInt64().ToString(),
|
||||
JsonValueKind.String => debianBugProp.GetString(),
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (bugNum == bugId && !matchingCves.Contains(cveProp.Name))
|
||||
{
|
||||
matchingCves.Add(cveProp.Name);
|
||||
}
|
||||
}
|
||||
|
||||
// Also check description/notes for bug references
|
||||
if (cveProp.Value.TryGetProperty("description", out var descProp) &&
|
||||
descProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var desc = descProp.GetString() ?? "";
|
||||
if ((desc.Contains($"#{bugId}") || desc.Contains($"bug {bugId}") || desc.Contains($"bug#{bugId}")) &&
|
||||
!matchingCves.Contains(cveProp.Name))
|
||||
{
|
||||
matchingCves.Add(cveProp.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matchingCves;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_loadLock.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IBugCveMappingService.cs
|
||||
// Sprint: SPRINT_20251230_001_BE_backport_resolver (BP-404)
|
||||
// Task: Create IBugCveMappingService interface for bug ID → CVE mapping
|
||||
// Description: Async lookup service for mapping bug tracker IDs to CVE identifiers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Concelier.SourceIntel.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for resolving bug tracker IDs to their associated CVE identifiers.
|
||||
/// Implementations query external bug trackers (Debian BTS, Red Hat Bugzilla, Launchpad)
|
||||
/// to discover CVE associations.
|
||||
/// </summary>
|
||||
public interface IBugCveMappingService
|
||||
{
|
||||
/// <summary>
|
||||
/// Look up CVE identifiers associated with a bug reference.
|
||||
/// </summary>
|
||||
/// <param name="bugReference">The bug reference to look up.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>
|
||||
/// A result containing the CVE IDs associated with the bug, or an empty result
|
||||
/// if no CVEs are linked or the lookup failed.
|
||||
/// </returns>
|
||||
Task<BugCveMappingResult> LookupCvesAsync(
|
||||
BugReference bugReference,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Batch lookup of CVE identifiers for multiple bug references.
|
||||
/// Implementations may optimize this for trackers that support batch queries.
|
||||
/// </summary>
|
||||
/// <param name="bugReferences">The bug references to look up.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A dictionary mapping bug references to their CVE lookup results.</returns>
|
||||
Task<IReadOnlyDictionary<BugReference, BugCveMappingResult>> LookupCvesBatchAsync(
|
||||
IEnumerable<BugReference> bugReferences,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Check if this service can handle lookups for the specified tracker.
|
||||
/// </summary>
|
||||
/// <param name="tracker">The bug tracker type.</param>
|
||||
/// <returns>True if this service can handle the tracker.</returns>
|
||||
bool SupportsTracker(BugTracker tracker);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a bug → CVE mapping lookup.
|
||||
/// </summary>
|
||||
public sealed record BugCveMappingResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The original bug reference that was looked up.
|
||||
/// </summary>
|
||||
public required BugReference Bug { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The CVE identifiers associated with this bug.
|
||||
/// Empty if no CVEs are linked.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> CveIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the lookup was successful (connected to the tracker).
|
||||
/// </summary>
|
||||
public required bool WasSuccessful { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence in the mapping (0.0 to 1.0).
|
||||
/// Higher for direct tracker data, lower for heuristic matches.
|
||||
/// </summary>
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable source of the mapping (e.g., "Debian Security Tracker", "RHBZ API").
|
||||
/// </summary>
|
||||
public required string Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when this mapping was retrieved.
|
||||
/// </summary>
|
||||
public required DateTimeOffset RetrievedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if the lookup failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Create a successful result with CVE mappings.
|
||||
/// </summary>
|
||||
public static BugCveMappingResult Success(
|
||||
BugReference bug,
|
||||
IReadOnlyList<string> cveIds,
|
||||
string source,
|
||||
double confidence = 0.80)
|
||||
{
|
||||
return new BugCveMappingResult
|
||||
{
|
||||
Bug = bug,
|
||||
CveIds = cveIds,
|
||||
WasSuccessful = true,
|
||||
Confidence = confidence,
|
||||
Source = source,
|
||||
RetrievedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a failed result indicating lookup failure.
|
||||
/// </summary>
|
||||
public static BugCveMappingResult Failure(
|
||||
BugReference bug,
|
||||
string source,
|
||||
string errorMessage)
|
||||
{
|
||||
return new BugCveMappingResult
|
||||
{
|
||||
Bug = bug,
|
||||
CveIds = [],
|
||||
WasSuccessful = false,
|
||||
Confidence = 0.0,
|
||||
Source = source,
|
||||
RetrievedAt = DateTimeOffset.UtcNow,
|
||||
ErrorMessage = errorMessage
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a result indicating no CVEs were found (but lookup succeeded).
|
||||
/// </summary>
|
||||
public static BugCveMappingResult NoCvesFound(BugReference bug, string source)
|
||||
{
|
||||
return new BugCveMappingResult
|
||||
{
|
||||
Bug = bug,
|
||||
CveIds = [],
|
||||
WasSuccessful = true,
|
||||
Confidence = 1.0, // High confidence that there are no CVEs
|
||||
Source = source,
|
||||
RetrievedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregates multiple bug → CVE mapping services and routes lookups
|
||||
/// to the appropriate implementation based on tracker type.
|
||||
/// </summary>
|
||||
public interface IBugCveMappingRouter
|
||||
{
|
||||
/// <summary>
|
||||
/// Look up CVEs for a bug reference, automatically selecting the right service.
|
||||
/// </summary>
|
||||
Task<BugCveMappingResult> LookupCvesAsync(
|
||||
BugReference bugReference,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Batch lookup across potentially multiple trackers.
|
||||
/// </summary>
|
||||
Task<IReadOnlyDictionary<BugReference, BugCveMappingResult>> LookupCvesBatchAsync(
|
||||
IEnumerable<BugReference> bugReferences,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RedHatErrataClient.cs
|
||||
// Sprint: SPRINT_20251230_001_BE_backport_resolver (BP-406)
|
||||
// Task: Implement RedHatErrataClient for bug → CVE mapping
|
||||
// Description: API client for Red Hat Bugzilla to resolve RHBZ IDs to CVEs
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Frozen;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Concelier.SourceIntel.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for Red Hat Bugzilla and Red Hat Security API.
|
||||
/// Resolves RHBZ bug numbers to their associated CVE identifiers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Red Hat provides APIs at:
|
||||
/// - Bugzilla: https://bugzilla.redhat.com/rest/bug/{id}
|
||||
/// - Security API: https://access.redhat.com/hydra/rest/securitydata/cve.json?bug={id}
|
||||
///
|
||||
/// The Security API is preferred as it provides direct CVE ↔ bug mappings.
|
||||
/// </remarks>
|
||||
public sealed class RedHatErrataClient : IBugCveMappingService, IDisposable
|
||||
{
|
||||
private const string SecurityApiBaseUrl = "https://access.redhat.com/hydra/rest/securitydata/cve.json";
|
||||
private const string BugzillaBaseUrl = "https://bugzilla.redhat.com/rest/bug";
|
||||
private const string SourceName = "Red Hat Bugzilla";
|
||||
private static readonly TimeSpan DefaultCacheDuration = TimeSpan.FromHours(24);
|
||||
private static readonly TimeSpan HttpTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ILogger<RedHatErrataClient> _logger;
|
||||
|
||||
public RedHatErrataClient(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IMemoryCache cache,
|
||||
ILogger<RedHatErrataClient> logger)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient("RedHatErrata");
|
||||
_httpClient.Timeout = HttpTimeout;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool SupportsTracker(BugTracker tracker) => tracker == BugTracker.RedHat;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<BugCveMappingResult> LookupCvesAsync(
|
||||
BugReference bugReference,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (bugReference.Tracker != BugTracker.RedHat)
|
||||
{
|
||||
return BugCveMappingResult.Failure(
|
||||
bugReference,
|
||||
SourceName,
|
||||
$"Unsupported tracker: {bugReference.Tracker}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Check cache first
|
||||
var cacheKey = $"rhbz_{bugReference.BugId}";
|
||||
if (_cache.TryGetValue<BugCveMappingResult>(cacheKey, out var cached))
|
||||
{
|
||||
_logger.LogDebug("Cache hit for RHBZ#{BugId}", bugReference.BugId);
|
||||
return cached!;
|
||||
}
|
||||
|
||||
// Try the Security API first (direct CVE mapping)
|
||||
var result = await LookupViaSecurityApiAsync(bugReference, cancellationToken);
|
||||
|
||||
if (!result.WasSuccessful || result.CveIds.Count == 0)
|
||||
{
|
||||
// Fallback to Bugzilla API for CVE aliases
|
||||
result = await LookupViaBugzillaApiAsync(bugReference, cancellationToken);
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
_cache.Set(cacheKey, result, DefaultCacheDuration);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to query Red Hat APIs for bug #{BugId}", bugReference.BugId);
|
||||
return BugCveMappingResult.Failure(bugReference, SourceName, $"HTTP error: {ex.Message}");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw; // Re-throw cancellation
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unexpected error looking up RHBZ#{BugId}", bugReference.BugId);
|
||||
return BugCveMappingResult.Failure(bugReference, SourceName, $"Unexpected error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyDictionary<BugReference, BugCveMappingResult>> LookupCvesBatchAsync(
|
||||
IEnumerable<BugReference> bugReferences,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var rhBugs = bugReferences.Where(b => b.Tracker == BugTracker.RedHat).ToList();
|
||||
var results = new Dictionary<BugReference, BugCveMappingResult>();
|
||||
|
||||
// Red Hat APIs don't support batch queries well, so we process sequentially
|
||||
// but with rate limiting to avoid overwhelming the API
|
||||
foreach (var bug in rhBugs)
|
||||
{
|
||||
var result = await LookupCvesAsync(bug, cancellationToken);
|
||||
results[bug] = result;
|
||||
|
||||
// Small delay between requests to be nice to the API
|
||||
await Task.Delay(100, cancellationToken);
|
||||
}
|
||||
|
||||
return results.ToFrozenDictionary();
|
||||
}
|
||||
|
||||
private async Task<BugCveMappingResult> LookupViaSecurityApiAsync(
|
||||
BugReference bugReference,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var url = $"{SecurityApiBaseUrl}?bug={bugReference.BugId}";
|
||||
|
||||
_logger.LogDebug("Querying Red Hat Security API for bug {BugId}", bugReference.BugId);
|
||||
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogDebug("Security API returned {StatusCode} for bug {BugId}",
|
||||
response.StatusCode, bugReference.BugId);
|
||||
return BugCveMappingResult.NoCvesFound(bugReference, SourceName);
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
var doc = await JsonDocument.ParseAsync(content, cancellationToken: cancellationToken);
|
||||
|
||||
var cves = new List<string>();
|
||||
|
||||
// Security API returns array of CVE objects: [{ "CVE": "CVE-2024-1234", ... }, ...]
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var cveObj in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
if (cveObj.TryGetProperty("CVE", out var cveProp) &&
|
||||
cveProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var cveId = cveProp.GetString();
|
||||
if (!string.IsNullOrEmpty(cveId) && !cves.Contains(cveId))
|
||||
{
|
||||
cves.Add(cveId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cves.Count > 0
|
||||
? BugCveMappingResult.Success(bugReference, cves, SourceName, 0.90)
|
||||
: BugCveMappingResult.NoCvesFound(bugReference, SourceName);
|
||||
}
|
||||
|
||||
private async Task<BugCveMappingResult> LookupViaBugzillaApiAsync(
|
||||
BugReference bugReference,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var url = $"{BugzillaBaseUrl}/{bugReference.BugId}?include_fields=id,alias,summary";
|
||||
|
||||
_logger.LogDebug("Querying Bugzilla API for bug {BugId}", bugReference.BugId);
|
||||
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogDebug("Bugzilla API returned {StatusCode} for bug {BugId}",
|
||||
response.StatusCode, bugReference.BugId);
|
||||
return BugCveMappingResult.NoCvesFound(bugReference, SourceName);
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
var doc = await JsonDocument.ParseAsync(content, cancellationToken: cancellationToken);
|
||||
|
||||
var cves = new List<string>();
|
||||
|
||||
// Bugzilla returns: { "bugs": [{ "id": 123, "alias": ["CVE-2024-1234"], "summary": "..." }] }
|
||||
if (doc.RootElement.TryGetProperty("bugs", out var bugsProp) &&
|
||||
bugsProp.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var bugObj in bugsProp.EnumerateArray())
|
||||
{
|
||||
// Check alias field for CVE IDs
|
||||
if (bugObj.TryGetProperty("alias", out var aliasProp) &&
|
||||
aliasProp.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var alias in aliasProp.EnumerateArray())
|
||||
{
|
||||
var aliasStr = alias.GetString();
|
||||
if (!string.IsNullOrEmpty(aliasStr) &&
|
||||
aliasStr.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase) &&
|
||||
!cves.Contains(aliasStr))
|
||||
{
|
||||
cves.Add(aliasStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check summary for CVE mentions
|
||||
if (bugObj.TryGetProperty("summary", out var summaryProp) &&
|
||||
summaryProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var summary = summaryProp.GetString() ?? "";
|
||||
var cveMatches = System.Text.RegularExpressions.Regex.Matches(
|
||||
summary, @"CVE-\d{4}-\d{4,}");
|
||||
foreach (System.Text.RegularExpressions.Match match in cveMatches)
|
||||
{
|
||||
if (!cves.Contains(match.Value))
|
||||
{
|
||||
cves.Add(match.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cves.Count > 0
|
||||
? BugCveMappingResult.Success(bugReference, cves, SourceName, 0.80)
|
||||
: BugCveMappingResult.NoCvesFound(bugReference, SourceName);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// HttpClient is managed by factory, don't dispose
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user