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:
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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>();
|
||||
|
||||
@@ -132,6 +132,7 @@ public static class CanonicalJsonSerializer
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
WriteIndented = writeIndented,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user