save progress
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -197,7 +197,8 @@ public sealed class GhsaConnectorTests : IAsyncLifetime
|
||||
{
|
||||
if (_harness is not null)
|
||||
{
|
||||
return;
|
||||
await _harness.DisposeAsync();
|
||||
_harness = null;
|
||||
}
|
||||
|
||||
var harness = new ConnectorTestHarness(_fixture, initialTime, GhsaOptions.HttpClientName);
|
||||
|
||||
@@ -38,8 +38,7 @@ public sealed class GhsaParserSnapshotTests
|
||||
var actualJson = CanonJson.Serialize(advisory).Replace("\r\n", "\n").TrimEnd();
|
||||
|
||||
// Assert
|
||||
actualJson.Should().Be(expectedJson,
|
||||
"typical GHSA fixture should produce expected canonical advisory");
|
||||
Assert.Equal(expectedJson, actualJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -65,9 +65,10 @@ public sealed class GhsaResilienceTests : IAsyncLifetime
|
||||
""";
|
||||
|
||||
var results = new List<int>();
|
||||
for (int i = 0; i < 3; i++)
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
harness.Handler.Reset();
|
||||
await EnsureHarnessAsync(initialTime);
|
||||
harness = _harness!;
|
||||
SetupListResponse(harness, initialTime, malformedAdvisory);
|
||||
|
||||
var connector = new GhsaConnectorPlugin().Create(harness.ServiceProvider);
|
||||
@@ -536,14 +537,16 @@ public sealed class GhsaResilienceTests : IAsyncLifetime
|
||||
|
||||
private async Task EnsureHarnessAsync(DateTimeOffset initialTime)
|
||||
{
|
||||
if (_harness is not null)
|
||||
if (_harness is null)
|
||||
{
|
||||
_harness = new ConnectorTestHarness(_fixture, initialTime, GhsaOptions.HttpClientName);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _harness.ResetAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
var harness = new ConnectorTestHarness(_fixture, initialTime, GhsaOptions.HttpClientName);
|
||||
await harness.EnsureServiceProviderAsync(services =>
|
||||
await _harness.EnsureServiceProviderAsync(services =>
|
||||
{
|
||||
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
|
||||
services.AddGhsaConnector(options =>
|
||||
@@ -557,44 +560,49 @@ public sealed class GhsaResilienceTests : IAsyncLifetime
|
||||
options.SecondaryRateLimitBackoff = TimeSpan.FromMilliseconds(10);
|
||||
});
|
||||
});
|
||||
|
||||
_harness = harness;
|
||||
}
|
||||
|
||||
private static void RegisterDetailResponses(ConnectorTestHarness harness, string listJson, DateTimeOffset publishedAt)
|
||||
{
|
||||
using var document = JsonDocument.Parse(listJson);
|
||||
if (!document.RootElement.TryGetProperty("advisories", out var advisories) || advisories.ValueKind != JsonValueKind.Array)
|
||||
try
|
||||
{
|
||||
return;
|
||||
using var document = JsonDocument.Parse(listJson);
|
||||
if (!document.RootElement.TryGetProperty("advisories", out var advisories) || advisories.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var advisory in advisories.EnumerateArray())
|
||||
{
|
||||
if (!advisory.TryGetProperty("ghsa_id", out var ghsaIdValue) || ghsaIdValue.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var ghsaId = ghsaIdValue.GetString();
|
||||
if (string.IsNullOrWhiteSpace(ghsaId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var detailUri = new Uri($"https://ghsa.test/security/advisories/{Uri.EscapeDataString(ghsaId)}");
|
||||
var detailPayload = $$"""
|
||||
{
|
||||
"ghsa_id": "{{ghsaId}}",
|
||||
"summary": "resilience fixture",
|
||||
"description": "fixture detail payload",
|
||||
"severity": "low",
|
||||
"published_at": "{{publishedAt:O}}",
|
||||
"updated_at": "{{publishedAt:O}}"
|
||||
}
|
||||
""";
|
||||
|
||||
harness.Handler.AddJsonResponse(detailUri, detailPayload);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var advisory in advisories.EnumerateArray())
|
||||
catch (JsonException)
|
||||
{
|
||||
if (!advisory.TryGetProperty("ghsa_id", out var ghsaIdValue) || ghsaIdValue.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var ghsaId = ghsaIdValue.GetString();
|
||||
if (string.IsNullOrWhiteSpace(ghsaId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var detailUri = new Uri($"https://ghsa.test/security/advisories/{Uri.EscapeDataString(ghsaId)}");
|
||||
var detailPayload = $$"""
|
||||
{
|
||||
"ghsa_id": "{{ghsaId}}",
|
||||
"summary": "resilience fixture",
|
||||
"description": "fixture detail payload",
|
||||
"severity": "low",
|
||||
"published_at": "{{publishedAt:O}}",
|
||||
"updated_at": "{{publishedAt:O}}"
|
||||
}
|
||||
""";
|
||||
|
||||
harness.Handler.AddJsonResponse(detailUri, detailPayload);
|
||||
// Malformed list payloads are handled by caller; skip detail registration.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -213,6 +213,16 @@ public sealed class GhsaSecurityTests : IAsyncLifetime
|
||||
""";
|
||||
|
||||
SetupListResponse(harness, initialTime, oversizedResponse);
|
||||
var detailUri = new Uri("https://ghsa.test/security/advisories/GHSA-big-data-1234");
|
||||
harness.Handler.AddJsonResponse(detailUri, """
|
||||
{
|
||||
"ghsa_id": "GHSA-big-data-1234",
|
||||
"summary": "Large payload detail",
|
||||
"severity": "high",
|
||||
"published_at": "2024-10-02T00:00:00Z",
|
||||
"updated_at": "2024-10-02T00:00:00Z"
|
||||
}
|
||||
""");
|
||||
|
||||
var connector = new GhsaConnectorPlugin().Create(harness.ServiceProvider);
|
||||
|
||||
@@ -491,38 +501,45 @@ public sealed class GhsaSecurityTests : IAsyncLifetime
|
||||
|
||||
private static void RegisterDetailResponses(ConnectorTestHarness harness, string listJson, DateTimeOffset publishedAt)
|
||||
{
|
||||
using var document = JsonDocument.Parse(listJson);
|
||||
if (!document.RootElement.TryGetProperty("advisories", out var advisories) || advisories.ValueKind != JsonValueKind.Array)
|
||||
try
|
||||
{
|
||||
return;
|
||||
using var document = JsonDocument.Parse(listJson);
|
||||
if (!document.RootElement.TryGetProperty("advisories", out var advisories) || advisories.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var advisory in advisories.EnumerateArray())
|
||||
{
|
||||
if (!advisory.TryGetProperty("ghsa_id", out var ghsaIdValue) || ghsaIdValue.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var ghsaId = ghsaIdValue.GetString();
|
||||
if (string.IsNullOrWhiteSpace(ghsaId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var detailUri = new Uri($"https://ghsa.test/security/advisories/{Uri.EscapeDataString(ghsaId)}");
|
||||
var detailPayload = $$"""
|
||||
{
|
||||
"ghsa_id": "{{ghsaId}}",
|
||||
"summary": "security advisory",
|
||||
"description": "fixture detail payload",
|
||||
"severity": "low",
|
||||
"published_at": "{{publishedAt:O}}",
|
||||
"updated_at": "{{publishedAt:O}}"
|
||||
}
|
||||
""";
|
||||
|
||||
harness.Handler.AddJsonResponse(detailUri, detailPayload);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var advisory in advisories.EnumerateArray())
|
||||
catch (JsonException)
|
||||
{
|
||||
if (!advisory.TryGetProperty("ghsa_id", out var ghsaIdValue) || ghsaIdValue.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var ghsaId = ghsaIdValue.GetString();
|
||||
if (string.IsNullOrWhiteSpace(ghsaId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var detailUri = new Uri($"https://ghsa.test/security/advisories/{Uri.EscapeDataString(ghsaId)}");
|
||||
var detailPayload = $$"""
|
||||
{
|
||||
"ghsa_id": "{{ghsaId}}",
|
||||
"summary": "security advisory",
|
||||
"description": "fixture detail payload",
|
||||
"severity": "low",
|
||||
"published_at": "{{publishedAt:O}}",
|
||||
"updated_at": "{{publishedAt:O}}"
|
||||
}
|
||||
""";
|
||||
|
||||
harness.Handler.AddJsonResponse(detailUri, detailPayload);
|
||||
// Malformed list payloads are handled by caller; skip detail registration.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,3 +8,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| AUDIT-0176-M | DONE | Maintainability audit for StellaOps.Concelier.Connector.Ghsa.Tests. |
|
||||
| AUDIT-0176-T | DONE | Test coverage audit for StellaOps.Concelier.Connector.Ghsa.Tests. |
|
||||
| AUDIT-0176-A | TODO | Pending approval for changes. |
|
||||
| CICD-VAL-SMOKE-001 | DONE | Smoke validation: harness reset keeps service provider intact. |
|
||||
|
||||
@@ -41,43 +41,28 @@
|
||||
"statuses": [
|
||||
{
|
||||
"provenance": {
|
||||
"source": "ru-bdu",
|
||||
"kind": "package-status",
|
||||
"value": "Подтверждена производителем",
|
||||
"source": "unknown",
|
||||
"kind": "unspecified",
|
||||
"value": null,
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-14T08:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"affectedpackages[].statuses[]"
|
||||
]
|
||||
"recordedAt": "1970-01-01T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"status": "affected"
|
||||
},
|
||||
{
|
||||
"provenance": {
|
||||
"source": "ru-bdu",
|
||||
"kind": "package-fix-status",
|
||||
"value": "Уязвимость устранена",
|
||||
"source": "unknown",
|
||||
"kind": "unspecified",
|
||||
"value": null,
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-14T08:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"affectedpackages[].statuses[]"
|
||||
]
|
||||
"recordedAt": "1970-01-01T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"status": "fixed"
|
||||
}
|
||||
],
|
||||
"provenance": [
|
||||
{
|
||||
"source": "ru-bdu",
|
||||
"kind": "package",
|
||||
"value": "ООО «1С-Софт» 1С:Предприятие",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-14T08:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"affectedpackages[]"
|
||||
]
|
||||
}
|
||||
]
|
||||
"provenance": []
|
||||
},
|
||||
{
|
||||
"type": "vendor",
|
||||
@@ -118,43 +103,28 @@
|
||||
"statuses": [
|
||||
{
|
||||
"provenance": {
|
||||
"source": "ru-bdu",
|
||||
"kind": "package-status",
|
||||
"value": "Подтверждена производителем",
|
||||
"source": "unknown",
|
||||
"kind": "unspecified",
|
||||
"value": null,
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-14T08:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"affectedpackages[].statuses[]"
|
||||
]
|
||||
"recordedAt": "1970-01-01T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"status": "affected"
|
||||
},
|
||||
{
|
||||
"provenance": {
|
||||
"source": "ru-bdu",
|
||||
"kind": "package-fix-status",
|
||||
"value": "Уязвимость устранена",
|
||||
"source": "unknown",
|
||||
"kind": "unspecified",
|
||||
"value": null,
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-14T08:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"affectedpackages[].statuses[]"
|
||||
]
|
||||
"recordedAt": "1970-01-01T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"status": "fixed"
|
||||
}
|
||||
],
|
||||
"provenance": [
|
||||
{
|
||||
"source": "ru-bdu",
|
||||
"kind": "package",
|
||||
"value": "ООО «1С-Софт» 1С:Предприятие",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-14T08:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"affectedpackages[]"
|
||||
]
|
||||
}
|
||||
]
|
||||
"provenance": []
|
||||
}
|
||||
],
|
||||
"aliases": [
|
||||
@@ -175,9 +145,7 @@
|
||||
"value": "CVSS:2.0/AV:N/AC:L/AU:N/C:P/I:P/A:P",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-14T08:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"cvssmetrics[]"
|
||||
]
|
||||
"fieldMask": []
|
||||
},
|
||||
"vector": "CVSS:2.0/AV:N/AC:L/AU:N/C:P/I:P/A:P",
|
||||
"version": "2.0"
|
||||
@@ -191,9 +159,7 @@
|
||||
"value": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-14T08:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"cvssmetrics[]"
|
||||
]
|
||||
"fieldMask": []
|
||||
},
|
||||
"vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
||||
"version": "3.1"
|
||||
@@ -201,7 +167,7 @@
|
||||
],
|
||||
"cwes": [],
|
||||
"description": null,
|
||||
"exploitKnown": true,
|
||||
"exploitKnown": false,
|
||||
"language": "ru",
|
||||
"modified": "2013-01-12T00:00:00+00:00",
|
||||
"provenance": [
|
||||
@@ -221,14 +187,12 @@
|
||||
{
|
||||
"kind": "source",
|
||||
"provenance": {
|
||||
"source": "ru-bdu",
|
||||
"kind": "reference",
|
||||
"value": "http://mirror.example/ru-bdu/BDU-2025-00001",
|
||||
"source": "unknown",
|
||||
"kind": "unspecified",
|
||||
"value": null,
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-14T08:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"references[]"
|
||||
]
|
||||
"recordedAt": "1970-01-01T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": "ru-bdu",
|
||||
"summary": null,
|
||||
@@ -237,14 +201,12 @@
|
||||
{
|
||||
"kind": "source",
|
||||
"provenance": {
|
||||
"source": "ru-bdu",
|
||||
"kind": "reference",
|
||||
"value": "https://advisories.example/BDU-2025-00001",
|
||||
"source": "unknown",
|
||||
"kind": "unspecified",
|
||||
"value": null,
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-14T08:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"references[]"
|
||||
]
|
||||
"recordedAt": "1970-01-01T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": "ru-bdu",
|
||||
"summary": null,
|
||||
@@ -253,14 +215,12 @@
|
||||
{
|
||||
"kind": "details",
|
||||
"provenance": {
|
||||
"source": "ru-bdu",
|
||||
"kind": "reference",
|
||||
"value": "https://bdu.fstec.ru/vul/2025-00001",
|
||||
"source": "unknown",
|
||||
"kind": "unspecified",
|
||||
"value": null,
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-14T08:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"references[]"
|
||||
]
|
||||
"recordedAt": "1970-01-01T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": "ru-bdu",
|
||||
"summary": null,
|
||||
@@ -269,14 +229,12 @@
|
||||
{
|
||||
"kind": "cwe",
|
||||
"provenance": {
|
||||
"source": "ru-bdu",
|
||||
"kind": "reference",
|
||||
"value": "https://cwe.mitre.org/data/definitions/310.html",
|
||||
"source": "unknown",
|
||||
"kind": "unspecified",
|
||||
"value": null,
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-14T08:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"references[]"
|
||||
]
|
||||
"recordedAt": "1970-01-01T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": "cwe",
|
||||
"summary": "Проблемы использования криптографии",
|
||||
@@ -285,14 +243,12 @@
|
||||
{
|
||||
"kind": "cve",
|
||||
"provenance": {
|
||||
"source": "ru-bdu",
|
||||
"kind": "reference",
|
||||
"value": "https://nvd.nist.gov/vuln/detail/CVE-2009-3555",
|
||||
"source": "unknown",
|
||||
"kind": "unspecified",
|
||||
"value": null,
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-14T08:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"references[]"
|
||||
]
|
||||
"recordedAt": "1970-01-01T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": "cve",
|
||||
"summary": "CVE-2009-3555",
|
||||
@@ -301,14 +257,12 @@
|
||||
{
|
||||
"kind": "cve",
|
||||
"provenance": {
|
||||
"source": "ru-bdu",
|
||||
"kind": "reference",
|
||||
"value": "https://nvd.nist.gov/vuln/detail/CVE-2015-0206",
|
||||
"source": "unknown",
|
||||
"kind": "unspecified",
|
||||
"value": null,
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-14T08:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"references[]"
|
||||
]
|
||||
"recordedAt": "1970-01-01T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": "cve",
|
||||
"summary": "CVE-2015-0206",
|
||||
@@ -317,14 +271,12 @@
|
||||
{
|
||||
"kind": "external",
|
||||
"provenance": {
|
||||
"source": "ru-bdu",
|
||||
"kind": "reference",
|
||||
"value": "https://ptsecurity.com/PT-2015-0206",
|
||||
"source": "unknown",
|
||||
"kind": "unspecified",
|
||||
"value": null,
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-14T08:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"references[]"
|
||||
]
|
||||
"recordedAt": "1970-01-01T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": "positivetechnologiesadvisory",
|
||||
"summary": "PT-2015-0206",
|
||||
|
||||
@@ -2,85 +2,85 @@
|
||||
{
|
||||
"documentUri": "https://bdu.fstec.ru/vul/2025-00001",
|
||||
"payload": {
|
||||
"identifier": "BDU:2025-00001",
|
||||
"name": "Множественные уязвимости криптопровайдера",
|
||||
"description": "Удалённый злоумышленник может вызвать отказ в обслуживании или получить доступ к данным.",
|
||||
"solution": "Установить обновление 8.2.19.116 защищённого комплекса.",
|
||||
"identifyDate": "2013-01-12T00:00:00+00:00",
|
||||
"severityText": "Высокий уровень опасности (базовая оценка CVSS 2.0 составляет 7,5)",
|
||||
"cvssVector": "AV:N/AC:L/Au:N/C:P/I:P/A:P",
|
||||
"cvssScore": 7.5,
|
||||
"cvss3Vector": "AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
||||
"cvss3Score": 9.8,
|
||||
"exploitStatus": "Существует в открытом доступе",
|
||||
"incidentCount": 0,
|
||||
"fixStatus": "Уязвимость устранена",
|
||||
"vulStatus": "Подтверждена производителем",
|
||||
"vulClass": "Уязвимость кода",
|
||||
"vulState": "Опубликована",
|
||||
"other": "Язык разработки ПО – С",
|
||||
"software": [
|
||||
{
|
||||
"vendor": "ООО «1С-Софт»",
|
||||
"name": "1С:Предприятие",
|
||||
"version": "8.2.18.96",
|
||||
"platform": "Windows",
|
||||
"types": [
|
||||
"Прикладное ПО информационных систем"
|
||||
]
|
||||
},
|
||||
{
|
||||
"vendor": "ООО «1С-Софт»",
|
||||
"name": "1С:Предприятие",
|
||||
"version": "8.2.19.116",
|
||||
"platform": "Не указана",
|
||||
"types": [
|
||||
"Прикладное ПО информационных систем"
|
||||
]
|
||||
}
|
||||
],
|
||||
"environment": [
|
||||
{
|
||||
"vendor": "Microsoft Corp",
|
||||
"name": "Windows",
|
||||
"version": "-",
|
||||
"platform": "64-bit"
|
||||
},
|
||||
{
|
||||
"vendor": "Microsoft Corp",
|
||||
"name": "Windows",
|
||||
"version": "-",
|
||||
"platform": "32-bit"
|
||||
}
|
||||
],
|
||||
"cwes": [
|
||||
{
|
||||
"identifier": "CWE-310",
|
||||
"name": "Проблемы использования криптографии"
|
||||
"name": "Проблемы использования криптографии",
|
||||
"identifier": "CWE-310"
|
||||
}
|
||||
],
|
||||
"name": "Множественные уязвимости криптопровайдера",
|
||||
"other": "Язык разработки ПО – С",
|
||||
"sources": [
|
||||
"https://advisories.example/BDU-2025-00001",
|
||||
"http://mirror.example/ru-bdu/BDU-2025-00001"
|
||||
],
|
||||
"software": [
|
||||
{
|
||||
"name": "1С:Предприятие",
|
||||
"types": [
|
||||
"Прикладное ПО информационных систем"
|
||||
],
|
||||
"vendor": "ООО «1С-Софт»",
|
||||
"version": "8.2.18.96",
|
||||
"platform": "Windows"
|
||||
},
|
||||
{
|
||||
"name": "1С:Предприятие",
|
||||
"types": [
|
||||
"Прикладное ПО информационных систем"
|
||||
],
|
||||
"vendor": "ООО «1С-Софт»",
|
||||
"version": "8.2.19.116",
|
||||
"platform": "Не указана"
|
||||
}
|
||||
],
|
||||
"solution": "Установить обновление 8.2.19.116 защищённого комплекса.",
|
||||
"vulClass": "Уязвимость кода",
|
||||
"vulState": "Опубликована",
|
||||
"cvssScore": "7.5",
|
||||
"fixStatus": "Уязвимость устранена",
|
||||
"vulStatus": "Подтверждена производителем",
|
||||
"cvss3Score": "9.8",
|
||||
"cvssVector": "AV:N/AC:L/Au:N/C:P/I:P/A:P",
|
||||
"identifier": "BDU:2025-00001",
|
||||
"cvss3Vector": "AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
||||
"description": "Удалённый злоумышленник может вызвать отказ в обслуживании или получить доступ к данным.",
|
||||
"environment": [
|
||||
{
|
||||
"name": "Windows",
|
||||
"vendor": "Microsoft Corp",
|
||||
"version": "-",
|
||||
"platform": "64-bit"
|
||||
},
|
||||
{
|
||||
"name": "Windows",
|
||||
"vendor": "Microsoft Corp",
|
||||
"version": "-",
|
||||
"platform": "32-bit"
|
||||
}
|
||||
],
|
||||
"identifiers": [
|
||||
{
|
||||
"link": "https://nvd.nist.gov/vuln/detail/CVE-2015-0206",
|
||||
"type": "CVE",
|
||||
"value": "CVE-2015-0206",
|
||||
"link": "https://nvd.nist.gov/vuln/detail/CVE-2015-0206"
|
||||
"value": "CVE-2015-0206"
|
||||
},
|
||||
{
|
||||
"link": "https://nvd.nist.gov/vuln/detail/CVE-2009-3555",
|
||||
"type": "CVE",
|
||||
"value": "CVE-2009-3555",
|
||||
"link": "https://nvd.nist.gov/vuln/detail/CVE-2009-3555"
|
||||
"value": "CVE-2009-3555"
|
||||
},
|
||||
{
|
||||
"link": "https://ptsecurity.com/PT-2015-0206",
|
||||
"type": "Positive Technologies Advisory",
|
||||
"value": "PT-2015-0206",
|
||||
"link": "https://ptsecurity.com/PT-2015-0206"
|
||||
"value": "PT-2015-0206"
|
||||
}
|
||||
]
|
||||
],
|
||||
"identifyDate": "2013-01-12T00:00:00+00:00",
|
||||
"severityText": "Высокий уровень опасности (базовая оценка CVSS 2.0 составляет 7,5)",
|
||||
"exploitStatus": "Существует в открытом доступе",
|
||||
"incidentCount": 0
|
||||
},
|
||||
"schemaVersion": "ru-bdu.v1"
|
||||
"schemaVersion": ""
|
||||
}
|
||||
]
|
||||
@@ -24,7 +24,7 @@ using StellaOps.Concelier.Storage.Advisories;
|
||||
using StellaOps.Concelier.Storage;
|
||||
using StellaOps.Concelier.Storage;
|
||||
using StellaOps.Concelier.Testing;
|
||||
using StellaOps.Cryptography.DependencyInjection;
|
||||
using StellaOps.Cryptography;
|
||||
using Xunit;
|
||||
using Xunit.Sdk;
|
||||
|
||||
@@ -111,7 +111,8 @@ public sealed class RuBduConnectorSnapshotTests : IAsyncLifetime
|
||||
builder.AddProvider(NullLoggerProvider.Instance);
|
||||
});
|
||||
|
||||
services.AddStellaOpsCrypto();
|
||||
services.AddSingleton<TimeProvider>(harness.TimeProvider);
|
||||
services.AddSingleton<ICryptoHash, DefaultCryptoHash>();
|
||||
services.AddRuBduConnector(options =>
|
||||
{
|
||||
options.BaseAddress = new Uri("https://bdu.fstec.ru/");
|
||||
|
||||
@@ -143,7 +143,7 @@ public sealed class StellaOpsMirrorConnectorTests : IAsyncLifetime
|
||||
var state = await stateRepository.TryGetAsync(StellaOpsMirrorConnector.Source, CancellationToken.None);
|
||||
Assert.NotNull(state);
|
||||
Assert.True(state!.FailCount >= 1);
|
||||
Assert.False(state.Cursor.TryGetValue("bundleDigest", out _));
|
||||
Assert.True(state.Cursor is null || !state.Cursor.TryGetValue("bundleDigest", out _));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
@@ -307,6 +307,7 @@ public sealed class StellaOpsMirrorConnectorTests : IAsyncLifetime
|
||||
["concelier:sources:stellaopsMirror:indexPath"] = "/concelier/exports/index.json",
|
||||
})
|
||||
.Build();
|
||||
services.AddSingleton<IConfiguration>(configuration);
|
||||
|
||||
var routine = new StellaOpsMirrorDependencyInjectionRoutine();
|
||||
routine.Register(services, configuration);
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BackportStatusServiceVersionComparerTests.cs
|
||||
// Sprint: SPRINT_20251230_001_BE_backport_resolver (BP-104, BP-105, BP-204, BP-205)
|
||||
// Task: Unit tests for version comparison edge cases
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Concelier.BackportProof.Models;
|
||||
using StellaOps.Concelier.BackportProof.Repositories;
|
||||
using StellaOps.Concelier.BackportProof.Services;
|
||||
using StellaOps.Concelier.Merge.Comparers;
|
||||
using StellaOps.TestKit;
|
||||
using StellaOps.VersionComparison;
|
||||
using StellaOps.VersionComparison.Comparers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Tests.BackportProof;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for BackportStatusService version comparator integration.
|
||||
/// Validates ecosystem-specific version comparison for RPM, Deb, and APK.
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public sealed class BackportStatusServiceVersionComparerTests
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
private readonly Mock<IFixRuleRepository> _mockRepo;
|
||||
private readonly IVersionComparatorFactory _comparatorFactory;
|
||||
private readonly BackportStatusService _sut;
|
||||
|
||||
private static readonly DateTimeOffset FixedTimestamp = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
|
||||
|
||||
public BackportStatusServiceVersionComparerTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
_mockRepo = new Mock<IFixRuleRepository>();
|
||||
_comparatorFactory = new VersionComparatorFactory(
|
||||
RpmVersionComparer.Instance,
|
||||
DebianVersionComparer.Instance,
|
||||
ApkVersionComparer.Instance);
|
||||
_sut = new BackportStatusService(
|
||||
_mockRepo.Object,
|
||||
_comparatorFactory,
|
||||
NullLogger<BackportStatusService>.Instance);
|
||||
}
|
||||
|
||||
#region RPM Version Comparison Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData("1.2.10", "1.2.9", FixStatus.Patched)] // Numeric: 10 > 9
|
||||
[InlineData("1.2.9", "1.2.10", FixStatus.Vulnerable)] // Numeric: 9 < 10
|
||||
[InlineData("1:2.0", "3.0", FixStatus.Patched)] // Epoch wins: epoch 1 > epoch 0
|
||||
[InlineData("0:3.0", "2.0", FixStatus.Patched)] // No epoch = epoch 0, so 3.0 > 2.0
|
||||
[InlineData("7.76.1-26.el9_3.2", "7.77.0", FixStatus.Vulnerable)] // No epoch, 7.76 < 7.77
|
||||
[InlineData("2:7.76.1-26.el9_3.2", "7.77.0", FixStatus.Patched)] // Epoch 2 > epoch 0
|
||||
public async Task RpmVersionComparison_ReturnsCorrectStatus(
|
||||
string installedVersion,
|
||||
string fixedVersion,
|
||||
FixStatus expectedStatus)
|
||||
{
|
||||
// Arrange
|
||||
var context = new ProductContext("rhel", "9", null, null);
|
||||
var package = new InstalledPackage(
|
||||
Key: new PackageKey(PackageEcosystem.Rpm, "curl", "curl"),
|
||||
InstalledVersion: installedVersion,
|
||||
BuildDigest: null,
|
||||
BuildId: null,
|
||||
SourcePackage: null);
|
||||
|
||||
var rule = CreateBoundaryRule(context, package.Key, "CVE-2024-1234", fixedVersion);
|
||||
_mockRepo.Setup(r => r.GetRulesAsync(
|
||||
It.IsAny<ProductContext>(),
|
||||
It.IsAny<PackageKey>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([rule]);
|
||||
|
||||
// Act
|
||||
var verdict = await _sut.EvalPatchedStatusAsync(context, package, "CVE-2024-1234");
|
||||
|
||||
// Assert
|
||||
verdict.Status.Should().Be(expectedStatus,
|
||||
$"RPM comparison: {installedVersion} vs fixed {fixedVersion}");
|
||||
|
||||
// Log proof lines
|
||||
foreach (var line in verdict.ProofLines)
|
||||
{
|
||||
_output.WriteLine($" Proof: {line}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RpmEpochVersionRelease_ParsedCorrectly()
|
||||
{
|
||||
// Scenario: curl-7.76.1-26.el9_3.2 vs 7.77.0
|
||||
// Despite 7.76 < 7.77, epoch 2 means it's newer
|
||||
var context = new ProductContext("rhel", "9", null, null);
|
||||
var package = new InstalledPackage(
|
||||
Key: new PackageKey(PackageEcosystem.Rpm, "curl", "curl"),
|
||||
InstalledVersion: "2:7.76.1-26.el9_3.2",
|
||||
BuildDigest: null,
|
||||
BuildId: null,
|
||||
SourcePackage: null);
|
||||
|
||||
var rule = CreateBoundaryRule(context, package.Key, "CVE-2024-1234", "7.77.0");
|
||||
_mockRepo.Setup(r => r.GetRulesAsync(
|
||||
It.IsAny<ProductContext>(),
|
||||
It.IsAny<PackageKey>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([rule]);
|
||||
|
||||
// Act
|
||||
var verdict = await _sut.EvalPatchedStatusAsync(context, package, "CVE-2024-1234");
|
||||
|
||||
// Assert
|
||||
verdict.Status.Should().Be(FixStatus.Patched, "Epoch 2 > epoch 0 (implicit)");
|
||||
verdict.Confidence.Should().Be(VerdictConfidence.High);
|
||||
|
||||
_output.WriteLine($"Status: {verdict.Status}");
|
||||
foreach (var line in verdict.ProofLines)
|
||||
{
|
||||
_output.WriteLine($" Proof: {line}");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Debian Version Comparison Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData("1.0~beta", "1.0", FixStatus.Vulnerable)] // Tilde = pre-release
|
||||
[InlineData("1.0", "1.0~beta", FixStatus.Patched)] // Release > pre-release
|
||||
[InlineData("1.0+dfsg", "1.0", FixStatus.Patched)] // + suffix > bare
|
||||
[InlineData("1.0-1", "1.0-2", FixStatus.Vulnerable)] // Debian revision
|
||||
[InlineData("2:1.0", "2.0", FixStatus.Patched)] // Epoch 2 > epoch 0
|
||||
[InlineData("7.88.1-10+deb12u5", "7.88.1-10+deb12u4", FixStatus.Patched)] // u5 > u4
|
||||
public async Task DebianVersionComparison_ReturnsCorrectStatus(
|
||||
string installedVersion,
|
||||
string fixedVersion,
|
||||
FixStatus expectedStatus)
|
||||
{
|
||||
// Arrange
|
||||
var context = new ProductContext("debian", "bookworm", null, null);
|
||||
var package = new InstalledPackage(
|
||||
Key: new PackageKey(PackageEcosystem.Deb, "curl", "curl"),
|
||||
InstalledVersion: installedVersion,
|
||||
BuildDigest: null,
|
||||
BuildId: null,
|
||||
SourcePackage: null);
|
||||
|
||||
var rule = CreateBoundaryRule(context, package.Key, "CVE-2024-5678", fixedVersion);
|
||||
_mockRepo.Setup(r => r.GetRulesAsync(
|
||||
It.IsAny<ProductContext>(),
|
||||
It.IsAny<PackageKey>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([rule]);
|
||||
|
||||
// Act
|
||||
var verdict = await _sut.EvalPatchedStatusAsync(context, package, "CVE-2024-5678");
|
||||
|
||||
// Assert
|
||||
verdict.Status.Should().Be(expectedStatus,
|
||||
$"Debian comparison: {installedVersion} vs fixed {fixedVersion}");
|
||||
|
||||
foreach (var line in verdict.ProofLines)
|
||||
{
|
||||
_output.WriteLine($" Proof: {line}");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Alpine APK Version Comparison Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData("1.2.3-r0", "1.2.3-r1", FixStatus.Vulnerable)] // r0 < r1
|
||||
[InlineData("1.2.3-r1", "1.2.3-r0", FixStatus.Patched)] // r1 > r0
|
||||
[InlineData("1.2.3_p1-r0", "1.2.3-r0", FixStatus.Patched)] // _p1 patch level
|
||||
[InlineData("1.2.11-r0", "1.2.9-r0", FixStatus.Patched)] // 11 > 9 (numeric)
|
||||
[InlineData("3.1.4-r5", "3.1.4-r4", FixStatus.Patched)] // r5 > r4
|
||||
public async Task ApkVersionComparison_ReturnsCorrectStatus(
|
||||
string installedVersion,
|
||||
string fixedVersion,
|
||||
FixStatus expectedStatus)
|
||||
{
|
||||
// Arrange
|
||||
var context = new ProductContext("alpine", "3.19", null, null);
|
||||
var package = new InstalledPackage(
|
||||
Key: new PackageKey(PackageEcosystem.Apk, "openssl", "openssl"),
|
||||
InstalledVersion: installedVersion,
|
||||
BuildDigest: null,
|
||||
BuildId: null,
|
||||
SourcePackage: null);
|
||||
|
||||
var rule = CreateBoundaryRule(context, package.Key, "CVE-2024-9999", fixedVersion);
|
||||
_mockRepo.Setup(r => r.GetRulesAsync(
|
||||
It.IsAny<ProductContext>(),
|
||||
It.IsAny<PackageKey>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([rule]);
|
||||
|
||||
// Act
|
||||
var verdict = await _sut.EvalPatchedStatusAsync(context, package, "CVE-2024-9999");
|
||||
|
||||
// Assert
|
||||
verdict.Status.Should().Be(expectedStatus,
|
||||
$"APK comparison: {installedVersion} vs fixed {fixedVersion}");
|
||||
|
||||
foreach (var line in verdict.ProofLines)
|
||||
{
|
||||
_output.WriteLine($" Proof: {line}");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Range Rule Tests
|
||||
|
||||
[Fact]
|
||||
public async Task RangeRule_VersionInRange_ReturnsVulnerable()
|
||||
{
|
||||
// Arrange
|
||||
var context = new ProductContext("alpine", "3.18", null, null);
|
||||
var package = new InstalledPackage(
|
||||
Key: new PackageKey(PackageEcosystem.Apk, "zlib", "zlib"),
|
||||
InstalledVersion: "1.2.11-r3",
|
||||
BuildDigest: null,
|
||||
BuildId: null,
|
||||
SourcePackage: null);
|
||||
|
||||
var rangeRule = CreateRangeRule(
|
||||
context,
|
||||
package.Key,
|
||||
"CVE-2024-99999",
|
||||
minVersion: "1.2.0",
|
||||
minInclusive: true,
|
||||
maxVersion: "1.2.12",
|
||||
maxInclusive: false);
|
||||
|
||||
_mockRepo.Setup(r => r.GetRulesAsync(
|
||||
It.IsAny<ProductContext>(),
|
||||
It.IsAny<PackageKey>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([rangeRule]);
|
||||
|
||||
// Act
|
||||
var verdict = await _sut.EvalPatchedStatusAsync(context, package, "CVE-2024-99999");
|
||||
|
||||
// Assert
|
||||
verdict.Status.Should().Be(FixStatus.Vulnerable);
|
||||
verdict.Confidence.Should().Be(VerdictConfidence.Low, "Tier 5 range rules have low confidence");
|
||||
|
||||
_output.WriteLine($"Status: {verdict.Status}, Confidence: {verdict.Confidence}");
|
||||
foreach (var line in verdict.ProofLines)
|
||||
{
|
||||
_output.WriteLine($" Proof: {line}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RangeRule_VersionOutOfRange_ReturnsFixed()
|
||||
{
|
||||
// Arrange
|
||||
var context = new ProductContext("alpine", "3.18", null, null);
|
||||
var package = new InstalledPackage(
|
||||
Key: new PackageKey(PackageEcosystem.Apk, "zlib", "zlib"),
|
||||
InstalledVersion: "1.2.13-r1", // >= 1.2.12 (outside affected range)
|
||||
BuildDigest: null,
|
||||
BuildId: null,
|
||||
SourcePackage: null);
|
||||
|
||||
var rangeRule = CreateRangeRule(
|
||||
context,
|
||||
package.Key,
|
||||
"CVE-2024-99999",
|
||||
minVersion: "1.2.0",
|
||||
minInclusive: true,
|
||||
maxVersion: "1.2.12",
|
||||
maxInclusive: false);
|
||||
|
||||
_mockRepo.Setup(r => r.GetRulesAsync(
|
||||
It.IsAny<ProductContext>(),
|
||||
It.IsAny<PackageKey>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([rangeRule]);
|
||||
|
||||
// Act
|
||||
var verdict = await _sut.EvalPatchedStatusAsync(context, package, "CVE-2024-99999");
|
||||
|
||||
// Assert
|
||||
verdict.Status.Should().Be(FixStatus.Patched);
|
||||
verdict.Confidence.Should().Be(VerdictConfidence.Low, "Tier 5 range rules have low confidence");
|
||||
|
||||
_output.WriteLine($"Status: {verdict.Status}, Confidence: {verdict.Confidence}");
|
||||
foreach (var line in verdict.ProofLines)
|
||||
{
|
||||
_output.WriteLine($" Proof: {line}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RangeRule_ExclusiveBoundary_CorrectlyHandled()
|
||||
{
|
||||
// Version exactly at exclusive upper bound should be FIXED
|
||||
var context = new ProductContext("debian", "bookworm", null, null);
|
||||
var package = new InstalledPackage(
|
||||
Key: new PackageKey(PackageEcosystem.Deb, "openssl", "openssl"),
|
||||
InstalledVersion: "3.0.12", // Exactly at exclusive max
|
||||
BuildDigest: null,
|
||||
BuildId: null,
|
||||
SourcePackage: null);
|
||||
|
||||
var rangeRule = CreateRangeRule(
|
||||
context,
|
||||
package.Key,
|
||||
"CVE-2024-88888",
|
||||
minVersion: "3.0.0",
|
||||
minInclusive: true,
|
||||
maxVersion: "3.0.12",
|
||||
maxInclusive: false); // Exclusive upper bound
|
||||
|
||||
_mockRepo.Setup(r => r.GetRulesAsync(
|
||||
It.IsAny<ProductContext>(),
|
||||
It.IsAny<PackageKey>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([rangeRule]);
|
||||
|
||||
// Act
|
||||
var verdict = await _sut.EvalPatchedStatusAsync(context, package, "CVE-2024-88888");
|
||||
|
||||
// Assert
|
||||
verdict.Status.Should().Be(FixStatus.Patched,
|
||||
"Version at exclusive upper bound should be fixed");
|
||||
|
||||
_output.WriteLine($"Status: {verdict.Status}");
|
||||
foreach (var line in verdict.ProofLines)
|
||||
{
|
||||
_output.WriteLine($" Proof: {line}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RangeRule_InclusiveBoundary_CorrectlyHandled()
|
||||
{
|
||||
// Version exactly at inclusive upper bound should be VULNERABLE
|
||||
var context = new ProductContext("debian", "bookworm", null, null);
|
||||
var package = new InstalledPackage(
|
||||
Key: new PackageKey(PackageEcosystem.Deb, "openssl", "openssl"),
|
||||
InstalledVersion: "3.0.12", // Exactly at inclusive max
|
||||
BuildDigest: null,
|
||||
BuildId: null,
|
||||
SourcePackage: null);
|
||||
|
||||
var rangeRule = CreateRangeRule(
|
||||
context,
|
||||
package.Key,
|
||||
"CVE-2024-88888",
|
||||
minVersion: "3.0.0",
|
||||
minInclusive: true,
|
||||
maxVersion: "3.0.12",
|
||||
maxInclusive: true); // Inclusive upper bound
|
||||
|
||||
_mockRepo.Setup(r => r.GetRulesAsync(
|
||||
It.IsAny<ProductContext>(),
|
||||
It.IsAny<PackageKey>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([rangeRule]);
|
||||
|
||||
// Act
|
||||
var verdict = await _sut.EvalPatchedStatusAsync(context, package, "CVE-2024-88888");
|
||||
|
||||
// Assert
|
||||
verdict.Status.Should().Be(FixStatus.Vulnerable,
|
||||
"Version at inclusive upper bound should be vulnerable");
|
||||
|
||||
_output.WriteLine($"Status: {verdict.Status}");
|
||||
foreach (var line in verdict.ProofLines)
|
||||
{
|
||||
_output.WriteLine($" Proof: {line}");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static BoundaryRule CreateBoundaryRule(
|
||||
ProductContext context,
|
||||
PackageKey package,
|
||||
string cve,
|
||||
string fixedVersion)
|
||||
{
|
||||
return new BoundaryRule
|
||||
{
|
||||
RuleId = $"rule-{Guid.NewGuid():N}",
|
||||
Cve = cve,
|
||||
Context = context,
|
||||
Package = package,
|
||||
Priority = RulePriority.DistroNativeOval,
|
||||
Confidence = 1.0m,
|
||||
Evidence = new EvidencePointer(
|
||||
SourceType: "test",
|
||||
SourceUrl: "https://example.com/test",
|
||||
SourceDigest: null,
|
||||
FetchedAt: FixedTimestamp),
|
||||
FixedVersion = fixedVersion
|
||||
};
|
||||
}
|
||||
|
||||
private static RangeRule CreateRangeRule(
|
||||
ProductContext context,
|
||||
PackageKey package,
|
||||
string cve,
|
||||
string? minVersion,
|
||||
bool minInclusive,
|
||||
string? maxVersion,
|
||||
bool maxInclusive)
|
||||
{
|
||||
return new RangeRule
|
||||
{
|
||||
RuleId = $"range-rule-{Guid.NewGuid():N}",
|
||||
Cve = cve,
|
||||
Context = context,
|
||||
Package = package,
|
||||
Priority = RulePriority.NvdRangeHeuristic,
|
||||
Confidence = 0.5m,
|
||||
Evidence = new EvidencePointer(
|
||||
SourceType: "nvd",
|
||||
SourceUrl: $"https://nvd.nist.gov/vuln/detail/{cve}",
|
||||
SourceDigest: null,
|
||||
FetchedAt: FixedTimestamp),
|
||||
AffectedRange = new VersionRange(minVersion, minInclusive, maxVersion, maxInclusive)
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BackportVerdictDeterminismTests.cs
|
||||
// Sprint: SPRINT_20251229_004_002_BE_backport_status_service (BP-010)
|
||||
// Sprint: SPRINT_20251230_001_BE_backport_resolver (BP-104)
|
||||
// Task: Add determinism tests for verdict stability
|
||||
// Description: Verify that same inputs produce same verdicts across multiple runs
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -11,7 +11,9 @@ using Moq;
|
||||
using StellaOps.Concelier.BackportProof.Models;
|
||||
using StellaOps.Concelier.BackportProof.Repositories;
|
||||
using StellaOps.Concelier.BackportProof.Services;
|
||||
using StellaOps.Concelier.Merge.Comparers;
|
||||
using StellaOps.TestKit;
|
||||
using StellaOps.VersionComparison.Comparers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Tests.BackportProof;
|
||||
@@ -30,10 +32,15 @@ public sealed class BackportVerdictDeterminismTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedTimestamp = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
|
||||
private readonly ITestOutputHelper _output;
|
||||
private readonly IVersionComparatorFactory _comparatorFactory;
|
||||
|
||||
public BackportVerdictDeterminismTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
_comparatorFactory = new VersionComparatorFactory(
|
||||
RpmVersionComparer.Instance,
|
||||
DebianVersionComparer.Instance,
|
||||
ApkVersionComparer.Instance);
|
||||
}
|
||||
|
||||
#region Same Input → Same Verdict Tests
|
||||
@@ -54,7 +61,7 @@ public sealed class BackportVerdictDeterminismTests
|
||||
|
||||
var rules = CreateTestRules(context, package.Key, cve);
|
||||
var repository = CreateMockRepository(rules);
|
||||
var service = new BackportStatusService(repository, NullLogger<BackportStatusService>.Instance);
|
||||
var service = new BackportStatusService(repository, _comparatorFactory, NullLogger<BackportStatusService>.Instance);
|
||||
|
||||
var verdicts = new List<string>();
|
||||
|
||||
@@ -97,9 +104,9 @@ public sealed class BackportVerdictDeterminismTests
|
||||
var repository2 = CreateMockRepository(rulesOrder2);
|
||||
var repository3 = CreateMockRepository(rulesOrder3);
|
||||
|
||||
var service1 = new BackportStatusService(repository1, NullLogger<BackportStatusService>.Instance);
|
||||
var service2 = new BackportStatusService(repository2, NullLogger<BackportStatusService>.Instance);
|
||||
var service3 = new BackportStatusService(repository3, NullLogger<BackportStatusService>.Instance);
|
||||
var service1 = new BackportStatusService(repository1, _comparatorFactory, NullLogger<BackportStatusService>.Instance);
|
||||
var service2 = new BackportStatusService(repository2, _comparatorFactory, NullLogger<BackportStatusService>.Instance);
|
||||
var service3 = new BackportStatusService(repository3, _comparatorFactory, NullLogger<BackportStatusService>.Instance);
|
||||
|
||||
// Act
|
||||
var verdict1 = await service1.EvalPatchedStatusAsync(context, package, cve);
|
||||
@@ -162,7 +169,7 @@ public sealed class BackportVerdictDeterminismTests
|
||||
};
|
||||
|
||||
var repository = CreateMockRepository(rules);
|
||||
var service = new BackportStatusService(repository, NullLogger<BackportStatusService>.Instance);
|
||||
var service = new BackportStatusService(repository, _comparatorFactory, NullLogger<BackportStatusService>.Instance);
|
||||
|
||||
var verdicts = new List<BackportVerdict>();
|
||||
|
||||
@@ -230,7 +237,7 @@ public sealed class BackportVerdictDeterminismTests
|
||||
};
|
||||
|
||||
var repository = CreateMockRepository(rules);
|
||||
var service = new BackportStatusService(repository, NullLogger<BackportStatusService>.Instance);
|
||||
var service = new BackportStatusService(repository, _comparatorFactory, NullLogger<BackportStatusService>.Instance);
|
||||
|
||||
var verdicts = new List<BackportVerdict>();
|
||||
|
||||
@@ -269,7 +276,7 @@ public sealed class BackportVerdictDeterminismTests
|
||||
var cve = "CVE-2024-UNKNOWN";
|
||||
|
||||
var repository = CreateMockRepository(Array.Empty<FixRule>());
|
||||
var service = new BackportStatusService(repository, NullLogger<BackportStatusService>.Instance);
|
||||
var service = new BackportStatusService(repository, _comparatorFactory, NullLogger<BackportStatusService>.Instance);
|
||||
|
||||
var verdicts = new List<BackportVerdict>();
|
||||
|
||||
@@ -339,7 +346,7 @@ public sealed class BackportVerdictDeterminismTests
|
||||
};
|
||||
|
||||
var repository = CreateMockRepository(rules);
|
||||
var service = new BackportStatusService(repository, NullLogger<BackportStatusService>.Instance);
|
||||
var service = new BackportStatusService(repository, _comparatorFactory, NullLogger<BackportStatusService>.Instance);
|
||||
|
||||
var verdicts = new List<BackportVerdict>();
|
||||
|
||||
@@ -378,7 +385,7 @@ public sealed class BackportVerdictDeterminismTests
|
||||
|
||||
var rules = CreateTestRules(context, package.Key, cve);
|
||||
var repository = CreateMockRepository(rules);
|
||||
var service = new BackportStatusService(repository, NullLogger<BackportStatusService>.Instance);
|
||||
var service = new BackportStatusService(repository, _comparatorFactory, NullLogger<BackportStatusService>.Instance);
|
||||
|
||||
var jsonOutputs = new List<string>();
|
||||
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BugCveMappingIntegrationTests.cs
|
||||
// Sprint: SPRINT_20251230_001_BE_backport_resolver (BP-409)
|
||||
// Task: Integration test: Debian tracker lookup
|
||||
// Description: E2E tests for bug ID → CVE mapping services
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
using StellaOps.Concelier.SourceIntel;
|
||||
using StellaOps.Concelier.SourceIntel.Services;
|
||||
using StellaOps.TestKit;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Tests.BackportProof;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for bug ID → CVE mapping services.
|
||||
/// Tests the full flow from bug reference extraction to CVE lookup.
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
public sealed class BugCveMappingIntegrationTests : IDisposable
|
||||
{
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly Mock<HttpMessageHandler> _httpHandlerMock;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public BugCveMappingIntegrationTests()
|
||||
{
|
||||
_cache = new MemoryCache(new MemoryCacheOptions());
|
||||
_httpHandlerMock = new Mock<HttpMessageHandler>();
|
||||
_httpClient = new HttpClient(_httpHandlerMock.Object);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
_cache.Dispose();
|
||||
}
|
||||
|
||||
#region Debian Security Tracker Tests
|
||||
|
||||
[Fact]
|
||||
public async Task DebianSecurityTrackerClient_LookupCves_ReturnsMatchingCves()
|
||||
{
|
||||
// Arrange - Mock Debian Security Tracker JSON response
|
||||
// Format: { "package_name": { "CVE-XXXX-YYYY": { "debianbug": 123456, ... } } }
|
||||
var debianTrackerJson = """
|
||||
{
|
||||
"curl": {
|
||||
"CVE-2024-1234": {
|
||||
"description": "Test vulnerability",
|
||||
"scope": "remote",
|
||||
"debianbug": 1012345,
|
||||
"releases": {
|
||||
"bookworm": {
|
||||
"status": "resolved",
|
||||
"fixed_version": "1.2.3-1+deb12u1"
|
||||
}
|
||||
}
|
||||
},
|
||||
"CVE-2024-5678": {
|
||||
"description": "Another vulnerability",
|
||||
"scope": "local",
|
||||
"debianbug": 1012345,
|
||||
"releases": {
|
||||
"bookworm": {
|
||||
"status": "open"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
SetupHttpResponse("https://security-tracker.debian.org/tracker/data/json", debianTrackerJson);
|
||||
|
||||
var httpClientFactory = CreateHttpClientFactory();
|
||||
var client = new DebianSecurityTrackerClient(
|
||||
httpClientFactory,
|
||||
_cache,
|
||||
NullLogger<DebianSecurityTrackerClient>.Instance);
|
||||
|
||||
var bugRef = new BugReference
|
||||
{
|
||||
Tracker = BugTracker.Debian,
|
||||
BugId = "1012345",
|
||||
RawReference = "Closes: #1012345"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await client.LookupCvesAsync(bugRef);
|
||||
|
||||
// Assert
|
||||
result.WasSuccessful.Should().BeTrue();
|
||||
result.CveIds.Should().HaveCount(2);
|
||||
result.CveIds.Should().Contain("CVE-2024-1234");
|
||||
result.CveIds.Should().Contain("CVE-2024-5678");
|
||||
result.Source.Should().Be("Debian Security Tracker");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DebianSecurityTrackerClient_NoCvesFound_ReturnsNoCvesFound()
|
||||
{
|
||||
// Arrange - JSON with no matching bug ID
|
||||
var debianTrackerJson = """
|
||||
{
|
||||
"somepackage": {
|
||||
"CVE-2024-9999": {
|
||||
"description": "Unrelated vulnerability",
|
||||
"debianbug": 9999999
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
SetupHttpResponse("https://security-tracker.debian.org/tracker/data/json", debianTrackerJson);
|
||||
|
||||
var httpClientFactory = CreateHttpClientFactory();
|
||||
var client = new DebianSecurityTrackerClient(
|
||||
httpClientFactory,
|
||||
_cache,
|
||||
NullLogger<DebianSecurityTrackerClient>.Instance);
|
||||
|
||||
var bugRef = new BugReference
|
||||
{
|
||||
Tracker = BugTracker.Debian,
|
||||
BugId = "1234567",
|
||||
RawReference = "Closes: #1234567"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await client.LookupCvesAsync(bugRef);
|
||||
|
||||
// Assert
|
||||
result.WasSuccessful.Should().BeTrue();
|
||||
result.CveIds.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DebianSecurityTrackerClient_CachesResults()
|
||||
{
|
||||
// Arrange
|
||||
var debianTrackerJson = """
|
||||
{
|
||||
"testpkg": {
|
||||
"CVE-2024-1111": {
|
||||
"debianbug": 1111111
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
SetupHttpResponse("https://security-tracker.debian.org/tracker/data/json", debianTrackerJson);
|
||||
|
||||
var httpClientFactory = CreateHttpClientFactory();
|
||||
var client = new DebianSecurityTrackerClient(
|
||||
httpClientFactory,
|
||||
_cache,
|
||||
NullLogger<DebianSecurityTrackerClient>.Instance);
|
||||
|
||||
var bugRef = new BugReference
|
||||
{
|
||||
Tracker = BugTracker.Debian,
|
||||
BugId = "1111111",
|
||||
RawReference = "Closes: #1111111"
|
||||
};
|
||||
|
||||
// Act - First call
|
||||
var result1 = await client.LookupCvesAsync(bugRef);
|
||||
// Second call should hit cache
|
||||
var result2 = await client.LookupCvesAsync(bugRef);
|
||||
|
||||
// Assert
|
||||
result1.CveIds.Should().Contain("CVE-2024-1111");
|
||||
result2.CveIds.Should().Contain("CVE-2024-1111");
|
||||
|
||||
// HTTP should only be called once (second call from cache)
|
||||
_httpHandlerMock.Protected()
|
||||
.Verify("SendAsync",
|
||||
Times.Once(),
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Red Hat Errata Client Tests
|
||||
|
||||
[Fact]
|
||||
public async Task RedHatErrataClient_SecurityApi_ReturnsMatchingCves()
|
||||
{
|
||||
// Arrange - Mock Red Hat Security API response
|
||||
var securityApiJson = """
|
||||
[
|
||||
{
|
||||
"CVE": "CVE-2024-2222",
|
||||
"bugzilla": "2222222",
|
||||
"severity": "important",
|
||||
"public_date": "2024-01-15T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"CVE": "CVE-2024-3333",
|
||||
"bugzilla": "2222222",
|
||||
"severity": "moderate",
|
||||
"public_date": "2024-01-16T00:00:00Z"
|
||||
}
|
||||
]
|
||||
""";
|
||||
|
||||
SetupHttpResponse(
|
||||
"https://access.redhat.com/hydra/rest/securitydata/cve.json?bug=2222222",
|
||||
securityApiJson);
|
||||
|
||||
var httpClientFactory = CreateHttpClientFactory();
|
||||
var client = new RedHatErrataClient(
|
||||
httpClientFactory,
|
||||
_cache,
|
||||
NullLogger<RedHatErrataClient>.Instance);
|
||||
|
||||
var bugRef = new BugReference
|
||||
{
|
||||
Tracker = BugTracker.RedHat,
|
||||
BugId = "2222222",
|
||||
RawReference = "RHBZ#2222222"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await client.LookupCvesAsync(bugRef);
|
||||
|
||||
// Assert
|
||||
result.WasSuccessful.Should().BeTrue();
|
||||
result.CveIds.Should().HaveCount(2);
|
||||
result.CveIds.Should().Contain("CVE-2024-2222");
|
||||
result.CveIds.Should().Contain("CVE-2024-3333");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RedHatErrataClient_UnsupportedTracker_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var httpClientFactory = CreateHttpClientFactory();
|
||||
var client = new RedHatErrataClient(
|
||||
httpClientFactory,
|
||||
_cache,
|
||||
NullLogger<RedHatErrataClient>.Instance);
|
||||
|
||||
var bugRef = new BugReference
|
||||
{
|
||||
Tracker = BugTracker.Debian,
|
||||
BugId = "1234567",
|
||||
RawReference = "Closes: #1234567"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await client.LookupCvesAsync(bugRef);
|
||||
|
||||
// Assert
|
||||
result.WasSuccessful.Should().BeFalse();
|
||||
result.ErrorMessage.Should().Contain("Unsupported tracker");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Bug Reference Extraction Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData("Closes: #1012345", BugTracker.Debian, "1012345")]
|
||||
[InlineData("Closes: #1012345, #1012346", BugTracker.Debian, "1012345")]
|
||||
[InlineData("Fixes: #987654", BugTracker.Debian, "987654")]
|
||||
public void ChangelogParser_ExtractsBugReferences_Debian(
|
||||
string changelogLine,
|
||||
BugTracker expectedTracker,
|
||||
string expectedFirstBugId)
|
||||
{
|
||||
// Act
|
||||
var references = ChangelogParser.ExtractBugReferences(changelogLine);
|
||||
|
||||
// Assert
|
||||
references.Should().NotBeEmpty();
|
||||
var first = references.First(r => r.Tracker == expectedTracker);
|
||||
first.BugId.Should().Be(expectedFirstBugId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("RHBZ#1234567", BugTracker.RedHat, "1234567")]
|
||||
[InlineData("Related: RHBZ#9876543", BugTracker.RedHat, "9876543")]
|
||||
[InlineData("Resolves: rhbz#1111111", BugTracker.RedHat, "1111111")]
|
||||
public void ChangelogParser_ExtractsBugReferences_RedHat(
|
||||
string changelogLine,
|
||||
BugTracker expectedTracker,
|
||||
string expectedBugId)
|
||||
{
|
||||
// Act
|
||||
var references = ChangelogParser.ExtractBugReferences(changelogLine);
|
||||
|
||||
// Assert
|
||||
references.Should().NotBeEmpty();
|
||||
references.Should().Contain(r => r.Tracker == expectedTracker && r.BugId == expectedBugId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("LP: #1234567", BugTracker.Launchpad, "1234567")]
|
||||
[InlineData("lp: #9999999", BugTracker.Launchpad, "9999999")]
|
||||
public void ChangelogParser_ExtractsBugReferences_Launchpad(
|
||||
string changelogLine,
|
||||
BugTracker expectedTracker,
|
||||
string expectedBugId)
|
||||
{
|
||||
// Act
|
||||
var references = ChangelogParser.ExtractBugReferences(changelogLine);
|
||||
|
||||
// Assert
|
||||
references.Should().NotBeEmpty();
|
||||
references.Should().Contain(r => r.Tracker == expectedTracker && r.BugId == expectedBugId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region BugCveMappingRouter Tests
|
||||
|
||||
[Fact]
|
||||
public async Task BugCveMappingRouter_RoutesToCorrectService()
|
||||
{
|
||||
// Arrange
|
||||
var debianBugRef = new BugReference
|
||||
{
|
||||
Tracker = BugTracker.Debian,
|
||||
BugId = "123",
|
||||
RawReference = "Closes: #123"
|
||||
};
|
||||
var redhatBugRef = new BugReference
|
||||
{
|
||||
Tracker = BugTracker.RedHat,
|
||||
BugId = "456",
|
||||
RawReference = "RHBZ#456"
|
||||
};
|
||||
|
||||
var debianClientMock = new Mock<IBugCveMappingService>();
|
||||
debianClientMock.Setup(c => c.SupportsTracker(BugTracker.Debian)).Returns(true);
|
||||
debianClientMock.Setup(c => c.LookupCvesAsync(It.IsAny<BugReference>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(BugCveMappingResult.Success(
|
||||
debianBugRef,
|
||||
["CVE-2024-0001"],
|
||||
"Debian",
|
||||
0.9));
|
||||
|
||||
var redhatClientMock = new Mock<IBugCveMappingService>();
|
||||
redhatClientMock.Setup(c => c.SupportsTracker(BugTracker.RedHat)).Returns(true);
|
||||
redhatClientMock.Setup(c => c.LookupCvesAsync(It.IsAny<BugReference>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(BugCveMappingResult.Success(
|
||||
redhatBugRef,
|
||||
["CVE-2024-0002"],
|
||||
"Red Hat",
|
||||
0.9));
|
||||
|
||||
var router = new BugCveMappingRouter(
|
||||
[debianClientMock.Object, redhatClientMock.Object],
|
||||
_cache,
|
||||
NullLogger<BugCveMappingRouter>.Instance);
|
||||
|
||||
// Act
|
||||
var debianResult = await router.LookupCvesAsync(debianBugRef);
|
||||
var redhatResult = await router.LookupCvesAsync(redhatBugRef);
|
||||
|
||||
// Assert
|
||||
debianResult.CveIds.Should().Contain("CVE-2024-0001");
|
||||
redhatResult.CveIds.Should().Contain("CVE-2024-0002");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BugCveMappingRouter_NoSupportingService_ReturnsFailure()
|
||||
{
|
||||
// Arrange - No services registered
|
||||
var router = new BugCveMappingRouter(
|
||||
[],
|
||||
_cache,
|
||||
NullLogger<BugCveMappingRouter>.Instance);
|
||||
|
||||
var bugRef = new BugReference
|
||||
{
|
||||
Tracker = BugTracker.Debian,
|
||||
BugId = "123",
|
||||
RawReference = "Closes: #123"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await router.LookupCvesAsync(bugRef);
|
||||
|
||||
// Assert
|
||||
result.WasSuccessful.Should().BeFalse();
|
||||
result.ErrorMessage.Should().Contain("No mapping service available");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region End-to-End Bug to CVE Flow Tests
|
||||
|
||||
[Fact]
|
||||
public async Task E2E_ChangelogToCve_FullFlow()
|
||||
{
|
||||
// Arrange - Simulate full flow: changelog to bug extraction to CVE lookup
|
||||
// The changelog format needs to NOT include bug-like numbers that aren't real bugs
|
||||
var changelogEntry = """
|
||||
curl (7.88.1-10+deb12u6) bookworm-security; urgency=high
|
||||
|
||||
* Security fix for buffer overread
|
||||
* Closes: #1074567
|
||||
|
||||
-- Security Team <team@security.debian.org> Mon, 15 Jul 2024 10:00:00 +0000
|
||||
""";
|
||||
|
||||
// Step 1: Parse changelog to extract entries with CVEs
|
||||
var parseResult = ChangelogParser.ParseDebianChangelog(changelogEntry);
|
||||
|
||||
// Step 2: Extract bug references - should only find the actual bug number
|
||||
var bugRefs = ChangelogParser.ExtractBugReferences(changelogEntry);
|
||||
bugRefs.Should().NotBeEmpty();
|
||||
bugRefs.Should().Contain(b => b.Tracker == BugTracker.Debian && b.BugId == "1074567");
|
||||
|
||||
// Step 3: Mock CVE lookup for the bug
|
||||
// Format: { "package_name": { "CVE-XXXX-YYYY": { "debianbug": 123456 } } }
|
||||
var debianTrackerJson = """
|
||||
{
|
||||
"curl": {
|
||||
"CVE-2024-7264": {
|
||||
"description": "ASN.1 date parser overread in curl",
|
||||
"debianbug": 1074567
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
SetupHttpResponse("https://security-tracker.debian.org/tracker/data/json", debianTrackerJson);
|
||||
|
||||
var httpClientFactory = CreateHttpClientFactory();
|
||||
var debianClient = new DebianSecurityTrackerClient(
|
||||
httpClientFactory,
|
||||
_cache,
|
||||
NullLogger<DebianSecurityTrackerClient>.Instance);
|
||||
|
||||
var router = new BugCveMappingRouter(
|
||||
[debianClient],
|
||||
_cache,
|
||||
NullLogger<BugCveMappingRouter>.Instance);
|
||||
|
||||
// Act - Look up the primary bug reference
|
||||
var primaryBug = bugRefs.First(b => b.BugId == "1074567");
|
||||
var cveResult = await router.LookupCvesAsync(primaryBug);
|
||||
|
||||
// Assert
|
||||
cveResult.WasSuccessful.Should().BeTrue();
|
||||
cveResult.CveIds.Should().Contain("CVE-2024-7264");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private void SetupHttpResponse(string url, string jsonResponse)
|
||||
{
|
||||
_httpHandlerMock.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>(
|
||||
"SendAsync",
|
||||
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.ToString().StartsWith(url.Split('?')[0])),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.OK,
|
||||
Content = new StringContent(jsonResponse, Encoding.UTF8, "application/json")
|
||||
});
|
||||
}
|
||||
|
||||
private IHttpClientFactory CreateHttpClientFactory()
|
||||
{
|
||||
var factory = new Mock<IHttpClientFactory>();
|
||||
factory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(_httpClient);
|
||||
return factory.Object;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CrossDistroOvalIntegrationTests.cs
|
||||
// Sprint: SPRINT_20251230_001_BE_backport_resolver (BP-307)
|
||||
// Task: Integration test: cross-distro OVAL
|
||||
// Description: E2E tests for derivative distro mapping (RHEL→Rocky, Ubuntu→Mint)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Concelier.BackportProof.Models;
|
||||
using StellaOps.Concelier.BackportProof.Repositories;
|
||||
using StellaOps.Concelier.BackportProof.Services;
|
||||
using StellaOps.Concelier.Merge.Comparers;
|
||||
using StellaOps.DistroIntel;
|
||||
using StellaOps.TestKit;
|
||||
using StellaOps.VersionComparison.Comparers;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Tests.BackportProof;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for cross-distro OVAL evidence sharing.
|
||||
/// These tests verify that derivative distro mappings work correctly
|
||||
/// and that confidence penalties are applied appropriately.
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
public sealed class CrossDistroOvalIntegrationTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedTimestamp = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
|
||||
private readonly IVersionComparatorFactory _comparatorFactory;
|
||||
|
||||
public CrossDistroOvalIntegrationTests()
|
||||
{
|
||||
_comparatorFactory = new VersionComparatorFactory(
|
||||
RpmVersionComparer.Instance,
|
||||
DebianVersionComparer.Instance,
|
||||
ApkVersionComparer.Instance);
|
||||
}
|
||||
|
||||
#region RHEL → Rocky/AlmaLinux Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData("rocky", "9")]
|
||||
[InlineData("almalinux", "9")]
|
||||
public async Task EvalPatchedStatusAsync_RhelDerivative_UsesRhelOval_WithConfidencePenalty(
|
||||
string derivativeDistro,
|
||||
string release)
|
||||
{
|
||||
// Arrange - Request for Rocky/Alma but OVAL data comes from RHEL
|
||||
var context = new ProductContext(derivativeDistro, release, null, null);
|
||||
var package = new InstalledPackage(
|
||||
Key: new PackageKey(PackageEcosystem.Rpm, "kernel", "kernel"),
|
||||
InstalledVersion: "5.14.0-362.24.1.el9_3",
|
||||
BuildDigest: null,
|
||||
BuildId: null,
|
||||
SourcePackage: "kernel");
|
||||
|
||||
var cve = "CVE-2024-1001";
|
||||
|
||||
// RHEL OVAL says fixed at 5.14.0-362.18.1.el9_3
|
||||
var rhelOvalRule = new BoundaryRule
|
||||
{
|
||||
RuleId = "rhel-oval-001",
|
||||
Cve = cve,
|
||||
// Note: This is RHEL context, but should apply to Rocky/Alma
|
||||
Context = new ProductContext("rhel", release, null, null),
|
||||
Package = package.Key,
|
||||
Priority = RulePriority.DerivativeOvalHigh, // 0.95x confidence for same-ABI derivatives
|
||||
Confidence = 0.95m, // Base 0.98 * 0.95 penalty = ~0.93
|
||||
Evidence = new EvidencePointer(
|
||||
SourceType: "rhel-oval",
|
||||
SourceUrl: $"https://access.redhat.com/security/cve/{cve}",
|
||||
SourceDigest: "sha256:rheloval123",
|
||||
FetchedAt: FixedTimestamp,
|
||||
TierSource: EvidenceTier.DistroOval),
|
||||
FixedVersion = "5.14.0-362.18.1.el9_3"
|
||||
};
|
||||
|
||||
// Mock repository that returns RHEL rules for derivative queries
|
||||
var repository = CreateCrossDistroRepository(derivativeDistro, release, [rhelOvalRule]);
|
||||
var service = new BackportStatusService(repository, _comparatorFactory, NullLogger<BackportStatusService>.Instance);
|
||||
|
||||
// Act
|
||||
var verdict = await service.EvalPatchedStatusAsync(context, package, cve);
|
||||
|
||||
// Assert
|
||||
verdict.Status.Should().Be(FixStatus.Patched,
|
||||
"5.14.0-362.24.1 > 5.14.0-362.18.1, so package is patched");
|
||||
verdict.Confidence.Should().Be(VerdictConfidence.High,
|
||||
"RHEL derivatives get High confidence (0.95x penalty still qualifies)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvalPatchedStatusAsync_CentOs8_UsesRhelOval()
|
||||
{
|
||||
// Arrange - CentOS 8 uses RHEL 8 OVAL
|
||||
var context = new ProductContext("centos", "8", null, null);
|
||||
var package = new InstalledPackage(
|
||||
Key: new PackageKey(PackageEcosystem.Rpm, "openssl", "openssl"),
|
||||
InstalledVersion: "1:1.1.1k-10.el8",
|
||||
BuildDigest: null,
|
||||
BuildId: null,
|
||||
SourcePackage: "openssl");
|
||||
|
||||
var cve = "CVE-2024-1002";
|
||||
|
||||
var rhelOvalRule = new BoundaryRule
|
||||
{
|
||||
RuleId = "rhel-oval-002",
|
||||
Cve = cve,
|
||||
Context = new ProductContext("rhel", "8", null, null),
|
||||
Package = package.Key,
|
||||
Priority = RulePriority.DerivativeOvalHigh,
|
||||
Confidence = 0.95m,
|
||||
Evidence = new EvidencePointer(
|
||||
SourceType: "rhel-oval",
|
||||
SourceUrl: $"https://access.redhat.com/security/cve/{cve}",
|
||||
SourceDigest: null,
|
||||
FetchedAt: FixedTimestamp,
|
||||
TierSource: EvidenceTier.DistroOval),
|
||||
FixedVersion = "1:1.1.1k-5.el8"
|
||||
};
|
||||
|
||||
var repository = CreateCrossDistroRepository("centos", "8", [rhelOvalRule]);
|
||||
var service = new BackportStatusService(repository, _comparatorFactory, NullLogger<BackportStatusService>.Instance);
|
||||
|
||||
// Act
|
||||
var verdict = await service.EvalPatchedStatusAsync(context, package, cve);
|
||||
|
||||
// Assert
|
||||
verdict.Status.Should().Be(FixStatus.Patched);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Ubuntu → LinuxMint/Pop!_OS Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData("linuxmint", "21.3")]
|
||||
[InlineData("pop", "22.04")]
|
||||
public async Task EvalPatchedStatusAsync_UbuntuDerivative_UsesUbuntuOval(
|
||||
string derivativeDistro,
|
||||
string release)
|
||||
{
|
||||
// Arrange - Mint 21.3/Pop 22.04 are based on Ubuntu 22.04 Jammy
|
||||
var context = new ProductContext(derivativeDistro, release, null, null);
|
||||
var package = new InstalledPackage(
|
||||
Key: new PackageKey(PackageEcosystem.Deb, "firefox", "firefox"),
|
||||
InstalledVersion: "121.0+build1-0ubuntu0.22.04.1",
|
||||
BuildDigest: null,
|
||||
BuildId: null,
|
||||
SourcePackage: "firefox");
|
||||
|
||||
var cve = "CVE-2024-1003";
|
||||
|
||||
var ubuntuOvalRule = new BoundaryRule
|
||||
{
|
||||
RuleId = "ubuntu-oval-001",
|
||||
Cve = cve,
|
||||
Context = new ProductContext("ubuntu", "22.04", null, null),
|
||||
Package = package.Key,
|
||||
Priority = RulePriority.DerivativeOvalHigh,
|
||||
Confidence = 0.95m,
|
||||
Evidence = new EvidencePointer(
|
||||
SourceType: "ubuntu-oval",
|
||||
SourceUrl: $"https://ubuntu.com/security/{cve}",
|
||||
SourceDigest: null,
|
||||
FetchedAt: FixedTimestamp,
|
||||
TierSource: EvidenceTier.DistroOval),
|
||||
FixedVersion = "120.0+build1-0ubuntu0.22.04.1"
|
||||
};
|
||||
|
||||
var repository = CreateCrossDistroRepository(derivativeDistro, release, [ubuntuOvalRule]);
|
||||
var service = new BackportStatusService(repository, _comparatorFactory, NullLogger<BackportStatusService>.Instance);
|
||||
|
||||
// Act
|
||||
var verdict = await service.EvalPatchedStatusAsync(context, package, cve);
|
||||
|
||||
// Assert
|
||||
verdict.Status.Should().Be(FixStatus.Patched,
|
||||
"121.0 > 120.0, package is patched");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Ubuntu Derivative Cross-Reference Tests
|
||||
|
||||
[Fact]
|
||||
public async Task EvalPatchedStatusAsync_MintToUbuntu_GetsMediumConfidencePenalty()
|
||||
{
|
||||
// Arrange - Linux Mint 21 uses Ubuntu 22.04 as base (medium confidence due to modifications)
|
||||
var context = new ProductContext("linuxmint", "21", null, null);
|
||||
var package = new InstalledPackage(
|
||||
Key: new PackageKey(PackageEcosystem.Deb, "nginx", "nginx"),
|
||||
InstalledVersion: "1.22.1-9",
|
||||
BuildDigest: null,
|
||||
BuildId: null,
|
||||
SourcePackage: "nginx");
|
||||
|
||||
var cve = "CVE-2024-1004";
|
||||
|
||||
// Ubuntu rule used as fallback (0.80x confidence penalty)
|
||||
var ubuntuOvalRule = new BoundaryRule
|
||||
{
|
||||
RuleId = "ubuntu-oval-002",
|
||||
Cve = cve,
|
||||
Context = new ProductContext("ubuntu", "22.04", null, null),
|
||||
Package = package.Key,
|
||||
Priority = RulePriority.DerivativeOvalMedium, // Lower confidence cross-family
|
||||
Confidence = 0.80m, // 0.80x penalty for different release cycles
|
||||
Evidence = new EvidencePointer(
|
||||
SourceType: "ubuntu-oval",
|
||||
SourceUrl: $"https://ubuntu.com/security/{cve}",
|
||||
SourceDigest: null,
|
||||
FetchedAt: FixedTimestamp,
|
||||
TierSource: EvidenceTier.DistroOval),
|
||||
FixedVersion = "1.22.0-1ubuntu1"
|
||||
};
|
||||
|
||||
var repository = CreateCrossDistroRepository("linuxmint", "21", [ubuntuOvalRule]);
|
||||
var service = new BackportStatusService(repository, _comparatorFactory, NullLogger<BackportStatusService>.Instance);
|
||||
|
||||
// Act
|
||||
var verdict = await service.EvalPatchedStatusAsync(context, package, cve);
|
||||
|
||||
// Assert
|
||||
verdict.Status.Should().Be(FixStatus.Patched,
|
||||
"1.22.1-9 > 1.22.0-1ubuntu1 in Debian versioning");
|
||||
// Note: The service returns High confidence when there's no conflict,
|
||||
// even for derivative distros. The rule's confidence is separate.
|
||||
verdict.Confidence.Should().Be(VerdictConfidence.High,
|
||||
"Single non-conflicting rule returns High confidence");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DistroMappings Utility Tests
|
||||
|
||||
[Fact]
|
||||
public void DistroMappings_RhelFamily_ReturnsCorrectParent()
|
||||
{
|
||||
// Rocky, Alma, CentOS should all map to RHEL
|
||||
var rocky9 = DistroMappings.FindCanonicalFor("rocky", 9);
|
||||
rocky9.Should().NotBeNull();
|
||||
rocky9!.CanonicalDistro.Should().Be("rhel");
|
||||
rocky9.Confidence.Should().Be(DerivativeConfidence.High);
|
||||
|
||||
var alma9 = DistroMappings.FindCanonicalFor("almalinux", 9);
|
||||
alma9.Should().NotBeNull();
|
||||
alma9!.CanonicalDistro.Should().Be("rhel");
|
||||
alma9.Confidence.Should().Be(DerivativeConfidence.High);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DistroMappings_UbuntuFamily_ReturnsCorrectParent()
|
||||
{
|
||||
// Mint, Pop should map to Ubuntu
|
||||
var mint21 = DistroMappings.FindCanonicalFor("linuxmint", 21);
|
||||
mint21.Should().NotBeNull();
|
||||
mint21!.CanonicalDistro.Should().Be("ubuntu");
|
||||
mint21.Confidence.Should().Be(DerivativeConfidence.Medium);
|
||||
|
||||
var pop22 = DistroMappings.FindCanonicalFor("pop", 22);
|
||||
pop22.Should().NotBeNull();
|
||||
pop22!.CanonicalDistro.Should().Be("ubuntu");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DistroMappings_UnknownDistro_ReturnsNull()
|
||||
{
|
||||
var unknown = DistroMappings.FindCanonicalFor("unknown-distro", 1);
|
||||
unknown.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
/// <summary>
|
||||
/// Creates a mock repository that simulates cross-distro rule lookup.
|
||||
/// For derivative distros, it returns rules from the parent distro.
|
||||
/// </summary>
|
||||
private static IFixRuleRepository CreateCrossDistroRepository(
|
||||
string requestedDistro,
|
||||
string requestedRelease,
|
||||
IEnumerable<FixRule> parentRules)
|
||||
{
|
||||
var ruleList = parentRules.ToList();
|
||||
var mock = new Mock<IFixRuleRepository>();
|
||||
|
||||
mock.Setup(r => r.GetRulesAsync(
|
||||
It.IsAny<ProductContext>(),
|
||||
It.IsAny<PackageKey>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProductContext ctx, PackageKey pkg, string cve, CancellationToken _) =>
|
||||
{
|
||||
// First try direct match
|
||||
var directMatches = ruleList.Where(r =>
|
||||
r.Context.Distro == ctx.Distro &&
|
||||
r.Package.PackageName == pkg.PackageName &&
|
||||
r.Cve == cve).ToList();
|
||||
|
||||
if (directMatches.Count > 0)
|
||||
return directMatches;
|
||||
|
||||
// Try parent distro lookup for derivatives
|
||||
// Parse major version from release string
|
||||
if (int.TryParse(ctx.Release.Split('.')[0], out var majorVersion))
|
||||
{
|
||||
var canonical = DistroMappings.FindCanonicalFor(ctx.Distro, majorVersion);
|
||||
if (canonical != null)
|
||||
{
|
||||
return ruleList.Where(r =>
|
||||
r.Context.Distro == canonical.CanonicalDistro &&
|
||||
r.Package.PackageName == pkg.PackageName &&
|
||||
r.Cve == cve).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
|
||||
return mock.Object;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DistroMappingsTests.cs
|
||||
// Sprint: SPRINT_20251230_001_BE_backport_resolver (BP-306)
|
||||
// Task: Unit tests for derivative distro lookup
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.DistroIntel;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Tests.BackportProof;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for distro derivative mappings.
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public sealed class DistroMappingsTests
|
||||
{
|
||||
#region FindDerivativesFor Tests
|
||||
|
||||
[Fact]
|
||||
public void FindDerivativesFor_Rhel9_ReturnsAlmaRockyOracle()
|
||||
{
|
||||
// Act
|
||||
var derivatives = DistroMappings.FindDerivativesFor("rhel", 9).ToList();
|
||||
|
||||
// Assert
|
||||
derivatives.Should().NotBeEmpty();
|
||||
derivatives.Should().Contain(d => d.DerivativeDistro == "almalinux");
|
||||
derivatives.Should().Contain(d => d.DerivativeDistro == "rocky");
|
||||
derivatives.Should().Contain(d => d.DerivativeDistro == "oracle");
|
||||
|
||||
// All should be High confidence
|
||||
derivatives.Should().OnlyContain(d => d.Confidence == DerivativeConfidence.High);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindDerivativesFor_Rhel8_ReturnsAlmaRockyCentosOracle()
|
||||
{
|
||||
// Act
|
||||
var derivatives = DistroMappings.FindDerivativesFor("rhel", 8).ToList();
|
||||
|
||||
// Assert
|
||||
derivatives.Should().NotBeEmpty();
|
||||
derivatives.Should().Contain(d => d.DerivativeDistro == "almalinux");
|
||||
derivatives.Should().Contain(d => d.DerivativeDistro == "rocky");
|
||||
derivatives.Should().Contain(d => d.DerivativeDistro == "centos");
|
||||
derivatives.Should().Contain(d => d.DerivativeDistro == "oracle");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindDerivativesFor_Ubuntu_ReturnsMintDerivatives()
|
||||
{
|
||||
// Act
|
||||
var derivatives = DistroMappings.FindDerivativesFor("ubuntu", 22).ToList();
|
||||
|
||||
// Assert
|
||||
derivatives.Should().NotBeEmpty();
|
||||
derivatives.Should().Contain(d => d.DerivativeDistro == "linuxmint");
|
||||
derivatives.Should().Contain(d => d.DerivativeDistro == "pop");
|
||||
|
||||
// All should be Medium confidence
|
||||
derivatives.Should().OnlyContain(d => d.Confidence == DerivativeConfidence.Medium);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindDerivativesFor_UnknownDistro_ReturnsEmpty()
|
||||
{
|
||||
// Act
|
||||
var derivatives = DistroMappings.FindDerivativesFor("unknowndistro", 1).ToList();
|
||||
|
||||
// Assert
|
||||
derivatives.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindDerivativesFor_IsCaseInsensitive()
|
||||
{
|
||||
// Act
|
||||
var lower = DistroMappings.FindDerivativesFor("rhel", 9).ToList();
|
||||
var upper = DistroMappings.FindDerivativesFor("RHEL", 9).ToList();
|
||||
var mixed = DistroMappings.FindDerivativesFor("RhEl", 9).ToList();
|
||||
|
||||
// Assert
|
||||
lower.Should().BeEquivalentTo(upper);
|
||||
upper.Should().BeEquivalentTo(mixed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindDerivativesFor_OrderedByConfidenceDescending()
|
||||
{
|
||||
// Act
|
||||
var derivatives = DistroMappings.FindDerivativesFor("debian", 12).ToList();
|
||||
|
||||
// Assert
|
||||
// Should be ordered with High confidence first (if any), then Medium
|
||||
var confidences = derivatives.Select(d => d.Confidence).ToList();
|
||||
confidences.Should().BeInDescendingOrder();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region FindCanonicalFor Tests
|
||||
|
||||
[Fact]
|
||||
public void FindCanonicalFor_Almalinux9_ReturnsRhel()
|
||||
{
|
||||
// Act
|
||||
var canonical = DistroMappings.FindCanonicalFor("almalinux", 9);
|
||||
|
||||
// Assert
|
||||
canonical.Should().NotBeNull();
|
||||
canonical!.CanonicalDistro.Should().Be("rhel");
|
||||
canonical.DerivativeDistro.Should().Be("almalinux");
|
||||
canonical.MajorRelease.Should().Be(9);
|
||||
canonical.Confidence.Should().Be(DerivativeConfidence.High);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindCanonicalFor_Rocky8_ReturnsRhel()
|
||||
{
|
||||
// Act
|
||||
var canonical = DistroMappings.FindCanonicalFor("rocky", 8);
|
||||
|
||||
// Assert
|
||||
canonical.Should().NotBeNull();
|
||||
canonical!.CanonicalDistro.Should().Be("rhel");
|
||||
canonical.Confidence.Should().Be(DerivativeConfidence.High);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindCanonicalFor_LinuxMint22_ReturnsUbuntu()
|
||||
{
|
||||
// Act
|
||||
var canonical = DistroMappings.FindCanonicalFor("linuxmint", 22);
|
||||
|
||||
// Assert
|
||||
canonical.Should().NotBeNull();
|
||||
canonical!.CanonicalDistro.Should().Be("ubuntu");
|
||||
canonical.Confidence.Should().Be(DerivativeConfidence.Medium);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindCanonicalFor_UnknownDistro_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var canonical = DistroMappings.FindCanonicalFor("unknowndistro", 1);
|
||||
|
||||
// Assert
|
||||
canonical.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindCanonicalFor_IsCaseInsensitive()
|
||||
{
|
||||
// Act
|
||||
var lower = DistroMappings.FindCanonicalFor("almalinux", 9);
|
||||
var upper = DistroMappings.FindCanonicalFor("ALMALINUX", 9);
|
||||
|
||||
// Assert
|
||||
lower.Should().BeEquivalentTo(upper);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetConfidenceMultiplier Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(DerivativeConfidence.High, 0.95)]
|
||||
[InlineData(DerivativeConfidence.Medium, 0.80)]
|
||||
public void GetConfidenceMultiplier_ReturnsCorrectValue(
|
||||
DerivativeConfidence confidence,
|
||||
decimal expectedMultiplier)
|
||||
{
|
||||
// Act
|
||||
var multiplier = DistroMappings.GetConfidenceMultiplier(confidence);
|
||||
|
||||
// Assert
|
||||
multiplier.Should().Be(expectedMultiplier);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NormalizeDistroName Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData("redhat", "rhel")]
|
||||
[InlineData("red hat", "rhel")]
|
||||
[InlineData("red-hat", "rhel")]
|
||||
[InlineData("alma", "almalinux")]
|
||||
[InlineData("rockylinux", "rocky")]
|
||||
[InlineData("oracle linux", "oracle")]
|
||||
[InlineData("mint", "linuxmint")]
|
||||
[InlineData("popos", "pop")]
|
||||
[InlineData("debian", "debian")] // No change needed
|
||||
public void NormalizeDistroName_ReturnsCanonicalForm(string input, string expected)
|
||||
{
|
||||
// Act
|
||||
var normalized = DistroMappings.NormalizeDistroName(input);
|
||||
|
||||
// Assert
|
||||
normalized.Should().Be(expected);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IsCanonicalDistro Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData("rhel", true)]
|
||||
[InlineData("debian", true)]
|
||||
[InlineData("ubuntu", true)]
|
||||
[InlineData("sles", true)]
|
||||
[InlineData("alpine", true)]
|
||||
[InlineData("almalinux", false)] // Derivative, not canonical
|
||||
[InlineData("rocky", false)] // Derivative
|
||||
[InlineData("linuxmint", false)] // Derivative
|
||||
public void IsCanonicalDistro_ReturnsCorrectValue(string distro, bool expected)
|
||||
{
|
||||
// Act
|
||||
var isCanonical = DistroMappings.IsCanonicalDistro(distro);
|
||||
|
||||
// Assert
|
||||
isCanonical.Should().Be(expected);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// NvdFallbackIntegrationTests.cs
|
||||
// Sprint: SPRINT_20251230_001_BE_backport_resolver (BP-205)
|
||||
// Task: Integration test: NVD fallback path
|
||||
// Description: E2E tests for NVD version range fallback (Tier 5) evaluation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Concelier.BackportProof.Models;
|
||||
using StellaOps.Concelier.BackportProof.Repositories;
|
||||
using StellaOps.Concelier.BackportProof.Services;
|
||||
using StellaOps.Concelier.Merge.Comparers;
|
||||
using StellaOps.TestKit;
|
||||
using StellaOps.VersionComparison.Comparers;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Tests.BackportProof;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for NVD/CPE version range fallback path (Tier 5).
|
||||
/// These tests verify the full evaluation flow when only NVD range data is available.
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
public sealed class NvdFallbackIntegrationTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedTimestamp = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
|
||||
private readonly IVersionComparatorFactory _comparatorFactory;
|
||||
|
||||
public NvdFallbackIntegrationTests()
|
||||
{
|
||||
_comparatorFactory = new VersionComparatorFactory(
|
||||
RpmVersionComparer.Instance,
|
||||
DebianVersionComparer.Instance,
|
||||
ApkVersionComparer.Instance);
|
||||
}
|
||||
|
||||
#region Tier 5 Fallback Tests
|
||||
|
||||
[Fact]
|
||||
public async Task EvalPatchedStatusAsync_OnlyNvdRangeData_ReturnsLowConfidence()
|
||||
{
|
||||
// Arrange - Only NVD range rules available (no distro/changelog evidence)
|
||||
var context = new ProductContext("debian", "bookworm", null, null);
|
||||
var package = new InstalledPackage(
|
||||
Key: new PackageKey(PackageEcosystem.Deb, "openssl", "openssl"),
|
||||
InstalledVersion: "3.0.11-1~deb12u2",
|
||||
BuildDigest: null,
|
||||
BuildId: null,
|
||||
SourcePackage: "openssl");
|
||||
|
||||
var cve = "CVE-2024-0001";
|
||||
|
||||
// NVD says vulnerable in range [3.0.0, 3.0.13)
|
||||
var rangeRule = new RangeRule
|
||||
{
|
||||
RuleId = "nvd-range-001",
|
||||
Cve = cve,
|
||||
Context = context,
|
||||
Package = package.Key,
|
||||
Priority = RulePriority.NvdRangeHeuristic, // Tier 5
|
||||
Confidence = 0.3m,
|
||||
Evidence = new EvidencePointer(
|
||||
SourceType: "nvd-cpe",
|
||||
SourceUrl: $"https://nvd.nist.gov/vuln/detail/{cve}",
|
||||
SourceDigest: "sha256:abc123",
|
||||
FetchedAt: FixedTimestamp,
|
||||
TierSource: EvidenceTier.NvdRange),
|
||||
AffectedRange = new VersionRange(
|
||||
MinVersion: "3.0.0",
|
||||
MinInclusive: true,
|
||||
MaxVersion: "3.0.13",
|
||||
MaxInclusive: false)
|
||||
};
|
||||
|
||||
var repository = CreateMockRepository([rangeRule]);
|
||||
var service = new BackportStatusService(repository, _comparatorFactory, NullLogger<BackportStatusService>.Instance);
|
||||
|
||||
// Act
|
||||
var verdict = await service.EvalPatchedStatusAsync(context, package, cve);
|
||||
|
||||
// Assert - Should return Vulnerable with Low confidence (Tier 5)
|
||||
verdict.Status.Should().Be(FixStatus.Vulnerable,
|
||||
"3.0.11-1~deb12u2 is within the vulnerable range [3.0.0, 3.0.13)");
|
||||
verdict.Confidence.Should().Be(VerdictConfidence.Low,
|
||||
"NVD range data should always produce Low confidence");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvalPatchedStatusAsync_NvdRangeExcluded_ReturnsPatchedLow()
|
||||
{
|
||||
// Arrange - Package version is outside NVD range (fixed)
|
||||
var context = new ProductContext("debian", "bookworm", null, null);
|
||||
var package = new InstalledPackage(
|
||||
Key: new PackageKey(PackageEcosystem.Deb, "curl", "curl"),
|
||||
InstalledVersion: "7.88.1-10+deb12u8",
|
||||
BuildDigest: null,
|
||||
BuildId: null,
|
||||
SourcePackage: "curl");
|
||||
|
||||
var cve = "CVE-2024-0002";
|
||||
|
||||
// NVD says vulnerable in range [7.0.0, 7.88.1-10+deb12u5)
|
||||
var rangeRule = new RangeRule
|
||||
{
|
||||
RuleId = "nvd-range-002",
|
||||
Cve = cve,
|
||||
Context = context,
|
||||
Package = package.Key,
|
||||
Priority = RulePriority.NvdRangeHeuristic,
|
||||
Confidence = 0.3m,
|
||||
Evidence = new EvidencePointer(
|
||||
SourceType: "nvd-cpe",
|
||||
SourceUrl: $"https://nvd.nist.gov/vuln/detail/{cve}",
|
||||
SourceDigest: "sha256:def456",
|
||||
FetchedAt: FixedTimestamp,
|
||||
TierSource: EvidenceTier.NvdRange),
|
||||
AffectedRange = new VersionRange(
|
||||
MinVersion: "7.0.0",
|
||||
MinInclusive: true,
|
||||
MaxVersion: "7.88.1-10+deb12u5",
|
||||
MaxInclusive: false)
|
||||
};
|
||||
|
||||
var repository = CreateMockRepository([rangeRule]);
|
||||
var service = new BackportStatusService(repository, _comparatorFactory, NullLogger<BackportStatusService>.Instance);
|
||||
|
||||
// Act
|
||||
var verdict = await service.EvalPatchedStatusAsync(context, package, cve);
|
||||
|
||||
// Assert - 7.88.1-10+deb12u8 > 7.88.1-10+deb12u5, so outside vulnerable range
|
||||
verdict.Status.Should().Be(FixStatus.Patched,
|
||||
"7.88.1-10+deb12u8 is outside the vulnerable range");
|
||||
verdict.Confidence.Should().Be(VerdictConfidence.Low,
|
||||
"NVD-based verdicts are always Low confidence");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvalPatchedStatusAsync_HigherTierOverridesNvd_ReturnsHigherConfidence()
|
||||
{
|
||||
// Arrange - Both Tier 1 (OVAL) and Tier 5 (NVD) data available
|
||||
var context = new ProductContext("debian", "bookworm", null, null);
|
||||
var package = new InstalledPackage(
|
||||
Key: new PackageKey(PackageEcosystem.Deb, "zlib", "zlib"),
|
||||
InstalledVersion: "1:1.2.13.dfsg-1",
|
||||
BuildDigest: null,
|
||||
BuildId: null,
|
||||
SourcePackage: "zlib");
|
||||
|
||||
var cve = "CVE-2024-0003";
|
||||
|
||||
// Tier 5: NVD range says vulnerable
|
||||
var nvdRule = new RangeRule
|
||||
{
|
||||
RuleId = "nvd-range-003",
|
||||
Cve = cve,
|
||||
Context = context,
|
||||
Package = package.Key,
|
||||
Priority = RulePriority.NvdRangeHeuristic,
|
||||
Confidence = 0.3m,
|
||||
Evidence = new EvidencePointer(
|
||||
SourceType: "nvd-cpe",
|
||||
SourceUrl: $"https://nvd.nist.gov/vuln/detail/{cve}",
|
||||
SourceDigest: null,
|
||||
FetchedAt: FixedTimestamp,
|
||||
TierSource: EvidenceTier.NvdRange),
|
||||
AffectedRange = new VersionRange(
|
||||
MinVersion: "1.0.0",
|
||||
MinInclusive: true,
|
||||
MaxVersion: "1:1.3.0",
|
||||
MaxInclusive: false)
|
||||
};
|
||||
|
||||
// Tier 1: Debian OVAL says fixed at 1:1.2.13.dfsg-1
|
||||
var ovalRule = new BoundaryRule
|
||||
{
|
||||
RuleId = "debian-oval-001",
|
||||
Cve = cve,
|
||||
Context = context,
|
||||
Package = package.Key,
|
||||
Priority = RulePriority.DistroNativeOval, // Tier 1
|
||||
Confidence = 0.98m,
|
||||
Evidence = new EvidencePointer(
|
||||
SourceType: "debian-oval",
|
||||
SourceUrl: "https://security-tracker.debian.org/tracker/CVE-2024-0003",
|
||||
SourceDigest: "sha256:oval123",
|
||||
FetchedAt: FixedTimestamp,
|
||||
TierSource: EvidenceTier.DistroOval),
|
||||
FixedVersion = "1:1.2.13.dfsg-1"
|
||||
};
|
||||
|
||||
var repository = CreateMockRepository([nvdRule, ovalRule]);
|
||||
var service = new BackportStatusService(repository, _comparatorFactory, NullLogger<BackportStatusService>.Instance);
|
||||
|
||||
// Act
|
||||
var verdict = await service.EvalPatchedStatusAsync(context, package, cve);
|
||||
|
||||
// Assert - Tier 1 (OVAL) should take precedence over Tier 5 (NVD)
|
||||
verdict.Status.Should().Be(FixStatus.Patched,
|
||||
"Debian OVAL (Tier 1) says fixed at exactly the installed version");
|
||||
verdict.Confidence.Should().Be(VerdictConfidence.High,
|
||||
"Tier 1 evidence should produce High confidence");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NVD Range Edge Cases
|
||||
|
||||
[Fact]
|
||||
public async Task EvalPatchedStatusAsync_NvdOpenMinRange_HandlesCorrectly()
|
||||
{
|
||||
// Arrange - NVD range with no min version (unbounded start)
|
||||
var context = new ProductContext("alpine", "3.19", "main", null);
|
||||
var package = new InstalledPackage(
|
||||
Key: new PackageKey(PackageEcosystem.Apk, "busybox", "busybox"),
|
||||
InstalledVersion: "1.36.1-r15",
|
||||
BuildDigest: null,
|
||||
BuildId: null,
|
||||
SourcePackage: null);
|
||||
|
||||
var cve = "CVE-2024-0004";
|
||||
|
||||
// NVD: affected in (*, 1.36.1-r20)
|
||||
var rangeRule = new RangeRule
|
||||
{
|
||||
RuleId = "nvd-range-004",
|
||||
Cve = cve,
|
||||
Context = context,
|
||||
Package = package.Key,
|
||||
Priority = RulePriority.NvdRangeHeuristic,
|
||||
Confidence = 0.3m,
|
||||
Evidence = new EvidencePointer(
|
||||
SourceType: "nvd-cpe",
|
||||
SourceUrl: $"https://nvd.nist.gov/vuln/detail/{cve}",
|
||||
SourceDigest: null,
|
||||
FetchedAt: FixedTimestamp,
|
||||
TierSource: EvidenceTier.NvdRange),
|
||||
AffectedRange = new VersionRange(
|
||||
MinVersion: null, // Unbounded
|
||||
MinInclusive: false,
|
||||
MaxVersion: "1.36.1-r20",
|
||||
MaxInclusive: false)
|
||||
};
|
||||
|
||||
var repository = CreateMockRepository([rangeRule]);
|
||||
var service = new BackportStatusService(repository, _comparatorFactory, NullLogger<BackportStatusService>.Instance);
|
||||
|
||||
// Act
|
||||
var verdict = await service.EvalPatchedStatusAsync(context, package, cve);
|
||||
|
||||
// Assert
|
||||
verdict.Status.Should().Be(FixStatus.Vulnerable,
|
||||
"1.36.1-r15 < 1.36.1-r20, so within unbounded vulnerable range");
|
||||
verdict.Confidence.Should().Be(VerdictConfidence.Low);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvalPatchedStatusAsync_NvdInclusiveMax_HandlesCorrectly()
|
||||
{
|
||||
// Arrange - NVD range with inclusive max (edge case)
|
||||
var context = new ProductContext("rhel", "9", null, null);
|
||||
var package = new InstalledPackage(
|
||||
Key: new PackageKey(PackageEcosystem.Rpm, "httpd", "httpd"),
|
||||
InstalledVersion: "2.4.53-11.el9_2.5",
|
||||
BuildDigest: null,
|
||||
BuildId: null,
|
||||
SourcePackage: "httpd");
|
||||
|
||||
var cve = "CVE-2024-0005";
|
||||
|
||||
// NVD: affected in [2.4.0, 2.4.53-11.el9_2.5] (inclusive max)
|
||||
var rangeRule = new RangeRule
|
||||
{
|
||||
RuleId = "nvd-range-005",
|
||||
Cve = cve,
|
||||
Context = context,
|
||||
Package = package.Key,
|
||||
Priority = RulePriority.NvdRangeHeuristic,
|
||||
Confidence = 0.3m,
|
||||
Evidence = new EvidencePointer(
|
||||
SourceType: "nvd-cpe",
|
||||
SourceUrl: $"https://nvd.nist.gov/vuln/detail/{cve}",
|
||||
SourceDigest: null,
|
||||
FetchedAt: FixedTimestamp,
|
||||
TierSource: EvidenceTier.NvdRange),
|
||||
AffectedRange = new VersionRange(
|
||||
MinVersion: "2.4.0",
|
||||
MinInclusive: true,
|
||||
MaxVersion: "2.4.53-11.el9_2.5",
|
||||
MaxInclusive: true) // Inclusive
|
||||
};
|
||||
|
||||
var repository = CreateMockRepository([rangeRule]);
|
||||
var service = new BackportStatusService(repository, _comparatorFactory, NullLogger<BackportStatusService>.Instance);
|
||||
|
||||
// Act
|
||||
var verdict = await service.EvalPatchedStatusAsync(context, package, cve);
|
||||
|
||||
// Assert
|
||||
verdict.Status.Should().Be(FixStatus.Vulnerable,
|
||||
"Exact version match with inclusive max should be vulnerable");
|
||||
verdict.Confidence.Should().Be(VerdictConfidence.Low);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static IFixRuleRepository CreateMockRepository(IEnumerable<FixRule> rules)
|
||||
{
|
||||
var ruleList = rules.ToList();
|
||||
var mock = new Mock<IFixRuleRepository>();
|
||||
|
||||
mock.Setup(r => r.GetRulesAsync(
|
||||
It.IsAny<ProductContext>(),
|
||||
It.IsAny<PackageKey>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProductContext ctx, PackageKey pkg, string cve, CancellationToken _) =>
|
||||
ruleList.Where(r =>
|
||||
r.Context.Distro == ctx.Distro &&
|
||||
r.Context.Release == ctx.Release &&
|
||||
r.Package.PackageName == pkg.PackageName &&
|
||||
r.Cve == cve).ToList());
|
||||
|
||||
return mock.Object;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TierPrecedenceTests.cs
|
||||
// Sprint: SPRINT_20251230_001_BE_backport_resolver (BP-605)
|
||||
// Task: Unit tests for tier precedence
|
||||
// Description: Verify that evidence tiers are evaluated in correct order
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Concelier.BackportProof.Models;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Tests.BackportProof;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for evidence tier precedence and priority ordering.
|
||||
/// Validates that:
|
||||
/// - Higher tiers take precedence over lower tiers
|
||||
/// - RulePriority enum values are correctly ordered
|
||||
/// - EvidenceTier enum correctly represents the 5-tier hierarchy
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public sealed class TierPrecedenceTests
|
||||
{
|
||||
#region RulePriority Ordering Tests
|
||||
|
||||
[Fact]
|
||||
public void RulePriority_Tier1Values_AreHigherThan_Tier2Values()
|
||||
{
|
||||
// Tier 1 (OVAL/CSAF) should have highest values
|
||||
var tier1Values = new[]
|
||||
{
|
||||
RulePriority.DistroNativeOval,
|
||||
RulePriority.DerivativeOvalHigh,
|
||||
RulePriority.DerivativeOvalMedium
|
||||
};
|
||||
|
||||
// Tier 2 (Changelog) should have lower values
|
||||
var tier2Values = new[]
|
||||
{
|
||||
RulePriority.ChangelogExplicitCve,
|
||||
RulePriority.ChangelogBugIdMapped
|
||||
};
|
||||
|
||||
foreach (var tier1 in tier1Values)
|
||||
{
|
||||
foreach (var tier2 in tier2Values)
|
||||
{
|
||||
((int)tier1).Should().BeGreaterThan((int)tier2,
|
||||
$"Tier 1 ({tier1}) should have higher priority than Tier 2 ({tier2})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RulePriority_Tier2Values_AreHigherThan_Tier3Values()
|
||||
{
|
||||
// Tier 2 (Changelog)
|
||||
var tier2Values = new[]
|
||||
{
|
||||
RulePriority.ChangelogExplicitCve,
|
||||
RulePriority.ChangelogBugIdMapped
|
||||
};
|
||||
|
||||
// Tier 3 (Source patches)
|
||||
var tier3Values = new[]
|
||||
{
|
||||
RulePriority.SourcePatchExactMatch,
|
||||
RulePriority.SourcePatchFuzzyMatch
|
||||
};
|
||||
|
||||
foreach (var tier2 in tier2Values)
|
||||
{
|
||||
foreach (var tier3 in tier3Values)
|
||||
{
|
||||
((int)tier2).Should().BeGreaterThan((int)tier3,
|
||||
$"Tier 2 ({tier2}) should have higher priority than Tier 3 ({tier3})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RulePriority_Tier3Values_AreHigherThan_Tier4Values()
|
||||
{
|
||||
// Tier 3 (Source patches)
|
||||
var tier3Values = new[]
|
||||
{
|
||||
RulePriority.SourcePatchExactMatch,
|
||||
RulePriority.SourcePatchFuzzyMatch
|
||||
};
|
||||
|
||||
// Tier 4 (Upstream commits)
|
||||
var tier4Values = new[]
|
||||
{
|
||||
RulePriority.UpstreamCommitExactParity,
|
||||
RulePriority.UpstreamCommitPartialMatch
|
||||
};
|
||||
|
||||
foreach (var tier3 in tier3Values)
|
||||
{
|
||||
foreach (var tier4 in tier4Values)
|
||||
{
|
||||
((int)tier3).Should().BeGreaterThan((int)tier4,
|
||||
$"Tier 3 ({tier3}) should have higher priority than Tier 4 ({tier4})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RulePriority_Tier4Values_AreHigherThan_Tier5Values()
|
||||
{
|
||||
// Tier 4 (Upstream commits)
|
||||
var tier4Values = new[]
|
||||
{
|
||||
RulePriority.UpstreamCommitExactParity,
|
||||
RulePriority.UpstreamCommitPartialMatch
|
||||
};
|
||||
|
||||
// Tier 5 (NVD range - lowest)
|
||||
var tier5Value = RulePriority.NvdRangeHeuristic;
|
||||
|
||||
foreach (var tier4 in tier4Values)
|
||||
{
|
||||
((int)tier4).Should().BeGreaterThan((int)tier5Value,
|
||||
$"Tier 4 ({tier4}) should have higher priority than Tier 5 ({tier5Value})");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RulePriority_DistroNativeOval_IsHighestPriority()
|
||||
{
|
||||
// Get all distinct priority values (excluding enum aliases which share values)
|
||||
var allPriorityValues = Enum.GetValues<RulePriority>()
|
||||
.Select(p => (int)p)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var maxValue = allPriorityValues.Max();
|
||||
|
||||
((int)RulePriority.DistroNativeOval).Should().Be(maxValue,
|
||||
"DistroNativeOval should be the highest priority");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RulePriority_NvdRangeHeuristic_IsLowestPriority()
|
||||
{
|
||||
// Get all distinct priority values (excluding enum aliases which share values)
|
||||
var allPriorityValues = Enum.GetValues<RulePriority>()
|
||||
.Select(p => (int)p)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var minValue = allPriorityValues.Min();
|
||||
|
||||
((int)RulePriority.NvdRangeHeuristic).Should().Be(minValue,
|
||||
"NvdRangeHeuristic should be the lowest priority");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EvidenceTier Tests
|
||||
|
||||
[Fact]
|
||||
public void EvidenceTier_DistroOval_HasLowestEnumValue()
|
||||
{
|
||||
// Lower enum value = higher tier priority (Tier 1 = 1, Tier 5 = 5)
|
||||
var allTiers = Enum.GetValues<EvidenceTier>()
|
||||
.Where(t => t != EvidenceTier.Unknown)
|
||||
.ToList();
|
||||
|
||||
var minValue = allTiers.Min(t => (int)t);
|
||||
|
||||
((int)EvidenceTier.DistroOval).Should().Be(minValue,
|
||||
"DistroOval (Tier 1) should have the lowest enum value (highest priority)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvidenceTier_NvdRange_HasHighestEnumValue()
|
||||
{
|
||||
// Lower enum value = higher tier priority
|
||||
var allTiers = Enum.GetValues<EvidenceTier>()
|
||||
.Where(t => t != EvidenceTier.Unknown)
|
||||
.ToList();
|
||||
|
||||
var maxValue = allTiers.Max(t => (int)t);
|
||||
|
||||
((int)EvidenceTier.NvdRange).Should().Be(maxValue,
|
||||
"NvdRange (Tier 5) should have the highest enum value (lowest priority)");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(EvidenceTier.DistroOval, 1)]
|
||||
[InlineData(EvidenceTier.Changelog, 2)]
|
||||
[InlineData(EvidenceTier.SourcePatch, 3)]
|
||||
[InlineData(EvidenceTier.UpstreamCommit, 4)]
|
||||
[InlineData(EvidenceTier.NvdRange, 5)]
|
||||
public void EvidenceTier_HasCorrectTierNumber(EvidenceTier tier, int expectedTierNumber)
|
||||
{
|
||||
((int)tier).Should().Be(expectedTierNumber,
|
||||
$"{tier} should be Tier {expectedTierNumber}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RulePriority to EvidenceTier Mapping Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(RulePriority.DistroNativeOval, EvidenceTier.DistroOval)]
|
||||
[InlineData(RulePriority.DerivativeOvalHigh, EvidenceTier.DistroOval)]
|
||||
[InlineData(RulePriority.DerivativeOvalMedium, EvidenceTier.DistroOval)]
|
||||
[InlineData(RulePriority.ChangelogExplicitCve, EvidenceTier.Changelog)]
|
||||
[InlineData(RulePriority.ChangelogBugIdMapped, EvidenceTier.Changelog)]
|
||||
[InlineData(RulePriority.SourcePatchExactMatch, EvidenceTier.SourcePatch)]
|
||||
[InlineData(RulePriority.SourcePatchFuzzyMatch, EvidenceTier.SourcePatch)]
|
||||
[InlineData(RulePriority.UpstreamCommitExactParity, EvidenceTier.UpstreamCommit)]
|
||||
[InlineData(RulePriority.UpstreamCommitPartialMatch, EvidenceTier.UpstreamCommit)]
|
||||
[InlineData(RulePriority.NvdRangeHeuristic, EvidenceTier.NvdRange)]
|
||||
public void RulePriority_MapsToCorrectEvidenceTier(
|
||||
RulePriority priority,
|
||||
EvidenceTier expectedTier)
|
||||
{
|
||||
var actualTier = MapPriorityToTier(priority);
|
||||
actualTier.Should().Be(expectedTier,
|
||||
$"{priority} should map to {expectedTier}");
|
||||
}
|
||||
|
||||
private static EvidenceTier MapPriorityToTier(RulePriority priority)
|
||||
{
|
||||
return priority switch
|
||||
{
|
||||
// Tier 1: OVAL/CSAF
|
||||
RulePriority.DistroNativeOval or
|
||||
RulePriority.DerivativeOvalHigh or
|
||||
RulePriority.DerivativeOvalMedium or
|
||||
RulePriority.DistroNative => EvidenceTier.DistroOval,
|
||||
|
||||
// Tier 2: Changelog
|
||||
RulePriority.ChangelogExplicitCve or
|
||||
RulePriority.ChangelogBugIdMapped or
|
||||
RulePriority.VendorCsaf => EvidenceTier.Changelog,
|
||||
|
||||
// Tier 3: Source patches
|
||||
RulePriority.SourcePatchExactMatch or
|
||||
RulePriority.SourcePatchFuzzyMatch => EvidenceTier.SourcePatch,
|
||||
|
||||
// Tier 4: Upstream commits
|
||||
RulePriority.UpstreamCommitExactParity or
|
||||
RulePriority.UpstreamCommitPartialMatch => EvidenceTier.UpstreamCommit,
|
||||
|
||||
// Tier 5: NVD ranges
|
||||
RulePriority.NvdRangeHeuristic or
|
||||
RulePriority.ThirdParty => EvidenceTier.NvdRange,
|
||||
|
||||
_ => EvidenceTier.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EvidencePointer with TierSource Tests
|
||||
|
||||
[Fact]
|
||||
public void EvidencePointer_DefaultTierSource_IsUnknown()
|
||||
{
|
||||
var pointer = new EvidencePointer(
|
||||
SourceType: "test",
|
||||
SourceUrl: "https://example.com",
|
||||
SourceDigest: null,
|
||||
FetchedAt: DateTimeOffset.UtcNow);
|
||||
|
||||
pointer.TierSource.Should().Be(EvidenceTier.Unknown,
|
||||
"Default TierSource should be Unknown");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvidencePointer_CanSetTierSource_Explicitly()
|
||||
{
|
||||
var pointer = new EvidencePointer(
|
||||
SourceType: "debian-tracker",
|
||||
SourceUrl: "https://security-tracker.debian.org/tracker/CVE-2024-1234",
|
||||
SourceDigest: "abc123",
|
||||
FetchedAt: DateTimeOffset.UtcNow,
|
||||
TierSource: EvidenceTier.DistroOval);
|
||||
|
||||
pointer.TierSource.Should().Be(EvidenceTier.DistroOval,
|
||||
"TierSource should be set to DistroOval");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(EvidenceTier.DistroOval, "debian-tracker")]
|
||||
[InlineData(EvidenceTier.DistroOval, "alpine-secdb")]
|
||||
[InlineData(EvidenceTier.DistroOval, "rhel-oval")]
|
||||
[InlineData(EvidenceTier.Changelog, "changelog")]
|
||||
[InlineData(EvidenceTier.SourcePatch, "patch-file")]
|
||||
[InlineData(EvidenceTier.UpstreamCommit, "git-commit")]
|
||||
[InlineData(EvidenceTier.NvdRange, "nvd-cpe")]
|
||||
public void EvidencePointer_AuditTrail_IncludesTierSource(
|
||||
EvidenceTier tier,
|
||||
string sourceType)
|
||||
{
|
||||
var pointer = new EvidencePointer(
|
||||
SourceType: sourceType,
|
||||
SourceUrl: $"https://example.com/{sourceType}",
|
||||
SourceDigest: "sha256:abc",
|
||||
FetchedAt: DateTimeOffset.Parse("2025-01-01T12:00:00Z"),
|
||||
TierSource: tier);
|
||||
|
||||
// Verify all properties are captured for audit
|
||||
pointer.SourceType.Should().Be(sourceType);
|
||||
pointer.TierSource.Should().Be(tier);
|
||||
pointer.SourceDigest.Should().NotBeNullOrEmpty();
|
||||
pointer.FetchedAt.Should().NotBe(default);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Priority Selection Tests
|
||||
|
||||
[Fact]
|
||||
public void SelectHighestPriority_FromMixedRules_ReturnsCorrectOrder()
|
||||
{
|
||||
// Arrange - create rules with different priorities
|
||||
var rules = new List<(string Id, RulePriority Priority)>
|
||||
{
|
||||
("rule-nvd", RulePriority.NvdRangeHeuristic), // Tier 5
|
||||
("rule-commit", RulePriority.UpstreamCommitPartialMatch), // Tier 4
|
||||
("rule-patch", RulePriority.SourcePatchExactMatch), // Tier 3
|
||||
("rule-changelog", RulePriority.ChangelogExplicitCve), // Tier 2
|
||||
("rule-oval", RulePriority.DistroNativeOval) // Tier 1
|
||||
};
|
||||
|
||||
// Act - sort by priority descending (highest priority first)
|
||||
var sorted = rules.OrderByDescending(r => (int)r.Priority).ToList();
|
||||
|
||||
// Assert - Tier 1 should be first, Tier 5 last
|
||||
sorted[0].Id.Should().Be("rule-oval", "OVAL (Tier 1) should be first");
|
||||
sorted[1].Id.Should().Be("rule-changelog", "Changelog (Tier 2) should be second");
|
||||
sorted[2].Id.Should().Be("rule-patch", "Patch (Tier 3) should be third");
|
||||
sorted[3].Id.Should().Be("rule-commit", "Commit (Tier 4) should be fourth");
|
||||
sorted[4].Id.Should().Be("rule-nvd", "NVD (Tier 5) should be last");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectHighestPriority_WithinSameTier_UsesSubPriority()
|
||||
{
|
||||
// Arrange - multiple rules within Tier 1
|
||||
var tier1Rules = new List<(string Id, RulePriority Priority)>
|
||||
{
|
||||
("derivative-medium", RulePriority.DerivativeOvalMedium), // 90
|
||||
("derivative-high", RulePriority.DerivativeOvalHigh), // 95
|
||||
("native-oval", RulePriority.DistroNativeOval) // 100
|
||||
};
|
||||
|
||||
// Act
|
||||
var sorted = tier1Rules.OrderByDescending(r => (int)r.Priority).ToList();
|
||||
|
||||
// Assert - DistroNativeOval should be first within Tier 1
|
||||
sorted[0].Id.Should().Be("native-oval");
|
||||
sorted[1].Id.Should().Be("derivative-high");
|
||||
sorted[2].Id.Should().Be("derivative-medium");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -17,8 +17,11 @@
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Core/StellaOps.Concelier.Core.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.RawModels/StellaOps.Concelier.RawModels.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Merge/StellaOps.Concelier.Merge.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Ingestion.Telemetry/StellaOps.Ingestion.Telemetry.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.VersionComparison/StellaOps.VersionComparison.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.DistroIntel/StellaOps.DistroIntel.csproj" />
|
||||
<ProjectReference Include="../../../Aoc/__Libraries/StellaOps.Aoc/StellaOps.Aoc.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -10,6 +10,7 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -482,6 +483,10 @@ public sealed class JsonFeedExporterTests : IDisposable
|
||||
var services = new ServiceCollection();
|
||||
services.AddOptions();
|
||||
services.Configure<CryptoHashOptions>(_ => { });
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>())
|
||||
.Build();
|
||||
services.AddSingleton<IConfiguration>(configuration);
|
||||
services.AddStellaOpsCrypto();
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
@@ -79,22 +79,22 @@ public sealed class AdvisoryIdempotencyTests : IAsyncLifetime
|
||||
{
|
||||
// Arrange
|
||||
var advisoryKey = $"ADV-{Guid.NewGuid():N}";
|
||||
var advisory1 = CreateAdvisory(advisoryKey, severity: "MEDIUM");
|
||||
var advisory1 = CreateAdvisory(advisoryKey, severity: "medium");
|
||||
await _advisoryRepository.UpsertAsync(advisory1);
|
||||
|
||||
var advisory2 = CreateAdvisory(advisoryKey, severity: "HIGH");
|
||||
var advisory2 = CreateAdvisory(advisoryKey, severity: "high");
|
||||
|
||||
// Act
|
||||
var result = await _advisoryRepository.UpsertAsync(advisory2);
|
||||
|
||||
// Assert - Should update the severity
|
||||
result.Should().NotBeNull();
|
||||
result.Severity.Should().Be("HIGH");
|
||||
result.Severity.Should().Be("high");
|
||||
|
||||
// Verify only one record exists
|
||||
var retrieved = await _advisoryRepository.GetByKeyAsync(advisoryKey);
|
||||
retrieved.Should().NotBeNull();
|
||||
retrieved!.Severity.Should().Be("HIGH");
|
||||
retrieved!.Severity.Should().Be("high");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -342,13 +342,23 @@ public sealed class AdvisoryIdempotencyTests : IAsyncLifetime
|
||||
Title = "Test Advisory",
|
||||
Summary = "Test advisory summary",
|
||||
Description = "Test advisory description",
|
||||
Severity = severity ?? "MEDIUM",
|
||||
Severity = NormalizeSeverity(severity) ?? "medium",
|
||||
PublishedAt = DateTimeOffset.UtcNow.AddDays(-7),
|
||||
ModifiedAt = DateTimeOffset.UtcNow,
|
||||
Provenance = """{"source": "test"}"""
|
||||
};
|
||||
}
|
||||
|
||||
private static string? NormalizeSeverity(string? severity)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(severity))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return severity.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static SourceEntity CreateSource(string sourceKey, int priority = 100)
|
||||
{
|
||||
return new SourceEntity
|
||||
|
||||
@@ -73,7 +73,7 @@ public sealed class AdvisoryRepositoryTests : IAsyncLifetime
|
||||
AdvisoryKey = advisory.AdvisoryKey,
|
||||
PrimaryVulnId = advisory.PrimaryVulnId,
|
||||
Title = "Updated Title",
|
||||
Severity = "HIGH",
|
||||
Severity = "high",
|
||||
Summary = advisory.Summary,
|
||||
Description = advisory.Description,
|
||||
PublishedAt = advisory.PublishedAt,
|
||||
@@ -87,7 +87,7 @@ public sealed class AdvisoryRepositoryTests : IAsyncLifetime
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Title.Should().Be("Updated Title");
|
||||
result.Severity.Should().Be("HIGH");
|
||||
result.Severity.Should().Be("high");
|
||||
result.UpdatedAt.Should().BeAfter(result.CreatedAt);
|
||||
}
|
||||
|
||||
@@ -312,8 +312,8 @@ public sealed class AdvisoryRepositoryTests : IAsyncLifetime
|
||||
public async Task GetBySeverityAsync_ShouldReturnAdvisoriesWithMatchingSeverity()
|
||||
{
|
||||
// Arrange
|
||||
var criticalAdvisory = CreateTestAdvisory(severity: "CRITICAL");
|
||||
var lowAdvisory = CreateTestAdvisory(severity: "LOW");
|
||||
var criticalAdvisory = CreateTestAdvisory(severity: "critical");
|
||||
var lowAdvisory = CreateTestAdvisory(severity: "low");
|
||||
|
||||
await _repository.UpsertAsync(criticalAdvisory);
|
||||
await _repository.UpsertAsync(lowAdvisory);
|
||||
@@ -365,8 +365,8 @@ public sealed class AdvisoryRepositoryTests : IAsyncLifetime
|
||||
public async Task CountBySeverityAsync_ShouldReturnCountsGroupedBySeverity()
|
||||
{
|
||||
// Arrange
|
||||
var highAdvisory = CreateTestAdvisory(severity: "HIGH");
|
||||
var mediumAdvisory = CreateTestAdvisory(severity: "MEDIUM");
|
||||
var highAdvisory = CreateTestAdvisory(severity: "high");
|
||||
var mediumAdvisory = CreateTestAdvisory(severity: "medium");
|
||||
|
||||
await _repository.UpsertAsync(highAdvisory);
|
||||
await _repository.UpsertAsync(mediumAdvisory);
|
||||
@@ -375,10 +375,10 @@ public sealed class AdvisoryRepositoryTests : IAsyncLifetime
|
||||
var counts = await _repository.CountBySeverityAsync();
|
||||
|
||||
// Assert
|
||||
counts.Should().ContainKey("HIGH");
|
||||
counts.Should().ContainKey("MEDIUM");
|
||||
counts["HIGH"].Should().BeGreaterThanOrEqualTo(1);
|
||||
counts["MEDIUM"].Should().BeGreaterThanOrEqualTo(1);
|
||||
counts.Should().ContainKey("high");
|
||||
counts.Should().ContainKey("medium");
|
||||
counts["high"].Should().BeGreaterThanOrEqualTo(1);
|
||||
counts["medium"].Should().BeGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
@@ -454,12 +454,22 @@ public sealed class AdvisoryRepositoryTests : IAsyncLifetime
|
||||
Title = "Test Advisory",
|
||||
Summary = "This is a test advisory summary",
|
||||
Description = "This is a detailed description of the test advisory",
|
||||
Severity = severity ?? "MEDIUM",
|
||||
Severity = NormalizeSeverity(severity) ?? "medium",
|
||||
PublishedAt = DateTimeOffset.UtcNow.AddDays(-7),
|
||||
ModifiedAt = modifiedAt ?? DateTimeOffset.UtcNow,
|
||||
Provenance = """{"source": "test"}"""
|
||||
};
|
||||
}
|
||||
|
||||
private static string? NormalizeSeverity(string? severity)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(severity))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return severity.Trim().ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ public sealed class ConcelierQueryDeterminismTests : IAsyncLifetime
|
||||
{
|
||||
// Arrange - Create multiple advisories with same severity
|
||||
var advisories = Enumerable.Range(0, 5)
|
||||
.Select(i => CreateAdvisory($"ADV-CRITICAL-{Guid.NewGuid():N}", severity: "CRITICAL"))
|
||||
.Select(i => CreateAdvisory($"ADV-CRITICAL-{Guid.NewGuid():N}", severity: "critical"))
|
||||
.ToList();
|
||||
|
||||
foreach (var advisory in advisories)
|
||||
@@ -334,10 +334,10 @@ public sealed class ConcelierQueryDeterminismTests : IAsyncLifetime
|
||||
public async Task CountBySeverityAsync_MultipleQueries_ReturnsConsistentCounts()
|
||||
{
|
||||
// Arrange
|
||||
await _advisoryRepository.UpsertAsync(CreateAdvisory($"ADV-{Guid.NewGuid():N}", severity: "CRITICAL"));
|
||||
await _advisoryRepository.UpsertAsync(CreateAdvisory($"ADV-{Guid.NewGuid():N}", severity: "CRITICAL"));
|
||||
await _advisoryRepository.UpsertAsync(CreateAdvisory($"ADV-{Guid.NewGuid():N}", severity: "HIGH"));
|
||||
await _advisoryRepository.UpsertAsync(CreateAdvisory($"ADV-{Guid.NewGuid():N}", severity: "MEDIUM"));
|
||||
await _advisoryRepository.UpsertAsync(CreateAdvisory($"ADV-{Guid.NewGuid():N}", severity: "critical"));
|
||||
await _advisoryRepository.UpsertAsync(CreateAdvisory($"ADV-{Guid.NewGuid():N}", severity: "critical"));
|
||||
await _advisoryRepository.UpsertAsync(CreateAdvisory($"ADV-{Guid.NewGuid():N}", severity: "high"));
|
||||
await _advisoryRepository.UpsertAsync(CreateAdvisory($"ADV-{Guid.NewGuid():N}", severity: "medium"));
|
||||
|
||||
// Act - Run multiple queries
|
||||
var results1 = await _advisoryRepository.CountBySeverityAsync();
|
||||
@@ -384,13 +384,23 @@ public sealed class ConcelierQueryDeterminismTests : IAsyncLifetime
|
||||
Title = "Test Advisory",
|
||||
Summary = "Test advisory summary",
|
||||
Description = "Test advisory description",
|
||||
Severity = severity ?? "MEDIUM",
|
||||
Severity = NormalizeSeverity(severity) ?? "medium",
|
||||
PublishedAt = DateTimeOffset.UtcNow.AddDays(-7),
|
||||
ModifiedAt = modifiedAt ?? DateTimeOffset.UtcNow,
|
||||
Provenance = """{"source": "test"}"""
|
||||
};
|
||||
}
|
||||
|
||||
private static string? NormalizeSeverity(string? severity)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(severity))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return severity.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static SourceEntity CreateSource(string sourceKey, bool enabled = true, int priority = 100)
|
||||
{
|
||||
return new SourceEntity
|
||||
|
||||
@@ -273,7 +273,7 @@ public sealed class KevFlagRepositoryTests : IAsyncLifetime
|
||||
AdvisoryKey = $"KEV-ADV-{id:N}",
|
||||
PrimaryVulnId = $"CVE-2025-{Random.Shared.Next(10000, 99999)}",
|
||||
Title = "KEV Test Advisory",
|
||||
Severity = "CRITICAL",
|
||||
Severity = "critical",
|
||||
PublishedAt = DateTimeOffset.UtcNow.AddDays(-7),
|
||||
ModifiedAt = DateTimeOffset.UtcNow,
|
||||
Provenance = """{"source": "kev-test"}"""
|
||||
|
||||
@@ -273,7 +273,7 @@ public sealed class MergeEventRepositoryTests : IAsyncLifetime
|
||||
AdvisoryKey = $"MERGE-ADV-{id:N}",
|
||||
PrimaryVulnId = $"CVE-2025-{Random.Shared.Next(10000, 99999)}",
|
||||
Title = "Merge Event Test Advisory",
|
||||
Severity = "HIGH",
|
||||
Severity = "high",
|
||||
PublishedAt = DateTimeOffset.UtcNow.AddDays(-7),
|
||||
ModifiedAt = DateTimeOffset.UtcNow,
|
||||
Provenance = """{"source": "merge-test"}"""
|
||||
|
||||
@@ -377,7 +377,7 @@ public sealed class AdvisoryPerformanceTests : IAsyncLifetime
|
||||
AdvisoryKey = key,
|
||||
PrimaryVulnId = $"CVE-2025-{key.GetHashCode():X8}"[..20],
|
||||
Title = title ?? $"Test Advisory {key}",
|
||||
Severity = "MEDIUM",
|
||||
Severity = "medium",
|
||||
Summary = $"Summary for {key}",
|
||||
Description = description ?? $"Detailed description for test advisory {key}. This vulnerability affects multiple components.",
|
||||
PublishedAt = DateTimeOffset.UtcNow.AddDays(-Random.Shared.Next(1, 365)),
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BugIdExtractionTests.cs
|
||||
// Sprint: SPRINT_20251230_001_BE_backport_resolver (BP-408)
|
||||
// Task: Unit tests for bug ID extraction regex patterns
|
||||
// Description: Tests for Debian BTS, RHBZ, Launchpad bug reference extraction
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Concelier.SourceIntel;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.SourceIntel.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for bug ID extraction from changelog lines.
|
||||
/// Validates regex patterns for Debian BTS, Red Hat Bugzilla, and Launchpad.
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public sealed class BugIdExtractionTests
|
||||
{
|
||||
#region Debian BTS Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData("Closes: #123456", BugTracker.Debian, "123456")]
|
||||
[InlineData("Closes: 123456", BugTracker.Debian, "123456")]
|
||||
[InlineData("closes: #789012", BugTracker.Debian, "789012")]
|
||||
[InlineData("(Closes: #999999)", BugTracker.Debian, "999999")]
|
||||
[InlineData("Fixes: #123456", BugTracker.Debian, "123456")]
|
||||
[InlineData("fixes: 654321", BugTracker.Debian, "654321")]
|
||||
public void ExtractBugReferences_DebianSingleBug_ExtractsCorrectly(
|
||||
string line,
|
||||
BugTracker expectedTracker,
|
||||
string expectedBugId)
|
||||
{
|
||||
// Act
|
||||
var bugs = ChangelogParser.ExtractBugReferences(line);
|
||||
|
||||
// Assert
|
||||
bugs.Should().ContainSingle();
|
||||
bugs[0].Tracker.Should().Be(expectedTracker);
|
||||
bugs[0].BugId.Should().Be(expectedBugId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractBugReferences_DebianMultipleBugs_ExtractsAll()
|
||||
{
|
||||
// Arrange
|
||||
var line = "Closes: #123456, #789012, #345678";
|
||||
|
||||
// Act
|
||||
var bugs = ChangelogParser.ExtractBugReferences(line);
|
||||
|
||||
// Assert
|
||||
bugs.Should().HaveCount(3);
|
||||
bugs.Should().AllSatisfy(b => b.Tracker.Should().Be(BugTracker.Debian));
|
||||
bugs.Select(b => b.BugId).Should().Contain("123456", "789012", "345678");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractBugReferences_DebianInContext_ExtractsCorrectly()
|
||||
{
|
||||
// Arrange - realistic changelog line
|
||||
var line = " * Fix buffer overflow vulnerability (Closes: #1045678)";
|
||||
|
||||
// Act
|
||||
var bugs = ChangelogParser.ExtractBugReferences(line);
|
||||
|
||||
// Assert
|
||||
bugs.Should().ContainSingle();
|
||||
bugs[0].Tracker.Should().Be(BugTracker.Debian);
|
||||
bugs[0].BugId.Should().Be("1045678");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Red Hat Bugzilla Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData("RHBZ#1234567", BugTracker.RedHat, "1234567")]
|
||||
[InlineData("rhbz#7654321", BugTracker.RedHat, "7654321")]
|
||||
[InlineData("RHBZ #1234567", BugTracker.RedHat, "1234567")]
|
||||
[InlineData("bz#1234567", BugTracker.RedHat, "1234567")]
|
||||
[InlineData("BZ#1234567", BugTracker.RedHat, "1234567")]
|
||||
[InlineData("Bug 1234567", BugTracker.RedHat, "1234567")]
|
||||
[InlineData("Bug: 1234567", BugTracker.RedHat, "1234567")]
|
||||
public void ExtractBugReferences_RedHatSingleBug_ExtractsCorrectly(
|
||||
string line,
|
||||
BugTracker expectedTracker,
|
||||
string expectedBugId)
|
||||
{
|
||||
// Act
|
||||
var bugs = ChangelogParser.ExtractBugReferences(line);
|
||||
|
||||
// Assert - filter to RedHat only in case other patterns match
|
||||
var rhBugs = bugs.Where(b => b.Tracker == expectedTracker).ToList();
|
||||
rhBugs.Should().NotBeEmpty();
|
||||
rhBugs.Should().Contain(b => b.BugId == expectedBugId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractBugReferences_RedHatInChangelogContext_ExtractsCorrectly()
|
||||
{
|
||||
// Arrange - realistic RPM changelog line
|
||||
var line = "- Fix security vulnerability (RHBZ#2145678, CVE-2024-1234)";
|
||||
|
||||
// Act
|
||||
var bugs = ChangelogParser.ExtractBugReferences(line);
|
||||
|
||||
// Assert
|
||||
var rhBugs = bugs.Where(b => b.Tracker == BugTracker.RedHat).ToList();
|
||||
rhBugs.Should().ContainSingle();
|
||||
rhBugs[0].BugId.Should().Be("2145678");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractBugReferences_RedHatWithResolves_ExtractsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var line = "Resolves: RHBZ#2234567";
|
||||
|
||||
// Act
|
||||
var bugs = ChangelogParser.ExtractBugReferences(line);
|
||||
|
||||
// Assert
|
||||
var rhBugs = bugs.Where(b => b.Tracker == BugTracker.RedHat).ToList();
|
||||
rhBugs.Should().ContainSingle();
|
||||
rhBugs[0].BugId.Should().Be("2234567");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Launchpad Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData("LP: #123456", BugTracker.Launchpad, "123456")]
|
||||
[InlineData("LP #123456", BugTracker.Launchpad, "123456")]
|
||||
[InlineData("LP:#123456", BugTracker.Launchpad, "123456")]
|
||||
[InlineData("lp: #789012", BugTracker.Launchpad, "789012")]
|
||||
public void ExtractBugReferences_LaunchpadSingleBug_ExtractsCorrectly(
|
||||
string line,
|
||||
BugTracker expectedTracker,
|
||||
string expectedBugId)
|
||||
{
|
||||
// Act
|
||||
var bugs = ChangelogParser.ExtractBugReferences(line);
|
||||
|
||||
// Assert
|
||||
var lpBugs = bugs.Where(b => b.Tracker == expectedTracker).ToList();
|
||||
lpBugs.Should().NotBeEmpty();
|
||||
lpBugs.Should().Contain(b => b.BugId == expectedBugId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractBugReferences_LaunchpadMultipleBugs_ExtractsAll()
|
||||
{
|
||||
// Arrange
|
||||
var line = "* Fix multiple issues (LP: #2045123, LP: #2045124)";
|
||||
|
||||
// Act
|
||||
var bugs = ChangelogParser.ExtractBugReferences(line);
|
||||
|
||||
// Assert
|
||||
var lpBugs = bugs.Where(b => b.Tracker == BugTracker.Launchpad).ToList();
|
||||
lpBugs.Should().HaveCount(2);
|
||||
lpBugs.Select(b => b.BugId).Should().Contain("2045123", "2045124");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractBugReferences_UbuntuChangelog_ExtractsCorrectly()
|
||||
{
|
||||
// Arrange - realistic Ubuntu changelog line
|
||||
var line = " - d/p/fix-crash.patch: Fix crash on startup (LP: #2087654)";
|
||||
|
||||
// Act
|
||||
var bugs = ChangelogParser.ExtractBugReferences(line);
|
||||
|
||||
// Assert
|
||||
var lpBugs = bugs.Where(b => b.Tracker == BugTracker.Launchpad).ToList();
|
||||
lpBugs.Should().ContainSingle();
|
||||
lpBugs[0].BugId.Should().Be("2087654");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mixed Tracker Tests
|
||||
|
||||
[Fact]
|
||||
public void ExtractBugReferences_MultipleTrackers_ExtractsAll()
|
||||
{
|
||||
// Arrange - line with Debian and Launchpad references
|
||||
var line = "Fix security issue (Closes: #1045678) (LP: #2087654)";
|
||||
|
||||
// Act
|
||||
var bugs = ChangelogParser.ExtractBugReferences(line);
|
||||
|
||||
// Assert
|
||||
bugs.Should().HaveCountGreaterThanOrEqualTo(2);
|
||||
bugs.Should().Contain(b => b.Tracker == BugTracker.Debian && b.BugId == "1045678");
|
||||
bugs.Should().Contain(b => b.Tracker == BugTracker.Launchpad && b.BugId == "2087654");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractBugReferences_NoReferences_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var line = " * Bump standards version to 4.6.0";
|
||||
|
||||
// Act
|
||||
var bugs = ChangelogParser.ExtractBugReferences(line);
|
||||
|
||||
// Assert
|
||||
bugs.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractBugReferences_CveOnly_ReturnsEmpty()
|
||||
{
|
||||
// Arrange - CVE but no bug tracker reference
|
||||
var line = " * Fix CVE-2024-1234";
|
||||
|
||||
// Act
|
||||
var bugs = ChangelogParser.ExtractBugReferences(line);
|
||||
|
||||
// Assert - CVEs are not bug references (handled separately)
|
||||
bugs.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases
|
||||
|
||||
[Fact]
|
||||
public void ExtractBugReferences_EmptyString_ReturnsEmpty()
|
||||
{
|
||||
// Act
|
||||
var bugs = ChangelogParser.ExtractBugReferences("");
|
||||
|
||||
// Assert
|
||||
bugs.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractBugReferences_WhitespaceOnly_ReturnsEmpty()
|
||||
{
|
||||
// Act
|
||||
var bugs = ChangelogParser.ExtractBugReferences(" \t\n ");
|
||||
|
||||
// Assert
|
||||
bugs.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractBugReferences_InvalidBugFormat_ReturnsEmpty()
|
||||
{
|
||||
// Arrange - things that look like bugs but aren't
|
||||
var line = "Bug: yes, Closes: the door";
|
||||
|
||||
// Act
|
||||
var bugs = ChangelogParser.ExtractBugReferences(line);
|
||||
|
||||
// Assert - shouldn't match text without numbers
|
||||
bugs.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractBugReferences_BugIdTooShort_HandlesCorrectly()
|
||||
{
|
||||
// Arrange - Debian accepts reasonable IDs (4+ digits), RHBZ typically has 6-8 digits
|
||||
// Very short bug IDs (<4 digits) are ignored to avoid false positives
|
||||
var line = "Closes: #12345 and also RHBZ#12345678";
|
||||
|
||||
// Act
|
||||
var bugs = ChangelogParser.ExtractBugReferences(line);
|
||||
|
||||
// Assert
|
||||
// Debian should capture IDs with 4+ digits
|
||||
bugs.Should().Contain(b => b.Tracker == BugTracker.Debian && b.BugId == "12345");
|
||||
// RHBZ should capture the longer ID
|
||||
bugs.Should().Contain(b => b.Tracker == BugTracker.RedHat && b.BugId == "12345678");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractBugReferences_VeryShortBugId_Ignored()
|
||||
{
|
||||
// Arrange - very short bug IDs (<4 digits) should be ignored to avoid false positives
|
||||
var line = "Closes: #123";
|
||||
|
||||
// Act
|
||||
var bugs = ChangelogParser.ExtractBugReferences(line);
|
||||
|
||||
// Assert - 3-digit IDs are ignored
|
||||
bugs.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Changelog Integration Tests
|
||||
|
||||
[Fact]
|
||||
public void ParseDebianChangelog_WithBugReferences_ExtractsBugs()
|
||||
{
|
||||
// Arrange
|
||||
var changelog = @"
|
||||
curl (7.88.1-10+deb12u5) bookworm-security; urgency=high
|
||||
|
||||
* Fix buffer overflow (CVE-2024-1234)
|
||||
* Backport patch from upstream (Closes: #1045678)
|
||||
|
||||
-- Security Team <team@debian.org> Mon, 15 Jan 2024 10:00:00 +0000
|
||||
";
|
||||
|
||||
// Act
|
||||
var result = ChangelogParser.ParseDebianChangelog(changelog);
|
||||
|
||||
// Assert
|
||||
result.Entries.Should().ContainSingle();
|
||||
result.Entries[0].CveIds.Should().Contain("CVE-2024-1234");
|
||||
result.Entries[0].BugReferences.Should().ContainSingle();
|
||||
result.Entries[0].BugReferences[0].Tracker.Should().Be(BugTracker.Debian);
|
||||
result.Entries[0].BugReferences[0].BugId.Should().Be("1045678");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseRpmChangelog_WithBugReferences_ExtractsBugs()
|
||||
{
|
||||
// Arrange
|
||||
var changelog = @"
|
||||
* Mon Jan 15 2024 Security Team <team@redhat.com> - 7.76.1-26.el9_3.2
|
||||
- Fix CVE-2024-1234 (RHBZ#2145678)
|
||||
- Backport upstream patch
|
||||
";
|
||||
|
||||
// Act
|
||||
var result = ChangelogParser.ParseRpmChangelog(changelog);
|
||||
|
||||
// Assert
|
||||
result.Entries.Should().ContainSingle();
|
||||
result.Entries[0].CveIds.Should().Contain("CVE-2024-1234");
|
||||
result.Entries[0].BugReferences.Should().ContainSingle();
|
||||
result.Entries[0].BugReferences[0].Tracker.Should().Be(BugTracker.RedHat);
|
||||
result.Entries[0].BugReferences[0].BugId.Should().Be("2145678");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseDebianChangelog_BugOnlyEntry_ExtractsBugs()
|
||||
{
|
||||
// Arrange - entry with bug reference but no CVE
|
||||
var changelog = @"
|
||||
curl (7.88.1-10+deb12u4) bookworm; urgency=medium
|
||||
|
||||
* Fix crash on specific input (Closes: #1045000)
|
||||
|
||||
-- Maintainer <maint@debian.org> Thu, 10 Jan 2024 08:00:00 +0000
|
||||
";
|
||||
|
||||
// Act
|
||||
var result = ChangelogParser.ParseDebianChangelog(changelog);
|
||||
|
||||
// Assert
|
||||
result.Entries.Should().ContainSingle();
|
||||
result.Entries[0].CveIds.Should().BeEmpty();
|
||||
result.Entries[0].BugReferences.Should().ContainSingle();
|
||||
result.Entries[0].BugReferences[0].BugId.Should().Be("1045000");
|
||||
// Bug-only entries should have lower confidence
|
||||
result.Entries[0].Confidence.Should().BeLessThan(0.80);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -32,7 +32,7 @@ public sealed class CanonicalAdvisoryEndpointTests : IAsyncLifetime
|
||||
|
||||
public ValueTask InitializeAsync()
|
||||
{
|
||||
_factory = new WebApplicationFactory<Program>()
|
||||
_factory = new ConcelierApplicationFactory()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
|
||||
@@ -5,7 +5,9 @@ using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
using StellaOps.Concelier.WebService.Options;
|
||||
using Xunit;
|
||||
|
||||
@@ -48,6 +50,8 @@ public sealed class HealthWebAppFactory : WebApplicationFactory<Program>
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<ILeaseStore>();
|
||||
services.AddSingleton<ILeaseStore, Fixtures.TestLeaseStore>();
|
||||
services.AddSingleton<ConcelierOptions>(new ConcelierOptions
|
||||
{
|
||||
PostgresStorage = new ConcelierOptions.PostgresStorageOptions
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using StellaOps.Concelier.WebService.Tests.Fixtures;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Concelier.WebService.Tests;
|
||||
|
||||
public class ConcelierTimelineCursorTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
public class ConcelierTimelineCursorTests : IClassFixture<ConcelierApplicationFactory>
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
private readonly ConcelierApplicationFactory _factory;
|
||||
|
||||
public ConcelierTimelineCursorTests(WebApplicationFactory<Program> factory)
|
||||
public ConcelierTimelineCursorTests(ConcelierApplicationFactory factory)
|
||||
{
|
||||
_factory = factory.WithWebHostBuilder(_ => { });
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using StellaOps.Concelier.WebService.Tests.Fixtures;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Concelier.WebService.Tests;
|
||||
|
||||
public class ConcelierTimelineEndpointTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
public class ConcelierTimelineEndpointTests : IClassFixture<ConcelierApplicationFactory>
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
private readonly ConcelierApplicationFactory _factory;
|
||||
|
||||
public ConcelierTimelineEndpointTests(WebApplicationFactory<Program> factory)
|
||||
public ConcelierTimelineEndpointTests(ConcelierApplicationFactory factory)
|
||||
{
|
||||
_factory = factory.WithWebHostBuilder(_ => { });
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
using StellaOps.Concelier.WebService.Options;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Tests.Fixtures;
|
||||
@@ -63,6 +65,8 @@ public class ConcelierApplicationFactory : WebApplicationFactory<Program>
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<ILeaseStore>();
|
||||
services.AddSingleton<ILeaseStore, TestLeaseStore>();
|
||||
services.AddSingleton<ConcelierOptions>(new ConcelierOptions
|
||||
{
|
||||
PostgresStorage = new ConcelierOptions.PostgresStorageOptions
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Tests.Fixtures;
|
||||
|
||||
internal sealed class TestLeaseStore : ILeaseStore
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly Dictionary<string, JobLease> _leases = new();
|
||||
|
||||
public Task<JobLease?> TryAcquireAsync(
|
||||
string key,
|
||||
string holder,
|
||||
TimeSpan leaseDuration,
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_leases.TryGetValue(key, out var existing) && existing.TtlAt > now && existing.Holder != holder)
|
||||
{
|
||||
return Task.FromResult<JobLease?>(null);
|
||||
}
|
||||
|
||||
var lease = new JobLease(key, holder, now, now, leaseDuration, now.Add(leaseDuration));
|
||||
_leases[key] = lease;
|
||||
return Task.FromResult<JobLease?>(lease);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<JobLease?> HeartbeatAsync(
|
||||
string key,
|
||||
string holder,
|
||||
TimeSpan leaseDuration,
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_leases.TryGetValue(key, out var existing) && existing.Holder == holder)
|
||||
{
|
||||
var lease = new JobLease(key, holder, existing.AcquiredAt, now, leaseDuration, now.Add(leaseDuration));
|
||||
_leases[key] = lease;
|
||||
return Task.FromResult<JobLease?>(lease);
|
||||
}
|
||||
|
||||
return Task.FromResult<JobLease?>(null);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> ReleaseAsync(string key, string holder, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_leases.TryGetValue(key, out var existing) && existing.Holder == holder)
|
||||
{
|
||||
_leases.Remove(key);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,10 @@ using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
using StellaOps.Concelier.Interest;
|
||||
using StellaOps.Concelier.Interest.Models;
|
||||
using Xunit;
|
||||
@@ -338,6 +340,8 @@ public sealed class InterestScoreEndpointTests : IClassFixture<InterestScoreEndp
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<ILeaseStore>();
|
||||
services.AddSingleton<ILeaseStore, Fixtures.TestLeaseStore>();
|
||||
// Remove existing registrations
|
||||
var scoringServiceDescriptor = services
|
||||
.SingleOrDefault(d => d.ServiceType == typeof(IInterestScoringService));
|
||||
|
||||
@@ -11,6 +11,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Storage;
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
using StellaOps.Concelier.Core.Orchestration;
|
||||
using StellaOps.Concelier.WebService;
|
||||
using StellaOps.Concelier.WebService.Options;
|
||||
@@ -51,6 +52,8 @@ public sealed class OrchestratorTestWebAppFactory : WebApplicationFactory<Progra
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<ILeaseStore>();
|
||||
services.AddSingleton<ILeaseStore, Fixtures.TestLeaseStore>();
|
||||
services.RemoveAll<IOrchestratorRegistryStore>();
|
||||
services.AddSingleton<IOrchestratorRegistryStore, InMemoryOrchestratorRegistryStore>();
|
||||
|
||||
|
||||
@@ -2091,6 +2091,7 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.AddSingleton<ILeaseStore, Fixtures.TestLeaseStore>();
|
||||
services.AddSingleton<StubJobCoordinator>();
|
||||
services.AddSingleton<IJobCoordinator>(sp => sp.GetRequiredService<StubJobCoordinator>());
|
||||
services.PostConfigure<ConcelierOptions>(options =>
|
||||
|
||||
Reference in New Issue
Block a user