save progress

This commit is contained in:
StellaOps Bot
2026-01-02 21:06:27 +02:00
parent f46bde5575
commit 3f197814c5
441 changed files with 21545 additions and 4306 deletions

View File

@@ -92,13 +92,44 @@ public sealed record VersionRange(
bool MaxInclusive);
/// <summary>
/// Evidence pointer to source document.
/// Evidence pointer to source document with tier classification for audit.
/// </summary>
/// <param name="SourceType">Type of evidence source (e.g., "debian-tracker", "alpine-secdb").</param>
/// <param name="SourceUrl">URL or URI to the evidence source.</param>
/// <param name="SourceDigest">Snapshot hash for deterministic replay.</param>
/// <param name="FetchedAt">Timestamp when evidence was retrieved.</param>
/// <param name="TierSource">Evidence tier classification (BP-604).</param>
public sealed record EvidencePointer(
string SourceType, // e.g., "debian-tracker", "alpine-secdb"
string SourceType,
string SourceUrl,
string? SourceDigest, // Snapshot hash for replay
DateTimeOffset FetchedAt);
string? SourceDigest,
DateTimeOffset FetchedAt,
EvidenceTier TierSource = EvidenceTier.Unknown);
/// <summary>
/// Evidence tier classification for the 5-tier hierarchy (BP-604).
/// Used for audit trails and confidence scoring.
/// </summary>
public enum EvidenceTier
{
/// <summary>Tier not determined or unknown source.</summary>
Unknown = 0,
/// <summary>Tier 5: NVD/CPE version ranges (lowest confidence, fallback).</summary>
NvdRange = 5,
/// <summary>Tier 4: Upstream commit mapping.</summary>
UpstreamCommit = 4,
/// <summary>Tier 3: Source patch files (HunkSig).</summary>
SourcePatch = 3,
/// <summary>Tier 2: Changelog CVE/bug mentions.</summary>
Changelog = 2,
/// <summary>Tier 1: OVAL/CSAF distro evidence (highest confidence).</summary>
DistroOval = 1
}
/// <summary>
/// Fix status values.
@@ -114,11 +145,46 @@ public enum FixStatus
}
/// <summary>
/// Rule priority levels.
/// Rule priority levels following the 5-tier evidence hierarchy.
/// Higher values indicate higher priority/confidence.
/// </summary>
public enum RulePriority
{
DistroNative = 100, // Highest - from distro's own security tracker
VendorCsaf = 90, // Vendor CSAF/VEX
ThirdParty = 50 // Lowest - inferred or community
// Tier 5: NVD range heuristic (lowest confidence)
/// <summary>NVD/CPE version range heuristic (Tier 5, Low confidence).</summary>
NvdRangeHeuristic = 20,
// Tier 4: Upstream commits
/// <summary>Partial upstream commit match (Tier 4).</summary>
UpstreamCommitPartialMatch = 45,
/// <summary>100% hunk parity with upstream commit (Tier 4).</summary>
UpstreamCommitExactParity = 55,
// Tier 3: Source patches
/// <summary>Fuzzy function name + context match (Tier 3).</summary>
SourcePatchFuzzyMatch = 60,
/// <summary>Exact hunk hash match (Tier 3).</summary>
SourcePatchExactMatch = 70,
// Tier 2: Changelog evidence
/// <summary>Bug ID mapped to CVE (Tier 2).</summary>
ChangelogBugIdMapped = 75,
/// <summary>Direct CVE mention in changelog (Tier 2).</summary>
ChangelogExplicitCve = 85,
// Tier 1: OVAL/CSAF evidence (highest confidence)
/// <summary>Medium-confidence derivative OVAL (e.g., Mint for Ubuntu) (Tier 1).</summary>
DerivativeOvalMedium = 90,
/// <summary>High-confidence derivative OVAL (e.g., Alma/Rocky for RHEL) (Tier 1).</summary>
DerivativeOvalHigh = 95,
/// <summary>Distro's own native OVAL/CSAF (Tier 1, highest priority).</summary>
DistroNativeOval = 100,
// Legacy compatibility aliases
/// <summary>Alias for DistroNativeOval for backward compatibility.</summary>
DistroNative = DistroNativeOval,
/// <summary>Alias for ChangelogExplicitCve for backward compatibility.</summary>
VendorCsaf = ChangelogExplicitCve,
/// <summary>Alias for NvdRangeHeuristic for backward compatibility.</summary>
ThirdParty = NvdRangeHeuristic
}

View File

@@ -1,29 +1,35 @@
// -----------------------------------------------------------------------------
// BackportStatusService.cs
// Sprint: SPRINT_20251229_004_002_BE_backport_status_service (BP-007)
// Task: Implement BackportStatusService.EvalPatchedStatus()
// Sprint: SPRINT_20251230_001_BE_backport_resolver (BP-102, BP-103, BP-201, BP-202)
// Task: Implement BackportStatusService with ecosystem-specific version comparators
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using StellaOps.Concelier.BackportProof.Models;
using StellaOps.Concelier.BackportProof.Repositories;
using StellaOps.VersionComparison;
namespace StellaOps.Concelier.BackportProof.Services;
/// <summary>
/// Implementation of backport status evaluation service.
/// Uses deterministic algorithm to compute patch status from fix rules.
/// Integrates ecosystem-specific version comparators (RPM, Deb, APK) for accurate
/// version comparison with proof-line generation for explainability.
/// </summary>
public sealed class BackportStatusService : IBackportStatusService
{
private readonly IFixRuleRepository _ruleRepository;
private readonly IVersionComparatorFactory _comparatorFactory;
private readonly ILogger<BackportStatusService> _logger;
public BackportStatusService(
IFixRuleRepository ruleRepository,
IVersionComparatorFactory comparatorFactory,
ILogger<BackportStatusService> logger)
{
_ruleRepository = ruleRepository;
_comparatorFactory = comparatorFactory;
_logger = logger;
}
@@ -181,6 +187,9 @@ public sealed class BackportStatusService : IBackportStatusService
InstalledPackage package,
IReadOnlyList<BoundaryRule> rules)
{
// Get ecosystem-specific version comparator
var comparator = _comparatorFactory.GetComparator(package.Key.Ecosystem);
// Get highest priority rules
var topPriority = rules.Max(r => r.Priority);
var topRules = rules.Where(r => r.Priority == topPriority).ToList();
@@ -189,20 +198,36 @@ public sealed class BackportStatusService : IBackportStatusService
var distinctFixVersions = topRules.Select(r => r.FixedVersion).Distinct().ToList();
var hasConflict = distinctFixVersions.Count > 1;
// For now, use simple string comparison
// TODO: Integrate proper version comparators (EVR, dpkg, apk, semver)
var fixedVersion = hasConflict
? distinctFixVersions.Max() // Conservative: use highest version
: distinctFixVersions[0];
// Use the best comparator to determine the highest (most restrictive) fix version
string fixedVersion;
if (hasConflict)
{
// Conservative: use highest version among conflicting rules
fixedVersion = distinctFixVersions
.OrderByDescending(v => v, Comparer<string>.Create((a, b) => comparator.Compare(a, b)))
.First();
}
else
{
fixedVersion = distinctFixVersions[0];
}
var isPatched = string.Compare(package.InstalledVersion, fixedVersion, StringComparison.Ordinal) >= 0;
// Compare installed version against fixed version using ecosystem-specific semantics
var comparisonResult = comparator.CompareWithProof(package.InstalledVersion, fixedVersion);
var isPatched = comparisonResult.IsGreaterThanOrEqual;
var status = isPatched ? FixStatus.Patched : FixStatus.Vulnerable;
var confidence = hasConflict ? VerdictConfidence.Medium : VerdictConfidence.High;
_logger.LogDebug(
"Boundary evaluation for {CVE}: installed={Installed}, fixed={Fixed}, status={Status}",
cve, package.InstalledVersion, fixedVersion, status);
"Boundary evaluation for {CVE}: installed={Installed}, fixed={Fixed}, status={Status}, comparator={Comparator}",
cve, package.InstalledVersion, fixedVersion, status, comparisonResult.Comparator);
// Include proof lines in evidence for explainability
foreach (var proofLine in comparisonResult.ProofLines)
{
_logger.LogDebug(" Proof: {ProofLine}", proofLine);
}
return new BackportVerdict(
Cve: cve,
@@ -213,7 +238,8 @@ public sealed class BackportStatusService : IBackportStatusService
HasConflict: hasConflict,
ConflictReason: hasConflict
? $"Multiple fix versions at priority {topPriority}: {string.Join(", ", distinctFixVersions)}"
: null
: null,
ProofLines: comparisonResult.ProofLines
);
}
@@ -222,20 +248,97 @@ public sealed class BackportStatusService : IBackportStatusService
InstalledPackage package,
IReadOnlyList<RangeRule> rules)
{
// Get ecosystem-specific version comparator
var comparator = _comparatorFactory.GetComparator(package.Key.Ecosystem);
var proofLines = new List<string>();
proofLines.Add($"Evaluating {rules.Count} range rule(s) for {package.Key.PackageName} @ {package.InstalledVersion}");
// Check if installed version is in any affected range
// TODO: Implement proper range checking with version comparators
// For now, return Unknown with medium confidence
foreach (var rule in rules.OrderByDescending(r => r.Priority))
{
var range = rule.AffectedRange;
var inRange = true;
var rangeDescription = new List<string>();
_logger.LogDebug("Range rules found for {CVE}, but not yet implemented", cve);
// Check lower bound
if (range.MinVersion != null)
{
var minResult = comparator.CompareWithProof(package.InstalledVersion, range.MinVersion);
var satisfiesMin = range.MinInclusive
? minResult.IsGreaterThanOrEqual
: minResult.IsGreaterThan;
inRange &= satisfiesMin;
var boundType = range.MinInclusive ? ">=" : ">";
var satisfiedStr = satisfiesMin ? "satisfied" : "NOT satisfied";
rangeDescription.Add($"Min bound: {package.InstalledVersion} {boundType} {range.MinVersion} ({satisfiedStr})");
}
else
{
rangeDescription.Add("Min bound: (unbounded)");
}
// Check upper bound
if (range.MaxVersion != null)
{
var maxResult = comparator.CompareWithProof(package.InstalledVersion, range.MaxVersion);
var satisfiesMax = range.MaxInclusive
? maxResult.Comparison <= 0
: maxResult.IsLessThan;
inRange &= satisfiesMax;
var boundType = range.MaxInclusive ? "<=" : "<";
var satisfiedStr = satisfiesMax ? "satisfied" : "NOT satisfied";
rangeDescription.Add($"Max bound: {package.InstalledVersion} {boundType} {range.MaxVersion} ({satisfiedStr})");
}
else
{
rangeDescription.Add("Max bound: (unbounded)");
}
proofLines.AddRange(rangeDescription);
if (inRange)
{
// Version is within affected range - VULNERABLE
proofLines.Add($"Version {package.InstalledVersion} is within affected range: VULNERABLE");
_logger.LogDebug(
"Range evaluation for {CVE}: {Version} is in affected range [{Min}, {Max}]",
cve, package.InstalledVersion, range.MinVersion ?? "∞", range.MaxVersion ?? "∞");
return new BackportVerdict(
Cve: cve,
Status: FixStatus.Vulnerable,
Confidence: VerdictConfidence.Low, // Tier 5 - NVD range heuristic
AppliedRuleIds: [rule.RuleId],
Evidence: [rule.Evidence],
HasConflict: false,
ConflictReason: null,
ProofLines: [.. proofLines]
);
}
}
// Version is outside all affected ranges
proofLines.Add($"Version {package.InstalledVersion} is outside all affected ranges: potentially FIXED (Low confidence)");
_logger.LogDebug(
"Range evaluation for {CVE}: {Version} is outside all affected ranges",
cve, package.InstalledVersion);
// If not in any range, consider it potentially fixed but with low confidence
// since NVD ranges are heuristic (Tier 5)
return new BackportVerdict(
Cve: cve,
Status: FixStatus.Unknown,
Confidence: VerdictConfidence.Medium,
Status: FixStatus.Patched,
Confidence: VerdictConfidence.Low, // Tier 5 - Low confidence
AppliedRuleIds: rules.Select(r => r.RuleId).ToList(),
Evidence: rules.Select(r => r.Evidence).ToList(),
HasConflict: false,
ConflictReason: "Range evaluation not fully implemented"
ConflictReason: "NVD range heuristic - low confidence; version outside affected ranges",
ProofLines: [.. proofLines]
);
}
}

View File

@@ -1,9 +1,10 @@
// -----------------------------------------------------------------------------
// IBackportStatusService.cs
// Sprint: SPRINT_20251229_004_002_BE_backport_status_service (BP-007)
// Task: Create BackportStatusService interface
// Sprint: SPRINT_20251230_001_BE_backport_resolver (BP-102, BP-103, BP-201)
// Task: Create BackportStatusService interface with proof lines support
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using StellaOps.Concelier.BackportProof.Models;
namespace StellaOps.Concelier.BackportProof.Services;
@@ -55,8 +56,16 @@ public sealed record InstalledPackage(
string? SourcePackage);
/// <summary>
/// Backport patch status verdict.
/// Backport patch status verdict with explainability support.
/// </summary>
/// <param name="Cve">The CVE identifier.</param>
/// <param name="Status">Computed fix status.</param>
/// <param name="Confidence">Confidence level of the verdict.</param>
/// <param name="AppliedRuleIds">Rule IDs that contributed to the verdict.</param>
/// <param name="Evidence">Evidence pointers from applied rules.</param>
/// <param name="HasConflict">True if conflicting rules were detected.</param>
/// <param name="ConflictReason">Description of the conflict if any.</param>
/// <param name="ProofLines">Human-readable proof lines explaining the verdict (for explainability).</param>
public sealed record BackportVerdict(
string Cve,
FixStatus Status,
@@ -64,7 +73,14 @@ public sealed record BackportVerdict(
IReadOnlyList<string> AppliedRuleIds,
IReadOnlyList<EvidencePointer> Evidence,
bool HasConflict,
string? ConflictReason);
string? ConflictReason,
ImmutableArray<string> ProofLines = default)
{
/// <summary>
/// Returns true if the verdict has proof lines.
/// </summary>
public bool HasProofLines => !ProofLines.IsDefaultOrEmpty;
}
/// <summary>
/// Verdict confidence levels.

View File

@@ -0,0 +1,90 @@
// -----------------------------------------------------------------------------
// IVersionComparatorFactory.cs
// Sprint: SPRINT_20251230_001_BE_backport_resolver (BP-101)
// Task: Create IVersionComparatorFactory interface for DI registration
// -----------------------------------------------------------------------------
using StellaOps.Concelier.BackportProof.Models;
using StellaOps.VersionComparison;
using VersionComparer = StellaOps.VersionComparison.IVersionComparator;
using MergeVersionComparer = StellaOps.Concelier.Merge.Comparers.IVersionComparator;
namespace StellaOps.Concelier.BackportProof.Services;
/// <summary>
/// Factory interface for obtaining ecosystem-specific version comparators.
/// Enables dependency injection and testability of version comparison logic.
/// </summary>
public interface IVersionComparatorFactory
{
/// <summary>
/// Gets the appropriate version comparator for the specified package ecosystem.
/// </summary>
/// <param name="ecosystem">The package ecosystem (RPM, Deb, Apk, etc.).</param>
/// <returns>An <see cref="IVersionComparator"/> instance suitable for the ecosystem.</returns>
VersionComparer GetComparator(PackageEcosystem ecosystem);
}
/// <summary>
/// Default implementation of <see cref="IVersionComparatorFactory"/>.
/// Returns ecosystem-specific comparators for RPM, Deb, and Apk,
/// with fallback to string comparison for unknown ecosystems.
/// </summary>
public sealed class VersionComparatorFactory : IVersionComparatorFactory
{
private readonly IReadOnlyDictionary<PackageEcosystem, VersionComparer> _comparators;
private readonly VersionComparer _fallback;
/// <summary>
/// Initializes a new instance with the provided comparators.
/// </summary>
/// <param name="rpmComparer">RPM version comparer.</param>
/// <param name="debianComparer">Debian/Ubuntu version comparer.</param>
/// <param name="apkComparer">Alpine APK version comparer (wrapped from Merge.Comparers).</param>
/// <param name="fallbackComparer">Fallback comparator for unknown ecosystems (optional).</param>
public VersionComparatorFactory(
VersionComparer rpmComparer,
VersionComparer debianComparer,
MergeVersionComparer apkComparer,
VersionComparer? fallbackComparer = null)
{
_comparators = new Dictionary<PackageEcosystem, VersionComparer>
{
[PackageEcosystem.Rpm] = rpmComparer,
[PackageEcosystem.Deb] = debianComparer,
[PackageEcosystem.Apk] = new MergeComparerAdapter(apkComparer),
}.AsReadOnly();
_fallback = fallbackComparer ?? StellaOps.VersionComparison.Comparers.StringVersionComparer.Instance;
}
/// <inheritdoc />
public VersionComparer GetComparator(PackageEcosystem ecosystem)
{
return _comparators.TryGetValue(ecosystem, out var comparator)
? comparator
: _fallback;
}
/// <summary>
/// Adapts a Merge.Comparers.IVersionComparator to VersionComparison.IVersionComparator.
/// Both interfaces have identical signatures, so this is a simple delegation.
/// </summary>
private sealed class MergeComparerAdapter : VersionComparer
{
private readonly MergeVersionComparer _inner;
public MergeComparerAdapter(MergeVersionComparer inner) => _inner = inner;
public ComparatorType ComparatorType => _inner.ComparatorType;
public int Compare(string? left, string? right) => _inner.Compare(left, right);
public VersionComparisonResult CompareWithProof(string? left, string? right)
{
var result = _inner.CompareWithProof(left, right);
// Both VersionComparisonResult types have identical structure
return new VersionComparisonResult(result.Comparison, result.ProofLines, result.Comparator);
}
}
}

View File

@@ -0,0 +1,41 @@
// -----------------------------------------------------------------------------
// ServiceCollectionExtensions.cs
// Sprint: SPRINT_20251230_001_BE_backport_resolver (BP-102)
// Task: Create DI registration for BackportProof services
// -----------------------------------------------------------------------------
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Concelier.BackportProof.Repositories;
using StellaOps.Concelier.Merge.Comparers;
using StellaOps.VersionComparison.Comparers;
namespace StellaOps.Concelier.BackportProof.Services;
/// <summary>
/// Extension methods for registering BackportProof services.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers BackportProof services including the BackportStatusService
/// and ecosystem-specific version comparators.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddBackportProofServices(this IServiceCollection services)
{
// Register comparator factory
services.AddSingleton<IVersionComparatorFactory>(sp =>
{
return new VersionComparatorFactory(
RpmVersionComparer.Instance,
DebianVersionComparer.Instance,
ApkVersionComparer.Instance);
});
// Register backport status service
services.AddScoped<IBackportStatusService, BackportStatusService>();
return services;
}
}

View File

@@ -16,5 +16,8 @@
<ItemGroup>
<ProjectReference Include="../../../__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.VersionComparison/StellaOps.VersionComparison.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.DistroIntel/StellaOps.DistroIntel.csproj" />
<ProjectReference Include="../StellaOps.Concelier.Merge/StellaOps.Concelier.Merge.csproj" />
</ItemGroup>
</Project>

View File

@@ -122,9 +122,16 @@ internal static class SuseCsafParser
continue;
}
productId = productId.Trim();
if (!productLookup.TryGetValue(productId, out var product))
{
continue;
if (!TryCreateProductFromId(productId, out product))
{
continue;
}
productLookup[productId] = product;
}
if (!packageBuilders.TryGetValue(productId, out var builder))
@@ -273,8 +280,9 @@ internal static class SuseCsafParser
if (!string.IsNullOrWhiteSpace(productId))
{
productId = productId.Trim();
var productName = productElement.TryGetProperty("name", out var productNameElement)
? productNameElement.GetString()
? productNameElement.GetString()?.Trim()
: productId;
var (platformName, packageSegment) = SplitProductId(productId!, nextPlatform);
@@ -323,6 +331,31 @@ internal static class SuseCsafParser
return (platformNormalized, packageNormalized);
}
private static bool TryCreateProductFromId(string productId, out SuseProduct product)
{
product = null!;
if (string.IsNullOrWhiteSpace(productId))
{
return false;
}
var (platform, packageSegment) = SplitProductId(productId.Trim(), null);
if (string.IsNullOrWhiteSpace(packageSegment))
{
return false;
}
if (!Nevra.TryParse(packageSegment.Trim(), out var nevra))
{
return false;
}
var platformName = string.IsNullOrWhiteSpace(platform) ? "SUSE" : platform.Trim();
product = new SuseProduct(productId.Trim(), platformName, nevra!, nevra!.Architecture);
return true;
}
private static string FormatNevraVersion(Nevra nevra)
{
var epochSegment = nevra.HasExplicitEpoch || nevra.Epoch > 0 ? $"{nevra.Epoch}:" : string.Empty;

View File

@@ -182,14 +182,16 @@ internal static class SuseMapper
private static IReadOnlyList<AffectedPackageStatus> BuildStatuses(SusePackageStateDto package, AdvisoryProvenance provenance)
{
if (string.IsNullOrWhiteSpace(package.Status))
if (!AffectedPackageStatusCatalog.TryNormalize(package.Status, out var normalized))
{
return Array.Empty<AffectedPackageStatus>();
normalized = string.IsNullOrWhiteSpace(package.FixedVersion)
? AffectedPackageStatusCatalog.UnderInvestigation
: AffectedPackageStatusCatalog.Fixed;
}
return new[]
{
new AffectedPackageStatus(package.Status, provenance)
new AffectedPackageStatus(normalized, provenance)
};
}

View File

@@ -8,3 +8,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| AUDIT-0169-M | DONE | Maintainability audit for StellaOps.Concelier.Connector.Distro.Suse. |
| AUDIT-0169-T | DONE | Test coverage audit for StellaOps.Concelier.Connector.Distro.Suse. |
| AUDIT-0169-A | TODO | Pending approval for changes. |
| CICD-VAL-SMOKE-001 | DOING | Smoke validation: trim CSAF product IDs to preserve package mapping. |

View File

@@ -254,7 +254,7 @@ public sealed class EpssConnector : IFeedConnector
metadata["epss.rowCount"] = session.RowCount.ToString(CultureInfo.InvariantCulture);
metadata["epss.contentHash"] = contentHash;
var updatedDocument = document with { Metadata = metadata };
var updatedDocument = document with { Metadata = metadata, Status = DocumentStatuses.PendingMap };
await _documentStore.UpsertAsync(updatedDocument, cancellationToken).ConfigureAwait(false);
remainingDocuments.Remove(documentId);

View File

@@ -8,3 +8,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| AUDIT-0173-M | DONE | Maintainability audit for StellaOps.Concelier.Connector.Epss. |
| AUDIT-0173-T | DONE | Test coverage audit for StellaOps.Concelier.Connector.Epss. |
| AUDIT-0173-A | TODO | Pending approval for changes. |
| CICD-VAL-SMOKE-001 | DONE | Smoke validation: keep document status as pending-map after parse. |

View File

@@ -288,14 +288,8 @@ internal static class GhsaMapper
}
var trimmed = cweId.Trim();
var dashIndex = trimmed.IndexOf('-');
if (dashIndex < 0 || dashIndex == trimmed.Length - 1)
{
return null;
}
var digits = new StringBuilder();
for (var i = dashIndex + 1; i < trimmed.Length; i++)
for (var i = 0; i < trimmed.Length; i++)
{
var ch = trimmed[i];
if (char.IsDigit(ch))

View File

@@ -328,7 +328,8 @@ public sealed class RuBduConnector : IFeedConnector
RuBduVulnerabilityDto dto;
try
{
dto = JsonSerializer.Deserialize<RuBduVulnerabilityDto>(dtoRecord.Payload.ToString(), SerializerOptions) ?? throw new InvalidOperationException("DTO deserialized to null");
var payloadJson = dtoRecord.Payload.ToJson();
dto = JsonSerializer.Deserialize<RuBduVulnerabilityDto>(payloadJson, SerializerOptions) ?? throw new InvalidOperationException("DTO deserialized to null");
}
catch (Exception ex)
{

View File

@@ -19,6 +19,7 @@ public static class JobServiceCollectionExtensions
services.AddSingleton(sp => sp.GetRequiredService<IOptions<JobSchedulerOptions>>().Value);
services.AddSingleton<JobDiagnostics>();
services.TryAddSingleton(TimeProvider.System);
services.TryAddSingleton<IJobStore, InMemoryJobStore>();
services.AddSingleton<IJobCoordinator, JobCoordinator>();
services.AddHostedService<JobSchedulerHostedService>();

View File

@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Encodings.Web;
@@ -174,6 +175,16 @@ public static class CanonicalJsonSerializer
}
}
if (info.Type == typeof(Advisory))
{
var mergeHashProperty = info.Properties
.FirstOrDefault(property => string.Equals(property.Name, "mergeHash", StringComparison.OrdinalIgnoreCase));
if (mergeHashProperty is not null)
{
mergeHashProperty.ShouldSerialize = (_, value) => value is not null;
}
}
return info;
}

View File

@@ -8,3 +8,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| AUDIT-0226-M | DONE | Maintainability audit for StellaOps.Concelier.Models. |
| AUDIT-0226-T | DONE | Test coverage audit for StellaOps.Concelier.Models. |
| AUDIT-0226-A | TODO | Pending approval for changes. |
| CICD-VAL-SMOKE-001 | DONE | Smoke validation: canonical snapshot mergeHash omission. |

View File

@@ -233,12 +233,17 @@ public sealed class PostgresAdvisoryStore : IPostgresAdvisoryStore, AdvisoryCont
c.CreditType,
c.Contact is not null ? new[] { c.Contact } : Array.Empty<string>(),
AdvisoryProvenance.Empty)).ToArray();
var referenceModels = references.Select(r => new AdvisoryReference(
r.Url,
r.RefType,
null,
null,
AdvisoryProvenance.Empty)).ToArray();
var referenceDetails = TryReadReferenceDetails(entity.RawPayload);
var referenceModels = references.Select(r =>
{
referenceDetails.TryGetValue(r.Url, out var detail);
return new AdvisoryReference(
r.Url,
r.RefType,
detail.SourceTag,
detail.Summary,
AdvisoryProvenance.Empty);
}).ToArray();
var cvssModels = cvss.Select(c => new CvssMetric(
c.CvssVersion,
c.VectorString,
@@ -269,7 +274,7 @@ public sealed class PostgresAdvisoryStore : IPostgresAdvisoryStore, AdvisoryCont
}
}
var (platform, normalizedVersions) = ReadDatabaseSpecific(a.DatabaseSpecific);
var (platform, normalizedVersions, statuses) = ReadDatabaseSpecific(a.DatabaseSpecific);
var effectivePlatform = platform ?? ResolvePlatformFromRanges(versionRanges);
var resolvedNormalizedVersions = normalizedVersions ?? BuildNormalizedVersions(versionRanges);
@@ -278,7 +283,7 @@ public sealed class PostgresAdvisoryStore : IPostgresAdvisoryStore, AdvisoryCont
a.PackageName,
effectivePlatform,
versionRanges,
Array.Empty<AffectedPackageStatus>(),
statuses ?? Array.Empty<AffectedPackageStatus>(),
Array.Empty<AdvisoryProvenance>(),
resolvedNormalizedVersions);
}).ToArray();
@@ -377,6 +382,63 @@ public sealed class PostgresAdvisoryStore : IPostgresAdvisoryStore, AdvisoryCont
}
}
private static IReadOnlyDictionary<string, (string? SourceTag, string? Summary)> TryReadReferenceDetails(string? rawPayload)
{
if (string.IsNullOrWhiteSpace(rawPayload))
{
return new Dictionary<string, (string?, string?)>(StringComparer.OrdinalIgnoreCase);
}
try
{
using var document = JsonDocument.Parse(rawPayload, new JsonDocumentOptions
{
AllowTrailingCommas = true
});
if (!document.RootElement.TryGetProperty("references", out var referencesElement)
|| referencesElement.ValueKind != JsonValueKind.Array)
{
return new Dictionary<string, (string?, string?)>(StringComparer.OrdinalIgnoreCase);
}
var lookup = new Dictionary<string, (string?, string?)>(StringComparer.OrdinalIgnoreCase);
foreach (var element in referencesElement.EnumerateArray())
{
if (element.ValueKind != JsonValueKind.Object)
{
continue;
}
if (!element.TryGetProperty("url", out var urlElement) || urlElement.ValueKind != JsonValueKind.String)
{
continue;
}
var url = urlElement.GetString();
if (string.IsNullOrWhiteSpace(url))
{
continue;
}
var sourceTag = element.TryGetProperty("sourceTag", out var sourceTagElement) && sourceTagElement.ValueKind == JsonValueKind.String
? sourceTagElement.GetString()
: null;
var summary = element.TryGetProperty("summary", out var summaryElement) && summaryElement.ValueKind == JsonValueKind.String
? summaryElement.GetString()
: null;
lookup[url] = (sourceTag, summary);
}
return lookup;
}
catch (JsonException)
{
return new Dictionary<string, (string?, string?)>(StringComparer.OrdinalIgnoreCase);
}
}
private static string MapEcosystemToType(string ecosystem)
{
return ecosystem.ToLowerInvariant() switch
@@ -402,11 +464,14 @@ public sealed class PostgresAdvisoryStore : IPostgresAdvisoryStore, AdvisoryCont
};
}
private static (string? Platform, IReadOnlyList<NormalizedVersionRule>? NormalizedVersions) ReadDatabaseSpecific(string? databaseSpecific)
private static (
string? Platform,
IReadOnlyList<NormalizedVersionRule>? NormalizedVersions,
IReadOnlyList<AffectedPackageStatus>? Statuses) ReadDatabaseSpecific(string? databaseSpecific)
{
if (string.IsNullOrWhiteSpace(databaseSpecific) || databaseSpecific == "{}")
{
return (null, null);
return (null, null, null);
}
try
@@ -426,11 +491,24 @@ public sealed class PostgresAdvisoryStore : IPostgresAdvisoryStore, AdvisoryCont
normalizedVersions = JsonSerializer.Deserialize<NormalizedVersionRule[]>(normalizedValue.GetRawText(), JsonOptions);
}
return (platform, normalizedVersions);
IReadOnlyList<AffectedPackageStatus>? statuses = null;
if (root.TryGetProperty("statuses", out var statusValue) && statusValue.ValueKind == JsonValueKind.Array)
{
var statusStrings = JsonSerializer.Deserialize<string[]>(statusValue.GetRawText(), JsonOptions);
if (statusStrings is { Length: > 0 })
{
statuses = statusStrings
.Where(static status => !string.IsNullOrWhiteSpace(status))
.Select(static status => new AffectedPackageStatus(status.Trim(), AdvisoryProvenance.Empty))
.ToArray();
}
}
return (platform, normalizedVersions, statuses);
}
catch (JsonException)
{
return (null, null);
return (null, null, null);
}
}

View File

@@ -88,7 +88,7 @@ public sealed class AdvisoryConverter
ImpactScore = null,
Source = metric.Provenance.Source,
IsPrimary = isPrimaryCvss,
CreatedAt = now
CreatedAt = metric.Provenance.RecordedAt
});
isPrimaryCvss = false;
}
@@ -264,6 +264,11 @@ public sealed class AdvisoryConverter
payload["normalizedVersions"] = package.NormalizedVersions;
}
if (!package.Statuses.IsEmpty)
{
payload["statuses"] = package.Statuses.Select(static status => status.Status).ToArray();
}
return payload.Count == 0
? null
: JsonSerializer.Serialize(payload, JsonOptions);

View File

@@ -130,6 +130,7 @@ public sealed class AdvisoryCanonicalRepository : RepositoryBase<ConcelierDataSo
public async Task<Guid> UpsertAsync(AdvisoryCanonicalEntity entity, CancellationToken ct = default)
{
var normalizedSeverity = NormalizeSeverity(entity.Severity);
const string sql = """
INSERT INTO vuln.advisory_canonical
(id, cve, affects_key, version_range, weakness, merge_hash,
@@ -161,7 +162,7 @@ public sealed class AdvisoryCanonicalRepository : RepositoryBase<ConcelierDataSo
AddTextArrayParameter(cmd, "weakness", entity.Weakness);
AddParameter(cmd, "merge_hash", entity.MergeHash);
AddParameter(cmd, "status", entity.Status);
AddParameter(cmd, "severity", entity.Severity);
AddParameter(cmd, "severity", normalizedSeverity);
AddParameter(cmd, "epss_score", entity.EpssScore);
AddParameter(cmd, "exploit_known", entity.ExploitKnown);
AddParameter(cmd, "title", entity.Title);
@@ -235,6 +236,16 @@ public sealed class AdvisoryCanonicalRepository : RepositoryBase<ConcelierDataSo
#endregion
private static string? NormalizeSeverity(string? severity)
{
if (string.IsNullOrWhiteSpace(severity))
{
return null;
}
return severity.Trim().ToLowerInvariant();
}
#region Source Edge Operations
public Task<IReadOnlyList<AdvisorySourceEdgeEntity>> GetSourceEdgesAsync(Guid canonicalId, CancellationToken ct = default)

View File

@@ -33,10 +33,10 @@ public sealed class AdvisoryCvssRepository : RepositoryBase<ConcelierDataSource>
const string insertSql = """
INSERT INTO vuln.advisory_cvss
(id, advisory_id, cvss_version, vector_string, base_score, base_severity,
exploitability_score, impact_score, source, is_primary)
exploitability_score, impact_score, source, is_primary, created_at)
VALUES
(@id, @advisory_id, @cvss_version, @vector_string, @base_score, @base_severity,
@exploitability_score, @impact_score, @source, @is_primary)
@exploitability_score, @impact_score, @source, @is_primary, @created_at)
""";
foreach (var score in scores)
@@ -53,6 +53,7 @@ public sealed class AdvisoryCvssRepository : RepositoryBase<ConcelierDataSource>
AddParameter(insertCmd, "impact_score", score.ImpactScore);
AddParameter(insertCmd, "source", score.Source);
AddParameter(insertCmd, "is_primary", score.IsPrimary);
AddParameter(insertCmd, "created_at", score.CreatedAt);
await insertCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}

View File

@@ -194,6 +194,7 @@ public sealed class AdvisoryRepository : RepositoryBase<ConcelierDataSource>, IA
int offset = 0,
CancellationToken cancellationToken = default)
{
var normalizedSeverity = NormalizeSeverity(severity);
var sql = """
SELECT id, advisory_key, primary_vuln_id, source_id, title, summary, description,
severity, published_at, modified_at, withdrawn_at, provenance::text, raw_Payload::text,
@@ -203,7 +204,7 @@ public sealed class AdvisoryRepository : RepositoryBase<ConcelierDataSource>, IA
WHERE search_vector @@ websearch_to_tsquery('english', @query)
""";
if (!string.IsNullOrEmpty(severity))
if (!string.IsNullOrEmpty(normalizedSeverity))
{
sql += " AND severity = @severity";
}
@@ -216,9 +217,9 @@ public sealed class AdvisoryRepository : RepositoryBase<ConcelierDataSource>, IA
cmd =>
{
AddParameter(cmd, "query", query);
if (!string.IsNullOrEmpty(severity))
if (!string.IsNullOrEmpty(normalizedSeverity))
{
AddParameter(cmd, "severity", severity);
AddParameter(cmd, "severity", normalizedSeverity);
}
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
@@ -234,6 +235,8 @@ public sealed class AdvisoryRepository : RepositoryBase<ConcelierDataSource>, IA
int offset = 0,
CancellationToken cancellationToken = default)
{
var normalizedSeverity = NormalizeSeverity(severity)
?? throw new ArgumentException("Severity must be provided.", nameof(severity));
const string sql = """
SELECT id, advisory_key, primary_vuln_id, source_id, title, summary, description,
severity, published_at, modified_at, withdrawn_at, provenance::text, raw_Payload::text,
@@ -249,7 +252,7 @@ public sealed class AdvisoryRepository : RepositoryBase<ConcelierDataSource>, IA
sql,
cmd =>
{
AddParameter(cmd, "severity", severity);
AddParameter(cmd, "severity", normalizedSeverity);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
},
@@ -363,6 +366,7 @@ public sealed class AdvisoryRepository : RepositoryBase<ConcelierDataSource>, IA
IEnumerable<KevFlagEntity>? kevFlags,
CancellationToken cancellationToken)
{
var normalizedSeverity = NormalizeSeverity(advisory.Severity);
const string sql = """
INSERT INTO vuln.advisories (
id, advisory_key, primary_vuln_id, source_id, title, summary, description,
@@ -404,7 +408,7 @@ public sealed class AdvisoryRepository : RepositoryBase<ConcelierDataSource>, IA
AddParameter(command, "title", advisory.Title);
AddParameter(command, "summary", advisory.Summary);
AddParameter(command, "description", advisory.Description);
AddParameter(command, "severity", advisory.Severity);
AddParameter(command, "severity", normalizedSeverity);
AddParameter(command, "published_at", advisory.PublishedAt);
AddParameter(command, "modified_at", advisory.ModifiedAt);
AddParameter(command, "withdrawn_at", advisory.WithdrawnAt);
@@ -450,6 +454,16 @@ public sealed class AdvisoryRepository : RepositoryBase<ConcelierDataSource>, IA
return result;
}
private static string? NormalizeSeverity(string? severity)
{
if (string.IsNullOrWhiteSpace(severity))
{
return null;
}
return severity.Trim().ToLowerInvariant();
}
private static async Task ReplaceAliasesAsync(
Guid advisoryId,
IEnumerable<AdvisoryAliasEntity> aliases,
@@ -498,10 +512,10 @@ public sealed class AdvisoryRepository : RepositoryBase<ConcelierDataSource>, IA
const string insertSql = """
INSERT INTO vuln.advisory_cvss
(id, advisory_id, cvss_version, vector_string, base_score, base_severity,
exploitability_score, impact_score, source, is_primary)
exploitability_score, impact_score, source, is_primary, created_at)
VALUES
(@id, @advisory_id, @cvss_version, @vector_string, @base_score, @base_severity,
@exploitability_score, @impact_score, @source, @is_primary)
@exploitability_score, @impact_score, @source, @is_primary, @created_at)
""";
foreach (var score in scores)
@@ -517,6 +531,7 @@ public sealed class AdvisoryRepository : RepositoryBase<ConcelierDataSource>, IA
insertCmd.Parameters.AddWithValue("impact_score", (object?)score.ImpactScore ?? DBNull.Value);
insertCmd.Parameters.AddWithValue("source", (object?)score.Source ?? DBNull.Value);
insertCmd.Parameters.AddWithValue("is_primary", score.IsPrimary);
insertCmd.Parameters.AddWithValue("created_at", score.CreatedAt);
await insertCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -8,3 +8,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| AUDIT-0230-M | DONE | Maintainability audit for StellaOps.Concelier.Persistence. |
| AUDIT-0230-T | DONE | Test coverage audit for StellaOps.Concelier.Persistence. |
| AUDIT-0230-A | TODO | Pending approval for changes. |
| CICD-VAL-SMOKE-001 | DONE | Smoke validation: restore reference summaries from raw payload. |

View File

@@ -136,9 +136,9 @@ public sealed class BackportProofService
if (advisory == null) return null;
// Create evidence from advisory data
var advisoryData = JsonDocument.Parse(JsonSerializer.Serialize(advisory));
var advisoryData = SerializeToElement(advisory, out var advisoryBytes);
var dataHash = StellaOps.Canonical.Json.CanonJson.Sha256Prefixed(
StellaOps.Canonical.Json.CanonJson.Canonicalize(advisoryData));
StellaOps.Canonical.Json.CanonJson.CanonicalizeParsedJson(advisoryBytes));
return new ProofEvidence
{
@@ -161,9 +161,9 @@ public sealed class BackportProofService
foreach (var changelog in changelogs)
{
var changelogData = JsonDocument.Parse(JsonSerializer.Serialize(changelog));
var changelogData = SerializeToElement(changelog, out var changelogBytes);
var dataHash = StellaOps.Canonical.Json.CanonJson.Sha256Prefixed(
StellaOps.Canonical.Json.CanonJson.Canonicalize(changelogData));
StellaOps.Canonical.Json.CanonJson.CanonicalizeParsedJson(changelogBytes));
evidences.Add(new ProofEvidence
{
@@ -190,9 +190,9 @@ public sealed class BackportProofService
var patchHeaders = await _patchRepo.FindPatchHeadersByCveAsync(cveId, cancellationToken);
foreach (var header in patchHeaders)
{
var headerData = JsonDocument.Parse(JsonSerializer.Serialize(header));
var headerData = SerializeToElement(header, out var headerBytes);
var dataHash = StellaOps.Canonical.Json.CanonJson.Sha256Prefixed(
StellaOps.Canonical.Json.CanonJson.Canonicalize(headerData));
StellaOps.Canonical.Json.CanonJson.CanonicalizeParsedJson(headerBytes));
evidences.Add(new ProofEvidence
{
@@ -209,9 +209,9 @@ public sealed class BackportProofService
var patchSigs = await _patchRepo.FindPatchSignaturesByCveAsync(cveId, cancellationToken);
foreach (var sig in patchSigs)
{
var sigData = JsonDocument.Parse(JsonSerializer.Serialize(sig));
var sigData = SerializeToElement(sig, out var sigBytes);
var dataHash = StellaOps.Canonical.Json.CanonJson.Sha256Prefixed(
StellaOps.Canonical.Json.CanonJson.Canonicalize(sigData));
StellaOps.Canonical.Json.CanonJson.CanonicalizeParsedJson(sigBytes));
evidences.Add(new ProofEvidence
{
@@ -242,9 +242,9 @@ public sealed class BackportProofService
var matchResult = await _fingerprintFactory.MatchBestAsync(binaryPath, knownFingerprints, cancellationToken);
if (matchResult?.IsMatch == true)
{
var fingerprintData = JsonDocument.Parse(JsonSerializer.Serialize(matchResult));
var fingerprintData = SerializeToElement(matchResult, out var fingerprintBytes);
var dataHash = StellaOps.Canonical.Json.CanonJson.Sha256Prefixed(
StellaOps.Canonical.Json.CanonJson.Canonicalize(fingerprintData));
StellaOps.Canonical.Json.CanonJson.CanonicalizeParsedJson(fingerprintBytes));
evidences.Add(new ProofEvidence
{
@@ -268,6 +268,13 @@ public sealed class BackportProofService
await Task.CompletedTask;
return null;
}
private static JsonElement SerializeToElement<T>(T value, out byte[] jsonBytes)
{
jsonBytes = JsonSerializer.SerializeToUtf8Bytes(value);
using var document = JsonDocument.Parse(jsonBytes);
return document.RootElement.Clone();
}
}
// Repository interfaces (to be implemented by storage layer)

View File

@@ -1,5 +1,6 @@
namespace StellaOps.Concelier.SourceIntel;
using System.Collections.Immutable;
using System.Text.RegularExpressions;
/// <summary>
@@ -8,7 +9,7 @@ using System.Text.RegularExpressions;
public static partial class ChangelogParser
{
/// <summary>
/// Parse Debian changelog for CVE mentions.
/// Parse Debian changelog for CVE mentions and bug references.
/// </summary>
public static ChangelogParseResult ParseDebianChangelog(string changelogContent)
{
@@ -19,6 +20,7 @@ public static partial class ChangelogParser
string? currentVersion = null;
DateTimeOffset? currentDate = null;
var currentCves = new List<string>();
var currentBugs = new List<BugReference>();
var currentDescription = new List<string>();
foreach (var line in lines)
@@ -28,22 +30,24 @@ public static partial class ChangelogParser
if (headerMatch.Success)
{
// Save previous entry
if (currentPackage != null && currentVersion != null && currentCves.Count > 0)
if (currentPackage != null && currentVersion != null && (currentCves.Count > 0 || currentBugs.Count > 0))
{
entries.Add(new ChangelogEntry
{
PackageName = currentPackage,
Version = currentVersion,
CveIds = currentCves.ToList(),
BugReferences = currentBugs.ToList(),
Description = string.Join(" ", currentDescription),
Date = currentDate ?? DateTimeOffset.UtcNow,
Confidence = 0.80
Confidence = currentCves.Count > 0 ? 0.80 : 0.75 // Bug-only entries have lower confidence
});
}
currentPackage = headerMatch.Groups[1].Value;
currentVersion = headerMatch.Groups[2].Value;
currentCves.Clear();
currentBugs.Clear();
currentDescription.Clear();
currentDate = null;
continue;
@@ -68,6 +72,16 @@ public static partial class ChangelogParser
}
}
// Content lines: look for bug references
var bugRefs = ExtractBugReferences(line);
foreach (var bug in bugRefs)
{
if (!currentBugs.Any(b => b.Tracker == bug.Tracker && b.BugId == bug.BugId))
{
currentBugs.Add(bug);
}
}
if (!string.IsNullOrWhiteSpace(line) && !line.StartsWith(" --"))
{
currentDescription.Add(line.Trim());
@@ -75,16 +89,17 @@ public static partial class ChangelogParser
}
// Save last entry
if (currentPackage != null && currentVersion != null && currentCves.Count > 0)
if (currentPackage != null && currentVersion != null && (currentCves.Count > 0 || currentBugs.Count > 0))
{
entries.Add(new ChangelogEntry
{
PackageName = currentPackage,
Version = currentVersion,
CveIds = currentCves.ToList(),
BugReferences = currentBugs.ToList(),
Description = string.Join(" ", currentDescription),
Date = currentDate ?? DateTimeOffset.UtcNow,
Confidence = 0.80
Confidence = currentCves.Count > 0 ? 0.80 : 0.75
});
}
@@ -96,7 +111,7 @@ public static partial class ChangelogParser
}
/// <summary>
/// Parse RPM changelog for CVE mentions.
/// Parse RPM changelog for CVE mentions and bug references.
/// </summary>
public static ChangelogParseResult ParseRpmChangelog(string changelogContent)
{
@@ -106,6 +121,7 @@ public static partial class ChangelogParser
string? currentVersion = null;
DateTimeOffset? currentDate = null;
var currentCves = new List<string>();
var currentBugs = new List<BugReference>();
var currentDescription = new List<string>();
foreach (var line in lines)
@@ -115,22 +131,24 @@ public static partial class ChangelogParser
if (headerMatch.Success)
{
// Save previous entry
if (currentVersion != null && currentCves.Count > 0)
if (currentVersion != null && (currentCves.Count > 0 || currentBugs.Count > 0))
{
entries.Add(new ChangelogEntry
{
PackageName = "rpm-package", // Extracted from spec file name
Version = currentVersion,
CveIds = currentCves.ToList(),
BugReferences = currentBugs.ToList(),
Description = string.Join(" ", currentDescription),
Date = currentDate ?? DateTimeOffset.UtcNow,
Confidence = 0.80
Confidence = currentCves.Count > 0 ? 0.80 : 0.75
});
}
currentDate = ParseRpmDate(headerMatch.Groups[1].Value);
currentVersion = headerMatch.Groups[2].Value;
currentCves.Clear();
currentBugs.Clear();
currentDescription.Clear();
continue;
}
@@ -146,6 +164,16 @@ public static partial class ChangelogParser
}
}
// Content lines: look for bug references
var bugRefs = ExtractBugReferences(line);
foreach (var bug in bugRefs)
{
if (!currentBugs.Any(b => b.Tracker == bug.Tracker && b.BugId == bug.BugId))
{
currentBugs.Add(bug);
}
}
if (!string.IsNullOrWhiteSpace(line) && !line.StartsWith("*"))
{
currentDescription.Add(line.Trim());
@@ -153,16 +181,17 @@ public static partial class ChangelogParser
}
// Save last entry
if (currentVersion != null && currentCves.Count > 0)
if (currentVersion != null && (currentCves.Count > 0 || currentBugs.Count > 0))
{
entries.Add(new ChangelogEntry
{
PackageName = "rpm-package",
Version = currentVersion,
CveIds = currentCves.ToList(),
BugReferences = currentBugs.ToList(),
Description = string.Join(" ", currentDescription),
Date = currentDate ?? DateTimeOffset.UtcNow,
Confidence = 0.80
Confidence = currentCves.Count > 0 ? 0.80 : 0.75
});
}
@@ -175,6 +204,8 @@ public static partial class ChangelogParser
/// <summary>
/// Parse Alpine APKBUILD secfixes for CVE mentions.
/// Alpine secfixes typically don't contain bug tracker references, but we include
/// the functionality for consistency.
/// </summary>
public static ChangelogParseResult ParseAlpineSecfixes(string secfixesContent)
{
@@ -183,6 +214,7 @@ public static partial class ChangelogParser
string? currentVersion = null;
var currentCves = new List<string>();
var currentBugs = new List<BugReference>();
foreach (var line in lines)
{
@@ -191,13 +223,14 @@ public static partial class ChangelogParser
if (versionMatch.Success)
{
// Save previous entry
if (currentVersion != null && currentCves.Count > 0)
if (currentVersion != null && (currentCves.Count > 0 || currentBugs.Count > 0))
{
entries.Add(new ChangelogEntry
{
PackageName = "alpine-package",
Version = currentVersion,
CveIds = currentCves.ToList(),
BugReferences = currentBugs.ToList(),
Description = $"Security fixes for {string.Join(", ", currentCves)}",
Date = DateTimeOffset.UtcNow,
Confidence = 0.85 // Alpine secfixes are explicit
@@ -206,6 +239,7 @@ public static partial class ChangelogParser
currentVersion = versionMatch.Groups[1].Value;
currentCves.Clear();
currentBugs.Clear();
continue;
}
@@ -219,16 +253,27 @@ public static partial class ChangelogParser
currentCves.Add(cveId);
}
}
// Bug references (rare in Alpine secfixes, but possible)
var bugRefs = ExtractBugReferences(line);
foreach (var bug in bugRefs)
{
if (!currentBugs.Any(b => b.Tracker == bug.Tracker && b.BugId == bug.BugId))
{
currentBugs.Add(bug);
}
}
}
// Save last entry
if (currentVersion != null && currentCves.Count > 0)
if (currentVersion != null && (currentCves.Count > 0 || currentBugs.Count > 0))
{
entries.Add(new ChangelogEntry
{
PackageName = "alpine-package",
Version = currentVersion,
CveIds = currentCves.ToList(),
BugReferences = currentBugs.ToList(),
Description = $"Security fixes for {string.Join(", ", currentCves)}",
Date = DateTimeOffset.UtcNow,
Confidence = 0.85
@@ -276,6 +321,120 @@ public static partial class ChangelogParser
[GeneratedRegex(@"CVE-\d{4}-[0-9A-Za-z]{4,}")]
private static partial Regex CvePatternRegex();
// Bug tracker patterns for BP-401, BP-402, BP-403
/// <summary>
/// Debian BTS pattern: matches the "Closes:" or "Fixes:" prefix to identify Debian bug sections.
/// The actual bug numbers are extracted separately using DebianBugNumberRegex.
/// </summary>
[GeneratedRegex(@"(?:Closes|Fixes):\s*(.+?)(?=\s*(?:\(|$|,\s*(?:Closes|Fixes):))", RegexOptions.IgnoreCase)]
private static partial Regex DebianBugSectionRegex();
/// <summary>
/// Extract individual bug numbers from a Debian bug section (after "Closes:" or "Fixes:").
/// </summary>
[GeneratedRegex(@"#?(\d{4,})", RegexOptions.IgnoreCase)]
private static partial Regex DebianBugNumberRegex();
/// <summary>
/// Red Hat Bugzilla pattern: "RHBZ#123456", "rhbz#123456", "bz#123456", "Bug 123456"
/// </summary>
[GeneratedRegex(@"(?:RHBZ|rhbz|bz|Bug|BZ)[\s#:]+(\d{6,8})", RegexOptions.IgnoreCase)]
private static partial Regex RedHatBugRegex();
/// <summary>
/// Launchpad pattern: "LP: #123456" or "LP #123456"
/// </summary>
[GeneratedRegex(@"LP[\s:#]+(\d+)", RegexOptions.IgnoreCase)]
private static partial Regex LaunchpadBugRegex();
/// <summary>
/// GitHub pattern: "Fixes #123", "GH-123", "#123" in commit context
/// </summary>
[GeneratedRegex(@"(?:Fixes|Closes|Resolves)?\s*(?:GH-|#)(\d+)", RegexOptions.IgnoreCase)]
private static partial Regex GitHubBugRegex();
/// <summary>
/// Extract all bug references from a changelog line.
/// </summary>
public static ImmutableArray<BugReference> ExtractBugReferences(string line)
{
var bugs = ImmutableArray.CreateBuilder<BugReference>();
// Debian BTS - find "Closes:" or "Fixes:" sections and extract all numbers
if (line.Contains("Closes:", StringComparison.OrdinalIgnoreCase) ||
line.Contains("Fixes:", StringComparison.OrdinalIgnoreCase))
{
// Look for all bug numbers after Closes: or Fixes:
var debianSection = DebianBugSectionRegex().Match(line);
if (debianSection.Success)
{
var section = debianSection.Groups[1].Value;
foreach (Match numMatch in DebianBugNumberRegex().Matches(section))
{
var bugId = numMatch.Groups[1].Value;
if (!bugs.Any(b => b.Tracker == BugTracker.Debian && b.BugId == bugId))
{
bugs.Add(new BugReference
{
Tracker = BugTracker.Debian,
BugId = bugId,
RawReference = debianSection.Value.Trim()
});
}
}
}
else
{
// Fallback: just find any bug number patterns in the line after Closes: or Fixes:
var keyword = line.Contains("Closes:", StringComparison.OrdinalIgnoreCase) ? "Closes:" : "Fixes:";
var idx = line.IndexOf(keyword, StringComparison.OrdinalIgnoreCase);
if (idx >= 0)
{
var start = idx + keyword.Length;
var afterKeyword = start <= line.Length ? line[start..] : string.Empty;
foreach (Match numMatch in DebianBugNumberRegex().Matches(afterKeyword))
{
var bugId = numMatch.Groups[1].Value;
if (!bugs.Any(b => b.Tracker == BugTracker.Debian && b.BugId == bugId))
{
bugs.Add(new BugReference
{
Tracker = BugTracker.Debian,
BugId = bugId,
RawReference = $"Closes: #{bugId}"
});
}
}
}
}
}
// Red Hat Bugzilla
foreach (Match match in RedHatBugRegex().Matches(line))
{
bugs.Add(new BugReference
{
Tracker = BugTracker.RedHat,
BugId = match.Groups[1].Value,
RawReference = match.Value
});
}
// Launchpad
foreach (Match match in LaunchpadBugRegex().Matches(line))
{
bugs.Add(new BugReference
{
Tracker = BugTracker.Launchpad,
BugId = match.Groups[1].Value,
RawReference = match.Value
});
}
return bugs.ToImmutable();
}
}
public sealed record ChangelogParseResult
@@ -289,7 +448,65 @@ public sealed record ChangelogEntry
public required string PackageName { get; init; }
public required string Version { get; init; }
public required IReadOnlyList<string> CveIds { get; init; }
public required IReadOnlyList<BugReference> BugReferences { get; init; }
public required string Description { get; init; }
public required DateTimeOffset Date { get; init; }
public required double Confidence { get; init; }
}
/// <summary>
/// Represents a bug tracker reference extracted from a changelog.
/// </summary>
public sealed record BugReference
{
/// <summary>
/// The bug tracker system.
/// </summary>
public required BugTracker Tracker { get; init; }
/// <summary>
/// The bug ID within that tracker.
/// </summary>
public required string BugId { get; init; }
/// <summary>
/// The full reference string as found in the changelog.
/// </summary>
public required string RawReference { get; init; }
}
/// <summary>
/// Supported bug tracker systems for CVE mapping.
/// </summary>
public enum BugTracker
{
/// <summary>
/// Debian BTS - "Closes: #123456" or "(Closes: #123)"
/// </summary>
Debian,
/// <summary>
/// Red Hat Bugzilla - "RHBZ#123456", "rhbz#123456", "bz#123456"
/// </summary>
RedHat,
/// <summary>
/// Launchpad - "LP: #123456"
/// </summary>
Launchpad,
/// <summary>
/// GitHub Issues - "Fixes #123", "GH-123"
/// </summary>
GitHub,
/// <summary>
/// GitLab Issues - "gitlab#123"
/// </summary>
GitLab,
/// <summary>
/// Unknown tracker type.
/// </summary>
Unknown
}

View File

@@ -37,8 +37,15 @@ public static partial class PatchHeaderParser
// DEP-3 Bug references
if (line.StartsWith("Bug:") || line.StartsWith("Bug-Debian:") || line.StartsWith("Bug-Ubuntu:"))
{
var bugRef = line.Split(':')[1].Trim();
bugReferences.Add(bugRef);
var separatorIndex = line.IndexOf(':');
if (separatorIndex >= 0 && separatorIndex + 1 < line.Length)
{
var bugRef = line[(separatorIndex + 1)..].Trim();
if (!string.IsNullOrWhiteSpace(bugRef))
{
bugReferences.Add(bugRef);
}
}
}
// DEP-3 Origin

View File

@@ -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}";
}

View File

@@ -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();
}
}

View File

@@ -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);
}

View File

@@ -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
}
}

View File

@@ -6,4 +6,11 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
</Project>