UI work to fill SBOM sourcing management gap. UI planning remaining functionality exposure. Work on CI/Tests stabilization

Introduces CGS determinism test runs to CI workflows for Windows, macOS, Linux, Alpine, and Debian, fulfilling CGS-008 cross-platform requirements. Updates local-ci scripts to support new smoke steps, test timeouts, progress intervals, and project slicing for improved test isolation and diagnostics.
This commit is contained in:
master
2025-12-29 19:12:38 +02:00
parent 41552d26ec
commit a4badc275e
286 changed files with 50918 additions and 992 deletions

View File

@@ -0,0 +1,124 @@
// -----------------------------------------------------------------------------
// FixRuleModels.cs
// Sprint: SPRINT_20251229_004_002_BE_backport_status_service (BP-001)
// Task: Define Fix Rule types
// -----------------------------------------------------------------------------
namespace StellaOps.Concelier.BackportProof.Models;
/// <summary>
/// Product context key for rule matching.
/// </summary>
public sealed record ProductContext(
string Distro, // e.g., "debian", "alpine", "rhel"
string Release, // e.g., "bookworm", "3.19", "9"
string? RepoScope, // e.g., "main", "security"
string? Architecture);
/// <summary>
/// Package identity for rule matching.
/// </summary>
public sealed record PackageKey(
PackageEcosystem Ecosystem, // rpm, deb, apk
string PackageName,
string? SourcePackageName);
/// <summary>
/// Package ecosystem types.
/// </summary>
public enum PackageEcosystem
{
Deb,
Rpm,
Apk,
Unknown
}
/// <summary>
/// Base class for fix rules.
/// </summary>
public abstract record FixRule
{
public required string RuleId { get; init; }
public required string Cve { get; init; }
public required ProductContext Context { get; init; }
public required PackageKey Package { get; init; }
public required RulePriority Priority { get; init; }
public required decimal Confidence { get; init; }
public required EvidencePointer Evidence { get; init; }
}
/// <summary>
/// CVE is fixed at a specific version boundary.
/// </summary>
public sealed record BoundaryRule : FixRule
{
public required string FixedVersion { get; init; }
}
/// <summary>
/// CVE affects a version range.
/// </summary>
public sealed record RangeRule : FixRule
{
public required VersionRange AffectedRange { get; init; }
}
/// <summary>
/// CVE status determined by exact binary build.
/// </summary>
public sealed record BuildDigestRule : FixRule
{
public required string BuildDigest { get; init; } // sha256 of binary
public required string? BuildId { get; init; } // ELF build-id
public required FixStatus Status { get; init; }
}
/// <summary>
/// Explicit status without version boundary.
/// </summary>
public sealed record StatusRule : FixRule
{
public required FixStatus Status { get; init; }
}
/// <summary>
/// Version range specification.
/// </summary>
public sealed record VersionRange(
string? MinVersion,
bool MinInclusive,
string? MaxVersion,
bool MaxInclusive);
/// <summary>
/// Evidence pointer to source document.
/// </summary>
public sealed record EvidencePointer(
string SourceType, // e.g., "debian-tracker", "alpine-secdb"
string SourceUrl,
string? SourceDigest, // Snapshot hash for replay
DateTimeOffset FetchedAt);
/// <summary>
/// Fix status values.
/// </summary>
public enum FixStatus
{
Patched,
Vulnerable,
NotAffected,
WontFix,
UnderInvestigation,
Unknown
}
/// <summary>
/// Rule priority levels.
/// </summary>
public enum RulePriority
{
DistroNative = 100, // Highest - from distro's own security tracker
VendorCsaf = 90, // Vendor CSAF/VEX
ThirdParty = 50 // Lowest - inferred or community
}

View File

@@ -0,0 +1,58 @@
// -----------------------------------------------------------------------------
// IFixRuleRepository.cs
// Sprint: SPRINT_20251229_004_002_BE_backport_status_service (BP-002)
// Task: Create IFixRuleRepository interface
// -----------------------------------------------------------------------------
using StellaOps.Concelier.BackportProof.Models;
namespace StellaOps.Concelier.BackportProof.Repositories;
/// <summary>
/// Repository for fix rules indexed by distro/package/CVE.
/// </summary>
public interface IFixRuleRepository
{
/// <summary>
/// Get fix rules for a specific context, package, and CVE.
/// </summary>
/// <param name="context">Product context (distro, release).</param>
/// <param name="package">Package key.</param>
/// <param name="cve">CVE identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of applicable fix rules.</returns>
ValueTask<IReadOnlyList<FixRule>> GetRulesAsync(
ProductContext context,
PackageKey package,
string cve,
CancellationToken ct = default);
/// <summary>
/// Get all rules for a CVE across all packages/contexts.
/// </summary>
ValueTask<IReadOnlyList<FixRule>> GetRulesByCveAsync(
string cve,
CancellationToken ct = default);
/// <summary>
/// Add or update a fix rule.
/// </summary>
ValueTask<FixRule> UpsertAsync(
FixRule rule,
CancellationToken ct = default);
/// <summary>
/// Batch insert fix rules (for bulk imports from extractors).
/// </summary>
ValueTask BatchUpsertAsync(
IReadOnlyList<FixRule> rules,
CancellationToken ct = default);
/// <summary>
/// Delete rules from a specific evidence source (for refresh).
/// </summary>
ValueTask DeleteBySourceAsync(
string sourceType,
DateTimeOffset olderThan,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,241 @@
// -----------------------------------------------------------------------------
// BackportStatusService.cs
// Sprint: SPRINT_20251229_004_002_BE_backport_status_service (BP-007)
// Task: Implement BackportStatusService.EvalPatchedStatus()
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using StellaOps.Concelier.BackportProof.Models;
using StellaOps.Concelier.BackportProof.Repositories;
namespace StellaOps.Concelier.BackportProof.Services;
/// <summary>
/// Implementation of backport status evaluation service.
/// Uses deterministic algorithm to compute patch status from fix rules.
/// </summary>
public sealed class BackportStatusService : IBackportStatusService
{
private readonly IFixRuleRepository _ruleRepository;
private readonly ILogger<BackportStatusService> _logger;
public BackportStatusService(
IFixRuleRepository ruleRepository,
ILogger<BackportStatusService> logger)
{
_ruleRepository = ruleRepository;
_logger = logger;
}
public async ValueTask<BackportVerdict> EvalPatchedStatusAsync(
ProductContext context,
InstalledPackage package,
string cve,
CancellationToken ct = default)
{
_logger.LogDebug(
"Evaluating patch status for {Distro}/{Release} {Package} {Version} {CVE}",
context.Distro, context.Release, package.Key.PackageName, package.InstalledVersion, cve);
// Fetch applicable rules
var rules = await _ruleRepository.GetRulesAsync(context, package.Key, cve, ct);
// Also fetch rules for source package if different
if (!string.IsNullOrWhiteSpace(package.SourcePackage) &&
package.SourcePackage != package.Key.PackageName)
{
var sourceKey = package.Key with { PackageName = package.SourcePackage };
var sourceRules = await _ruleRepository.GetRulesAsync(context, sourceKey, cve, ct);
rules = rules.Concat(sourceRules).ToList();
}
if (rules.Count == 0)
{
_logger.LogDebug("No fix rules found for {CVE}, returning Unknown", cve);
return new BackportVerdict(
Cve: cve,
Status: FixStatus.Unknown,
Confidence: VerdictConfidence.Low,
AppliedRuleIds: [],
Evidence: [],
HasConflict: false,
ConflictReason: null
);
}
// Apply evaluation algorithm
return EvaluateRules(cve, package, rules);
}
public async ValueTask<IReadOnlyDictionary<string, BackportVerdict>> EvalBatchAsync(
ProductContext context,
InstalledPackage package,
IReadOnlyList<string> cves,
CancellationToken ct = default)
{
var results = new Dictionary<string, BackportVerdict>();
foreach (var cve in cves)
{
var verdict = await EvalPatchedStatusAsync(context, package, cve, ct);
results[cve] = verdict;
}
return results;
}
/// <summary>
/// Core evaluation algorithm implementing deterministic verdict logic.
/// Algorithm:
/// 1. Not-affected wins immediately (highest priority)
/// 2. Exact build digest match
/// 3. Evaluate boundary rules with conflict detection
/// 4. Evaluate range rules
/// 5. Fallback to Unknown
/// </summary>
private BackportVerdict EvaluateRules(
string cve,
InstalledPackage package,
IReadOnlyList<FixRule> rules)
{
// Step 1: Check for not-affected status (highest priority)
var notAffectedRules = rules
.OfType<StatusRule>()
.Where(r => r.Status == FixStatus.NotAffected)
.OrderByDescending(r => r.Priority)
.ToList();
if (notAffectedRules.Count > 0)
{
var topRule = notAffectedRules[0];
_logger.LogDebug("CVE {CVE} marked as NotAffected by rule {RuleId}", cve, topRule.RuleId);
return new BackportVerdict(
Cve: cve,
Status: FixStatus.NotAffected,
Confidence: VerdictConfidence.High,
AppliedRuleIds: [topRule.RuleId],
Evidence: [topRule.Evidence],
HasConflict: false,
ConflictReason: null
);
}
// Step 2: Check build digest match
if (!string.IsNullOrWhiteSpace(package.BuildDigest))
{
var digestRules = rules
.OfType<BuildDigestRule>()
.Where(r => r.BuildDigest.Equals(package.BuildDigest, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(r => r.Priority)
.ToList();
if (digestRules.Count > 0)
{
var topRule = digestRules[0];
_logger.LogDebug("Build digest match for {CVE}: {Status}", cve, topRule.Status);
return new BackportVerdict(
Cve: cve,
Status: topRule.Status,
Confidence: VerdictConfidence.High,
AppliedRuleIds: [topRule.RuleId],
Evidence: [topRule.Evidence],
HasConflict: false,
ConflictReason: null
);
}
}
// Step 3: Evaluate boundary rules
var boundaryRules = rules
.OfType<BoundaryRule>()
.OrderByDescending(r => r.Priority)
.ToList();
if (boundaryRules.Count > 0)
{
return EvaluateBoundaryRules(cve, package, boundaryRules);
}
// Step 4: Evaluate range rules
var rangeRules = rules.OfType<RangeRule>().ToList();
if (rangeRules.Count > 0)
{
return EvaluateRangeRules(cve, package, rangeRules);
}
// Step 5: Fallback to unknown
_logger.LogDebug("No applicable rules for {CVE}, returning Unknown", cve);
return new BackportVerdict(
Cve: cve,
Status: FixStatus.Unknown,
Confidence: VerdictConfidence.Low,
AppliedRuleIds: [],
Evidence: [],
HasConflict: false,
ConflictReason: null
);
}
private BackportVerdict EvaluateBoundaryRules(
string cve,
InstalledPackage package,
IReadOnlyList<BoundaryRule> rules)
{
// Get highest priority rules
var topPriority = rules.Max(r => r.Priority);
var topRules = rules.Where(r => r.Priority == topPriority).ToList();
// Check for conflicts (multiple different fix versions at same priority)
var distinctFixVersions = topRules.Select(r => r.FixedVersion).Distinct().ToList();
var hasConflict = distinctFixVersions.Count > 1;
// For now, use simple string comparison
// TODO: Integrate proper version comparators (EVR, dpkg, apk, semver)
var fixedVersion = hasConflict
? distinctFixVersions.Max() // Conservative: use highest version
: distinctFixVersions[0];
var isPatched = string.Compare(package.InstalledVersion, fixedVersion, StringComparison.Ordinal) >= 0;
var status = isPatched ? FixStatus.Patched : FixStatus.Vulnerable;
var confidence = hasConflict ? VerdictConfidence.Medium : VerdictConfidence.High;
_logger.LogDebug(
"Boundary evaluation for {CVE}: installed={Installed}, fixed={Fixed}, status={Status}",
cve, package.InstalledVersion, fixedVersion, status);
return new BackportVerdict(
Cve: cve,
Status: status,
Confidence: confidence,
AppliedRuleIds: topRules.Select(r => r.RuleId).ToList(),
Evidence: topRules.Select(r => r.Evidence).ToList(),
HasConflict: hasConflict,
ConflictReason: hasConflict
? $"Multiple fix versions at priority {topPriority}: {string.Join(", ", distinctFixVersions)}"
: null
);
}
private BackportVerdict EvaluateRangeRules(
string cve,
InstalledPackage package,
IReadOnlyList<RangeRule> rules)
{
// Check if installed version is in any affected range
// TODO: Implement proper range checking with version comparators
// For now, return Unknown with medium confidence
_logger.LogDebug("Range rules found for {CVE}, but not yet implemented", cve);
return new BackportVerdict(
Cve: cve,
Status: FixStatus.Unknown,
Confidence: VerdictConfidence.Medium,
AppliedRuleIds: rules.Select(r => r.RuleId).ToList(),
Evidence: rules.Select(r => r.Evidence).ToList(),
HasConflict: false,
ConflictReason: "Range evaluation not fully implemented"
);
}
}

View File

@@ -0,0 +1,353 @@
// -----------------------------------------------------------------------------
// FixIndexService.cs
// Sprint: SPRINT_20251229_004_002_BE_backport_status_service (BP-006)
// Task: Implement FixIndex snapshot service
// -----------------------------------------------------------------------------
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Concelier.BackportProof.Models;
using StellaOps.Concelier.BackportProof.Repositories;
namespace StellaOps.Concelier.BackportProof.Services;
/// <summary>
/// Implementation of fix rule index service with in-memory snapshots.
/// Provides O(1) lookups indexed by (distro, release, package, CVE).
/// </summary>
public sealed class FixIndexService : IFixIndexService
{
private readonly IFixRuleRepository _repository;
private readonly ILogger<FixIndexService> _logger;
// Active in-memory index
private FixIndexState? _activeIndex;
private readonly object _indexLock = new();
// Snapshot storage (in production, this would be PostgreSQL or blob storage)
private readonly ConcurrentDictionary<string, FixIndexState> _snapshots = new();
public FixIndexService(
IFixRuleRepository repository,
ILogger<FixIndexService> logger)
{
_repository = repository;
_logger = logger;
}
public ValueTask<string?> GetActiveSnapshotIdAsync(CancellationToken ct = default)
{
lock (_indexLock)
{
return ValueTask.FromResult(_activeIndex?.Snapshot.SnapshotId);
}
}
public async ValueTask<FixIndexSnapshot> CreateSnapshotAsync(
string sourceLabel,
CancellationToken ct = default)
{
_logger.LogInformation("Creating fix index snapshot: {Label}", sourceLabel);
var startTime = DateTimeOffset.UtcNow;
// Load all rules from repository
// In a real implementation, this would need pagination for large datasets
var allRules = new List<FixRule>();
// For now, we'll need to implement a GetAllRulesAsync method or iterate through CVEs
// This is a simplified implementation that assumes a method to get all rules
// In production, you'd want batched loading
// Build the index
var index = BuildIndex(allRules);
// Generate snapshot ID and digest
var snapshotId = $"fix-index-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}";
var digest = ComputeIndexDigest(allRules);
var snapshot = new FixIndexSnapshot(
SnapshotId: snapshotId,
SourceLabel: sourceLabel,
CreatedAt: startTime,
RuleCount: allRules.Count,
IndexDigest: digest);
var indexState = new FixIndexState(
Snapshot: snapshot,
Index: index,
Rules: allRules);
// Store snapshot
_snapshots[snapshotId] = indexState;
var elapsed = DateTimeOffset.UtcNow - startTime;
_logger.LogInformation(
"Created snapshot {SnapshotId} with {Count} rules in {Elapsed}ms",
snapshotId, allRules.Count, elapsed.TotalMilliseconds);
return snapshot;
}
public ValueTask ActivateSnapshotAsync(string snapshotId, CancellationToken ct = default)
{
if (!_snapshots.TryGetValue(snapshotId, out var indexState))
{
throw new InvalidOperationException($"Snapshot not found: {snapshotId}");
}
lock (_indexLock)
{
_activeIndex = indexState;
}
_logger.LogInformation("Activated snapshot {SnapshotId}", snapshotId);
return ValueTask.CompletedTask;
}
public ValueTask<IReadOnlyList<FixRule>> LookupAsync(
ProductContext context,
PackageKey package,
string cve,
CancellationToken ct = default)
{
FixIndexState? index;
lock (_indexLock)
{
index = _activeIndex;
}
if (index == null)
{
_logger.LogWarning("No active index snapshot, returning empty results");
return ValueTask.FromResult<IReadOnlyList<FixRule>>(Array.Empty<FixRule>());
}
var contextKey = new ContextKey(context);
var packageKey = new IndexPackageKey(package);
if (index.Index.TryGetValue(contextKey, out var packageIndex) &&
packageIndex.TryGetValue(packageKey, out var cveIndex) &&
cveIndex.TryGetValue(cve, out var rules))
{
return ValueTask.FromResult<IReadOnlyList<FixRule>>(rules);
}
return ValueTask.FromResult<IReadOnlyList<FixRule>>(Array.Empty<FixRule>());
}
public ValueTask<IReadOnlyList<FixRule>> LookupByPackageAsync(
ProductContext context,
PackageKey package,
CancellationToken ct = default)
{
FixIndexState? index;
lock (_indexLock)
{
index = _activeIndex;
}
if (index == null)
{
return ValueTask.FromResult<IReadOnlyList<FixRule>>(Array.Empty<FixRule>());
}
var contextKey = new ContextKey(context);
var packageKey = new IndexPackageKey(package);
if (index.Index.TryGetValue(contextKey, out var packageIndex) &&
packageIndex.TryGetValue(packageKey, out var cveIndex))
{
var allRules = cveIndex.Values.SelectMany(r => r).ToList();
return ValueTask.FromResult<IReadOnlyList<FixRule>>(allRules);
}
return ValueTask.FromResult<IReadOnlyList<FixRule>>(Array.Empty<FixRule>());
}
public ValueTask<IReadOnlyList<FixIndexSnapshotInfo>> ListSnapshotsAsync(
CancellationToken ct = default)
{
string? activeId;
lock (_indexLock)
{
activeId = _activeIndex?.Snapshot.SnapshotId;
}
var snapshots = _snapshots.Values
.Select(s => new FixIndexSnapshotInfo(
SnapshotId: s.Snapshot.SnapshotId,
SourceLabel: s.Snapshot.SourceLabel,
CreatedAt: s.Snapshot.CreatedAt,
RuleCount: s.Snapshot.RuleCount,
SizeBytes: EstimateSize(s),
IsActive: s.Snapshot.SnapshotId == activeId))
.OrderByDescending(s => s.CreatedAt)
.ToList();
return ValueTask.FromResult<IReadOnlyList<FixIndexSnapshotInfo>>(snapshots);
}
public ValueTask PruneOldSnapshotsAsync(int keepCount, CancellationToken ct = default)
{
var snapshots = _snapshots.Values
.OrderByDescending(s => s.Snapshot.CreatedAt)
.ToList();
if (snapshots.Count <= keepCount)
{
return ValueTask.CompletedTask;
}
var toRemove = snapshots.Skip(keepCount).ToList();
foreach (var snapshot in toRemove)
{
_snapshots.TryRemove(snapshot.Snapshot.SnapshotId, out _);
_logger.LogInformation("Pruned old snapshot {SnapshotId}", snapshot.Snapshot.SnapshotId);
}
return ValueTask.CompletedTask;
}
public ValueTask<FixIndexStats> GetStatsAsync(
string? snapshotId = null,
CancellationToken ct = default)
{
FixIndexState? index;
if (snapshotId != null)
{
_snapshots.TryGetValue(snapshotId, out index);
}
else
{
lock (_indexLock)
{
index = _activeIndex;
}
}
if (index == null)
{
return ValueTask.FromResult(new FixIndexStats(
TotalRules: 0,
UniqueCves: 0,
UniquePackages: 0,
UniqueDistros: 0,
RulesByDistro: new Dictionary<string, int>(),
RulesByPriority: new Dictionary<RulePriority, int>(),
RulesByType: new Dictionary<string, int>()));
}
var rules = index.Rules;
var stats = new FixIndexStats(
TotalRules: rules.Count,
UniqueCves: rules.Select(r => r.Cve).Distinct().Count(),
UniquePackages: rules.Select(r => r.Package.PackageName).Distinct().Count(),
UniqueDistros: rules.Select(r => r.Context.Distro).Distinct().Count(),
RulesByDistro: rules
.GroupBy(r => r.Context.Distro)
.ToDictionary(g => g.Key, g => g.Count()),
RulesByPriority: rules
.GroupBy(r => r.Priority)
.ToDictionary(g => g.Key, g => g.Count()),
RulesByType: rules
.GroupBy(r => r.GetType().Name)
.ToDictionary(g => g.Key, g => g.Count()));
return ValueTask.FromResult(stats);
}
#region Private Helper Methods
private static Dictionary<ContextKey, Dictionary<IndexPackageKey, Dictionary<string, List<FixRule>>>> BuildIndex(
IReadOnlyList<FixRule> rules)
{
var index = new Dictionary<ContextKey, Dictionary<IndexPackageKey, Dictionary<string, List<FixRule>>>>();
foreach (var rule in rules)
{
var contextKey = new ContextKey(rule.Context);
var packageKey = new IndexPackageKey(rule.Package);
var cve = rule.Cve;
if (!index.TryGetValue(contextKey, out var packageIndex))
{
packageIndex = new Dictionary<IndexPackageKey, Dictionary<string, List<FixRule>>>();
index[contextKey] = packageIndex;
}
if (!packageIndex.TryGetValue(packageKey, out var cveIndex))
{
cveIndex = new Dictionary<string, List<FixRule>>(StringComparer.OrdinalIgnoreCase);
packageIndex[packageKey] = cveIndex;
}
if (!cveIndex.TryGetValue(cve, out var ruleList))
{
ruleList = new List<FixRule>();
cveIndex[cve] = ruleList;
}
ruleList.Add(rule);
}
return index;
}
private static string ComputeIndexDigest(IReadOnlyList<FixRule> rules)
{
// Sort rule IDs for deterministic digest
var sortedIds = rules
.Select(r => r.RuleId)
.OrderBy(id => id, StringComparer.Ordinal)
.ToList();
var json = JsonSerializer.Serialize(sortedIds);
var bytes = Encoding.UTF8.GetBytes(json);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static long EstimateSize(FixIndexState state)
{
// Rough estimate: 500 bytes per rule
return state.Rules.Count * 500L;
}
#endregion
#region Index Keys
private readonly record struct ContextKey(string Distro, string Release, string? RepoScope, string? Architecture)
{
public ContextKey(ProductContext context)
: this(context.Distro, context.Release, context.RepoScope, context.Architecture)
{
}
}
private readonly record struct IndexPackageKey(PackageEcosystem Ecosystem, string PackageName)
{
public IndexPackageKey(PackageKey package)
: this(package.Ecosystem, package.PackageName)
{
}
}
#endregion
#region Internal State
private sealed record FixIndexState(
FixIndexSnapshot Snapshot,
Dictionary<ContextKey, Dictionary<IndexPackageKey, Dictionary<string, List<FixRule>>>> Index,
IReadOnlyList<FixRule> Rules);
#endregion
}

View File

@@ -0,0 +1,88 @@
// -----------------------------------------------------------------------------
// IBackportStatusService.cs
// Sprint: SPRINT_20251229_004_002_BE_backport_status_service (BP-007)
// Task: Create BackportStatusService interface
// -----------------------------------------------------------------------------
using StellaOps.Concelier.BackportProof.Models;
namespace StellaOps.Concelier.BackportProof.Services;
/// <summary>
/// Service for evaluating backport patch status with deterministic verdicts.
/// </summary>
public interface IBackportStatusService
{
/// <summary>
/// Evaluate patched status for a package installation.
/// Implements deterministic algorithm with evidence chain.
/// </summary>
/// <param name="context">Product context (distro, release).</param>
/// <param name="package">Installed package details.</param>
/// <param name="cve">CVE identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Backport verdict with status, confidence, and evidence.</returns>
ValueTask<BackportVerdict> EvalPatchedStatusAsync(
ProductContext context,
InstalledPackage package,
string cve,
CancellationToken ct = default);
/// <summary>
/// Batch evaluate patch status for multiple CVEs.
/// More efficient than calling EvalPatchedStatusAsync multiple times.
/// </summary>
/// <param name="context">Product context.</param>
/// <param name="package">Installed package.</param>
/// <param name="cves">List of CVEs to check.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Dictionary of CVE to verdict.</returns>
ValueTask<IReadOnlyDictionary<string, BackportVerdict>> EvalBatchAsync(
ProductContext context,
InstalledPackage package,
IReadOnlyList<string> cves,
CancellationToken ct = default);
}
/// <summary>
/// Installed package details for status evaluation.
/// </summary>
public sealed record InstalledPackage(
PackageKey Key,
string InstalledVersion,
string? BuildDigest,
string? BuildId,
string? SourcePackage);
/// <summary>
/// Backport patch status verdict.
/// </summary>
public sealed record BackportVerdict(
string Cve,
FixStatus Status,
VerdictConfidence Confidence,
IReadOnlyList<string> AppliedRuleIds,
IReadOnlyList<EvidencePointer> Evidence,
bool HasConflict,
string? ConflictReason);
/// <summary>
/// Verdict confidence levels.
/// </summary>
public enum VerdictConfidence
{
/// <summary>
/// Low confidence - heuristic or fallback.
/// </summary>
Low,
/// <summary>
/// Medium confidence - inferred from range or fingerprint.
/// </summary>
Medium,
/// <summary>
/// High confidence - explicit advisory or boundary.
/// </summary>
High
}

View File

@@ -0,0 +1,109 @@
// -----------------------------------------------------------------------------
// IFixIndexService.cs
// Sprint: SPRINT_20251229_004_002_BE_backport_status_service (BP-006)
// Task: Create FixIndex snapshot service
// -----------------------------------------------------------------------------
using StellaOps.Concelier.BackportProof.Models;
namespace StellaOps.Concelier.BackportProof.Services;
/// <summary>
/// Service for managing fix rule index snapshots.
/// Provides fast in-memory lookups indexed by (distro, release, package).
/// </summary>
public interface IFixIndexService
{
/// <summary>
/// Get the current active snapshot ID.
/// </summary>
ValueTask<string?> GetActiveSnapshotIdAsync(CancellationToken ct = default);
/// <summary>
/// Create a new snapshot from current repository state.
/// </summary>
/// <param name="sourceLabel">Label for snapshot (e.g., "debian-2025-12-29")</param>
/// <param name="ct">Cancellation token</param>
/// <returns>Snapshot ID</returns>
ValueTask<FixIndexSnapshot> CreateSnapshotAsync(
string sourceLabel,
CancellationToken ct = default);
/// <summary>
/// Load a snapshot into active memory.
/// </summary>
ValueTask ActivateSnapshotAsync(
string snapshotId,
CancellationToken ct = default);
/// <summary>
/// Fast lookup of rules for a specific context/package/CVE.
/// Uses active in-memory snapshot.
/// </summary>
ValueTask<IReadOnlyList<FixRule>> LookupAsync(
ProductContext context,
PackageKey package,
string cve,
CancellationToken ct = default);
/// <summary>
/// Get all rules for a package across all CVEs.
/// </summary>
ValueTask<IReadOnlyList<FixRule>> LookupByPackageAsync(
ProductContext context,
PackageKey package,
CancellationToken ct = default);
/// <summary>
/// List available snapshots.
/// </summary>
ValueTask<IReadOnlyList<FixIndexSnapshotInfo>> ListSnapshotsAsync(
CancellationToken ct = default);
/// <summary>
/// Delete old snapshots (retention policy).
/// </summary>
ValueTask PruneOldSnapshotsAsync(
int keepCount,
CancellationToken ct = default);
/// <summary>
/// Get snapshot statistics.
/// </summary>
ValueTask<FixIndexStats> GetStatsAsync(
string? snapshotId = null,
CancellationToken ct = default);
}
/// <summary>
/// Snapshot of fix rule index at a point in time.
/// </summary>
public sealed record FixIndexSnapshot(
string SnapshotId,
string SourceLabel,
DateTimeOffset CreatedAt,
int RuleCount,
string IndexDigest); // SHA-256 of sorted rule IDs for integrity
/// <summary>
/// Snapshot metadata for listing.
/// </summary>
public sealed record FixIndexSnapshotInfo(
string SnapshotId,
string SourceLabel,
DateTimeOffset CreatedAt,
int RuleCount,
long SizeBytes,
bool IsActive);
/// <summary>
/// Statistics about fix index content.
/// </summary>
public sealed record FixIndexStats(
int TotalRules,
int UniqueCves,
int UniquePackages,
int UniqueDistros,
IReadOnlyDictionary<string, int> RulesByDistro,
IReadOnlyDictionary<RulePriority, int> RulesByPriority,
IReadOnlyDictionary<string, int> RulesByType); // BoundaryRule, RangeRule, etc.

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Concelier.BackportProof</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj" />
</ItemGroup>
</Project>

View File

@@ -9,6 +9,7 @@ using StellaOps.Concelier.Connector.Common.Xml;
using StellaOps.Concelier.Core.Aoc;
using StellaOps.Concelier.Core.Linksets;
using StellaOps.Concelier.Storage;
using StellaOps.Cryptography;
namespace StellaOps.Concelier.Connector.Common.Http;
@@ -168,6 +169,10 @@ public static class ServiceCollectionExtensions
services.AddSingleton<XmlSchemaValidator>();
services.AddSingleton<IXmlSchemaValidator>(sp => sp.GetRequiredService<XmlSchemaValidator>());
services.AddSingleton<Fetch.IJitterSource, Fetch.CryptoJitterSource>();
services.TryAddSingleton<TimeProvider>(TimeProvider.System);
services.AddOptions<StorageOptions>();
services.AddOptions<CryptoHashOptions>();
services.TryAddSingleton<ICryptoHash, DefaultCryptoHash>();
services.AddConcelierAocGuards();
services.AddConcelierLinksetMappers();
services.TryAddScoped<IDocumentStore, InMemoryDocumentStore>();

View File

@@ -132,6 +132,7 @@ public static class CanonicalJsonSerializer
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
PropertyNameCaseInsensitive = true,
WriteIndented = writeIndented,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};

View File

@@ -2,8 +2,6 @@
-- Consolidated from migrations 001-017 (pre_1.0 archived)
-- Creates the complete vuln and concelier schemas for vulnerability advisory management
BEGIN;
-- ============================================================================
-- SECTION 1: Schema and Extension Creation
-- ============================================================================
@@ -44,6 +42,14 @@ BEGIN
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION vuln.sync_advisory_provenance_ingested_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.provenance_ingested_at = NULLIF(NEW.provenance->>'ingested_at', '')::TIMESTAMPTZ;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- SECTION 3: Core vuln Tables
-- ============================================================================
@@ -118,7 +124,7 @@ CREATE TABLE IF NOT EXISTS vuln.advisories (
-- Generated columns for provenance
provenance_source_key TEXT GENERATED ALWAYS AS (provenance->>'source_key') STORED,
provenance_feed_id TEXT GENERATED ALWAYS AS (provenance->>'feed_id') STORED,
provenance_ingested_at TIMESTAMPTZ GENERATED ALWAYS AS ((provenance->>'ingested_at')::TIMESTAMPTZ) STORED
provenance_ingested_at TIMESTAMPTZ
);
CREATE INDEX idx_advisories_vuln_id ON vuln.advisories(primary_vuln_id);
@@ -136,6 +142,10 @@ CREATE TRIGGER trg_advisories_search_vector
BEFORE INSERT OR UPDATE ON vuln.advisories
FOR EACH ROW EXECUTE FUNCTION vuln.update_advisory_search_vector();
CREATE TRIGGER trg_advisories_provenance_ingested_at
BEFORE INSERT OR UPDATE OF provenance ON vuln.advisories
FOR EACH ROW EXECUTE FUNCTION vuln.sync_advisory_provenance_ingested_at();
CREATE TRIGGER trg_advisories_updated_at
BEFORE UPDATE ON vuln.advisories
FOR EACH ROW EXECUTE FUNCTION vuln.update_updated_at();
@@ -725,4 +735,3 @@ AS $$
WHERE cve LIKE 'CVE-' || p_year::TEXT || '-%' AND status = 'active';
$$;
COMMIT;

View File

@@ -33,9 +33,17 @@ public sealed class PostgresAdvisoryStore : IPostgresAdvisoryStore, AdvisoryCont
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
WriteIndented = false
};
private static readonly JsonSerializerOptions RawPayloadOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
public PostgresAdvisoryStore(
IAdvisoryRepository advisoryRepository,
IAdvisoryAliasRepository aliasRepository,
@@ -186,13 +194,23 @@ public sealed class PostgresAdvisoryStore : IPostgresAdvisoryStore, AdvisoryCont
{
try
{
var advisory = JsonSerializer.Deserialize<Advisory>(entity.RawPayload, JsonOptions);
var advisory = CanonicalJsonSerializer.Deserialize<Advisory>(entity.RawPayload);
return advisory;
}
catch (Exception ex) when (ex is JsonException or InvalidOperationException)
{
_logger.LogWarning(ex, "Failed to deserialize raw payload for advisory {AdvisoryKey}, attempting fallback JSON parse", entity.AdvisoryKey);
}
try
{
var advisory = JsonSerializer.Deserialize<Advisory>(entity.RawPayload, RawPayloadOptions);
if (advisory is not null)
{
return advisory;
}
}
catch (JsonException ex)
catch (Exception ex) when (ex is JsonException or InvalidOperationException)
{
_logger.LogWarning(ex, "Failed to deserialize raw payload for advisory {AdvisoryKey}, reconstructing from entities", entity.AdvisoryKey);
}

View File

@@ -370,7 +370,7 @@ public sealed class AdvisoryRepository : RepositoryBase<ConcelierDataSource>, IA
)
VALUES (
@id, @advisory_key, @primary_vuln_id, @source_id, @title, @summary, @description,
@severity, @published_at, @modified_at, @withdrawn_at, @provenance::jsonb, @raw_Payload::jsonb
@severity, @published_at, @modified_at, @withdrawn_at, @provenance::jsonb, @raw_payload::jsonb
)
ON CONFLICT (advisory_key) DO UPDATE SET
primary_vuln_id = EXCLUDED.primary_vuln_id,

View File

@@ -58,7 +58,7 @@ INSERT INTO concelier.source_documents (
headers_json, metadata_json, etag, last_modified, payload, created_at, updated_at, expires_at)
VALUES (
@Id, @SourceId, @SourceName, @Uri, @Sha256, @Status, @ContentType,
@HeadersJson, @MetadataJson, @Etag, @LastModified, @Payload, @CreatedAt, @UpdatedAt, @ExpiresAt)
@HeadersJson::jsonb, @MetadataJson::jsonb, @Etag, @LastModified, @Payload, @CreatedAt, @UpdatedAt, @ExpiresAt)
ON CONFLICT (source_name, uri) DO UPDATE SET
sha256 = EXCLUDED.sha256,
status = EXCLUDED.status,

View File

@@ -24,14 +24,22 @@ internal sealed class PostgresDtoStore : IDtoStore, Contracts.IStorageDtoStore
{
const string sql = """
INSERT INTO concelier.dtos (id, document_id, source_name, format, payload_json, schema_version, created_at, validated_at)
VALUES (@Id, @DocumentId, @SourceName, @Format, @PayloadJson, @SchemaVersion, @CreatedAt, @ValidatedAt)
VALUES (@Id, @DocumentId, @SourceName, @Format, @PayloadJson::jsonb, @SchemaVersion, @CreatedAt, @ValidatedAt)
ON CONFLICT (document_id) DO UPDATE
SET payload_json = EXCLUDED.payload_json,
schema_version = EXCLUDED.schema_version,
source_name = EXCLUDED.source_name,
format = EXCLUDED.format,
validated_at = EXCLUDED.validated_at
RETURNING id, document_id, source_name, format, payload_json, schema_version, created_at, validated_at;
RETURNING
id AS "Id",
document_id AS "DocumentId",
source_name AS "SourceName",
format AS "Format",
payload_json::text AS "PayloadJson",
schema_version AS "SchemaVersion",
created_at AS "CreatedAt",
validated_at AS "ValidatedAt";
""";
var payloadJson = record.Payload.ToJson();
@@ -55,7 +63,15 @@ internal sealed class PostgresDtoStore : IDtoStore, Contracts.IStorageDtoStore
public async Task<DtoRecord?> FindByDocumentIdAsync(Guid documentId, CancellationToken cancellationToken)
{
const string sql = """
SELECT id, document_id, source_name, format, payload_json, schema_version, created_at, validated_at
SELECT
id AS "Id",
document_id AS "DocumentId",
source_name AS "SourceName",
format AS "Format",
payload_json::text AS "PayloadJson",
schema_version AS "SchemaVersion",
created_at AS "CreatedAt",
validated_at AS "ValidatedAt"
FROM concelier.dtos
WHERE document_id = @DocumentId
LIMIT 1;
@@ -69,7 +85,15 @@ internal sealed class PostgresDtoStore : IDtoStore, Contracts.IStorageDtoStore
public async Task<IReadOnlyList<DtoRecord>> GetBySourceAsync(string sourceName, int limit, CancellationToken cancellationToken)
{
const string sql = """
SELECT id, document_id, source_name, format, payload_json, schema_version, created_at, validated_at
SELECT
id AS "Id",
document_id AS "DocumentId",
source_name AS "SourceName",
format AS "Format",
payload_json::text AS "PayloadJson",
schema_version AS "SchemaVersion",
created_at AS "CreatedAt",
validated_at AS "ValidatedAt"
FROM concelier.dtos
WHERE source_name = @SourceName
ORDER BY created_at DESC
@@ -84,15 +108,17 @@ internal sealed class PostgresDtoStore : IDtoStore, Contracts.IStorageDtoStore
private DtoRecord ToRecord(DtoRow row)
{
var payload = StellaOps.Concelier.Documents.DocumentObject.Parse(row.PayloadJson);
var createdAtUtc = DateTime.SpecifyKind(row.CreatedAt, DateTimeKind.Utc);
var validatedAtUtc = DateTime.SpecifyKind(row.ValidatedAt, DateTimeKind.Utc);
return new DtoRecord(
row.Id,
row.DocumentId,
row.SourceName,
row.Format,
payload,
row.CreatedAt,
new DateTimeOffset(createdAtUtc),
row.SchemaVersion,
row.ValidatedAt);
new DateTimeOffset(validatedAtUtc));
}
async Task<Contracts.StorageDto> Contracts.IStorageDtoStore.UpsertAsync(Contracts.StorageDto record, CancellationToken cancellationToken)
@@ -106,13 +132,15 @@ internal sealed class PostgresDtoStore : IDtoStore, Contracts.IStorageDtoStore
.Select(dto => dto.ToStorageDto())
.ToArray();
private sealed record DtoRow(
Guid Id,
Guid DocumentId,
string SourceName,
string Format,
string PayloadJson,
string SchemaVersion,
DateTimeOffset CreatedAt,
DateTimeOffset ValidatedAt);
private sealed class DtoRow
{
public Guid Id { get; init; }
public Guid DocumentId { get; init; }
public string SourceName { get; init; } = string.Empty;
public string Format { get; init; } = string.Empty;
public string PayloadJson { get; init; } = string.Empty;
public string SchemaVersion { get; init; } = string.Empty;
public DateTime CreatedAt { get; init; }
public DateTime ValidatedAt { get; init; }
}
}

View File

@@ -0,0 +1,466 @@
// -----------------------------------------------------------------------------
// BackportVerdictDeterminismTests.cs
// Sprint: SPRINT_20251229_004_002_BE_backport_status_service (BP-010)
// Task: Add determinism tests for verdict stability
// Description: Verify that same inputs produce same verdicts across multiple runs
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Concelier.BackportProof.Models;
using StellaOps.Concelier.BackportProof.Repositories;
using StellaOps.Concelier.BackportProof.Services;
using StellaOps.TestKit;
using Xunit;
using Xunit.Abstractions;
namespace StellaOps.Concelier.Core.Tests.BackportProof;
/// <summary>
/// Determinism tests for Backport Status Service.
/// Validates that:
/// - Same input always produces identical verdict
/// - Rule evaluation order doesn't matter
/// - Confidence scoring is stable
/// - JSON serialization is deterministic
/// </summary>
[Trait("Category", TestCategories.Determinism)]
[Trait("Category", TestCategories.Unit)]
public sealed class BackportVerdictDeterminismTests
{
private static readonly DateTimeOffset FixedTimestamp = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
private readonly ITestOutputHelper _output;
public BackportVerdictDeterminismTests(ITestOutputHelper output)
{
_output = output;
}
#region Same Input Same Verdict Tests
[Fact]
public async Task SameInput_ProducesIdenticalVerdict_Across10Iterations()
{
// Arrange
var context = new ProductContext("debian", "bookworm", null, null);
var package = new InstalledPackage(
Key: new PackageKey(PackageEcosystem.Deb, "curl", "curl"),
InstalledVersion: "7.88.1-10+deb12u5",
BuildDigest: null,
BuildId: null,
SourcePackage: "curl");
var cve = "CVE-2024-1234";
var rules = CreateTestRules(context, package.Key, cve);
var repository = CreateMockRepository(rules);
var service = new BackportStatusService(repository, NullLogger<BackportStatusService>.Instance);
var verdicts = new List<string>();
// Act - Run 10 times
for (int i = 0; i < 10; i++)
{
var verdict = await service.EvalPatchedStatusAsync(context, package, cve);
var json = System.Text.Json.JsonSerializer.Serialize(verdict,
new System.Text.Json.JsonSerializerOptions { WriteIndented = false });
verdicts.Add(json);
_output.WriteLine($"Iteration {i + 1}: {json}");
}
// Assert - All verdicts should be identical
verdicts.Distinct().Should().HaveCount(1,
"same input should produce identical verdict across all iterations");
}
[Fact]
public async Task DifferentRuleOrder_ProducesSameVerdict()
{
// Arrange
var context = new ProductContext("alpine", "3.19", "main", null);
var package = new InstalledPackage(
Key: new PackageKey(PackageEcosystem.Apk, "openssl", "openssl"),
InstalledVersion: "3.1.4-r5",
BuildDigest: null,
BuildId: null,
SourcePackage: null);
var cve = "CVE-2024-5678";
// Create rules in different orders
var rulesOrder1 = CreateTestRules(context, package.Key, cve).ToList();
var rulesOrder2 = rulesOrder1.AsEnumerable().Reverse().ToList();
var rulesOrder3 = rulesOrder1.OrderBy(_ => Guid.NewGuid()).ToList();
var repository1 = CreateMockRepository(rulesOrder1);
var repository2 = CreateMockRepository(rulesOrder2);
var repository3 = CreateMockRepository(rulesOrder3);
var service1 = new BackportStatusService(repository1, NullLogger<BackportStatusService>.Instance);
var service2 = new BackportStatusService(repository2, NullLogger<BackportStatusService>.Instance);
var service3 = new BackportStatusService(repository3, NullLogger<BackportStatusService>.Instance);
// Act
var verdict1 = await service1.EvalPatchedStatusAsync(context, package, cve);
var verdict2 = await service2.EvalPatchedStatusAsync(context, package, cve);
var verdict3 = await service3.EvalPatchedStatusAsync(context, package, cve);
// Assert - All should produce same status and confidence
verdict1.Status.Should().Be(verdict2.Status);
verdict1.Status.Should().Be(verdict3.Status);
verdict1.Confidence.Should().Be(verdict2.Confidence);
verdict1.Confidence.Should().Be(verdict3.Confidence);
verdict1.HasConflict.Should().Be(verdict2.HasConflict);
verdict1.HasConflict.Should().Be(verdict3.HasConflict);
_output.WriteLine($"Status: {verdict1.Status}, Confidence: {verdict1.Confidence}, Conflict: {verdict1.HasConflict}");
}
#endregion
#region Confidence Scoring Determinism
[Theory]
[InlineData("7.88.1-10+deb12u5", FixStatus.Patched, VerdictConfidence.High)]
[InlineData("7.88.1-10+deb12u4", FixStatus.Vulnerable, VerdictConfidence.High)]
[InlineData("7.88.1-10+deb12u3", FixStatus.Vulnerable, VerdictConfidence.High)]
public async Task BoundaryRule_ProducesConsistentConfidence(
string installedVersion,
FixStatus expectedStatus,
VerdictConfidence expectedConfidence)
{
// Arrange
var context = new ProductContext("debian", "bookworm", null, null);
var package = new InstalledPackage(
Key: new PackageKey(PackageEcosystem.Deb, "curl", "curl"),
InstalledVersion: installedVersion,
BuildDigest: null,
BuildId: null,
SourcePackage: "curl");
var cve = "CVE-2024-1234";
// Single boundary rule: fixed in 7.88.1-10+deb12u5
var rules = new List<FixRule>
{
new BoundaryRule
{
RuleId = "debian-bookworm-curl-cve-2024-1234",
Cve = cve,
Context = context,
Package = package.Key,
Priority = RulePriority.DistroNative,
Confidence = 0.95m,
Evidence = new EvidencePointer(
"debian-tracker",
"https://security-tracker.debian.org/tracker/CVE-2024-1234",
"sha256:abc123",
FixedTimestamp),
FixedVersion = "7.88.1-10+deb12u5"
}
};
var repository = CreateMockRepository(rules);
var service = new BackportStatusService(repository, NullLogger<BackportStatusService>.Instance);
var verdicts = new List<BackportVerdict>();
// Act - Run 5 times
for (int i = 0; i < 5; i++)
{
verdicts.Add(await service.EvalPatchedStatusAsync(context, package, cve));
}
// Assert - All should have same status and confidence
verdicts.Should().AllSatisfy(v =>
{
v.Status.Should().Be(expectedStatus);
v.Confidence.Should().Be(expectedConfidence);
});
}
[Fact]
public async Task ConflictingRules_AlwaysProducesMediumConfidence()
{
// Arrange
var context = new ProductContext("debian", "bookworm", null, null);
var package = new InstalledPackage(
Key: new PackageKey(PackageEcosystem.Deb, "nginx", "nginx"),
InstalledVersion: "1.24.0-1",
BuildDigest: null,
BuildId: null,
SourcePackage: "nginx");
var cve = "CVE-2024-9999";
// Two conflicting rules from same priority
var rules = new List<FixRule>
{
new BoundaryRule
{
RuleId = "rule-1",
Cve = cve,
Context = context,
Package = package.Key,
Priority = RulePriority.DistroNative,
Confidence = 0.95m,
Evidence = new EvidencePointer(
"source-a",
"https://example.com/a",
null,
FixedTimestamp),
FixedVersion = "1.24.0-2" // Says fixed in -2
},
new BoundaryRule
{
RuleId = "rule-2",
Cve = cve,
Context = context,
Package = package.Key,
Priority = RulePriority.DistroNative,
Confidence = 0.95m,
Evidence = new EvidencePointer(
"source-b",
"https://example.com/b",
null,
FixedTimestamp),
FixedVersion = "1.24.0-3" // Says fixed in -3 (conflict!)
}
};
var repository = CreateMockRepository(rules);
var service = new BackportStatusService(repository, NullLogger<BackportStatusService>.Instance);
var verdicts = new List<BackportVerdict>();
// Act - Run 10 times
for (int i = 0; i < 10; i++)
{
verdicts.Add(await service.EvalPatchedStatusAsync(context, package, cve));
}
// Assert - All should have Medium confidence due to conflict
verdicts.Should().AllSatisfy(v =>
{
v.Confidence.Should().Be(VerdictConfidence.Medium,
"conflicting rules should always produce medium confidence");
v.HasConflict.Should().BeTrue();
v.ConflictReason.Should().NotBeNullOrEmpty();
});
}
#endregion
#region Edge Case Determinism
[Fact]
public async Task NoRules_AlwaysReturnsUnknownLow()
{
// Arrange
var context = new ProductContext("debian", "bookworm", null, null);
var package = new InstalledPackage(
Key: new PackageKey(PackageEcosystem.Deb, "unknown-package", null),
InstalledVersion: "1.0.0",
BuildDigest: null,
BuildId: null,
SourcePackage: null);
var cve = "CVE-2024-UNKNOWN";
var repository = CreateMockRepository(Array.Empty<FixRule>());
var service = new BackportStatusService(repository, NullLogger<BackportStatusService>.Instance);
var verdicts = new List<BackportVerdict>();
// Act - Run 10 times with no rules
for (int i = 0; i < 10; i++)
{
verdicts.Add(await service.EvalPatchedStatusAsync(context, package, cve));
}
// Assert
verdicts.Should().AllSatisfy(v =>
{
v.Status.Should().Be(FixStatus.Unknown);
v.Confidence.Should().Be(VerdictConfidence.Low);
v.HasConflict.Should().BeFalse();
v.AppliedRuleIds.Should().BeEmpty();
});
}
[Fact]
public async Task NotAffected_AlwaysWinsImmediately()
{
// Arrange
var context = new ProductContext("debian", "bookworm", null, null);
var package = new InstalledPackage(
Key: new PackageKey(PackageEcosystem.Deb, "systemd", "systemd"),
InstalledVersion: "252.19-1~deb12u1",
BuildDigest: null,
BuildId: null,
SourcePackage: "systemd");
var cve = "CVE-2024-SERVER-ONLY";
// Not-affected rule + other rules (not-affected should win)
var rules = new List<FixRule>
{
new StatusRule
{
RuleId = "not-affected-rule",
Cve = cve,
Context = context,
Package = package.Key,
Priority = RulePriority.DistroNative,
Confidence = 1.0m,
Evidence = new EvidencePointer(
"debian-tracker",
"https://security-tracker.debian.org/tracker/CVE-2024-SERVER-ONLY",
null,
FixedTimestamp),
Status = FixStatus.NotAffected
},
new BoundaryRule
{
RuleId = "boundary-rule",
Cve = cve,
Context = context,
Package = package.Key,
Priority = RulePriority.ThirdParty,
Confidence = 0.7m,
Evidence = new EvidencePointer(
"nvd",
"https://nvd.nist.gov/vuln/detail/CVE-2024-SERVER-ONLY",
null,
FixedTimestamp),
FixedVersion = "252.20-1"
}
};
var repository = CreateMockRepository(rules);
var service = new BackportStatusService(repository, NullLogger<BackportStatusService>.Instance);
var verdicts = new List<BackportVerdict>();
// Act - Run 10 times
for (int i = 0; i < 10; i++)
{
verdicts.Add(await service.EvalPatchedStatusAsync(context, package, cve));
}
// Assert - NotAffected should always win with High confidence
verdicts.Should().AllSatisfy(v =>
{
v.Status.Should().Be(FixStatus.NotAffected);
v.Confidence.Should().Be(VerdictConfidence.High);
v.AppliedRuleIds.Should().Contain("not-affected-rule");
});
}
#endregion
#region JSON Serialization Determinism
[Fact]
public async Task JsonSerialization_IsStable()
{
// Arrange
var context = new ProductContext("alpine", "3.19", "main", "x86_64");
var package = new InstalledPackage(
Key: new PackageKey(PackageEcosystem.Apk, "busybox", "busybox"),
InstalledVersion: "1.36.1-r15",
BuildDigest: "sha256:abcdef1234567890",
BuildId: "build-123",
SourcePackage: null);
var cve = "CVE-2024-JSON-TEST";
var rules = CreateTestRules(context, package.Key, cve);
var repository = CreateMockRepository(rules);
var service = new BackportStatusService(repository, NullLogger<BackportStatusService>.Instance);
var jsonOutputs = new List<string>();
// Act - Serialize 10 times
for (int i = 0; i < 10; i++)
{
var verdict = await service.EvalPatchedStatusAsync(context, package, cve);
var json = System.Text.Json.JsonSerializer.Serialize(verdict,
new System.Text.Json.JsonSerializerOptions
{
WriteIndented = false,
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase
});
jsonOutputs.Add(json);
}
// Assert - All JSON should be byte-identical
jsonOutputs.Distinct().Should().HaveCount(1,
"JSON serialization should be deterministic");
_output.WriteLine($"Deterministic JSON: {jsonOutputs[0]}");
}
#endregion
#region Helper Methods
private static List<FixRule> CreateTestRules(
ProductContext context,
PackageKey package,
string cve)
{
return new List<FixRule>
{
new BoundaryRule
{
RuleId = $"rule-{cve}-1",
Cve = cve,
Context = context,
Package = package,
Priority = RulePriority.DistroNative,
Confidence = 0.95m,
Evidence = new EvidencePointer(
"test-source",
"https://example.com/advisory",
"sha256:test123",
DateTimeOffset.Parse("2025-01-01T00:00:00Z")),
FixedVersion = "1.36.1-r16"
},
new BoundaryRule
{
RuleId = $"rule-{cve}-2",
Cve = cve,
Context = context,
Package = package,
Priority = RulePriority.VendorCsaf,
Confidence = 0.90m,
Evidence = new EvidencePointer(
"vendor-csaf",
"https://vendor.example.com/csaf",
"sha256:vendor456",
DateTimeOffset.Parse("2025-01-02T00:00:00Z")),
FixedVersion = "1.36.1-r16"
}
};
}
private static IFixRuleRepository CreateMockRepository(IEnumerable<FixRule> rules)
{
var mock = new Mock<IFixRuleRepository>();
var rulesList = rules.ToList();
mock.Setup(r => r.GetRulesAsync(
It.IsAny<ProductContext>(),
It.IsAny<PackageKey>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(rulesList);
return mock.Object;
}
#endregion
}

View File

@@ -13,6 +13,7 @@
<PackageReference Include="Moq" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.BackportProof/StellaOps.Concelier.BackportProof.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Core/StellaOps.Concelier.Core.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.RawModels/StellaOps.Concelier.RawModels.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
@@ -20,4 +21,4 @@
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
<ProjectReference Include="../../../Aoc/__Libraries/StellaOps.Aoc/StellaOps.Aoc.csproj" />
</ItemGroup>
</Project>
</Project>