save dev progress

This commit is contained in:
StellaOps Bot
2025-12-26 00:32:35 +02:00
parent aa70af062e
commit ed3079543c
142 changed files with 23771 additions and 232 deletions

View File

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

View File

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

View File

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

View File

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

View File

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