save dev progress
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BackportEvidenceResolver.cs
|
||||
// Sprint: SPRINT_8200_0015_0001_CONCEL_backport_integration
|
||||
// Tasks: BACKPORT-8200-006, BACKPORT-8200-007, BACKPORT-8200-008
|
||||
// Description: Resolves backport evidence by calling proof generator
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Backport;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves backport evidence by delegating to proof generator
|
||||
/// and extracting patch lineage for merge hash computation.
|
||||
/// </summary>
|
||||
public sealed partial class BackportEvidenceResolver : IBackportEvidenceResolver
|
||||
{
|
||||
private readonly IProofGenerator _proofGenerator;
|
||||
private readonly ILogger<BackportEvidenceResolver> _logger;
|
||||
|
||||
public BackportEvidenceResolver(
|
||||
IProofGenerator proofGenerator,
|
||||
ILogger<BackportEvidenceResolver> logger)
|
||||
{
|
||||
_proofGenerator = proofGenerator ?? throw new ArgumentNullException(nameof(proofGenerator));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<BackportEvidence?> ResolveAsync(
|
||||
string cveId,
|
||||
string packagePurl,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packagePurl);
|
||||
|
||||
_logger.LogDebug("Resolving backport evidence for {CveId} in {Package}", cveId, packagePurl);
|
||||
|
||||
var proof = await _proofGenerator.GenerateProofAsync(cveId, packagePurl, ct);
|
||||
|
||||
if (proof is null || proof.Confidence < 0.1)
|
||||
{
|
||||
_logger.LogDebug("No sufficient evidence for {CveId} in {Package}", cveId, packagePurl);
|
||||
return null;
|
||||
}
|
||||
|
||||
return ExtractBackportEvidence(cveId, packagePurl, proof);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<BackportEvidence>> ResolveBatchAsync(
|
||||
string cveId,
|
||||
IEnumerable<string> packagePurls,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
|
||||
ArgumentNullException.ThrowIfNull(packagePurls);
|
||||
|
||||
var requests = packagePurls.Select(purl => (cveId, purl));
|
||||
var proofs = await _proofGenerator.GenerateProofBatchAsync(requests, ct);
|
||||
|
||||
var results = new List<BackportEvidence>();
|
||||
foreach (var proof in proofs)
|
||||
{
|
||||
var purl = ExtractPurlFromSubjectId(proof.SubjectId);
|
||||
if (purl != null)
|
||||
{
|
||||
var evidence = ExtractBackportEvidence(cveId, purl, proof);
|
||||
if (evidence != null)
|
||||
{
|
||||
results.Add(evidence);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> HasEvidenceAsync(
|
||||
string cveId,
|
||||
string packagePurl,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var evidence = await ResolveAsync(cveId, packagePurl, ct);
|
||||
return evidence is not null && evidence.Confidence >= 0.3;
|
||||
}
|
||||
|
||||
private BackportEvidence? ExtractBackportEvidence(string cveId, string packagePurl, ProofResult proof)
|
||||
{
|
||||
var distroRelease = ExtractDistroRelease(packagePurl);
|
||||
var tier = DetermineHighestTier(proof.Evidences);
|
||||
var (patchId, patchOrigin) = ExtractPatchLineage(proof.Evidences);
|
||||
var backportVersion = ExtractBackportVersion(proof.Evidences, packagePurl);
|
||||
|
||||
if (tier == BackportEvidenceTier.DistroAdvisory && proof.Confidence < 0.3)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new BackportEvidence
|
||||
{
|
||||
CveId = cveId,
|
||||
PackagePurl = packagePurl,
|
||||
DistroRelease = distroRelease,
|
||||
Tier = tier,
|
||||
Confidence = proof.Confidence,
|
||||
PatchId = patchId,
|
||||
BackportVersion = backportVersion,
|
||||
PatchOrigin = patchOrigin,
|
||||
ProofId = proof.ProofId,
|
||||
EvidenceDate = proof.CreatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static BackportEvidenceTier DetermineHighestTier(IReadOnlyList<ProofEvidenceItem> evidences)
|
||||
{
|
||||
var highestTier = BackportEvidenceTier.DistroAdvisory;
|
||||
|
||||
foreach (var evidence in evidences)
|
||||
{
|
||||
var tier = evidence.Type.ToUpperInvariant() switch
|
||||
{
|
||||
"BINARYFINGERPRINT" => BackportEvidenceTier.BinaryFingerprint,
|
||||
"PATCHHEADER" => BackportEvidenceTier.PatchHeader,
|
||||
"CHANGELOGMENTION" => BackportEvidenceTier.ChangelogMention,
|
||||
"DISTROADVISORY" => BackportEvidenceTier.DistroAdvisory,
|
||||
_ => BackportEvidenceTier.DistroAdvisory
|
||||
};
|
||||
|
||||
if (tier > highestTier)
|
||||
{
|
||||
highestTier = tier;
|
||||
}
|
||||
}
|
||||
|
||||
return highestTier;
|
||||
}
|
||||
|
||||
private static (string? PatchId, PatchOrigin Origin) ExtractPatchLineage(IReadOnlyList<ProofEvidenceItem> evidences)
|
||||
{
|
||||
// Priority order: PatchHeader > Changelog > Advisory
|
||||
var patchEvidence = evidences
|
||||
.Where(e => e.Type.Equals("PatchHeader", StringComparison.OrdinalIgnoreCase) ||
|
||||
e.Type.Equals("ChangelogMention", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(e => e.Type.Equals("PatchHeader", StringComparison.OrdinalIgnoreCase) ? 1 : 0)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (patchEvidence is null)
|
||||
{
|
||||
return (null, PatchOrigin.Upstream);
|
||||
}
|
||||
|
||||
string? patchId = null;
|
||||
var origin = PatchOrigin.Upstream;
|
||||
|
||||
// Try to extract patch info from data dictionary
|
||||
if (patchEvidence.Data.TryGetValue("commit_sha", out var sha))
|
||||
{
|
||||
patchId = sha;
|
||||
origin = PatchOrigin.Upstream;
|
||||
}
|
||||
else if (patchEvidence.Data.TryGetValue("patch_id", out var pid))
|
||||
{
|
||||
patchId = pid;
|
||||
}
|
||||
else if (patchEvidence.Data.TryGetValue("upstream_commit", out var uc))
|
||||
{
|
||||
patchId = uc;
|
||||
origin = PatchOrigin.Upstream;
|
||||
}
|
||||
else if (patchEvidence.Data.TryGetValue("distro_patch_id", out var dpid))
|
||||
{
|
||||
patchId = dpid;
|
||||
origin = PatchOrigin.Distro;
|
||||
}
|
||||
|
||||
// Try to determine origin from source field
|
||||
if (origin == PatchOrigin.Upstream)
|
||||
{
|
||||
var source = patchEvidence.Source.ToLowerInvariant();
|
||||
origin = source switch
|
||||
{
|
||||
"upstream" or "github" or "gitlab" => PatchOrigin.Upstream,
|
||||
"debian" or "redhat" or "suse" or "ubuntu" or "alpine" => PatchOrigin.Distro,
|
||||
"vendor" or "cisco" or "oracle" or "microsoft" => PatchOrigin.Vendor,
|
||||
_ => PatchOrigin.Upstream
|
||||
};
|
||||
}
|
||||
|
||||
// If still no patch ID, try to extract from evidence ID
|
||||
if (patchId is null && patchEvidence.EvidenceId.Contains(':'))
|
||||
{
|
||||
var match = CommitShaRegex().Match(patchEvidence.EvidenceId);
|
||||
if (match.Success)
|
||||
{
|
||||
patchId = match.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return (patchId, origin);
|
||||
}
|
||||
|
||||
private static string? ExtractBackportVersion(IReadOnlyList<ProofEvidenceItem> evidences, string packagePurl)
|
||||
{
|
||||
// Try to extract version from advisory evidence
|
||||
var advisory = evidences.FirstOrDefault(e =>
|
||||
e.Type.Equals("DistroAdvisory", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (advisory is not null)
|
||||
{
|
||||
if (advisory.Data.TryGetValue("fixed_version", out var fv))
|
||||
{
|
||||
return fv;
|
||||
}
|
||||
if (advisory.Data.TryGetValue("patched_version", out var pv))
|
||||
{
|
||||
return pv;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: extract version from PURL if present
|
||||
var match = PurlVersionRegex().Match(packagePurl);
|
||||
return match.Success ? match.Groups[1].Value : null;
|
||||
}
|
||||
|
||||
private static string ExtractDistroRelease(string packagePurl)
|
||||
{
|
||||
// Extract distro from PURL
|
||||
// Format: pkg:deb/debian/curl@7.64.0-4 -> debian
|
||||
// Format: pkg:rpm/redhat/openssl@1.0.2k-19.el7 -> redhat
|
||||
var match = PurlDistroRegex().Match(packagePurl);
|
||||
if (match.Success)
|
||||
{
|
||||
// Group 2 is the distro name (debian, ubuntu, etc.), Group 1 is package type (deb, rpm, apk)
|
||||
var distro = match.Groups[2].Value.ToLowerInvariant();
|
||||
|
||||
// Try to extract release codename from version
|
||||
var versionMatch = PurlVersionRegex().Match(packagePurl);
|
||||
if (versionMatch.Success)
|
||||
{
|
||||
var version = versionMatch.Groups[1].Value;
|
||||
|
||||
// Debian patterns: ~deb11, ~deb12, +deb12
|
||||
var debMatch = DebianReleaseRegex().Match(version);
|
||||
if (debMatch.Success)
|
||||
{
|
||||
var debVersion = debMatch.Groups[1].Value;
|
||||
var codename = debVersion switch
|
||||
{
|
||||
"11" => "bullseye",
|
||||
"12" => "bookworm",
|
||||
"13" => "trixie",
|
||||
_ => debVersion
|
||||
};
|
||||
return $"{distro}:{codename}";
|
||||
}
|
||||
|
||||
// RHEL patterns: .el7, .el8, .el9
|
||||
var rhelMatch = RhelReleaseRegex().Match(version);
|
||||
if (rhelMatch.Success)
|
||||
{
|
||||
return $"{distro}:{rhelMatch.Groups[1].Value}";
|
||||
}
|
||||
|
||||
// Ubuntu patterns: ~22.04, +22.04
|
||||
var ubuntuMatch = UbuntuReleaseRegex().Match(version);
|
||||
if (ubuntuMatch.Success)
|
||||
{
|
||||
return $"{distro}:{ubuntuMatch.Groups[1].Value}";
|
||||
}
|
||||
}
|
||||
|
||||
return distro;
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
private static string? ExtractPurlFromSubjectId(string subjectId)
|
||||
{
|
||||
// Format: CVE-XXXX-YYYY:pkg:...
|
||||
var colonIndex = subjectId.IndexOf("pkg:", StringComparison.Ordinal);
|
||||
return colonIndex >= 0 ? subjectId[colonIndex..] : null;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"[0-9a-f]{40}", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex CommitShaRegex();
|
||||
|
||||
[GeneratedRegex(@"@([^@]+)$")]
|
||||
private static partial Regex PurlVersionRegex();
|
||||
|
||||
[GeneratedRegex(@"pkg:(deb|rpm|apk)/([^/]+)/")]
|
||||
private static partial Regex PurlDistroRegex();
|
||||
|
||||
[GeneratedRegex(@"[+~]deb(\d+)")]
|
||||
private static partial Regex DebianReleaseRegex();
|
||||
|
||||
[GeneratedRegex(@"\.el(\d+)")]
|
||||
private static partial Regex RhelReleaseRegex();
|
||||
|
||||
[GeneratedRegex(@"[+~](\d+\.\d+)")]
|
||||
private static partial Regex UbuntuReleaseRegex();
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IBackportEvidenceResolver.cs
|
||||
// Sprint: SPRINT_8200_0015_0001_CONCEL_backport_integration
|
||||
// Task: BACKPORT-8200-005
|
||||
// Description: Interface for resolving backport evidence from proof service
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Backport;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves backport evidence for CVE + package combinations.
|
||||
/// Bridges BackportProofService to the merge deduplication pipeline.
|
||||
/// </summary>
|
||||
public interface IBackportEvidenceResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolve backport evidence for a CVE + package combination.
|
||||
/// </summary>
|
||||
/// <param name="cveId">CVE identifier (e.g., CVE-2024-1234)</param>
|
||||
/// <param name="packagePurl">Package URL (e.g., pkg:deb/debian/curl@7.64.0-4)</param>
|
||||
/// <param name="ct">Cancellation token</param>
|
||||
/// <returns>Backport evidence with patch lineage and confidence, or null if no evidence</returns>
|
||||
Task<BackportEvidence?> ResolveAsync(
|
||||
string cveId,
|
||||
string packagePurl,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolve evidence for multiple packages in batch.
|
||||
/// </summary>
|
||||
/// <param name="cveId">CVE identifier</param>
|
||||
/// <param name="packagePurls">Package URLs to check</param>
|
||||
/// <param name="ct">Cancellation token</param>
|
||||
/// <returns>Evidence for each package that has backport proof</returns>
|
||||
Task<IReadOnlyList<BackportEvidence>> ResolveBatchAsync(
|
||||
string cveId,
|
||||
IEnumerable<string> packagePurls,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Check if backport evidence exists without retrieving full details.
|
||||
/// </summary>
|
||||
Task<bool> HasEvidenceAsync(
|
||||
string cveId,
|
||||
string packagePurl,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for generating proof blobs (wraps BackportProofService).
|
||||
/// Allows the Merge library to consume proof without direct dependency.
|
||||
/// </summary>
|
||||
public interface IProofGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Generate proof for a CVE + package combination.
|
||||
/// </summary>
|
||||
Task<ProofResult?> GenerateProofAsync(
|
||||
string cveId,
|
||||
string packagePurl,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Generate proofs for multiple CVE + package combinations.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ProofResult>> GenerateProofBatchAsync(
|
||||
IEnumerable<(string CveId, string PackagePurl)> requests,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simplified proof result for merge library consumption.
|
||||
/// Maps from ProofBlob to avoid direct Attestor dependency.
|
||||
/// </summary>
|
||||
public sealed record ProofResult
|
||||
{
|
||||
/// <summary>Proof identifier.</summary>
|
||||
public required string ProofId { get; init; }
|
||||
|
||||
/// <summary>Subject identifier (CVE:PURL).</summary>
|
||||
public required string SubjectId { get; init; }
|
||||
|
||||
/// <summary>Confidence score (0.0-1.0).</summary>
|
||||
public double Confidence { get; init; }
|
||||
|
||||
/// <summary>When the proof was generated.</summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>Evidence items.</summary>
|
||||
public IReadOnlyList<ProofEvidenceItem> Evidences { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simplified evidence item for merge library consumption.
|
||||
/// </summary>
|
||||
public sealed record ProofEvidenceItem
|
||||
{
|
||||
/// <summary>Evidence identifier.</summary>
|
||||
public required string EvidenceId { get; init; }
|
||||
|
||||
/// <summary>Evidence type (DistroAdvisory, ChangelogMention, PatchHeader, BinaryFingerprint).</summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>Source of the evidence.</summary>
|
||||
public required string Source { get; init; }
|
||||
|
||||
/// <summary>Evidence timestamp.</summary>
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>Extracted data fields (optional, type-specific).</summary>
|
||||
public IReadOnlyDictionary<string, string> Data { get; init; } = new Dictionary<string, string>();
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IProvenanceScopeService.cs
|
||||
// Sprint: SPRINT_8200_0015_0001_CONCEL_backport_integration
|
||||
// Task: BACKPORT-8200-014
|
||||
// Description: Service interface for provenance scope management
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Backport;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing provenance scope during canonical advisory lifecycle.
|
||||
/// Populates and updates provenance_scope table with backport evidence.
|
||||
/// </summary>
|
||||
public interface IProvenanceScopeService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates or updates provenance scope for a canonical advisory during ingest.
|
||||
/// Called when a new canonical is created or when new evidence arrives.
|
||||
/// </summary>
|
||||
/// <param name="request">Provenance scope creation request</param>
|
||||
/// <param name="ct">Cancellation token</param>
|
||||
/// <returns>Result indicating success and scope ID</returns>
|
||||
Task<ProvenanceScopeResult> CreateOrUpdateAsync(
|
||||
ProvenanceScopeRequest request,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all provenance scopes for a canonical advisory.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ProvenanceScope>> GetByCanonicalIdAsync(
|
||||
Guid canonicalId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates provenance scope when new backport evidence is discovered.
|
||||
/// </summary>
|
||||
Task<ProvenanceScopeResult> UpdateFromEvidenceAsync(
|
||||
Guid canonicalId,
|
||||
BackportEvidence evidence,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Links a provenance scope to a proof entry reference.
|
||||
/// </summary>
|
||||
Task LinkEvidenceRefAsync(
|
||||
Guid provenanceScopeId,
|
||||
Guid evidenceRef,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all provenance scopes for a canonical (cascade on canonical delete).
|
||||
/// </summary>
|
||||
Task DeleteByCanonicalIdAsync(
|
||||
Guid canonicalId,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create or update provenance scope.
|
||||
/// </summary>
|
||||
public sealed record ProvenanceScopeRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Canonical advisory ID to associate provenance with.
|
||||
/// </summary>
|
||||
public required Guid CanonicalId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVE identifier (for evidence resolution).
|
||||
/// </summary>
|
||||
public required string CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package PURL (for evidence resolution and distro extraction).
|
||||
/// </summary>
|
||||
public required string PackagePurl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source name (debian, redhat, etc.).
|
||||
/// </summary>
|
||||
public required string Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Patch lineage if already known from advisory.
|
||||
/// </summary>
|
||||
public string? PatchLineage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Fixed version from advisory.
|
||||
/// </summary>
|
||||
public string? FixedVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to resolve additional evidence from proof service.
|
||||
/// </summary>
|
||||
public bool ResolveEvidence { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of provenance scope operation.
|
||||
/// </summary>
|
||||
public sealed record ProvenanceScopeResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the operation succeeded.
|
||||
/// </summary>
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Created or updated provenance scope ID.
|
||||
/// </summary>
|
||||
public Guid? ProvenanceScopeId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Linked evidence reference (if any).
|
||||
/// </summary>
|
||||
public Guid? EvidenceRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if operation failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether a new scope was created vs updated.
|
||||
/// </summary>
|
||||
public bool WasCreated { get; init; }
|
||||
|
||||
public static ProvenanceScopeResult Created(Guid scopeId, Guid? evidenceRef = null) => new()
|
||||
{
|
||||
Success = true,
|
||||
ProvenanceScopeId = scopeId,
|
||||
EvidenceRef = evidenceRef,
|
||||
WasCreated = true
|
||||
};
|
||||
|
||||
public static ProvenanceScopeResult Updated(Guid scopeId, Guid? evidenceRef = null) => new()
|
||||
{
|
||||
Success = true,
|
||||
ProvenanceScopeId = scopeId,
|
||||
EvidenceRef = evidenceRef,
|
||||
WasCreated = false
|
||||
};
|
||||
|
||||
public static ProvenanceScopeResult Failed(string error) => new()
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = error
|
||||
};
|
||||
|
||||
public static ProvenanceScopeResult NoEvidence() => new()
|
||||
{
|
||||
Success = true,
|
||||
ProvenanceScopeId = null,
|
||||
WasCreated = false
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ProvenanceScope.cs
|
||||
// Sprint: SPRINT_8200_0015_0001 (Backport Integration)
|
||||
// Task: BACKPORT-8200-001
|
||||
// Description: Domain model for distro-specific provenance tracking.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Backport;
|
||||
|
||||
/// <summary>
|
||||
/// Distro-specific provenance for a canonical advisory.
|
||||
/// Tracks backport versions, patch lineage, and evidence confidence.
|
||||
/// </summary>
|
||||
public sealed record ProvenanceScope
|
||||
{
|
||||
/// <summary>Unique identifier.</summary>
|
||||
public Guid Id { get; init; }
|
||||
|
||||
/// <summary>Referenced canonical advisory.</summary>
|
||||
public required Guid CanonicalId { get; init; }
|
||||
|
||||
/// <summary>Linux distribution release (e.g., 'debian:bookworm', 'rhel:9.2', 'ubuntu:22.04').</summary>
|
||||
public required string DistroRelease { get; init; }
|
||||
|
||||
/// <summary>Distro's backported version if different from upstream fixed version.</summary>
|
||||
public string? BackportSemver { get; init; }
|
||||
|
||||
/// <summary>Upstream commit SHA or patch identifier.</summary>
|
||||
public string? PatchId { get; init; }
|
||||
|
||||
/// <summary>Source of the patch.</summary>
|
||||
public PatchOrigin? PatchOrigin { get; init; }
|
||||
|
||||
/// <summary>Reference to BackportProofService evidence in proofchain.</summary>
|
||||
public Guid? EvidenceRef { get; init; }
|
||||
|
||||
/// <summary>Confidence score from BackportProofService (0.0-1.0).</summary>
|
||||
public double Confidence { get; init; }
|
||||
|
||||
/// <summary>Record creation timestamp.</summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>Last update timestamp.</summary>
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source of a patch in provenance tracking.
|
||||
/// </summary>
|
||||
public enum PatchOrigin
|
||||
{
|
||||
/// <summary>Unknown or unspecified origin.</summary>
|
||||
Unknown = 0,
|
||||
|
||||
/// <summary>Patch from upstream project.</summary>
|
||||
Upstream = 1,
|
||||
|
||||
/// <summary>Distro-specific patch by maintainers.</summary>
|
||||
Distro = 2,
|
||||
|
||||
/// <summary>Vendor-specific patch.</summary>
|
||||
Vendor = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence used in backport determination.
|
||||
/// </summary>
|
||||
public sealed record BackportEvidence
|
||||
{
|
||||
/// <summary>CVE identifier.</summary>
|
||||
public required string CveId { get; init; }
|
||||
|
||||
/// <summary>Package PURL.</summary>
|
||||
public required string PackagePurl { get; init; }
|
||||
|
||||
/// <summary>Linux distribution release.</summary>
|
||||
public required string DistroRelease { get; init; }
|
||||
|
||||
/// <summary>Evidence tier (quality level).</summary>
|
||||
public BackportEvidenceTier Tier { get; init; }
|
||||
|
||||
/// <summary>Confidence score (0.0-1.0).</summary>
|
||||
public double Confidence { get; init; }
|
||||
|
||||
/// <summary>Upstream commit SHA or patch identifier.</summary>
|
||||
public string? PatchId { get; init; }
|
||||
|
||||
/// <summary>Distro's backported version.</summary>
|
||||
public string? BackportVersion { get; init; }
|
||||
|
||||
/// <summary>Origin of the patch.</summary>
|
||||
public PatchOrigin PatchOrigin { get; init; }
|
||||
|
||||
/// <summary>Reference to the proof blob ID for traceability.</summary>
|
||||
public string? ProofId { get; init; }
|
||||
|
||||
/// <summary>When the evidence was collected.</summary>
|
||||
public DateTimeOffset EvidenceDate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tiers of backport evidence quality.
|
||||
/// </summary>
|
||||
public enum BackportEvidenceTier
|
||||
{
|
||||
/// <summary>No evidence found.</summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>Tier 1: Direct distro advisory confirms fix.</summary>
|
||||
DistroAdvisory = 1,
|
||||
|
||||
/// <summary>Tier 2: Changelog mentions CVE.</summary>
|
||||
ChangelogMention = 2,
|
||||
|
||||
/// <summary>Tier 3: Patch header or HunkSig match.</summary>
|
||||
PatchHeader = 3,
|
||||
|
||||
/// <summary>Tier 4: Binary fingerprint match.</summary>
|
||||
BinaryFingerprint = 4
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ProvenanceScopeService.cs
|
||||
// Sprint: SPRINT_8200_0015_0001_CONCEL_backport_integration
|
||||
// Tasks: BACKPORT-8200-014, BACKPORT-8200-015, BACKPORT-8200-016
|
||||
// Description: Service for managing provenance scope lifecycle
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Backport;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing provenance scope during canonical advisory lifecycle.
|
||||
/// </summary>
|
||||
public sealed partial class ProvenanceScopeService : IProvenanceScopeService
|
||||
{
|
||||
private readonly IProvenanceScopeStore _store;
|
||||
private readonly IBackportEvidenceResolver? _evidenceResolver;
|
||||
private readonly ILogger<ProvenanceScopeService> _logger;
|
||||
|
||||
public ProvenanceScopeService(
|
||||
IProvenanceScopeStore store,
|
||||
ILogger<ProvenanceScopeService> logger,
|
||||
IBackportEvidenceResolver? evidenceResolver = null)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_evidenceResolver = evidenceResolver; // Optional - if not provided, uses advisory data only
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ProvenanceScopeResult> CreateOrUpdateAsync(
|
||||
ProvenanceScopeRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Creating/updating provenance scope for canonical {CanonicalId}, source {Source}",
|
||||
request.CanonicalId, request.Source);
|
||||
|
||||
// 1. Extract distro release from package PURL
|
||||
var distroRelease = ExtractDistroRelease(request.PackagePurl, request.Source);
|
||||
|
||||
// 2. Resolve backport evidence if resolver is available
|
||||
BackportEvidence? evidence = null;
|
||||
if (_evidenceResolver is not null && request.ResolveEvidence)
|
||||
{
|
||||
try
|
||||
{
|
||||
evidence = await _evidenceResolver.ResolveAsync(
|
||||
request.CveId,
|
||||
request.PackagePurl,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
if (evidence is not null)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Resolved backport evidence for {CveId}/{Package}: tier={Tier}, confidence={Confidence:P0}",
|
||||
request.CveId, request.PackagePurl, evidence.Tier, evidence.Confidence);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to resolve backport evidence for {CveId}/{Package}",
|
||||
request.CveId, request.PackagePurl);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check for existing scope
|
||||
var existing = await _store.GetByCanonicalAndDistroAsync(
|
||||
request.CanonicalId,
|
||||
distroRelease,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
// 4. Prepare scope data
|
||||
var scope = new ProvenanceScope
|
||||
{
|
||||
Id = existing?.Id ?? Guid.NewGuid(),
|
||||
CanonicalId = request.CanonicalId,
|
||||
DistroRelease = distroRelease,
|
||||
BackportSemver = evidence?.BackportVersion ?? request.FixedVersion,
|
||||
PatchId = evidence?.PatchId ?? ExtractPatchId(request.PatchLineage),
|
||||
PatchOrigin = evidence?.PatchOrigin ?? DeterminePatchOrigin(request.Source),
|
||||
EvidenceRef = null, // Will be linked separately
|
||||
Confidence = evidence?.Confidence ?? DetermineDefaultConfidence(request.Source),
|
||||
CreatedAt = existing?.CreatedAt ?? DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// 5. Upsert scope
|
||||
var scopeId = await _store.UpsertAsync(scope, ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"{Action} provenance scope {ScopeId} for canonical {CanonicalId} ({Distro})",
|
||||
existing is null ? "Created" : "Updated",
|
||||
scopeId, request.CanonicalId, distroRelease);
|
||||
|
||||
return existing is null
|
||||
? ProvenanceScopeResult.Created(scopeId)
|
||||
: ProvenanceScopeResult.Updated(scopeId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ProvenanceScope>> GetByCanonicalIdAsync(
|
||||
Guid canonicalId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return await _store.GetByCanonicalIdAsync(canonicalId, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ProvenanceScopeResult> UpdateFromEvidenceAsync(
|
||||
Guid canonicalId,
|
||||
BackportEvidence evidence,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(evidence);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Updating provenance scope for canonical {CanonicalId} from evidence (tier={Tier})",
|
||||
canonicalId, evidence.Tier);
|
||||
|
||||
// Check for existing scope
|
||||
var existing = await _store.GetByCanonicalAndDistroAsync(
|
||||
canonicalId,
|
||||
evidence.DistroRelease,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
// Only update if evidence is better (higher tier or confidence)
|
||||
if (existing is not null &&
|
||||
existing.Confidence >= evidence.Confidence &&
|
||||
!string.IsNullOrEmpty(existing.PatchId))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Skipping update - existing scope has equal/better confidence ({Existing:P0} >= {New:P0})",
|
||||
existing.Confidence, evidence.Confidence);
|
||||
|
||||
return ProvenanceScopeResult.Updated(existing.Id);
|
||||
}
|
||||
|
||||
var scope = new ProvenanceScope
|
||||
{
|
||||
Id = existing?.Id ?? Guid.NewGuid(),
|
||||
CanonicalId = canonicalId,
|
||||
DistroRelease = evidence.DistroRelease,
|
||||
BackportSemver = evidence.BackportVersion,
|
||||
PatchId = evidence.PatchId,
|
||||
PatchOrigin = evidence.PatchOrigin,
|
||||
EvidenceRef = null,
|
||||
Confidence = evidence.Confidence,
|
||||
CreatedAt = existing?.CreatedAt ?? DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var scopeId = await _store.UpsertAsync(scope, ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Updated provenance scope {ScopeId} from evidence (tier={Tier}, confidence={Confidence:P0})",
|
||||
scopeId, evidence.Tier, evidence.Confidence);
|
||||
|
||||
return existing is null
|
||||
? ProvenanceScopeResult.Created(scopeId)
|
||||
: ProvenanceScopeResult.Updated(scopeId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task LinkEvidenceRefAsync(
|
||||
Guid provenanceScopeId,
|
||||
Guid evidenceRef,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Linking evidence ref {EvidenceRef} to provenance scope {ScopeId}",
|
||||
evidenceRef, provenanceScopeId);
|
||||
|
||||
await _store.LinkEvidenceRefAsync(provenanceScopeId, evidenceRef, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteByCanonicalIdAsync(
|
||||
Guid canonicalId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await _store.DeleteByCanonicalIdAsync(canonicalId, ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Deleted provenance scopes for canonical {CanonicalId}",
|
||||
canonicalId);
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static string ExtractDistroRelease(string packagePurl, string source)
|
||||
{
|
||||
// Try to extract from PURL first
|
||||
var match = PurlDistroRegex().Match(packagePurl);
|
||||
if (match.Success)
|
||||
{
|
||||
// Group 2 is the distro name (debian, ubuntu, etc.), Group 1 is package type (deb, rpm, apk)
|
||||
var purlDistro = match.Groups[2].Value.ToLowerInvariant();
|
||||
|
||||
// Try to get release from version
|
||||
var versionMatch = PurlVersionRegex().Match(packagePurl);
|
||||
if (versionMatch.Success)
|
||||
{
|
||||
var version = versionMatch.Groups[1].Value;
|
||||
|
||||
// Debian: ~deb11, ~deb12
|
||||
var debMatch = DebianReleaseRegex().Match(version);
|
||||
if (debMatch.Success)
|
||||
{
|
||||
return $"{purlDistro}:{MapDebianCodename(debMatch.Groups[1].Value)}";
|
||||
}
|
||||
|
||||
// RHEL: .el7, .el8, .el9
|
||||
var rhelMatch = RhelReleaseRegex().Match(version);
|
||||
if (rhelMatch.Success)
|
||||
{
|
||||
return $"{purlDistro}:{rhelMatch.Groups[1].Value}";
|
||||
}
|
||||
|
||||
// Ubuntu: ~22.04
|
||||
var ubuntuMatch = UbuntuReleaseRegex().Match(version);
|
||||
if (ubuntuMatch.Success)
|
||||
{
|
||||
return $"{purlDistro}:{ubuntuMatch.Groups[1].Value}";
|
||||
}
|
||||
}
|
||||
|
||||
return purlDistro;
|
||||
}
|
||||
|
||||
// Fall back to source name
|
||||
return source.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string MapDebianCodename(string version)
|
||||
{
|
||||
return version switch
|
||||
{
|
||||
"10" => "buster",
|
||||
"11" => "bullseye",
|
||||
"12" => "bookworm",
|
||||
"13" => "trixie",
|
||||
_ => version
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ExtractPatchId(string? patchLineage)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(patchLineage))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to extract commit SHA
|
||||
var shaMatch = CommitShaRegex().Match(patchLineage);
|
||||
if (shaMatch.Success)
|
||||
{
|
||||
return shaMatch.Value.ToLowerInvariant();
|
||||
}
|
||||
|
||||
return patchLineage.Trim();
|
||||
}
|
||||
|
||||
private static PatchOrigin DeterminePatchOrigin(string source)
|
||||
{
|
||||
return source.ToLowerInvariant() switch
|
||||
{
|
||||
"debian" or "redhat" or "suse" or "ubuntu" or "alpine" or "astra" => PatchOrigin.Distro,
|
||||
"vendor" or "cisco" or "oracle" or "microsoft" or "adobe" => PatchOrigin.Vendor,
|
||||
_ => PatchOrigin.Upstream
|
||||
};
|
||||
}
|
||||
|
||||
private static double DetermineDefaultConfidence(string source)
|
||||
{
|
||||
// Distro sources have higher default confidence
|
||||
return source.ToLowerInvariant() switch
|
||||
{
|
||||
"debian" or "redhat" or "suse" or "ubuntu" or "alpine" => 0.7,
|
||||
"vendor" or "cisco" or "oracle" => 0.8,
|
||||
_ => 0.5
|
||||
};
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"pkg:(deb|rpm|apk)/([^/]+)/")]
|
||||
private static partial Regex PurlDistroRegex();
|
||||
|
||||
[GeneratedRegex(@"@([^@]+)$")]
|
||||
private static partial Regex PurlVersionRegex();
|
||||
|
||||
[GeneratedRegex(@"[+~]deb(\d+)")]
|
||||
private static partial Regex DebianReleaseRegex();
|
||||
|
||||
[GeneratedRegex(@"\.el(\d+)")]
|
||||
private static partial Regex RhelReleaseRegex();
|
||||
|
||||
[GeneratedRegex(@"[+~](\d+\.\d+)")]
|
||||
private static partial Regex UbuntuReleaseRegex();
|
||||
|
||||
[GeneratedRegex(@"[0-9a-f]{40}", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex CommitShaRegex();
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store interface for provenance scope persistence.
|
||||
/// </summary>
|
||||
public interface IProvenanceScopeStore
|
||||
{
|
||||
Task<ProvenanceScope?> GetByCanonicalAndDistroAsync(
|
||||
Guid canonicalId,
|
||||
string distroRelease,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task<IReadOnlyList<ProvenanceScope>> GetByCanonicalIdAsync(
|
||||
Guid canonicalId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task<Guid> UpsertAsync(
|
||||
ProvenanceScope scope,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task LinkEvidenceRefAsync(
|
||||
Guid provenanceScopeId,
|
||||
Guid evidenceRef,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task DeleteByCanonicalIdAsync(
|
||||
Guid canonicalId,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BackportServiceCollectionExtensions.cs
|
||||
// Sprint: SPRINT_8200_0015_0001_CONCEL_backport_integration
|
||||
// Task: BACKPORT-8200-023
|
||||
// Description: DI registration for backport-related services
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Concelier.Merge.Backport;
|
||||
using StellaOps.Concelier.Merge.Precedence;
|
||||
|
||||
namespace StellaOps.Concelier.Merge;
|
||||
|
||||
/// <summary>
|
||||
/// Extensions for registering backport-related services.
|
||||
/// </summary>
|
||||
public static class BackportServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds backport-related services including provenance scope management and source precedence.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddBackportServices(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
// Configure precedence options from configuration
|
||||
var section = configuration.GetSection("concelier:merge:precedence");
|
||||
services.AddSingleton(sp =>
|
||||
{
|
||||
var config = new PrecedenceConfig();
|
||||
|
||||
if (section.Exists())
|
||||
{
|
||||
var backportBoostThreshold = section.GetValue<double?>("backportBoostThreshold");
|
||||
var backportBoostAmount = section.GetValue<int?>("backportBoostAmount");
|
||||
var enableBackportBoost = section.GetValue<bool?>("enableBackportBoost");
|
||||
|
||||
config = new PrecedenceConfig
|
||||
{
|
||||
BackportBoostThreshold = backportBoostThreshold ?? config.BackportBoostThreshold,
|
||||
BackportBoostAmount = backportBoostAmount ?? config.BackportBoostAmount,
|
||||
EnableBackportBoost = enableBackportBoost ?? config.EnableBackportBoost
|
||||
};
|
||||
}
|
||||
|
||||
return Microsoft.Extensions.Options.Options.Create(config);
|
||||
});
|
||||
|
||||
// Register source precedence lattice
|
||||
services.TryAddSingleton<ISourcePrecedenceLattice, ConfigurableSourcePrecedenceLattice>();
|
||||
|
||||
// Register provenance scope service
|
||||
services.TryAddScoped<IProvenanceScopeService, ProvenanceScopeService>();
|
||||
|
||||
// Register backport evidence resolver (optional - depends on proof generator availability)
|
||||
services.TryAddScoped<IBackportEvidenceResolver, BackportEvidenceResolver>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds backport services with default configuration.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddBackportServices(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
// Use default configuration
|
||||
services.AddSingleton(_ => Microsoft.Extensions.Options.Options.Create(new PrecedenceConfig()));
|
||||
|
||||
services.TryAddSingleton<ISourcePrecedenceLattice, ConfigurableSourcePrecedenceLattice>();
|
||||
services.TryAddScoped<IProvenanceScopeService, ProvenanceScopeService>();
|
||||
services.TryAddScoped<IBackportEvidenceResolver, BackportEvidenceResolver>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -34,9 +34,11 @@ public sealed partial class PatchLineageNormalizer : IPatchLineageNormalizer
|
||||
|
||||
/// <summary>
|
||||
/// Pattern for GitHub/GitLab commit URLs.
|
||||
/// GitHub: /owner/repo/commit/sha
|
||||
/// GitLab: /owner/repo/-/commit/sha
|
||||
/// </summary>
|
||||
[GeneratedRegex(
|
||||
@"(?:github\.com|gitlab\.com)/[^/]+/[^/]+/commit/([0-9a-f]{7,40})",
|
||||
@"(?:github\.com|gitlab\.com)/[^/]+/[^/]+(?:/-)?/commit/([0-9a-f]{7,40})",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||
private static partial Regex CommitUrlPattern();
|
||||
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ConfigurableSourcePrecedenceLattice.cs
|
||||
// Sprint: SPRINT_8200_0015_0001_CONCEL_backport_integration
|
||||
// Tasks: BACKPORT-8200-019, BACKPORT-8200-020, BACKPORT-8200-021
|
||||
// Description: Configurable source precedence with backport-aware overrides
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Merge.Backport;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Precedence;
|
||||
|
||||
/// <summary>
|
||||
/// Configurable source precedence lattice with backport-aware dynamic overrides.
|
||||
/// Distro sources with high-confidence backport evidence can take precedence
|
||||
/// over upstream/vendor sources for affected CVE contexts.
|
||||
/// </summary>
|
||||
public sealed class ConfigurableSourcePrecedenceLattice : ISourcePrecedenceLattice
|
||||
{
|
||||
private readonly PrecedenceConfig _config;
|
||||
private readonly ILogger<ConfigurableSourcePrecedenceLattice> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Sources that are considered distro sources for backport boost eligibility.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> DistroSources = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"debian",
|
||||
"redhat",
|
||||
"suse",
|
||||
"ubuntu",
|
||||
"alpine",
|
||||
"astra",
|
||||
"centos",
|
||||
"fedora",
|
||||
"rocky",
|
||||
"alma",
|
||||
"oracle-linux"
|
||||
};
|
||||
|
||||
public ConfigurableSourcePrecedenceLattice(
|
||||
IOptions<PrecedenceConfig> options,
|
||||
ILogger<ConfigurableSourcePrecedenceLattice> logger)
|
||||
{
|
||||
_config = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a lattice with default configuration.
|
||||
/// </summary>
|
||||
public ConfigurableSourcePrecedenceLattice(ILogger<ConfigurableSourcePrecedenceLattice> logger)
|
||||
: this(Microsoft.Extensions.Options.Options.Create(new PrecedenceConfig()), logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int BackportBoostAmount => _config.BackportBoostAmount;
|
||||
|
||||
/// <inheritdoc />
|
||||
public double BackportBoostThreshold => _config.BackportBoostThreshold;
|
||||
|
||||
/// <inheritdoc />
|
||||
public int GetPrecedence(string source, BackportContext? context = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(source);
|
||||
|
||||
var normalizedSource = source.ToLowerInvariant();
|
||||
|
||||
// 1. Check for CVE-specific override first
|
||||
if (context is not null)
|
||||
{
|
||||
var overrideKey = $"{context.CveId}:{normalizedSource}";
|
||||
if (_config.Overrides.TryGetValue(overrideKey, out var cveOverride))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Using CVE-specific override for {Source} on {CveId}: {Precedence}",
|
||||
source, context.CveId, cveOverride);
|
||||
return cveOverride;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Get base precedence
|
||||
var basePrecedence = GetBasePrecedence(normalizedSource);
|
||||
|
||||
// 3. Apply backport boost if eligible
|
||||
if (context is not null && ShouldApplyBackportBoost(normalizedSource, context))
|
||||
{
|
||||
var boostedPrecedence = basePrecedence - _config.BackportBoostAmount;
|
||||
|
||||
_logger.LogDebug(
|
||||
"Applied backport boost to {Source}: {Base} -> {Boosted} (evidence tier={Tier}, confidence={Confidence:P0})",
|
||||
source, basePrecedence, boostedPrecedence, context.EvidenceTier, context.EvidenceConfidence);
|
||||
|
||||
return boostedPrecedence;
|
||||
}
|
||||
|
||||
return basePrecedence;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public SourceComparison Compare(
|
||||
string source1,
|
||||
string source2,
|
||||
BackportContext? context = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(source1);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(source2);
|
||||
|
||||
var precedence1 = GetPrecedence(source1, context);
|
||||
var precedence2 = GetPrecedence(source2, context);
|
||||
|
||||
// Lower precedence value = higher priority
|
||||
if (precedence1 < precedence2)
|
||||
{
|
||||
return SourceComparison.Source1Higher;
|
||||
}
|
||||
|
||||
if (precedence2 < precedence1)
|
||||
{
|
||||
return SourceComparison.Source2Higher;
|
||||
}
|
||||
|
||||
return SourceComparison.Equal;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsDistroSource(string source)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(source);
|
||||
return DistroSources.Contains(source);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the base precedence for a source without any context-dependent boosts.
|
||||
/// </summary>
|
||||
private int GetBasePrecedence(string normalizedSource)
|
||||
{
|
||||
if (_config.DefaultPrecedence.TryGetValue(normalizedSource, out var configured))
|
||||
{
|
||||
return configured;
|
||||
}
|
||||
|
||||
// Unknown sources get lowest priority
|
||||
_logger.LogDebug(
|
||||
"Unknown source '{Source}' - assigning default precedence 1000",
|
||||
normalizedSource);
|
||||
|
||||
return 1000;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if backport boost should be applied to a source in the given context.
|
||||
/// </summary>
|
||||
private bool ShouldApplyBackportBoost(string normalizedSource, BackportContext context)
|
||||
{
|
||||
// Only distro sources are eligible for backport boost
|
||||
if (!IsDistroSource(normalizedSource))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Boost must be enabled in config
|
||||
if (!_config.EnableBackportBoost)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must have backport evidence
|
||||
if (!context.HasBackportEvidence)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Confidence must meet threshold
|
||||
if (context.EvidenceConfidence < _config.BackportBoostThreshold)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Backport evidence confidence {Confidence:P0} below threshold {Threshold:P0} for {Source}",
|
||||
context.EvidenceConfidence, _config.BackportBoostThreshold, normalizedSource);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Evidence tier 1-2 gets boost (direct advisory or changelog mention)
|
||||
// Tier 3-4 (patch header, binary fingerprint) require higher confidence
|
||||
if (context.EvidenceTier >= BackportEvidenceTier.PatchHeader &&
|
||||
context.EvidenceConfidence < 0.9)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Lower tier evidence (tier={Tier}) requires 90% confidence, got {Confidence:P0}",
|
||||
context.EvidenceTier, context.EvidenceConfidence);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception rule for source precedence that can override defaults for specific CVE patterns.
|
||||
/// </summary>
|
||||
public sealed record PrecedenceExceptionRule
|
||||
{
|
||||
/// <summary>
|
||||
/// CVE pattern to match (supports wildcards: CVE-2024-* or exact: CVE-2024-1234).
|
||||
/// </summary>
|
||||
public required string CvePattern { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source this rule applies to.
|
||||
/// </summary>
|
||||
public required string Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Precedence value to use when rule matches.
|
||||
/// </summary>
|
||||
public required int Precedence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional comment explaining why this exception exists.
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this rule is currently active.
|
||||
/// </summary>
|
||||
public bool IsActive { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if this rule matches the given CVE ID.
|
||||
/// </summary>
|
||||
public bool Matches(string cveId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cveId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (CvePattern.EndsWith('*'))
|
||||
{
|
||||
var prefix = CvePattern[..^1];
|
||||
return cveId.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return string.Equals(cveId, CvePattern, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extended precedence configuration with exception rules.
|
||||
/// Uses composition to extend PrecedenceConfig.
|
||||
/// </summary>
|
||||
public sealed record ExtendedPrecedenceConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Base precedence configuration.
|
||||
/// </summary>
|
||||
public PrecedenceConfig BaseConfig { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Exception rules that override default precedence for matching CVEs.
|
||||
/// </summary>
|
||||
public List<PrecedenceExceptionRule> ExceptionRules { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets all active exception rules.
|
||||
/// </summary>
|
||||
public IEnumerable<PrecedenceExceptionRule> GetActiveRules() =>
|
||||
ExceptionRules.Where(r => r.IsActive);
|
||||
|
||||
/// <summary>
|
||||
/// Finds the first matching exception rule for a CVE/source combination.
|
||||
/// </summary>
|
||||
public PrecedenceExceptionRule? FindMatchingRule(string cveId, string source)
|
||||
{
|
||||
var normalizedSource = source.ToLowerInvariant();
|
||||
|
||||
return GetActiveRules()
|
||||
.FirstOrDefault(r =>
|
||||
string.Equals(r.Source, normalizedSource, StringComparison.OrdinalIgnoreCase) &&
|
||||
r.Matches(cveId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ISourcePrecedenceLattice.cs
|
||||
// Sprint: SPRINT_8200_0015_0001_CONCEL_backport_integration
|
||||
// Task: BACKPORT-8200-018
|
||||
// Description: Interface for configurable source precedence with backport awareness
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Concelier.Merge.Backport;
|
||||
|
||||
namespace StellaOps.Concelier.Merge.Precedence;
|
||||
|
||||
/// <summary>
|
||||
/// Lattice for determining source precedence in merge decisions.
|
||||
/// Supports backport-aware overrides where distro sources with backport
|
||||
/// evidence can take precedence over upstream/vendor sources.
|
||||
/// </summary>
|
||||
public interface ISourcePrecedenceLattice
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the precedence rank for a source (lower = higher priority).
|
||||
/// </summary>
|
||||
/// <param name="source">Source identifier (debian, redhat, nvd, etc.)</param>
|
||||
/// <param name="context">Optional backport context for dynamic precedence</param>
|
||||
/// <returns>Precedence rank (lower values = higher priority)</returns>
|
||||
int GetPrecedence(string source, BackportContext? context = null);
|
||||
|
||||
/// <summary>
|
||||
/// Compares two sources to determine which takes precedence.
|
||||
/// </summary>
|
||||
/// <param name="source1">First source identifier</param>
|
||||
/// <param name="source2">Second source identifier</param>
|
||||
/// <param name="context">Optional backport context for dynamic precedence</param>
|
||||
/// <returns>Comparison result indicating which source has higher precedence</returns>
|
||||
SourceComparison Compare(
|
||||
string source1,
|
||||
string source2,
|
||||
BackportContext? context = null);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a source is a distro source that benefits from backport boost.
|
||||
/// </summary>
|
||||
bool IsDistroSource(string source);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the backport boost amount applied to distro sources with evidence.
|
||||
/// </summary>
|
||||
int BackportBoostAmount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the minimum confidence threshold for backport boost to apply.
|
||||
/// </summary>
|
||||
double BackportBoostThreshold { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context for backport-aware precedence decisions.
|
||||
/// </summary>
|
||||
public sealed record BackportContext
|
||||
{
|
||||
/// <summary>
|
||||
/// CVE identifier being evaluated.
|
||||
/// </summary>
|
||||
public required string CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Distro release context (e.g., debian:bookworm).
|
||||
/// </summary>
|
||||
public string? DistroRelease { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether backport evidence exists for this CVE/distro.
|
||||
/// </summary>
|
||||
public bool HasBackportEvidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence score from backport evidence (0.0-1.0).
|
||||
/// </summary>
|
||||
public double EvidenceConfidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence tier (1-4).
|
||||
/// </summary>
|
||||
public BackportEvidenceTier EvidenceTier { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates context indicating no backport evidence.
|
||||
/// </summary>
|
||||
public static BackportContext NoEvidence(string cveId) => new()
|
||||
{
|
||||
CveId = cveId,
|
||||
HasBackportEvidence = false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates context from backport evidence.
|
||||
/// </summary>
|
||||
public static BackportContext FromEvidence(BackportEvidence evidence) => new()
|
||||
{
|
||||
CveId = evidence.CveId,
|
||||
DistroRelease = evidence.DistroRelease,
|
||||
HasBackportEvidence = true,
|
||||
EvidenceConfidence = evidence.Confidence,
|
||||
EvidenceTier = evidence.Tier
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of source precedence comparison.
|
||||
/// </summary>
|
||||
public enum SourceComparison
|
||||
{
|
||||
/// <summary>Source1 has higher precedence (should be preferred).</summary>
|
||||
Source1Higher,
|
||||
|
||||
/// <summary>Source2 has higher precedence (should be preferred).</summary>
|
||||
Source2Higher,
|
||||
|
||||
/// <summary>Both sources have equal precedence.</summary>
|
||||
Equal
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for source precedence rules.
|
||||
/// </summary>
|
||||
public sealed record PrecedenceConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Default precedence ranks by source (lower = higher priority).
|
||||
/// </summary>
|
||||
public Dictionary<string, int> DefaultPrecedence { get; init; } = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// Vendor PSIRT sources (highest priority)
|
||||
["vendor-psirt"] = 10,
|
||||
["cisco"] = 10,
|
||||
["oracle"] = 10,
|
||||
["microsoft"] = 10,
|
||||
["adobe"] = 10,
|
||||
|
||||
// Distro sources
|
||||
["debian"] = 20,
|
||||
["redhat"] = 20,
|
||||
["suse"] = 20,
|
||||
["ubuntu"] = 20,
|
||||
["alpine"] = 20,
|
||||
["astra"] = 20,
|
||||
|
||||
// Aggregated sources
|
||||
["osv"] = 30,
|
||||
["ghsa"] = 35,
|
||||
|
||||
// NVD (baseline)
|
||||
["nvd"] = 40,
|
||||
|
||||
// CERT sources
|
||||
["cert-cc"] = 50,
|
||||
["cert-bund"] = 50,
|
||||
["cert-fr"] = 50,
|
||||
|
||||
// Community/fallback
|
||||
["community"] = 100
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Specific CVE/source pair overrides.
|
||||
/// Format: "CVE-2024-1234:debian" -> precedence value.
|
||||
/// </summary>
|
||||
public Dictionary<string, int> Overrides { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Minimum confidence for backport boost to apply.
|
||||
/// </summary>
|
||||
public double BackportBoostThreshold { get; init; } = 0.7;
|
||||
|
||||
/// <summary>
|
||||
/// Precedence points subtracted for distro with backport evidence.
|
||||
/// Lower = higher priority, so subtracting makes the source more preferred.
|
||||
/// </summary>
|
||||
public int BackportBoostAmount { get; init; } = 15;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable backport-aware precedence boost.
|
||||
/// </summary>
|
||||
public bool EnableBackportBoost { get; init; } = true;
|
||||
}
|
||||
@@ -13,6 +13,8 @@ using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage.Advisories;
|
||||
using StellaOps.Concelier.Storage.Aliases;
|
||||
using StellaOps.Concelier.Storage.MergeEvents;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Provcache.Events;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Provenance;
|
||||
|
||||
@@ -43,6 +45,7 @@ public sealed class AdvisoryMergeService
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly CanonicalMerger _canonicalMerger;
|
||||
private readonly IMergeHashCalculator? _mergeHashCalculator;
|
||||
private readonly IEventStream<FeedEpochAdvancedEvent>? _feedEpochEventStream;
|
||||
private readonly ILogger<AdvisoryMergeService> _logger;
|
||||
|
||||
public AdvisoryMergeService(
|
||||
@@ -54,7 +57,8 @@ public sealed class AdvisoryMergeService
|
||||
IAdvisoryEventLog eventLog,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<AdvisoryMergeService> logger,
|
||||
IMergeHashCalculator? mergeHashCalculator = null)
|
||||
IMergeHashCalculator? mergeHashCalculator = null,
|
||||
IEventStream<FeedEpochAdvancedEvent>? feedEpochEventStream = null)
|
||||
{
|
||||
_aliasResolver = aliasResolver ?? throw new ArgumentNullException(nameof(aliasResolver));
|
||||
_advisoryStore = advisoryStore ?? throw new ArgumentNullException(nameof(advisoryStore));
|
||||
@@ -65,6 +69,7 @@ public sealed class AdvisoryMergeService
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_mergeHashCalculator = mergeHashCalculator; // Optional during migration
|
||||
_feedEpochEventStream = feedEpochEventStream; // Optional for feed epoch invalidation
|
||||
}
|
||||
|
||||
public async Task<AdvisoryMergeResult> MergeAsync(string seedAdvisoryKey, CancellationToken cancellationToken)
|
||||
@@ -141,9 +146,93 @@ public sealed class AdvisoryMergeService
|
||||
|
||||
var conflictSummaries = await AppendEventLogAsync(canonicalKey, normalizedInputs, merged, conflictDetails, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Publish FeedEpochAdvancedEvent if merge produced changes
|
||||
await PublishFeedEpochAdvancedAsync(before, merged, inputs, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new AdvisoryMergeResult(seedAdvisoryKey, canonicalKey, component, inputs, before, merged, conflictSummaries);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes a FeedEpochAdvancedEvent when merge produces a new or modified canonical advisory.
|
||||
/// This triggers Provcache invalidation for cached decisions based on older feed data.
|
||||
/// </summary>
|
||||
private async Task PublishFeedEpochAdvancedAsync(
|
||||
Advisory? before,
|
||||
Advisory merged,
|
||||
IReadOnlyList<Advisory> inputs,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_feedEpochEventStream is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine if this is a new or modified canonical
|
||||
var isNew = before is null;
|
||||
var isModified = before is not null && before.MergeHash != merged.MergeHash;
|
||||
|
||||
if (!isNew && !isModified)
|
||||
{
|
||||
return; // No change, no need to publish
|
||||
}
|
||||
|
||||
// Extract primary source from inputs for feedId
|
||||
var feedId = ExtractPrimaryFeedId(inputs) ?? "canonical";
|
||||
|
||||
// Compute epochs based on modification timestamps
|
||||
var previousEpoch = before?.Modified?.ToString("O") ?? "initial";
|
||||
var newEpoch = merged.Modified?.ToString("O") ?? _timeProvider.GetUtcNow().ToString("O");
|
||||
var effectiveAt = _timeProvider.GetUtcNow();
|
||||
|
||||
var @event = FeedEpochAdvancedEvent.Create(
|
||||
feedId: feedId,
|
||||
previousEpoch: previousEpoch,
|
||||
newEpoch: newEpoch,
|
||||
effectiveAt: effectiveAt,
|
||||
advisoriesAdded: isNew ? 1 : 0,
|
||||
advisoriesModified: isModified ? 1 : 0);
|
||||
|
||||
try
|
||||
{
|
||||
await _feedEpochEventStream.PublishAsync(@event, options: null, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogDebug(
|
||||
"Published FeedEpochAdvancedEvent for feed {FeedId}: {PreviousEpoch} -> {NewEpoch}",
|
||||
feedId, previousEpoch, newEpoch);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log but don't fail the merge operation for event publishing failures
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to publish FeedEpochAdvancedEvent for feed {FeedId}",
|
||||
feedId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the primary feed identifier from merged advisory inputs.
|
||||
/// </summary>
|
||||
private static string? ExtractPrimaryFeedId(IReadOnlyList<Advisory> inputs)
|
||||
{
|
||||
foreach (var advisory in inputs)
|
||||
{
|
||||
foreach (var provenance in advisory.Provenance)
|
||||
{
|
||||
if (string.Equals(provenance.Kind, "merge", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(provenance.Source))
|
||||
{
|
||||
return provenance.Source.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<MergeConflictSummary>> AppendEventLogAsync(
|
||||
string vulnerabilityKey,
|
||||
IReadOnlyList<Advisory> inputs,
|
||||
|
||||
@@ -3,6 +3,7 @@ namespace StellaOps.Concelier.Merge.Services;
|
||||
using System.Security.Cryptography;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Concelier.Merge.Backport;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage.MergeEvents;
|
||||
|
||||
@@ -35,6 +36,28 @@ public sealed class MergeEventWriter
|
||||
IReadOnlyList<Guid> inputDocumentIds,
|
||||
IReadOnlyList<MergeFieldDecision>? fieldDecisions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await AppendAsync(
|
||||
advisoryKey,
|
||||
before,
|
||||
after,
|
||||
inputDocumentIds,
|
||||
fieldDecisions,
|
||||
backportEvidence: null,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends a merge event with optional backport evidence for audit.
|
||||
/// </summary>
|
||||
public async Task<MergeEventRecord> AppendAsync(
|
||||
string advisoryKey,
|
||||
Advisory? before,
|
||||
Advisory after,
|
||||
IReadOnlyList<Guid> inputDocumentIds,
|
||||
IReadOnlyList<MergeFieldDecision>? fieldDecisions,
|
||||
IReadOnlyList<BackportEvidence>? backportEvidence,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(advisoryKey);
|
||||
ArgumentNullException.ThrowIfNull(after);
|
||||
@@ -44,6 +67,9 @@ public sealed class MergeEventWriter
|
||||
var timestamp = _timeProvider.GetUtcNow();
|
||||
var documentIds = inputDocumentIds?.ToArray() ?? Array.Empty<Guid>();
|
||||
|
||||
// Convert backport evidence to audit decisions
|
||||
var evidenceDecisions = ConvertToAuditDecisions(backportEvidence);
|
||||
|
||||
var record = new MergeEventRecord(
|
||||
Guid.NewGuid(),
|
||||
advisoryKey,
|
||||
@@ -51,7 +77,8 @@ public sealed class MergeEventWriter
|
||||
afterHash,
|
||||
timestamp,
|
||||
documentIds,
|
||||
fieldDecisions ?? Array.Empty<MergeFieldDecision>());
|
||||
fieldDecisions ?? Array.Empty<MergeFieldDecision>(),
|
||||
evidenceDecisions);
|
||||
|
||||
if (!CryptographicOperations.FixedTimeEquals(beforeHash, afterHash))
|
||||
{
|
||||
@@ -66,7 +93,34 @@ public sealed class MergeEventWriter
|
||||
_logger.LogInformation("Merge event for {AdvisoryKey} recorded without hash change", advisoryKey);
|
||||
}
|
||||
|
||||
if (evidenceDecisions is { Count: > 0 })
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Merge event for {AdvisoryKey} includes {Count} backport evidence decision(s)",
|
||||
advisoryKey,
|
||||
evidenceDecisions.Count);
|
||||
}
|
||||
|
||||
await _mergeEventStore.AppendAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
return record;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<BackportEvidenceDecision>? ConvertToAuditDecisions(
|
||||
IReadOnlyList<BackportEvidence>? evidence)
|
||||
{
|
||||
if (evidence is null || evidence.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return evidence.Select(e => new BackportEvidenceDecision(
|
||||
e.CveId,
|
||||
e.DistroRelease,
|
||||
e.Tier.ToString(),
|
||||
e.Confidence,
|
||||
e.PatchId,
|
||||
e.PatchOrigin.ToString(),
|
||||
e.ProofId,
|
||||
e.EvidenceDate)).ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
<ProjectReference Include="../StellaOps.Concelier.Core/StellaOps.Concelier.Core.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Concelier.Normalization/StellaOps.Concelier.Normalization.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Concelier.ProofService/StellaOps.Concelier.ProofService.csproj" />
|
||||
<ProjectReference Include="../../../Attestor/__Libraries/StellaOps.Attestor.ProofChain/StellaOps.Attestor.ProofChain.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Messaging/StellaOps.Messaging.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Provcache/StellaOps.Provcache.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.VersionComparison/StellaOps.VersionComparison.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user