Add unit and integration tests for VexCandidateEmitter and SmartDiff repositories

- Implemented comprehensive unit tests for VexCandidateEmitter to validate candidate emission logic based on various scenarios including absent and present APIs, confidence thresholds, and rate limiting.
- Added integration tests for SmartDiff PostgreSQL repositories, covering snapshot storage and retrieval, candidate storage, and material risk change handling.
- Ensured tests validate correct behavior for storing, retrieving, and querying snapshots and candidates, including edge cases and expected outcomes.
This commit is contained in:
master
2025-12-16 18:44:25 +02:00
parent 2170a58734
commit 3a2100aa78
126 changed files with 15776 additions and 542 deletions

View File

@@ -0,0 +1,332 @@
using System.Collections.Immutable;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Scanner.SmartDiff.Detection;
using StellaOps.Scanner.Storage.Postgres;
using StellaOps.Scanner.WebService.Security;
namespace StellaOps.Scanner.WebService.Endpoints;
/// <summary>
/// Smart-Diff API endpoints for material risk changes and VEX candidates.
/// Per Sprint 3500.3 - Smart-Diff Detection Rules.
/// </summary>
internal static class SmartDiffEndpoints
{
public static void MapSmartDiffEndpoints(this RouteGroupBuilder apiGroup, string prefix = "/smart-diff")
{
ArgumentNullException.ThrowIfNull(apiGroup);
var group = apiGroup.MapGroup(prefix);
// Material risk changes endpoints
group.MapGet("/scans/{scanId}/changes", HandleGetScanChangesAsync)
.WithName("scanner.smartdiff.scan-changes")
.WithTags("SmartDiff")
.Produces<MaterialChangesResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
// VEX candidate endpoints
group.MapGet("/images/{digest}/candidates", HandleGetCandidatesAsync)
.WithName("scanner.smartdiff.candidates")
.WithTags("SmartDiff")
.Produces<VexCandidatesResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
group.MapGet("/candidates/{candidateId}", HandleGetCandidateAsync)
.WithName("scanner.smartdiff.candidate")
.WithTags("SmartDiff")
.Produces<VexCandidateResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansRead);
group.MapPost("/candidates/{candidateId}/review", HandleReviewCandidateAsync)
.WithName("scanner.smartdiff.review")
.WithTags("SmartDiff")
.Produces<ReviewResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScannerPolicies.ScansWrite);
}
/// <summary>
/// GET /smart-diff/scans/{scanId}/changes - Get material risk changes for a scan.
/// </summary>
private static async Task<IResult> HandleGetScanChangesAsync(
string scanId,
IMaterialRiskChangeRepository repository,
double? minPriority = null,
CancellationToken ct = default)
{
var changes = await repository.GetChangesForScanAsync(scanId, ct);
if (minPriority.HasValue)
{
changes = changes.Where(c => c.PriorityScore >= minPriority.Value).ToList();
}
var response = new MaterialChangesResponse
{
ScanId = scanId,
TotalChanges = changes.Count,
Changes = changes.Select(ToChangeDto).ToImmutableArray()
};
return Results.Ok(response);
}
/// <summary>
/// GET /smart-diff/images/{digest}/candidates - Get VEX candidates for an image.
/// </summary>
private static async Task<IResult> HandleGetCandidatesAsync(
string digest,
IVexCandidateStore store,
double? minConfidence = null,
bool? pendingOnly = null,
CancellationToken ct = default)
{
var normalizedDigest = NormalizeDigest(digest);
var candidates = await store.GetCandidatesAsync(normalizedDigest, ct);
if (minConfidence.HasValue)
{
candidates = candidates.Where(c => c.Confidence >= minConfidence.Value).ToList();
}
if (pendingOnly == true)
{
candidates = candidates.Where(c => c.RequiresReview).ToList();
}
var response = new VexCandidatesResponse
{
ImageDigest = normalizedDigest,
TotalCandidates = candidates.Count,
Candidates = candidates.Select(ToCandidateDto).ToImmutableArray()
};
return Results.Ok(response);
}
/// <summary>
/// GET /smart-diff/candidates/{candidateId} - Get a specific VEX candidate.
/// </summary>
private static async Task<IResult> HandleGetCandidateAsync(
string candidateId,
IVexCandidateStore store,
CancellationToken ct = default)
{
var candidate = await store.GetCandidateAsync(candidateId, ct);
if (candidate is null)
{
return Results.NotFound(new { error = "Candidate not found", candidateId });
}
var response = new VexCandidateResponse
{
Candidate = ToCandidateDto(candidate)
};
return Results.Ok(response);
}
/// <summary>
/// POST /smart-diff/candidates/{candidateId}/review - Review a VEX candidate.
/// </summary>
private static async Task<IResult> HandleReviewCandidateAsync(
string candidateId,
ReviewRequest request,
IVexCandidateStore store,
HttpContext httpContext,
CancellationToken ct = default)
{
if (!Enum.TryParse<VexReviewAction>(request.Action, true, out var action))
{
return Results.BadRequest(new { error = "Invalid action", validActions = new[] { "accept", "reject", "defer" } });
}
var reviewer = httpContext.User.Identity?.Name ?? "anonymous";
var review = new VexCandidateReview(
Action: action,
Reviewer: reviewer,
ReviewedAt: DateTimeOffset.UtcNow,
Comment: request.Comment);
var success = await store.ReviewCandidateAsync(candidateId, review, ct);
if (!success)
{
return Results.NotFound(new { error = "Candidate not found", candidateId });
}
return Results.Ok(new ReviewResponse
{
CandidateId = candidateId,
Action = action.ToString().ToLowerInvariant(),
ReviewedBy = reviewer,
ReviewedAt = review.ReviewedAt
});
}
#region Helper Methods
private static string NormalizeDigest(string digest)
{
// Handle URL-encoded colons
return digest.Replace("%3A", ":", StringComparison.OrdinalIgnoreCase);
}
private static MaterialChangeDto ToChangeDto(MaterialRiskChangeResult change)
{
return new MaterialChangeDto
{
VulnId = change.FindingKey.VulnId,
Purl = change.FindingKey.Purl,
HasMaterialChange = change.HasMaterialChange,
PriorityScore = change.PriorityScore,
PreviousStateHash = change.PreviousStateHash,
CurrentStateHash = change.CurrentStateHash,
Changes = change.Changes.Select(c => new DetectedChangeDto
{
Rule = c.Rule.ToString(),
ChangeType = c.ChangeType.ToString(),
Direction = c.Direction.ToString().ToLowerInvariant(),
Reason = c.Reason,
PreviousValue = c.PreviousValue,
CurrentValue = c.CurrentValue,
Weight = c.Weight,
SubType = c.SubType
}).ToImmutableArray()
};
}
private static VexCandidateDto ToCandidateDto(VexCandidate candidate)
{
return new VexCandidateDto
{
CandidateId = candidate.CandidateId,
VulnId = candidate.FindingKey.VulnId,
Purl = candidate.FindingKey.Purl,
ImageDigest = candidate.ImageDigest,
SuggestedStatus = candidate.SuggestedStatus.ToString().ToLowerInvariant(),
Justification = MapJustificationToString(candidate.Justification),
Rationale = candidate.Rationale,
EvidenceLinks = candidate.EvidenceLinks.Select(e => new EvidenceLinkDto
{
Type = e.Type,
Uri = e.Uri,
Digest = e.Digest
}).ToImmutableArray(),
Confidence = candidate.Confidence,
GeneratedAt = candidate.GeneratedAt,
ExpiresAt = candidate.ExpiresAt,
RequiresReview = candidate.RequiresReview
};
}
private static string MapJustificationToString(VexJustification justification)
{
return justification switch
{
VexJustification.ComponentNotPresent => "component_not_present",
VexJustification.VulnerableCodeNotPresent => "vulnerable_code_not_present",
VexJustification.VulnerableCodeNotInExecutePath => "vulnerable_code_not_in_execute_path",
VexJustification.VulnerableCodeCannotBeControlledByAdversary => "vulnerable_code_cannot_be_controlled_by_adversary",
VexJustification.InlineMitigationsAlreadyExist => "inline_mitigations_already_exist",
_ => "unknown"
};
}
#endregion
}
#region DTOs
/// <summary>Response for GET /scans/{id}/changes</summary>
public sealed class MaterialChangesResponse
{
public required string ScanId { get; init; }
public int TotalChanges { get; init; }
public required ImmutableArray<MaterialChangeDto> Changes { get; init; }
}
public sealed class MaterialChangeDto
{
public required string VulnId { get; init; }
public required string Purl { get; init; }
public bool HasMaterialChange { get; init; }
public int PriorityScore { get; init; }
public required string PreviousStateHash { get; init; }
public required string CurrentStateHash { get; init; }
public required ImmutableArray<DetectedChangeDto> Changes { get; init; }
}
public sealed class DetectedChangeDto
{
public required string Rule { get; init; }
public required string ChangeType { get; init; }
public required string Direction { get; init; }
public required string Reason { get; init; }
public required string PreviousValue { get; init; }
public required string CurrentValue { get; init; }
public double Weight { get; init; }
public string? SubType { get; init; }
}
/// <summary>Response for GET /images/{digest}/candidates</summary>
public sealed class VexCandidatesResponse
{
public required string ImageDigest { get; init; }
public int TotalCandidates { get; init; }
public required ImmutableArray<VexCandidateDto> Candidates { get; init; }
}
/// <summary>Response for GET /candidates/{id}</summary>
public sealed class VexCandidateResponse
{
public required VexCandidateDto Candidate { get; init; }
}
public sealed class VexCandidateDto
{
public required string CandidateId { get; init; }
public required string VulnId { get; init; }
public required string Purl { get; init; }
public required string ImageDigest { get; init; }
public required string SuggestedStatus { get; init; }
public required string Justification { get; init; }
public required string Rationale { get; init; }
public required ImmutableArray<EvidenceLinkDto> EvidenceLinks { get; init; }
public double Confidence { get; init; }
public DateTimeOffset GeneratedAt { get; init; }
public DateTimeOffset ExpiresAt { get; init; }
public bool RequiresReview { get; init; }
}
public sealed class EvidenceLinkDto
{
public required string Type { get; init; }
public required string Uri { get; init; }
public string? Digest { get; init; }
}
/// <summary>Request for POST /candidates/{id}/review</summary>
public sealed class ReviewRequest
{
public required string Action { get; init; }
public string? Comment { get; init; }
}
/// <summary>Response for POST /candidates/{id}/review</summary>
public sealed class ReviewResponse
{
public required string CandidateId { get; init; }
public required string Action { get; init; }
public required string ReviewedBy { get; init; }
public DateTimeOffset ReviewedAt { get; init; }
}
#endregion

View File

@@ -0,0 +1,167 @@
namespace StellaOps.Scanner.SmartDiff.Detection;
/// <summary>
/// Bridges the 7-state reachability lattice to the 3-bit gate model.
/// Per Sprint 3500.3 - Smart-Diff Detection Rules.
/// </summary>
public static class ReachabilityGateBridge
{
/// <summary>
/// Converts a lattice state to a 3-bit reachability gate.
/// </summary>
public static ReachabilityGate FromLatticeState(
string latticeState,
bool? configActivated = null,
bool? runningUser = null)
{
var (reachable, confidence) = MapLatticeToReachable(latticeState);
return new ReachabilityGate(
Reachable: reachable,
ConfigActivated: configActivated,
RunningUser: runningUser,
Confidence: confidence,
LatticeState: latticeState,
Rationale: GenerateRationale(latticeState, reachable));
}
/// <summary>
/// Maps the 7-state lattice to the reachable boolean with confidence.
/// </summary>
/// <returns>Tuple of (reachable, confidence)</returns>
public static (bool? Reachable, double Confidence) MapLatticeToReachable(string latticeState)
{
return latticeState.ToUpperInvariant() switch
{
// Confirmed states - highest confidence
"CR" or "CONFIRMED_REACHABLE" => (true, 1.0),
"CU" or "CONFIRMED_UNREACHABLE" => (false, 1.0),
// Static analysis states - high confidence
"SR" or "STATIC_REACHABLE" => (true, 0.85),
"SU" or "STATIC_UNREACHABLE" => (false, 0.85),
// Runtime observation states - medium-high confidence
"RO" or "RUNTIME_OBSERVED" => (true, 0.90),
"RU" or "RUNTIME_UNOBSERVED" => (false, 0.70), // Lower because absence != proof
// Unknown - no confidence
"U" or "UNKNOWN" => (null, 0.0),
// Contested - conflicting evidence
"X" or "CONTESTED" => (null, 0.5),
// Likely states (for systems with uncertainty quantification)
"LR" or "LIKELY_REACHABLE" => (true, 0.75),
"LU" or "LIKELY_UNREACHABLE" => (false, 0.75),
// Default for unrecognized
_ => (null, 0.0)
};
}
/// <summary>
/// Generates human-readable rationale for the gate.
/// </summary>
public static string GenerateRationale(string latticeState, bool? reachable)
{
var stateDescription = latticeState.ToUpperInvariant() switch
{
"CR" or "CONFIRMED_REACHABLE" => "Confirmed reachable via static + runtime evidence",
"CU" or "CONFIRMED_UNREACHABLE" => "Confirmed unreachable via static + runtime evidence",
"SR" or "STATIC_REACHABLE" => "Statically reachable (call graph analysis)",
"SU" or "STATIC_UNREACHABLE" => "Statically unreachable (no path in call graph)",
"RO" or "RUNTIME_OBSERVED" => "Observed at runtime (instrumentation)",
"RU" or "RUNTIME_UNOBSERVED" => "Not observed at runtime (no hits)",
"U" or "UNKNOWN" => "Reachability unknown (insufficient evidence)",
"X" or "CONTESTED" => "Contested (conflicting evidence)",
"LR" or "LIKELY_REACHABLE" => "Likely reachable (heuristic analysis)",
"LU" or "LIKELY_UNREACHABLE" => "Likely unreachable (heuristic analysis)",
_ => $"Unrecognized lattice state: {latticeState}"
};
var reachableStr = reachable switch
{
true => "REACHABLE",
false => "UNREACHABLE",
null => "UNKNOWN"
};
return $"[{reachableStr}] {stateDescription}";
}
/// <summary>
/// Computes the 3-bit class from the gate values.
/// </summary>
public static int ComputeClass(ReachabilityGate gate)
{
// 3-bit encoding: [reachable][configActivated][runningUser]
var bit0 = gate.Reachable == true ? 1 : 0;
var bit1 = gate.ConfigActivated == true ? 1 : 0;
var bit2 = gate.RunningUser == true ? 1 : 0;
return (bit2 << 2) | (bit1 << 1) | bit0;
}
/// <summary>
/// Interprets the 3-bit class as a risk level.
/// </summary>
public static string InterpretClass(int gateClass)
{
// Class meanings:
// 0 (000) - Not reachable, not activated, not running as user - lowest risk
// 1 (001) - Reachable but not activated and not running as user
// 2 (010) - Activated but not reachable and not running as user
// 3 (011) - Reachable and activated but not running as user
// 4 (100) - Running as user but not reachable or activated
// 5 (101) - Reachable and running as user
// 6 (110) - Activated and running as user
// 7 (111) - All three true - highest risk
return gateClass switch
{
0 => "LOW - No conditions met",
1 => "MEDIUM-LOW - Code reachable only",
2 => "LOW - Config activated but unreachable",
3 => "MEDIUM - Reachable and config activated",
4 => "MEDIUM-LOW - Running as user only",
5 => "MEDIUM-HIGH - Reachable as user",
6 => "MEDIUM - Config activated as user",
7 => "HIGH - All conditions met",
_ => "UNKNOWN"
};
}
}
/// <summary>
/// 3-bit reachability gate representation.
/// </summary>
public sealed record ReachabilityGate(
bool? Reachable,
bool? ConfigActivated,
bool? RunningUser,
double Confidence,
string? LatticeState,
string Rationale)
{
/// <summary>
/// Computes the 3-bit class.
/// </summary>
public int ComputeClass() => ReachabilityGateBridge.ComputeClass(this);
/// <summary>
/// Gets the risk interpretation.
/// </summary>
public string RiskInterpretation => ReachabilityGateBridge.InterpretClass(ComputeClass());
/// <summary>
/// Creates a gate with default null values.
/// </summary>
public static ReachabilityGate Unknown { get; } = new(
Reachable: null,
ConfigActivated: null,
RunningUser: null,
Confidence: 0.0,
LatticeState: "U",
Rationale: "[UNKNOWN] Reachability unknown");
}

View File

@@ -0,0 +1,239 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.SmartDiff.Detection;
/// <summary>
/// Repository interface for risk state snapshots.
/// Per Sprint 3500.3 - Smart-Diff Detection Rules.
/// </summary>
public interface IRiskStateRepository
{
/// <summary>
/// Store a risk state snapshot.
/// </summary>
Task StoreSnapshotAsync(RiskStateSnapshot snapshot, CancellationToken ct = default);
/// <summary>
/// Store multiple risk state snapshots.
/// </summary>
Task StoreSnapshotsAsync(IReadOnlyList<RiskStateSnapshot> snapshots, CancellationToken ct = default);
/// <summary>
/// Get the latest snapshot for a finding.
/// </summary>
Task<RiskStateSnapshot?> GetLatestSnapshotAsync(FindingKey findingKey, CancellationToken ct = default);
/// <summary>
/// Get snapshots for a scan.
/// </summary>
Task<IReadOnlyList<RiskStateSnapshot>> GetSnapshotsForScanAsync(string scanId, CancellationToken ct = default);
/// <summary>
/// Get snapshot history for a finding.
/// </summary>
Task<IReadOnlyList<RiskStateSnapshot>> GetSnapshotHistoryAsync(
FindingKey findingKey,
int limit = 10,
CancellationToken ct = default);
/// <summary>
/// Get snapshots by state hash.
/// </summary>
Task<IReadOnlyList<RiskStateSnapshot>> GetSnapshotsByHashAsync(string stateHash, CancellationToken ct = default);
}
/// <summary>
/// Repository interface for material risk changes.
/// </summary>
public interface IMaterialRiskChangeRepository
{
/// <summary>
/// Store a material risk change result.
/// </summary>
Task StoreChangeAsync(MaterialRiskChangeResult change, string scanId, CancellationToken ct = default);
/// <summary>
/// Store multiple material risk change results.
/// </summary>
Task StoreChangesAsync(IReadOnlyList<MaterialRiskChangeResult> changes, string scanId, CancellationToken ct = default);
/// <summary>
/// Get material changes for a scan.
/// </summary>
Task<IReadOnlyList<MaterialRiskChangeResult>> GetChangesForScanAsync(string scanId, CancellationToken ct = default);
/// <summary>
/// Get material changes for a finding.
/// </summary>
Task<IReadOnlyList<MaterialRiskChangeResult>> GetChangesForFindingAsync(
FindingKey findingKey,
int limit = 10,
CancellationToken ct = default);
/// <summary>
/// Query material changes with filters.
/// </summary>
Task<MaterialRiskChangeQueryResult> QueryChangesAsync(
MaterialRiskChangeQuery query,
CancellationToken ct = default);
}
/// <summary>
/// Query for material risk changes.
/// </summary>
public sealed record MaterialRiskChangeQuery(
string? ImageDigest = null,
DateTimeOffset? Since = null,
DateTimeOffset? Until = null,
ImmutableArray<DetectionRule>? Rules = null,
ImmutableArray<RiskDirection>? Directions = null,
double? MinPriorityScore = null,
int Offset = 0,
int Limit = 100);
/// <summary>
/// Result of material risk change query.
/// </summary>
public sealed record MaterialRiskChangeQueryResult(
ImmutableArray<MaterialRiskChangeResult> Changes,
int TotalCount,
int Offset,
int Limit);
/// <summary>
/// In-memory implementation for testing.
/// </summary>
public sealed class InMemoryRiskStateRepository : IRiskStateRepository
{
private readonly List<RiskStateSnapshot> _snapshots = [];
private readonly object _lock = new();
public Task StoreSnapshotAsync(RiskStateSnapshot snapshot, CancellationToken ct = default)
{
lock (_lock)
{
_snapshots.Add(snapshot);
}
return Task.CompletedTask;
}
public Task StoreSnapshotsAsync(IReadOnlyList<RiskStateSnapshot> snapshots, CancellationToken ct = default)
{
lock (_lock)
{
_snapshots.AddRange(snapshots);
}
return Task.CompletedTask;
}
public Task<RiskStateSnapshot?> GetLatestSnapshotAsync(FindingKey findingKey, CancellationToken ct = default)
{
lock (_lock)
{
var snapshot = _snapshots
.Where(s => s.FindingKey == findingKey)
.OrderByDescending(s => s.CapturedAt)
.FirstOrDefault();
return Task.FromResult(snapshot);
}
}
public Task<IReadOnlyList<RiskStateSnapshot>> GetSnapshotsForScanAsync(string scanId, CancellationToken ct = default)
{
lock (_lock)
{
var snapshots = _snapshots
.Where(s => s.ScanId == scanId)
.ToList();
return Task.FromResult<IReadOnlyList<RiskStateSnapshot>>(snapshots);
}
}
public Task<IReadOnlyList<RiskStateSnapshot>> GetSnapshotHistoryAsync(
FindingKey findingKey,
int limit = 10,
CancellationToken ct = default)
{
lock (_lock)
{
var snapshots = _snapshots
.Where(s => s.FindingKey == findingKey)
.OrderByDescending(s => s.CapturedAt)
.Take(limit)
.ToList();
return Task.FromResult<IReadOnlyList<RiskStateSnapshot>>(snapshots);
}
}
public Task<IReadOnlyList<RiskStateSnapshot>> GetSnapshotsByHashAsync(string stateHash, CancellationToken ct = default)
{
lock (_lock)
{
var snapshots = _snapshots
.Where(s => s.ComputeStateHash() == stateHash)
.ToList();
return Task.FromResult<IReadOnlyList<RiskStateSnapshot>>(snapshots);
}
}
}
/// <summary>
/// In-memory implementation for testing.
/// </summary>
public sealed class InMemoryVexCandidateStore : IVexCandidateStore
{
private readonly Dictionary<string, VexCandidate> _candidates = [];
private readonly Dictionary<string, VexCandidateReview> _reviews = [];
private readonly object _lock = new();
public Task StoreCandidatesAsync(IReadOnlyList<VexCandidate> candidates, CancellationToken ct = default)
{
lock (_lock)
{
foreach (var candidate in candidates)
{
_candidates[candidate.CandidateId] = candidate;
}
}
return Task.CompletedTask;
}
public Task<IReadOnlyList<VexCandidate>> GetCandidatesAsync(string imageDigest, CancellationToken ct = default)
{
lock (_lock)
{
var candidates = _candidates.Values
.Where(c => c.ImageDigest == imageDigest)
.ToList();
return Task.FromResult<IReadOnlyList<VexCandidate>>(candidates);
}
}
public Task<VexCandidate?> GetCandidateAsync(string candidateId, CancellationToken ct = default)
{
lock (_lock)
{
_candidates.TryGetValue(candidateId, out var candidate);
return Task.FromResult(candidate);
}
}
public Task<bool> ReviewCandidateAsync(string candidateId, VexCandidateReview review, CancellationToken ct = default)
{
lock (_lock)
{
if (!_candidates.ContainsKey(candidateId))
return Task.FromResult(false);
_reviews[candidateId] = review;
// Update candidate to mark as reviewed
if (_candidates.TryGetValue(candidateId, out var candidate))
{
_candidates[candidateId] = candidate with { RequiresReview = false };
}
return Task.FromResult(true);
}
}
}

View File

@@ -0,0 +1,194 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Scanner.SmartDiff.Detection;
/// <summary>
/// Emits VEX candidates for findings where vulnerable APIs are no longer present.
/// Per Sprint 3500.3 - Smart-Diff Detection Rules.
/// </summary>
public sealed class VexCandidateEmitter
{
private readonly VexCandidateEmitterOptions _options;
private readonly IVexCandidateStore? _store;
public VexCandidateEmitter(VexCandidateEmitterOptions? options = null, IVexCandidateStore? store = null)
{
_options = options ?? VexCandidateEmitterOptions.Default;
_store = store;
}
/// <summary>
/// Evaluate findings and emit VEX candidates for those with absent vulnerable APIs.
/// </summary>
public async Task<VexCandidateEmissionResult> EmitCandidatesAsync(
VexCandidateEmissionContext context,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(context);
var candidates = new List<VexCandidate>();
// Build lookup of current findings
var currentFindingKeys = new HashSet<FindingKey>(
context.CurrentFindings.Select(f => f.FindingKey));
// Evaluate previous findings that are still present
foreach (var prevFinding in context.PreviousFindings)
{
// Skip if finding is no longer present (component removed)
if (!currentFindingKeys.Contains(prevFinding.FindingKey))
continue;
// Skip if already has a VEX status
if (prevFinding.VexStatus != VexStatusType.Unknown &&
prevFinding.VexStatus != VexStatusType.Affected)
continue;
// Check if vulnerable APIs are now absent
var apiCheck = CheckVulnerableApisAbsent(
prevFinding,
context.PreviousCallGraph,
context.CurrentCallGraph);
if (!apiCheck.AllApisAbsent)
continue;
// Check confidence threshold
var confidence = ComputeConfidence(apiCheck);
if (confidence < _options.MinConfidence)
continue;
// Generate VEX candidate
var candidate = CreateVexCandidate(prevFinding, apiCheck, context, confidence);
candidates.Add(candidate);
// Rate limit per image
if (candidates.Count >= _options.MaxCandidatesPerImage)
break;
}
// Store candidates (if configured)
if (candidates.Count > 0 && _options.PersistCandidates && _store is not null)
{
await _store.StoreCandidatesAsync(candidates, ct);
}
return new VexCandidateEmissionResult(
ImageDigest: context.TargetImageDigest,
CandidatesEmitted: candidates.Count,
Candidates: [.. candidates],
Timestamp: DateTimeOffset.UtcNow);
}
/// <summary>
/// Checks if all vulnerable APIs for a finding are absent in current scan.
/// </summary>
private static VulnerableApiCheckResult CheckVulnerableApisAbsent(
FindingSnapshot finding,
CallGraphSnapshot? previousGraph,
CallGraphSnapshot? currentGraph)
{
if (previousGraph is null || currentGraph is null)
{
return new VulnerableApiCheckResult(
AllApisAbsent: false,
AbsentApis: [],
PresentApis: [],
Reason: "Call graph not available");
}
var vulnerableApis = finding.VulnerableApis;
if (vulnerableApis.IsDefaultOrEmpty)
{
return new VulnerableApiCheckResult(
AllApisAbsent: false,
AbsentApis: [],
PresentApis: [],
Reason: "No vulnerable APIs tracked");
}
var absentApis = new List<string>();
var presentApis = new List<string>();
foreach (var api in vulnerableApis)
{
var isPresentInCurrent = currentGraph.ContainsSymbol(api);
if (isPresentInCurrent)
presentApis.Add(api);
else
absentApis.Add(api);
}
return new VulnerableApiCheckResult(
AllApisAbsent: presentApis.Count == 0 && absentApis.Count > 0,
AbsentApis: [.. absentApis],
PresentApis: [.. presentApis],
Reason: presentApis.Count == 0
? $"All {absentApis.Count} vulnerable APIs absent"
: $"{presentApis.Count} vulnerable APIs still present");
}
/// <summary>
/// Creates a VEX candidate from a finding and API check.
/// </summary>
private VexCandidate CreateVexCandidate(
FindingSnapshot finding,
VulnerableApiCheckResult apiCheck,
VexCandidateEmissionContext context,
double confidence)
{
var evidenceLinks = new List<EvidenceLink>
{
new(
Type: "callgraph_diff",
Uri: $"callgraph://{context.PreviousScanId}/{context.CurrentScanId}",
Digest: context.CurrentCallGraph?.Digest)
};
foreach (var api in apiCheck.AbsentApis)
{
evidenceLinks.Add(new EvidenceLink(
Type: "absent_api",
Uri: $"symbol://{api}"));
}
return new VexCandidate(
CandidateId: GenerateCandidateId(finding, context),
FindingKey: finding.FindingKey,
SuggestedStatus: VexStatusType.NotAffected,
Justification: VexJustification.VulnerableCodeNotPresent,
Rationale: $"Vulnerable APIs no longer present in image: {string.Join(", ", apiCheck.AbsentApis)}",
EvidenceLinks: [.. evidenceLinks],
Confidence: confidence,
ImageDigest: context.TargetImageDigest,
GeneratedAt: DateTimeOffset.UtcNow,
ExpiresAt: DateTimeOffset.UtcNow.Add(_options.CandidateTtl),
RequiresReview: true);
}
private static string GenerateCandidateId(
FindingSnapshot finding,
VexCandidateEmissionContext context)
{
var input = $"{context.TargetImageDigest}:{finding.FindingKey}:{DateTimeOffset.UtcNow.Ticks}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return $"vexc-{Convert.ToHexString(hash).ToLowerInvariant()[..16]}";
}
private static double ComputeConfidence(VulnerableApiCheckResult apiCheck)
{
if (apiCheck.PresentApis.Length > 0)
return 0.0;
// Higher confidence with more absent APIs
return apiCheck.AbsentApis.Length switch
{
>= 3 => 0.95,
2 => 0.85,
1 => 0.75,
_ => 0.5
};
}
}

View File

@@ -0,0 +1,180 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.SmartDiff.Detection;
/// <summary>
/// A VEX candidate generated by Smart-Diff.
/// Per Sprint 3500.3 - Smart-Diff Detection Rules.
/// </summary>
public sealed record VexCandidate(
[property: JsonPropertyName("candidateId")] string CandidateId,
[property: JsonPropertyName("findingKey")] FindingKey FindingKey,
[property: JsonPropertyName("suggestedStatus")] VexStatusType SuggestedStatus,
[property: JsonPropertyName("justification")] VexJustification Justification,
[property: JsonPropertyName("rationale")] string Rationale,
[property: JsonPropertyName("evidenceLinks")] ImmutableArray<EvidenceLink> EvidenceLinks,
[property: JsonPropertyName("confidence")] double Confidence,
[property: JsonPropertyName("imageDigest")] string ImageDigest,
[property: JsonPropertyName("generatedAt")] DateTimeOffset GeneratedAt,
[property: JsonPropertyName("expiresAt")] DateTimeOffset ExpiresAt,
[property: JsonPropertyName("requiresReview")] bool RequiresReview);
/// <summary>
/// VEX justification codes per OpenVEX specification.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<VexJustification>))]
public enum VexJustification
{
[JsonStringEnumMemberName("component_not_present")]
ComponentNotPresent,
[JsonStringEnumMemberName("vulnerable_code_not_present")]
VulnerableCodeNotPresent,
[JsonStringEnumMemberName("vulnerable_code_not_in_execute_path")]
VulnerableCodeNotInExecutePath,
[JsonStringEnumMemberName("vulnerable_code_cannot_be_controlled_by_adversary")]
VulnerableCodeCannotBeControlledByAdversary,
[JsonStringEnumMemberName("inline_mitigations_already_exist")]
InlineMitigationsAlreadyExist
}
/// <summary>
/// Result of vulnerable API presence check.
/// </summary>
public sealed record VulnerableApiCheckResult(
[property: JsonPropertyName("allApisAbsent")] bool AllApisAbsent,
[property: JsonPropertyName("absentApis")] ImmutableArray<string> AbsentApis,
[property: JsonPropertyName("presentApis")] ImmutableArray<string> PresentApis,
[property: JsonPropertyName("reason")] string Reason);
/// <summary>
/// Result of VEX candidate emission.
/// </summary>
public sealed record VexCandidateEmissionResult(
[property: JsonPropertyName("imageDigest")] string ImageDigest,
[property: JsonPropertyName("candidatesEmitted")] int CandidatesEmitted,
[property: JsonPropertyName("candidates")] ImmutableArray<VexCandidate> Candidates,
[property: JsonPropertyName("timestamp")] DateTimeOffset Timestamp);
/// <summary>
/// Context for VEX candidate emission.
/// </summary>
public sealed record VexCandidateEmissionContext(
string PreviousScanId,
string CurrentScanId,
string TargetImageDigest,
IReadOnlyList<FindingSnapshot> PreviousFindings,
IReadOnlyList<FindingSnapshot> CurrentFindings,
CallGraphSnapshot? PreviousCallGraph,
CallGraphSnapshot? CurrentCallGraph);
/// <summary>
/// Snapshot of a finding for VEX evaluation.
/// </summary>
public sealed record FindingSnapshot(
[property: JsonPropertyName("findingKey")] FindingKey FindingKey,
[property: JsonPropertyName("vexStatus")] VexStatusType VexStatus,
[property: JsonPropertyName("vulnerableApis")] ImmutableArray<string> VulnerableApis);
/// <summary>
/// Snapshot of call graph for API presence checking.
/// </summary>
public sealed class CallGraphSnapshot
{
private readonly HashSet<string> _symbols;
public string Digest { get; }
public CallGraphSnapshot(string digest, IEnumerable<string> symbols)
{
Digest = digest;
_symbols = new HashSet<string>(symbols, StringComparer.Ordinal);
}
public bool ContainsSymbol(string symbol) => _symbols.Contains(symbol);
public int SymbolCount => _symbols.Count;
}
/// <summary>
/// Configuration for VEX candidate emission.
/// </summary>
public sealed class VexCandidateEmitterOptions
{
public static readonly VexCandidateEmitterOptions Default = new();
/// <summary>
/// Maximum candidates to emit per image.
/// </summary>
public int MaxCandidatesPerImage { get; init; } = 50;
/// <summary>
/// Whether to persist candidates to storage.
/// </summary>
public bool PersistCandidates { get; init; } = true;
/// <summary>
/// TTL for generated candidates.
/// </summary>
public TimeSpan CandidateTtl { get; init; } = TimeSpan.FromDays(30);
/// <summary>
/// Minimum confidence threshold for emission.
/// </summary>
public double MinConfidence { get; init; } = 0.7;
}
/// <summary>
/// Interface for VEX candidate storage.
/// </summary>
public interface IVexCandidateStore
{
/// <summary>
/// Store candidates.
/// </summary>
Task StoreCandidatesAsync(IReadOnlyList<VexCandidate> candidates, CancellationToken ct = default);
/// <summary>
/// Get candidates for an image.
/// </summary>
Task<IReadOnlyList<VexCandidate>> GetCandidatesAsync(string imageDigest, CancellationToken ct = default);
/// <summary>
/// Get a specific candidate by ID.
/// </summary>
Task<VexCandidate?> GetCandidateAsync(string candidateId, CancellationToken ct = default);
/// <summary>
/// Mark a candidate as reviewed.
/// </summary>
Task<bool> ReviewCandidateAsync(string candidateId, VexCandidateReview review, CancellationToken ct = default);
}
/// <summary>
/// Review action for a VEX candidate.
/// </summary>
public sealed record VexCandidateReview(
[property: JsonPropertyName("action")] VexReviewAction Action,
[property: JsonPropertyName("reviewer")] string Reviewer,
[property: JsonPropertyName("comment")] string? Comment,
[property: JsonPropertyName("reviewedAt")] DateTimeOffset ReviewedAt);
/// <summary>
/// Review action types.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<VexReviewAction>))]
public enum VexReviewAction
{
[JsonStringEnumMemberName("accept")]
Accept,
[JsonStringEnumMemberName("reject")]
Reject,
[JsonStringEnumMemberName("defer")]
Defer
}

View File

@@ -0,0 +1,370 @@
-- Migration: 005_smart_diff_tables
-- Sprint: SPRINT_3500_0003_0001_smart_diff_detection
-- Task: SDIFF-DET-016
-- Description: Smart-Diff risk state snapshots, material changes, and VEX candidates
-- Ensure scanner schema exists
CREATE SCHEMA IF NOT EXISTS scanner;
-- =============================================================================
-- Enums for Smart-Diff
-- =============================================================================
-- VEX status types
DO $$ BEGIN
CREATE TYPE scanner.vex_status_type AS ENUM (
'unknown',
'affected',
'not_affected',
'fixed',
'under_investigation'
);
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
-- Policy decision types
DO $$ BEGIN
CREATE TYPE scanner.policy_decision_type AS ENUM (
'allow',
'warn',
'block'
);
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
-- Detection rule types
DO $$ BEGIN
CREATE TYPE scanner.detection_rule AS ENUM (
'R1_ReachabilityFlip',
'R2_VexFlip',
'R3_RangeBoundary',
'R4_IntelligenceFlip'
);
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
-- Material change types
DO $$ BEGIN
CREATE TYPE scanner.material_change_type AS ENUM (
'reachability_flip',
'vex_flip',
'range_boundary',
'kev_added',
'kev_removed',
'epss_threshold',
'policy_flip'
);
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
-- Risk direction
DO $$ BEGIN
CREATE TYPE scanner.risk_direction AS ENUM (
'increased',
'decreased',
'neutral'
);
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
-- VEX justification codes
DO $$ BEGIN
CREATE TYPE scanner.vex_justification AS ENUM (
'component_not_present',
'vulnerable_code_not_present',
'vulnerable_code_not_in_execute_path',
'vulnerable_code_cannot_be_controlled_by_adversary',
'inline_mitigations_already_exist'
);
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
-- VEX review actions
DO $$ BEGIN
CREATE TYPE scanner.vex_review_action AS ENUM (
'accept',
'reject',
'defer'
);
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
-- =============================================================================
-- Table: scanner.risk_state_snapshots
-- Purpose: Store point-in-time risk state for findings
-- =============================================================================
CREATE TABLE IF NOT EXISTS scanner.risk_state_snapshots (
-- Identity
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
-- Finding identification (composite key)
vuln_id TEXT NOT NULL,
purl TEXT NOT NULL,
-- Scan context
scan_id TEXT NOT NULL,
captured_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Risk state dimensions
reachable BOOLEAN,
lattice_state TEXT,
vex_status scanner.vex_status_type NOT NULL DEFAULT 'unknown',
in_affected_range BOOLEAN,
-- Intelligence signals
kev BOOLEAN NOT NULL DEFAULT FALSE,
epss_score NUMERIC(5, 4),
-- Policy state
policy_flags TEXT[] DEFAULT '{}',
policy_decision scanner.policy_decision_type,
-- State hash for change detection (deterministic)
state_hash TEXT NOT NULL,
-- Audit
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT risk_state_unique_per_scan UNIQUE (tenant_id, scan_id, vuln_id, purl)
);
-- Indexes for risk_state_snapshots
CREATE INDEX IF NOT EXISTS idx_risk_state_tenant_finding
ON scanner.risk_state_snapshots (tenant_id, vuln_id, purl);
CREATE INDEX IF NOT EXISTS idx_risk_state_scan
ON scanner.risk_state_snapshots (scan_id);
CREATE INDEX IF NOT EXISTS idx_risk_state_captured_at
ON scanner.risk_state_snapshots USING BRIN (captured_at);
CREATE INDEX IF NOT EXISTS idx_risk_state_hash
ON scanner.risk_state_snapshots (state_hash);
-- =============================================================================
-- Table: scanner.material_risk_changes
-- Purpose: Store detected material risk changes between scans
-- =============================================================================
CREATE TABLE IF NOT EXISTS scanner.material_risk_changes (
-- Identity
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
-- Finding identification
vuln_id TEXT NOT NULL,
purl TEXT NOT NULL,
-- Scan context
scan_id TEXT NOT NULL,
-- Change summary
has_material_change BOOLEAN NOT NULL DEFAULT FALSE,
priority_score NUMERIC(6, 4) NOT NULL DEFAULT 0,
-- State hashes
previous_state_hash TEXT NOT NULL,
current_state_hash TEXT NOT NULL,
-- Detected changes (JSONB array)
changes JSONB NOT NULL DEFAULT '[]',
-- Audit
detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT material_change_unique_per_scan UNIQUE (tenant_id, scan_id, vuln_id, purl)
);
-- Indexes for material_risk_changes
CREATE INDEX IF NOT EXISTS idx_material_changes_tenant_scan
ON scanner.material_risk_changes (tenant_id, scan_id);
CREATE INDEX IF NOT EXISTS idx_material_changes_priority
ON scanner.material_risk_changes (priority_score DESC)
WHERE has_material_change = TRUE;
CREATE INDEX IF NOT EXISTS idx_material_changes_detected_at
ON scanner.material_risk_changes USING BRIN (detected_at);
-- GIN index for JSON querying
CREATE INDEX IF NOT EXISTS idx_material_changes_changes_gin
ON scanner.material_risk_changes USING GIN (changes);
-- =============================================================================
-- Table: scanner.vex_candidates
-- Purpose: Store auto-generated VEX candidates for review
-- =============================================================================
CREATE TABLE IF NOT EXISTS scanner.vex_candidates (
-- Identity
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
candidate_id TEXT NOT NULL UNIQUE,
tenant_id UUID NOT NULL,
-- Finding identification
vuln_id TEXT NOT NULL,
purl TEXT NOT NULL,
-- Image context
image_digest TEXT NOT NULL,
-- Suggested VEX assertion
suggested_status scanner.vex_status_type NOT NULL,
justification scanner.vex_justification NOT NULL,
rationale TEXT NOT NULL,
-- Evidence links (JSONB array)
evidence_links JSONB NOT NULL DEFAULT '[]',
-- Confidence and validity
confidence NUMERIC(4, 3) NOT NULL,
generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
-- Review state
requires_review BOOLEAN NOT NULL DEFAULT TRUE,
review_action scanner.vex_review_action,
reviewed_by TEXT,
reviewed_at TIMESTAMPTZ,
review_comment TEXT,
-- Audit
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Indexes for vex_candidates
CREATE INDEX IF NOT EXISTS idx_vex_candidates_tenant_image
ON scanner.vex_candidates (tenant_id, image_digest);
CREATE INDEX IF NOT EXISTS idx_vex_candidates_pending_review
ON scanner.vex_candidates (tenant_id, requires_review, confidence DESC)
WHERE requires_review = TRUE;
CREATE INDEX IF NOT EXISTS idx_vex_candidates_expires
ON scanner.vex_candidates (expires_at);
CREATE INDEX IF NOT EXISTS idx_vex_candidates_candidate_id
ON scanner.vex_candidates (candidate_id);
-- GIN index for evidence links
CREATE INDEX IF NOT EXISTS idx_vex_candidates_evidence_gin
ON scanner.vex_candidates USING GIN (evidence_links);
-- =============================================================================
-- RLS Policies (for multi-tenant isolation)
-- =============================================================================
-- Enable RLS
ALTER TABLE scanner.risk_state_snapshots ENABLE ROW LEVEL SECURITY;
ALTER TABLE scanner.material_risk_changes ENABLE ROW LEVEL SECURITY;
ALTER TABLE scanner.vex_candidates ENABLE ROW LEVEL SECURITY;
-- RLS function for tenant isolation
CREATE OR REPLACE FUNCTION scanner.current_tenant_id()
RETURNS UUID AS $$
BEGIN
RETURN NULLIF(current_setting('app.current_tenant_id', TRUE), '')::UUID;
END;
$$ LANGUAGE plpgsql STABLE;
-- Policies for risk_state_snapshots
DROP POLICY IF EXISTS risk_state_tenant_isolation ON scanner.risk_state_snapshots;
CREATE POLICY risk_state_tenant_isolation ON scanner.risk_state_snapshots
USING (tenant_id = scanner.current_tenant_id());
-- Policies for material_risk_changes
DROP POLICY IF EXISTS material_changes_tenant_isolation ON scanner.material_risk_changes;
CREATE POLICY material_changes_tenant_isolation ON scanner.material_risk_changes
USING (tenant_id = scanner.current_tenant_id());
-- Policies for vex_candidates
DROP POLICY IF EXISTS vex_candidates_tenant_isolation ON scanner.vex_candidates;
CREATE POLICY vex_candidates_tenant_isolation ON scanner.vex_candidates
USING (tenant_id = scanner.current_tenant_id());
-- =============================================================================
-- Helper Functions
-- =============================================================================
-- Function to get material changes for a scan
CREATE OR REPLACE FUNCTION scanner.get_material_changes_for_scan(
p_scan_id TEXT,
p_min_priority NUMERIC DEFAULT NULL
)
RETURNS TABLE (
vuln_id TEXT,
purl TEXT,
priority_score NUMERIC,
changes JSONB
) AS $$
BEGIN
RETURN QUERY
SELECT
mc.vuln_id,
mc.purl,
mc.priority_score,
mc.changes
FROM scanner.material_risk_changes mc
WHERE mc.scan_id = p_scan_id
AND mc.has_material_change = TRUE
AND (p_min_priority IS NULL OR mc.priority_score >= p_min_priority)
ORDER BY mc.priority_score DESC;
END;
$$ LANGUAGE plpgsql STABLE;
-- Function to get pending VEX candidates for review
CREATE OR REPLACE FUNCTION scanner.get_pending_vex_candidates(
p_image_digest TEXT DEFAULT NULL,
p_min_confidence NUMERIC DEFAULT 0.7,
p_limit INT DEFAULT 50
)
RETURNS TABLE (
candidate_id TEXT,
vuln_id TEXT,
purl TEXT,
image_digest TEXT,
suggested_status scanner.vex_status_type,
justification scanner.vex_justification,
rationale TEXT,
confidence NUMERIC,
evidence_links JSONB
) AS $$
BEGIN
RETURN QUERY
SELECT
vc.candidate_id,
vc.vuln_id,
vc.purl,
vc.image_digest,
vc.suggested_status,
vc.justification,
vc.rationale,
vc.confidence,
vc.evidence_links
FROM scanner.vex_candidates vc
WHERE vc.requires_review = TRUE
AND vc.expires_at > NOW()
AND vc.confidence >= p_min_confidence
AND (p_image_digest IS NULL OR vc.image_digest = p_image_digest)
ORDER BY vc.confidence DESC
LIMIT p_limit;
END;
$$ LANGUAGE plpgsql STABLE;
-- =============================================================================
-- Comments
-- =============================================================================
COMMENT ON TABLE scanner.risk_state_snapshots IS
'Point-in-time risk state snapshots for Smart-Diff change detection';
COMMENT ON TABLE scanner.material_risk_changes IS
'Detected material risk changes between scans (R1-R4 rules)';
COMMENT ON TABLE scanner.vex_candidates IS
'Auto-generated VEX candidates based on absent vulnerable APIs';
COMMENT ON COLUMN scanner.risk_state_snapshots.state_hash IS
'SHA-256 of normalized state for deterministic change detection';
COMMENT ON COLUMN scanner.material_risk_changes.changes IS
'JSONB array of DetectedChange records';
COMMENT ON COLUMN scanner.vex_candidates.evidence_links IS
'JSONB array of EvidenceLink records with type, uri, digest';

View File

@@ -0,0 +1,244 @@
using System.Collections.Immutable;
using System.Text.Json;
using Dapper;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Scanner.SmartDiff.Detection;
namespace StellaOps.Scanner.Storage.Postgres;
/// <summary>
/// PostgreSQL implementation of IMaterialRiskChangeRepository.
/// Per Sprint 3500.3 - Smart-Diff Detection Rules.
/// </summary>
public sealed class PostgresMaterialRiskChangeRepository : IMaterialRiskChangeRepository
{
private readonly ScannerDataSource _dataSource;
private readonly ILogger<PostgresMaterialRiskChangeRepository> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public PostgresMaterialRiskChangeRepository(
ScannerDataSource dataSource,
ILogger<PostgresMaterialRiskChangeRepository> logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task StoreChangeAsync(MaterialRiskChangeResult change, string scanId, CancellationToken ct = default)
{
await using var connection = await _dataSource.OpenConnectionAsync(ct);
await InsertChangeAsync(connection, change, scanId, ct);
}
public async Task StoreChangesAsync(IReadOnlyList<MaterialRiskChangeResult> changes, string scanId, CancellationToken ct = default)
{
if (changes.Count == 0)
return;
await using var connection = await _dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
try
{
foreach (var change in changes)
{
await InsertChangeAsync(connection, change, scanId, ct, transaction);
}
await transaction.CommitAsync(ct);
_logger.LogDebug("Stored {Count} material risk changes for scan {ScanId}", changes.Count, scanId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to store material risk changes for scan {ScanId}", scanId);
await transaction.RollbackAsync(ct);
throw;
}
}
public async Task<IReadOnlyList<MaterialRiskChangeResult>> GetChangesForScanAsync(string scanId, CancellationToken ct = default)
{
const string sql = """
SELECT
vuln_id, purl, has_material_change, priority_score,
previous_state_hash, current_state_hash, changes
FROM scanner.material_risk_changes
WHERE scan_id = @ScanId
ORDER BY priority_score DESC
""";
await using var connection = await _dataSource.OpenConnectionAsync(ct);
var rows = await connection.QueryAsync<MaterialRiskChangeRow>(sql, new { ScanId = scanId });
return rows.Select(r => r.ToResult()).ToList();
}
public async Task<IReadOnlyList<MaterialRiskChangeResult>> GetChangesForFindingAsync(
FindingKey findingKey,
int limit = 10,
CancellationToken ct = default)
{
const string sql = """
SELECT
vuln_id, purl, has_material_change, priority_score,
previous_state_hash, current_state_hash, changes
FROM scanner.material_risk_changes
WHERE vuln_id = @VulnId AND purl = @Purl
ORDER BY detected_at DESC
LIMIT @Limit
""";
await using var connection = await _dataSource.OpenConnectionAsync(ct);
var rows = await connection.QueryAsync<MaterialRiskChangeRow>(sql, new
{
VulnId = findingKey.VulnId,
Purl = findingKey.Purl,
Limit = limit
});
return rows.Select(r => r.ToResult()).ToList();
}
public async Task<MaterialRiskChangeQueryResult> QueryChangesAsync(
MaterialRiskChangeQuery query,
CancellationToken ct = default)
{
var conditions = new List<string> { "has_material_change = TRUE" };
var parameters = new DynamicParameters();
if (!string.IsNullOrEmpty(query.ImageDigest))
{
// Would need a join with scan metadata for image filtering
// For now, skip this filter
}
if (query.Since.HasValue)
{
conditions.Add("detected_at >= @Since");
parameters.Add("Since", query.Since.Value);
}
if (query.Until.HasValue)
{
conditions.Add("detected_at <= @Until");
parameters.Add("Until", query.Until.Value);
}
if (query.MinPriorityScore.HasValue)
{
conditions.Add("priority_score >= @MinPriority");
parameters.Add("MinPriority", query.MinPriorityScore.Value);
}
var whereClause = string.Join(" AND ", conditions);
// Count query
var countSql = $"SELECT COUNT(*) FROM scanner.material_risk_changes WHERE {whereClause}";
// Data query
var dataSql = $"""
SELECT
vuln_id, purl, has_material_change, priority_score,
previous_state_hash, current_state_hash, changes
FROM scanner.material_risk_changes
WHERE {whereClause}
ORDER BY priority_score DESC
OFFSET @Offset LIMIT @Limit
""";
parameters.Add("Offset", query.Offset);
parameters.Add("Limit", query.Limit);
await using var connection = await _dataSource.OpenConnectionAsync(ct);
var totalCount = await connection.ExecuteScalarAsync<int>(countSql, parameters);
var rows = await connection.QueryAsync<MaterialRiskChangeRow>(dataSql, parameters);
var changes = rows.Select(r => r.ToResult()).ToImmutableArray();
return new MaterialRiskChangeQueryResult(
Changes: changes,
TotalCount: totalCount,
Offset: query.Offset,
Limit: query.Limit);
}
private static async Task InsertChangeAsync(
NpgsqlConnection connection,
MaterialRiskChangeResult change,
string scanId,
CancellationToken ct,
NpgsqlTransaction? transaction = null)
{
const string sql = """
INSERT INTO scanner.material_risk_changes (
tenant_id, vuln_id, purl, scan_id,
has_material_change, priority_score,
previous_state_hash, current_state_hash, changes
) VALUES (
@TenantId, @VulnId, @Purl, @ScanId,
@HasMaterialChange, @PriorityScore,
@PreviousStateHash, @CurrentStateHash, @Changes::jsonb
)
ON CONFLICT (tenant_id, scan_id, vuln_id, purl) DO UPDATE SET
has_material_change = EXCLUDED.has_material_change,
priority_score = EXCLUDED.priority_score,
previous_state_hash = EXCLUDED.previous_state_hash,
current_state_hash = EXCLUDED.current_state_hash,
changes = EXCLUDED.changes
""";
var tenantId = GetCurrentTenantId();
var changesJson = JsonSerializer.Serialize(change.Changes, JsonOptions);
await connection.ExecuteAsync(new CommandDefinition(sql, new
{
TenantId = tenantId,
VulnId = change.FindingKey.VulnId,
Purl = change.FindingKey.Purl,
ScanId = scanId,
HasMaterialChange = change.HasMaterialChange,
PriorityScore = change.PriorityScore,
PreviousStateHash = change.PreviousStateHash,
CurrentStateHash = change.CurrentStateHash,
Changes = changesJson
}, transaction: transaction, cancellationToken: ct));
}
private static Guid GetCurrentTenantId()
{
return Guid.Parse("00000000-0000-0000-0000-000000000001");
}
/// <summary>
/// Row mapping class for Dapper.
/// </summary>
private sealed class MaterialRiskChangeRow
{
public string vuln_id { get; set; } = "";
public string purl { get; set; } = "";
public bool has_material_change { get; set; }
public decimal priority_score { get; set; }
public string previous_state_hash { get; set; } = "";
public string current_state_hash { get; set; } = "";
public string changes { get; set; } = "[]";
public MaterialRiskChangeResult ToResult()
{
var detectedChanges = JsonSerializer.Deserialize<List<DetectedChange>>(changes, JsonOptions)
?? [];
return new MaterialRiskChangeResult(
FindingKey: new FindingKey(vuln_id, purl),
HasMaterialChange: has_material_change,
Changes: [.. detectedChanges],
PriorityScore: (int)priority_score,
PreviousStateHash: previous_state_hash,
CurrentStateHash: current_state_hash);
}
}
}

View File

@@ -0,0 +1,261 @@
using System.Collections.Immutable;
using System.Data;
using System.Text.Json;
using Dapper;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Scanner.SmartDiff.Detection;
namespace StellaOps.Scanner.Storage.Postgres;
/// <summary>
/// PostgreSQL implementation of IRiskStateRepository.
/// Per Sprint 3500.3 - Smart-Diff Detection Rules.
/// </summary>
public sealed class PostgresRiskStateRepository : IRiskStateRepository
{
private readonly ScannerDataSource _dataSource;
private readonly ILogger<PostgresRiskStateRepository> _logger;
public PostgresRiskStateRepository(
ScannerDataSource dataSource,
ILogger<PostgresRiskStateRepository> logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task StoreSnapshotAsync(RiskStateSnapshot snapshot, CancellationToken ct = default)
{
await using var connection = await _dataSource.OpenConnectionAsync(ct);
await InsertSnapshotAsync(connection, snapshot, ct);
}
public async Task StoreSnapshotsAsync(IReadOnlyList<RiskStateSnapshot> snapshots, CancellationToken ct = default)
{
if (snapshots.Count == 0)
return;
await using var connection = await _dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
try
{
foreach (var snapshot in snapshots)
{
await InsertSnapshotAsync(connection, snapshot, ct, transaction);
}
await transaction.CommitAsync(ct);
}
catch
{
await transaction.RollbackAsync(ct);
throw;
}
}
public async Task<RiskStateSnapshot?> GetLatestSnapshotAsync(FindingKey findingKey, CancellationToken ct = default)
{
const string sql = """
SELECT
vuln_id, purl, scan_id, captured_at,
reachable, lattice_state, vex_status::TEXT, in_affected_range,
kev, epss_score, policy_flags, policy_decision::TEXT, state_hash
FROM scanner.risk_state_snapshots
WHERE vuln_id = @VulnId AND purl = @Purl
ORDER BY captured_at DESC
LIMIT 1
""";
await using var connection = await _dataSource.OpenConnectionAsync(ct);
var row = await connection.QuerySingleOrDefaultAsync<RiskStateRow>(sql, new
{
VulnId = findingKey.VulnId,
Purl = findingKey.Purl
});
return row?.ToSnapshot();
}
public async Task<IReadOnlyList<RiskStateSnapshot>> GetSnapshotsForScanAsync(string scanId, CancellationToken ct = default)
{
const string sql = """
SELECT
vuln_id, purl, scan_id, captured_at,
reachable, lattice_state, vex_status::TEXT, in_affected_range,
kev, epss_score, policy_flags, policy_decision::TEXT, state_hash
FROM scanner.risk_state_snapshots
WHERE scan_id = @ScanId
ORDER BY vuln_id, purl
""";
await using var connection = await _dataSource.OpenConnectionAsync(ct);
var rows = await connection.QueryAsync<RiskStateRow>(sql, new { ScanId = scanId });
return rows.Select(r => r.ToSnapshot()).ToList();
}
public async Task<IReadOnlyList<RiskStateSnapshot>> GetSnapshotHistoryAsync(
FindingKey findingKey,
int limit = 10,
CancellationToken ct = default)
{
const string sql = """
SELECT
vuln_id, purl, scan_id, captured_at,
reachable, lattice_state, vex_status::TEXT, in_affected_range,
kev, epss_score, policy_flags, policy_decision::TEXT, state_hash
FROM scanner.risk_state_snapshots
WHERE vuln_id = @VulnId AND purl = @Purl
ORDER BY captured_at DESC
LIMIT @Limit
""";
await using var connection = await _dataSource.OpenConnectionAsync(ct);
var rows = await connection.QueryAsync<RiskStateRow>(sql, new
{
VulnId = findingKey.VulnId,
Purl = findingKey.Purl,
Limit = limit
});
return rows.Select(r => r.ToSnapshot()).ToList();
}
public async Task<IReadOnlyList<RiskStateSnapshot>> GetSnapshotsByHashAsync(string stateHash, CancellationToken ct = default)
{
const string sql = """
SELECT
vuln_id, purl, scan_id, captured_at,
reachable, lattice_state, vex_status::TEXT, in_affected_range,
kev, epss_score, policy_flags, policy_decision::TEXT, state_hash
FROM scanner.risk_state_snapshots
WHERE state_hash = @StateHash
ORDER BY captured_at DESC
""";
await using var connection = await _dataSource.OpenConnectionAsync(ct);
var rows = await connection.QueryAsync<RiskStateRow>(sql, new { StateHash = stateHash });
return rows.Select(r => r.ToSnapshot()).ToList();
}
private static async Task InsertSnapshotAsync(
NpgsqlConnection connection,
RiskStateSnapshot snapshot,
CancellationToken ct,
NpgsqlTransaction? transaction = null)
{
const string sql = """
INSERT INTO scanner.risk_state_snapshots (
tenant_id, vuln_id, purl, scan_id, captured_at,
reachable, lattice_state, vex_status, in_affected_range,
kev, epss_score, policy_flags, policy_decision, state_hash
) VALUES (
@TenantId, @VulnId, @Purl, @ScanId, @CapturedAt,
@Reachable, @LatticeState, @VexStatus::scanner.vex_status_type, @InAffectedRange,
@Kev, @EpssScore, @PolicyFlags, @PolicyDecision::scanner.policy_decision_type, @StateHash
)
ON CONFLICT (tenant_id, scan_id, vuln_id, purl) DO UPDATE SET
reachable = EXCLUDED.reachable,
lattice_state = EXCLUDED.lattice_state,
vex_status = EXCLUDED.vex_status,
in_affected_range = EXCLUDED.in_affected_range,
kev = EXCLUDED.kev,
epss_score = EXCLUDED.epss_score,
policy_flags = EXCLUDED.policy_flags,
policy_decision = EXCLUDED.policy_decision,
state_hash = EXCLUDED.state_hash
""";
var tenantId = GetCurrentTenantId();
await connection.ExecuteAsync(new CommandDefinition(sql, new
{
TenantId = tenantId,
VulnId = snapshot.FindingKey.VulnId,
Purl = snapshot.FindingKey.Purl,
ScanId = snapshot.ScanId,
CapturedAt = snapshot.CapturedAt,
Reachable = snapshot.Reachable,
LatticeState = snapshot.LatticeState,
VexStatus = snapshot.VexStatus.ToString().ToLowerInvariant(),
InAffectedRange = snapshot.InAffectedRange,
Kev = snapshot.Kev,
EpssScore = snapshot.EpssScore,
PolicyFlags = snapshot.PolicyFlags.ToArray(),
PolicyDecision = snapshot.PolicyDecision?.ToString().ToLowerInvariant(),
StateHash = snapshot.ComputeStateHash()
}, transaction: transaction, cancellationToken: ct));
}
private static Guid GetCurrentTenantId()
{
// In production, this would come from the current context
// For now, return a default tenant ID
return Guid.Parse("00000000-0000-0000-0000-000000000001");
}
/// <summary>
/// Row mapping class for Dapper.
/// </summary>
private sealed class RiskStateRow
{
public string vuln_id { get; set; } = "";
public string purl { get; set; } = "";
public string scan_id { get; set; } = "";
public DateTimeOffset captured_at { get; set; }
public bool? reachable { get; set; }
public string? lattice_state { get; set; }
public string vex_status { get; set; } = "unknown";
public bool? in_affected_range { get; set; }
public bool kev { get; set; }
public decimal? epss_score { get; set; }
public string[]? policy_flags { get; set; }
public string? policy_decision { get; set; }
public string state_hash { get; set; } = "";
public RiskStateSnapshot ToSnapshot()
{
return new RiskStateSnapshot(
FindingKey: new FindingKey(vuln_id, purl),
ScanId: scan_id,
CapturedAt: captured_at,
Reachable: reachable,
LatticeState: lattice_state,
VexStatus: ParseVexStatus(vex_status),
InAffectedRange: in_affected_range,
Kev: kev,
EpssScore: epss_score.HasValue ? (double)epss_score.Value : null,
PolicyFlags: policy_flags?.ToImmutableArray() ?? [],
PolicyDecision: ParsePolicyDecision(policy_decision));
}
private static VexStatusType ParseVexStatus(string value)
{
return value.ToLowerInvariant() switch
{
"affected" => VexStatusType.Affected,
"not_affected" => VexStatusType.NotAffected,
"fixed" => VexStatusType.Fixed,
"under_investigation" => VexStatusType.UnderInvestigation,
_ => VexStatusType.Unknown
};
}
private static PolicyDecisionType? ParsePolicyDecision(string? value)
{
if (string.IsNullOrEmpty(value))
return null;
return value.ToLowerInvariant() switch
{
"allow" => PolicyDecisionType.Allow,
"warn" => PolicyDecisionType.Warn,
"block" => PolicyDecisionType.Block,
_ => null
};
}
}
}

View File

@@ -0,0 +1,268 @@
using System.Collections.Immutable;
using System.Text.Json;
using Dapper;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Scanner.SmartDiff.Detection;
namespace StellaOps.Scanner.Storage.Postgres;
/// <summary>
/// PostgreSQL implementation of IVexCandidateStore.
/// Per Sprint 3500.3 - Smart-Diff Detection Rules.
/// </summary>
public sealed class PostgresVexCandidateStore : IVexCandidateStore
{
private readonly ScannerDataSource _dataSource;
private readonly ILogger<PostgresVexCandidateStore> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public PostgresVexCandidateStore(
ScannerDataSource dataSource,
ILogger<PostgresVexCandidateStore> logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task StoreCandidatesAsync(IReadOnlyList<VexCandidate> candidates, CancellationToken ct = default)
{
if (candidates.Count == 0)
return;
await using var connection = await _dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
try
{
foreach (var candidate in candidates)
{
await InsertCandidateAsync(connection, candidate, ct, transaction);
}
await transaction.CommitAsync(ct);
_logger.LogDebug("Stored {Count} VEX candidates", candidates.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to store VEX candidates");
await transaction.RollbackAsync(ct);
throw;
}
}
public async Task<IReadOnlyList<VexCandidate>> GetCandidatesAsync(string imageDigest, CancellationToken ct = default)
{
const string sql = """
SELECT
candidate_id, vuln_id, purl, image_digest,
suggested_status::TEXT, justification::TEXT, rationale,
evidence_links, confidence, generated_at, expires_at,
requires_review, review_action::TEXT, reviewed_by, reviewed_at, review_comment
FROM scanner.vex_candidates
WHERE image_digest = @ImageDigest
ORDER BY confidence DESC
""";
await using var connection = await _dataSource.OpenConnectionAsync(ct);
var rows = await connection.QueryAsync<VexCandidateRow>(sql, new { ImageDigest = imageDigest });
return rows.Select(r => r.ToCandidate()).ToList();
}
public async Task<VexCandidate?> GetCandidateAsync(string candidateId, CancellationToken ct = default)
{
const string sql = """
SELECT
candidate_id, vuln_id, purl, image_digest,
suggested_status::TEXT, justification::TEXT, rationale,
evidence_links, confidence, generated_at, expires_at,
requires_review, review_action::TEXT, reviewed_by, reviewed_at, review_comment
FROM scanner.vex_candidates
WHERE candidate_id = @CandidateId
""";
await using var connection = await _dataSource.OpenConnectionAsync(ct);
var row = await connection.QuerySingleOrDefaultAsync<VexCandidateRow>(sql, new { CandidateId = candidateId });
return row?.ToCandidate();
}
public async Task<bool> ReviewCandidateAsync(string candidateId, VexCandidateReview review, CancellationToken ct = default)
{
const string sql = """
UPDATE scanner.vex_candidates SET
requires_review = FALSE,
review_action = @ReviewAction::scanner.vex_review_action,
reviewed_by = @ReviewedBy,
reviewed_at = @ReviewedAt,
review_comment = @ReviewComment
WHERE candidate_id = @CandidateId
""";
await using var connection = await _dataSource.OpenConnectionAsync(ct);
var affected = await connection.ExecuteAsync(sql, new
{
CandidateId = candidateId,
ReviewAction = review.Action.ToString().ToLowerInvariant(),
ReviewedBy = review.Reviewer,
ReviewedAt = review.ReviewedAt,
ReviewComment = review.Comment
});
if (affected > 0)
{
_logger.LogInformation("Reviewed VEX candidate {CandidateId} with action {Action}",
candidateId, review.Action);
}
return affected > 0;
}
private static async Task InsertCandidateAsync(
NpgsqlConnection connection,
VexCandidate candidate,
CancellationToken ct,
NpgsqlTransaction? transaction = null)
{
const string sql = """
INSERT INTO scanner.vex_candidates (
tenant_id, candidate_id, vuln_id, purl, image_digest,
suggested_status, justification, rationale,
evidence_links, confidence, generated_at, expires_at, requires_review
) VALUES (
@TenantId, @CandidateId, @VulnId, @Purl, @ImageDigest,
@SuggestedStatus::scanner.vex_status_type, @Justification::scanner.vex_justification, @Rationale,
@EvidenceLinks::jsonb, @Confidence, @GeneratedAt, @ExpiresAt, @RequiresReview
)
ON CONFLICT (candidate_id) DO UPDATE SET
suggested_status = EXCLUDED.suggested_status,
justification = EXCLUDED.justification,
rationale = EXCLUDED.rationale,
evidence_links = EXCLUDED.evidence_links,
confidence = EXCLUDED.confidence,
expires_at = EXCLUDED.expires_at
""";
var tenantId = GetCurrentTenantId();
var evidenceLinksJson = JsonSerializer.Serialize(candidate.EvidenceLinks, JsonOptions);
await connection.ExecuteAsync(new CommandDefinition(sql, new
{
TenantId = tenantId,
CandidateId = candidate.CandidateId,
VulnId = candidate.FindingKey.VulnId,
Purl = candidate.FindingKey.Purl,
ImageDigest = candidate.ImageDigest,
SuggestedStatus = MapVexStatus(candidate.SuggestedStatus),
Justification = MapJustification(candidate.Justification),
Rationale = candidate.Rationale,
EvidenceLinks = evidenceLinksJson,
Confidence = candidate.Confidence,
GeneratedAt = candidate.GeneratedAt,
ExpiresAt = candidate.ExpiresAt,
RequiresReview = candidate.RequiresReview
}, transaction: transaction, cancellationToken: ct));
}
private static string MapVexStatus(VexStatusType status)
{
return status switch
{
VexStatusType.Affected => "affected",
VexStatusType.NotAffected => "not_affected",
VexStatusType.Fixed => "fixed",
VexStatusType.UnderInvestigation => "under_investigation",
_ => "unknown"
};
}
private static string MapJustification(VexJustification justification)
{
return justification switch
{
VexJustification.ComponentNotPresent => "component_not_present",
VexJustification.VulnerableCodeNotPresent => "vulnerable_code_not_present",
VexJustification.VulnerableCodeNotInExecutePath => "vulnerable_code_not_in_execute_path",
VexJustification.VulnerableCodeCannotBeControlledByAdversary => "vulnerable_code_cannot_be_controlled_by_adversary",
VexJustification.InlineMitigationsAlreadyExist => "inline_mitigations_already_exist",
_ => "vulnerable_code_not_present"
};
}
private static Guid GetCurrentTenantId()
{
// In production, this would come from the current context
return Guid.Parse("00000000-0000-0000-0000-000000000001");
}
/// <summary>
/// Row mapping class for Dapper.
/// </summary>
private sealed class VexCandidateRow
{
public string candidate_id { get; set; } = "";
public string vuln_id { get; set; } = "";
public string purl { get; set; } = "";
public string image_digest { get; set; } = "";
public string suggested_status { get; set; } = "not_affected";
public string justification { get; set; } = "vulnerable_code_not_present";
public string rationale { get; set; } = "";
public string evidence_links { get; set; } = "[]";
public decimal confidence { get; set; }
public DateTimeOffset generated_at { get; set; }
public DateTimeOffset expires_at { get; set; }
public bool requires_review { get; set; }
public string? review_action { get; set; }
public string? reviewed_by { get; set; }
public DateTimeOffset? reviewed_at { get; set; }
public string? review_comment { get; set; }
public VexCandidate ToCandidate()
{
var links = JsonSerializer.Deserialize<List<EvidenceLink>>(evidence_links, JsonOptions)
?? [];
return new VexCandidate(
CandidateId: candidate_id,
FindingKey: new FindingKey(vuln_id, purl),
SuggestedStatus: ParseVexStatus(suggested_status),
Justification: ParseJustification(justification),
Rationale: rationale,
EvidenceLinks: [.. links],
Confidence: (double)confidence,
ImageDigest: image_digest,
GeneratedAt: generated_at,
ExpiresAt: expires_at,
RequiresReview: requires_review);
}
private static VexStatusType ParseVexStatus(string value)
{
return value.ToLowerInvariant() switch
{
"affected" => VexStatusType.Affected,
"not_affected" => VexStatusType.NotAffected,
"fixed" => VexStatusType.Fixed,
"under_investigation" => VexStatusType.UnderInvestigation,
_ => VexStatusType.Unknown
};
}
private static VexJustification ParseJustification(string value)
{
return value.ToLowerInvariant() switch
{
"component_not_present" => VexJustification.ComponentNotPresent,
"vulnerable_code_not_present" => VexJustification.VulnerableCodeNotPresent,
"vulnerable_code_not_in_execute_path" => VexJustification.VulnerableCodeNotInExecutePath,
"vulnerable_code_cannot_be_controlled_by_adversary" => VexJustification.VulnerableCodeCannotBeControlledByAdversary,
"inline_mitigations_already_exist" => VexJustification.InlineMitigationsAlreadyExist,
_ => VexJustification.VulnerableCodeNotPresent
};
}
}
}

View File

@@ -0,0 +1,472 @@
{
"$schema": "https://stellaops.io/schemas/smart-diff/v1/state-comparison.json",
"version": "1.0.0",
"description": "Golden fixtures for Smart-Diff state comparison determinism testing",
"testCases": [
{
"id": "R1-001",
"name": "Reachability flip: unreachable to reachable",
"rule": "R1_ReachabilityFlip",
"previous": {
"findingKey": {
"vulnId": "CVE-2024-1234",
"purl": "pkg:npm/lodash@4.17.20"
},
"scanId": "scan-prev-001",
"capturedAt": "2024-12-01T10:00:00Z",
"reachable": false,
"latticeState": "SU",
"vexStatus": "affected",
"inAffectedRange": true,
"kev": false,
"epssScore": 0.05,
"policyFlags": [],
"policyDecision": null
},
"current": {
"findingKey": {
"vulnId": "CVE-2024-1234",
"purl": "pkg:npm/lodash@4.17.20"
},
"scanId": "scan-curr-001",
"capturedAt": "2024-12-15T10:00:00Z",
"reachable": true,
"latticeState": "CR",
"vexStatus": "affected",
"inAffectedRange": true,
"kev": false,
"epssScore": 0.05,
"policyFlags": [],
"policyDecision": null
},
"expected": {
"hasMaterialChange": true,
"direction": "increased",
"changeType": "reachability_flip",
"priorityScoreContribution": 500
}
},
{
"id": "R1-002",
"name": "Reachability flip: reachable to unreachable",
"rule": "R1_ReachabilityFlip",
"previous": {
"findingKey": {
"vulnId": "CVE-2024-5678",
"purl": "pkg:pypi/requests@2.28.0"
},
"scanId": "scan-prev-002",
"capturedAt": "2024-12-01T10:00:00Z",
"reachable": true,
"latticeState": "CR",
"vexStatus": "affected",
"inAffectedRange": true,
"kev": false,
"epssScore": 0.10,
"policyFlags": [],
"policyDecision": null
},
"current": {
"findingKey": {
"vulnId": "CVE-2024-5678",
"purl": "pkg:pypi/requests@2.28.0"
},
"scanId": "scan-curr-002",
"capturedAt": "2024-12-15T10:00:00Z",
"reachable": false,
"latticeState": "CU",
"vexStatus": "affected",
"inAffectedRange": true,
"kev": false,
"epssScore": 0.10,
"policyFlags": [],
"policyDecision": null
},
"expected": {
"hasMaterialChange": true,
"direction": "decreased",
"changeType": "reachability_flip",
"priorityScoreContribution": 500
}
},
{
"id": "R2-001",
"name": "VEX flip: affected to not_affected",
"rule": "R2_VexFlip",
"previous": {
"findingKey": {
"vulnId": "CVE-2024-9999",
"purl": "pkg:maven/org.example/core@1.0.0"
},
"scanId": "scan-prev-003",
"capturedAt": "2024-12-01T10:00:00Z",
"reachable": true,
"latticeState": "SR",
"vexStatus": "affected",
"inAffectedRange": true,
"kev": false,
"epssScore": 0.02,
"policyFlags": [],
"policyDecision": null
},
"current": {
"findingKey": {
"vulnId": "CVE-2024-9999",
"purl": "pkg:maven/org.example/core@1.0.0"
},
"scanId": "scan-curr-003",
"capturedAt": "2024-12-15T10:00:00Z",
"reachable": true,
"latticeState": "SR",
"vexStatus": "not_affected",
"inAffectedRange": true,
"kev": false,
"epssScore": 0.02,
"policyFlags": [],
"policyDecision": null
},
"expected": {
"hasMaterialChange": true,
"direction": "decreased",
"changeType": "vex_flip",
"priorityScoreContribution": 150
}
},
{
"id": "R2-002",
"name": "VEX flip: not_affected to affected",
"rule": "R2_VexFlip",
"previous": {
"findingKey": {
"vulnId": "CVE-2024-8888",
"purl": "pkg:golang/github.com/example/pkg@v1.2.3"
},
"scanId": "scan-prev-004",
"capturedAt": "2024-12-01T10:00:00Z",
"reachable": true,
"latticeState": "SR",
"vexStatus": "not_affected",
"inAffectedRange": true,
"kev": false,
"epssScore": 0.03,
"policyFlags": [],
"policyDecision": null
},
"current": {
"findingKey": {
"vulnId": "CVE-2024-8888",
"purl": "pkg:golang/github.com/example/pkg@v1.2.3"
},
"scanId": "scan-curr-004",
"capturedAt": "2024-12-15T10:00:00Z",
"reachable": true,
"latticeState": "SR",
"vexStatus": "affected",
"inAffectedRange": true,
"kev": false,
"epssScore": 0.03,
"policyFlags": [],
"policyDecision": null
},
"expected": {
"hasMaterialChange": true,
"direction": "increased",
"changeType": "vex_flip",
"priorityScoreContribution": 150
}
},
{
"id": "R3-001",
"name": "Range boundary: exits affected range",
"rule": "R3_RangeBoundary",
"previous": {
"findingKey": {
"vulnId": "CVE-2024-7777",
"purl": "pkg:npm/express@4.17.0"
},
"scanId": "scan-prev-005",
"capturedAt": "2024-12-01T10:00:00Z",
"reachable": true,
"latticeState": "SR",
"vexStatus": "affected",
"inAffectedRange": true,
"kev": false,
"epssScore": 0.04,
"policyFlags": [],
"policyDecision": null
},
"current": {
"findingKey": {
"vulnId": "CVE-2024-7777",
"purl": "pkg:npm/express@4.18.0"
},
"scanId": "scan-curr-005",
"capturedAt": "2024-12-15T10:00:00Z",
"reachable": true,
"latticeState": "SR",
"vexStatus": "affected",
"inAffectedRange": false,
"kev": false,
"epssScore": 0.04,
"policyFlags": [],
"policyDecision": null
},
"expected": {
"hasMaterialChange": true,
"direction": "decreased",
"changeType": "range_boundary",
"priorityScoreContribution": 200
}
},
{
"id": "R4-001",
"name": "KEV added",
"rule": "R4_IntelligenceFlip",
"previous": {
"findingKey": {
"vulnId": "CVE-2024-6666",
"purl": "pkg:npm/axios@0.21.0"
},
"scanId": "scan-prev-006",
"capturedAt": "2024-12-01T10:00:00Z",
"reachable": true,
"latticeState": "SR",
"vexStatus": "affected",
"inAffectedRange": true,
"kev": false,
"epssScore": 0.08,
"policyFlags": [],
"policyDecision": null
},
"current": {
"findingKey": {
"vulnId": "CVE-2024-6666",
"purl": "pkg:npm/axios@0.21.0"
},
"scanId": "scan-curr-006",
"capturedAt": "2024-12-15T10:00:00Z",
"reachable": true,
"latticeState": "SR",
"vexStatus": "affected",
"inAffectedRange": true,
"kev": true,
"epssScore": 0.45,
"policyFlags": [],
"policyDecision": null
},
"expected": {
"hasMaterialChange": true,
"direction": "increased",
"changeType": "kev_added",
"priorityScoreContribution": 1000
}
},
{
"id": "R4-002",
"name": "EPSS crosses threshold (0.1)",
"rule": "R4_IntelligenceFlip",
"previous": {
"findingKey": {
"vulnId": "CVE-2024-5555",
"purl": "pkg:pypi/django@3.2.0"
},
"scanId": "scan-prev-007",
"capturedAt": "2024-12-01T10:00:00Z",
"reachable": true,
"latticeState": "SR",
"vexStatus": "affected",
"inAffectedRange": true,
"kev": false,
"epssScore": 0.05,
"policyFlags": [],
"policyDecision": null
},
"current": {
"findingKey": {
"vulnId": "CVE-2024-5555",
"purl": "pkg:pypi/django@3.2.0"
},
"scanId": "scan-curr-007",
"capturedAt": "2024-12-15T10:00:00Z",
"reachable": true,
"latticeState": "SR",
"vexStatus": "affected",
"inAffectedRange": true,
"kev": false,
"epssScore": 0.15,
"policyFlags": [],
"policyDecision": null
},
"expected": {
"hasMaterialChange": true,
"direction": "increased",
"changeType": "epss_threshold",
"priorityScoreContribution": 0
}
},
{
"id": "R4-003",
"name": "Policy flip: allow to block",
"rule": "R4_IntelligenceFlip",
"previous": {
"findingKey": {
"vulnId": "CVE-2024-4444",
"purl": "pkg:npm/moment@2.29.0"
},
"scanId": "scan-prev-008",
"capturedAt": "2024-12-01T10:00:00Z",
"reachable": true,
"latticeState": "SR",
"vexStatus": "affected",
"inAffectedRange": true,
"kev": false,
"epssScore": 0.06,
"policyFlags": [],
"policyDecision": "allow"
},
"current": {
"findingKey": {
"vulnId": "CVE-2024-4444",
"purl": "pkg:npm/moment@2.29.0"
},
"scanId": "scan-curr-008",
"capturedAt": "2024-12-15T10:00:00Z",
"reachable": true,
"latticeState": "SR",
"vexStatus": "affected",
"inAffectedRange": true,
"kev": false,
"epssScore": 0.06,
"policyFlags": ["HIGH_SEVERITY"],
"policyDecision": "block"
},
"expected": {
"hasMaterialChange": true,
"direction": "increased",
"changeType": "policy_flip",
"priorityScoreContribution": 300
}
},
{
"id": "MULTI-001",
"name": "Multiple changes: KEV + reachability flip",
"rule": "Multiple",
"previous": {
"findingKey": {
"vulnId": "CVE-2024-3333",
"purl": "pkg:npm/jquery@3.5.0"
},
"scanId": "scan-prev-009",
"capturedAt": "2024-12-01T10:00:00Z",
"reachable": false,
"latticeState": "SU",
"vexStatus": "affected",
"inAffectedRange": true,
"kev": false,
"epssScore": 0.07,
"policyFlags": [],
"policyDecision": null
},
"current": {
"findingKey": {
"vulnId": "CVE-2024-3333",
"purl": "pkg:npm/jquery@3.5.0"
},
"scanId": "scan-curr-009",
"capturedAt": "2024-12-15T10:00:00Z",
"reachable": true,
"latticeState": "CR",
"vexStatus": "affected",
"inAffectedRange": true,
"kev": true,
"epssScore": 0.35,
"policyFlags": [],
"policyDecision": null
},
"expected": {
"hasMaterialChange": true,
"direction": "increased",
"changeCount": 2,
"totalPriorityScore": 1500
}
},
{
"id": "NO-CHANGE-001",
"name": "No material change - identical states",
"rule": "None",
"previous": {
"findingKey": {
"vulnId": "CVE-2024-2222",
"purl": "pkg:npm/underscore@1.13.0"
},
"scanId": "scan-prev-010",
"capturedAt": "2024-12-01T10:00:00Z",
"reachable": true,
"latticeState": "SR",
"vexStatus": "affected",
"inAffectedRange": true,
"kev": false,
"epssScore": 0.02,
"policyFlags": [],
"policyDecision": null
},
"current": {
"findingKey": {
"vulnId": "CVE-2024-2222",
"purl": "pkg:npm/underscore@1.13.0"
},
"scanId": "scan-curr-010",
"capturedAt": "2024-12-15T10:00:00Z",
"reachable": true,
"latticeState": "SR",
"vexStatus": "affected",
"inAffectedRange": true,
"kev": false,
"epssScore": 0.02,
"policyFlags": [],
"policyDecision": null
},
"expected": {
"hasMaterialChange": false,
"changeCount": 0,
"totalPriorityScore": 0
}
}
],
"stateHashTestCases": [
{
"id": "HASH-001",
"name": "State hash determinism - same input produces same hash",
"state": {
"findingKey": {
"vulnId": "CVE-2024-1111",
"purl": "pkg:npm/test@1.0.0"
},
"scanId": "scan-hash-001",
"capturedAt": "2024-12-15T10:00:00Z",
"reachable": true,
"latticeState": "CR",
"vexStatus": "affected",
"inAffectedRange": true,
"kev": false,
"epssScore": 0.05,
"policyFlags": ["FLAG_A", "FLAG_B"],
"policyDecision": "warn"
},
"expectedHashPrefix": "sha256:"
},
{
"id": "HASH-002",
"name": "State hash differs with reachability change",
"state1": {
"reachable": true,
"vexStatus": "affected"
},
"state2": {
"reachable": false,
"vexStatus": "affected"
},
"expectDifferentHash": true
}
]
}

View File

@@ -0,0 +1,447 @@
using System.Collections.Immutable;
using StellaOps.Scanner.SmartDiff.Detection;
using Xunit;
namespace StellaOps.Scanner.SmartDiff.Tests;
public class MaterialRiskChangeDetectorTests
{
private readonly MaterialRiskChangeDetector _detector = new();
private static RiskStateSnapshot CreateSnapshot(
string vulnId = "CVE-2024-1234",
string purl = "pkg:npm/example@1.0.0",
string scanId = "scan-1",
bool? reachable = null,
VexStatusType vexStatus = VexStatusType.Unknown,
bool? inAffectedRange = null,
bool kev = false,
double? epssScore = null,
PolicyDecisionType? policyDecision = null)
{
return new RiskStateSnapshot(
FindingKey: new FindingKey(vulnId, purl),
ScanId: scanId,
CapturedAt: DateTimeOffset.UtcNow,
Reachable: reachable,
LatticeState: null,
VexStatus: vexStatus,
InAffectedRange: inAffectedRange,
Kev: kev,
EpssScore: epssScore,
PolicyFlags: [],
PolicyDecision: policyDecision);
}
#region R1: Reachability Flip Tests
[Fact]
public void R1_Detects_ReachabilityFlip_FalseToTrue()
{
// Arrange
var prev = CreateSnapshot(reachable: false);
var curr = CreateSnapshot(reachable: true);
// Act
var result = _detector.Compare(prev, curr);
// Assert
Assert.True(result.HasMaterialChange);
Assert.Single(result.Changes);
Assert.Equal(DetectionRule.R1_ReachabilityFlip, result.Changes[0].Rule);
Assert.Equal(RiskDirection.Increased, result.Changes[0].Direction);
}
[Fact]
public void R1_Detects_ReachabilityFlip_TrueToFalse()
{
// Arrange
var prev = CreateSnapshot(reachable: true);
var curr = CreateSnapshot(reachable: false);
// Act
var result = _detector.Compare(prev, curr);
// Assert
Assert.True(result.HasMaterialChange);
Assert.Single(result.Changes);
Assert.Equal(DetectionRule.R1_ReachabilityFlip, result.Changes[0].Rule);
Assert.Equal(RiskDirection.Decreased, result.Changes[0].Direction);
}
[Fact]
public void R1_Ignores_NullToValue()
{
// Arrange
var prev = CreateSnapshot(reachable: null);
var curr = CreateSnapshot(reachable: true);
// Act
var result = _detector.Compare(prev, curr);
// Assert
Assert.False(result.HasMaterialChange);
Assert.Empty(result.Changes);
}
[Fact]
public void R1_Ignores_NoChange()
{
// Arrange
var prev = CreateSnapshot(reachable: true);
var curr = CreateSnapshot(reachable: true);
// Act
var result = _detector.Compare(prev, curr);
// Assert
Assert.False(result.HasMaterialChange);
Assert.Empty(result.Changes);
}
#endregion
#region R2: VEX Status Flip Tests
[Fact]
public void R2_Detects_VexFlip_NotAffectedToAffected()
{
// Arrange
var prev = CreateSnapshot(vexStatus: VexStatusType.NotAffected);
var curr = CreateSnapshot(vexStatus: VexStatusType.Affected);
// Act
var result = _detector.Compare(prev, curr);
// Assert
Assert.True(result.HasMaterialChange);
Assert.Single(result.Changes);
Assert.Equal(DetectionRule.R2_VexFlip, result.Changes[0].Rule);
Assert.Equal(RiskDirection.Increased, result.Changes[0].Direction);
}
[Fact]
public void R2_Detects_VexFlip_AffectedToFixed()
{
// Arrange
var prev = CreateSnapshot(vexStatus: VexStatusType.Affected);
var curr = CreateSnapshot(vexStatus: VexStatusType.Fixed);
// Act
var result = _detector.Compare(prev, curr);
// Assert
Assert.True(result.HasMaterialChange);
Assert.Single(result.Changes);
Assert.Equal(DetectionRule.R2_VexFlip, result.Changes[0].Rule);
Assert.Equal(RiskDirection.Decreased, result.Changes[0].Direction);
}
[Fact]
public void R2_Detects_VexFlip_UnknownToAffected()
{
// Arrange
var prev = CreateSnapshot(vexStatus: VexStatusType.Unknown);
var curr = CreateSnapshot(vexStatus: VexStatusType.Affected);
// Act
var result = _detector.Compare(prev, curr);
// Assert
Assert.True(result.HasMaterialChange);
Assert.Equal(RiskDirection.Increased, result.Changes[0].Direction);
}
[Fact]
public void R2_Ignores_NonMeaningfulTransition()
{
// Arrange - Fixed to NotAffected isn't meaningful (both safe states)
var prev = CreateSnapshot(vexStatus: VexStatusType.Fixed);
var curr = CreateSnapshot(vexStatus: VexStatusType.NotAffected);
// Act
var result = _detector.Compare(prev, curr);
// Assert
Assert.False(result.HasMaterialChange);
}
#endregion
#region R3: Affected Range Boundary Tests
[Fact]
public void R3_Detects_RangeEntry()
{
// Arrange
var prev = CreateSnapshot(inAffectedRange: false);
var curr = CreateSnapshot(inAffectedRange: true);
// Act
var result = _detector.Compare(prev, curr);
// Assert
Assert.True(result.HasMaterialChange);
Assert.Single(result.Changes);
Assert.Equal(DetectionRule.R3_RangeBoundary, result.Changes[0].Rule);
Assert.Equal(RiskDirection.Increased, result.Changes[0].Direction);
}
[Fact]
public void R3_Detects_RangeExit()
{
// Arrange
var prev = CreateSnapshot(inAffectedRange: true);
var curr = CreateSnapshot(inAffectedRange: false);
// Act
var result = _detector.Compare(prev, curr);
// Assert
Assert.True(result.HasMaterialChange);
Assert.Single(result.Changes);
Assert.Equal(DetectionRule.R3_RangeBoundary, result.Changes[0].Rule);
Assert.Equal(RiskDirection.Decreased, result.Changes[0].Direction);
}
[Fact]
public void R3_Ignores_NullTransition()
{
// Arrange
var prev = CreateSnapshot(inAffectedRange: null);
var curr = CreateSnapshot(inAffectedRange: true);
// Act
var result = _detector.Compare(prev, curr);
// Assert
Assert.False(result.HasMaterialChange);
}
#endregion
#region R4: Intelligence/Policy Flip Tests
[Fact]
public void R4_Detects_KevAdded()
{
// Arrange
var prev = CreateSnapshot(kev: false);
var curr = CreateSnapshot(kev: true);
// Act
var result = _detector.Compare(prev, curr);
// Assert
Assert.True(result.HasMaterialChange);
Assert.Single(result.Changes);
Assert.Equal(DetectionRule.R4_IntelligenceFlip, result.Changes[0].Rule);
Assert.Equal(MaterialChangeType.KevAdded, result.Changes[0].ChangeType);
Assert.Equal(RiskDirection.Increased, result.Changes[0].Direction);
}
[Fact]
public void R4_Detects_KevRemoved()
{
// Arrange
var prev = CreateSnapshot(kev: true);
var curr = CreateSnapshot(kev: false);
// Act
var result = _detector.Compare(prev, curr);
// Assert
Assert.True(result.HasMaterialChange);
Assert.Equal(MaterialChangeType.KevRemoved, result.Changes[0].ChangeType);
Assert.Equal(RiskDirection.Decreased, result.Changes[0].Direction);
}
[Fact]
public void R4_Detects_EpssThresholdCrossing_Up()
{
// Arrange - EPSS crossing above 0.5 threshold
var prev = CreateSnapshot(epssScore: 0.3);
var curr = CreateSnapshot(epssScore: 0.7);
// Act
var result = _detector.Compare(prev, curr);
// Assert
Assert.True(result.HasMaterialChange);
Assert.Single(result.Changes);
Assert.Equal(MaterialChangeType.EpssThreshold, result.Changes[0].ChangeType);
Assert.Equal(RiskDirection.Increased, result.Changes[0].Direction);
}
[Fact]
public void R4_Detects_EpssThresholdCrossing_Down()
{
// Arrange
var prev = CreateSnapshot(epssScore: 0.7);
var curr = CreateSnapshot(epssScore: 0.3);
// Act
var result = _detector.Compare(prev, curr);
// Assert
Assert.True(result.HasMaterialChange);
Assert.Equal(MaterialChangeType.EpssThreshold, result.Changes[0].ChangeType);
Assert.Equal(RiskDirection.Decreased, result.Changes[0].Direction);
}
[Fact]
public void R4_Ignores_EpssWithinThreshold()
{
// Arrange - Both below threshold
var prev = CreateSnapshot(epssScore: 0.2);
var curr = CreateSnapshot(epssScore: 0.4);
// Act
var result = _detector.Compare(prev, curr);
// Assert
Assert.False(result.HasMaterialChange);
}
[Fact]
public void R4_Detects_PolicyFlip_AllowToBlock()
{
// Arrange
var prev = CreateSnapshot(policyDecision: PolicyDecisionType.Allow);
var curr = CreateSnapshot(policyDecision: PolicyDecisionType.Block);
// Act
var result = _detector.Compare(prev, curr);
// Assert
Assert.True(result.HasMaterialChange);
Assert.Equal(MaterialChangeType.PolicyFlip, result.Changes[0].ChangeType);
Assert.Equal(RiskDirection.Increased, result.Changes[0].Direction);
}
[Fact]
public void R4_Detects_PolicyFlip_BlockToAllow()
{
// Arrange
var prev = CreateSnapshot(policyDecision: PolicyDecisionType.Block);
var curr = CreateSnapshot(policyDecision: PolicyDecisionType.Allow);
// Act
var result = _detector.Compare(prev, curr);
// Assert
Assert.True(result.HasMaterialChange);
Assert.Equal(MaterialChangeType.PolicyFlip, result.Changes[0].ChangeType);
Assert.Equal(RiskDirection.Decreased, result.Changes[0].Direction);
}
#endregion
#region Multiple Changes Tests
[Fact]
public void Detects_MultipleChanges()
{
// Arrange - Multiple rule violations
var prev = CreateSnapshot(reachable: false, kev: false);
var curr = CreateSnapshot(reachable: true, kev: true);
// Act
var result = _detector.Compare(prev, curr);
// Assert
Assert.True(result.HasMaterialChange);
Assert.Equal(2, result.Changes.Length);
Assert.Contains(result.Changes, c => c.Rule == DetectionRule.R1_ReachabilityFlip);
Assert.Contains(result.Changes, c => c.ChangeType == MaterialChangeType.KevAdded);
}
#endregion
#region Priority Score Tests
[Fact]
public void ComputesPriorityScore_ForRiskIncrease()
{
// Arrange
var prev = CreateSnapshot(reachable: false, epssScore: 0.8);
var curr = CreateSnapshot(reachable: true, epssScore: 0.8);
// Act
var result = _detector.Compare(prev, curr);
// Assert
Assert.True(result.PriorityScore > 0);
}
[Fact]
public void ComputesPriorityScore_ForRiskDecrease()
{
// Arrange
var prev = CreateSnapshot(reachable: true, epssScore: 0.8);
var curr = CreateSnapshot(reachable: false, epssScore: 0.8);
// Act
var result = _detector.Compare(prev, curr);
// Assert
Assert.True(result.PriorityScore < 0);
}
[Fact]
public void PriorityScore_ZeroWhenNoChanges()
{
// Arrange
var prev = CreateSnapshot();
var curr = CreateSnapshot();
// Act
var result = _detector.Compare(prev, curr);
// Assert
Assert.Equal(0, result.PriorityScore);
}
#endregion
#region State Hash Tests
[Fact]
public void StateHash_DifferentForDifferentStates()
{
// Arrange
var snap1 = CreateSnapshot(reachable: true);
var snap2 = CreateSnapshot(reachable: false);
// Act & Assert
Assert.NotEqual(snap1.ComputeStateHash(), snap2.ComputeStateHash());
}
[Fact]
public void StateHash_SameForSameState()
{
// Arrange
var snap1 = CreateSnapshot(reachable: true, kev: true);
var snap2 = CreateSnapshot(reachable: true, kev: true);
// Act & Assert
Assert.Equal(snap1.ComputeStateHash(), snap2.ComputeStateHash());
}
#endregion
#region Error Handling Tests
[Fact]
public void ThrowsOnFindingKeyMismatch()
{
// Arrange
var prev = CreateSnapshot(vulnId: "CVE-2024-1111");
var curr = CreateSnapshot(vulnId: "CVE-2024-2222");
// Act & Assert
Assert.Throws<ArgumentException>(() => _detector.Compare(prev, curr));
}
#endregion
}

View File

@@ -0,0 +1,298 @@
using StellaOps.Scanner.SmartDiff.Detection;
using Xunit;
namespace StellaOps.Scanner.SmartDiff.Tests;
public class ReachabilityGateBridgeTests
{
#region Lattice State Mapping Tests
[Theory]
[InlineData("CR", true, 1.0)]
[InlineData("CONFIRMED_REACHABLE", true, 1.0)]
[InlineData("CU", false, 1.0)]
[InlineData("CONFIRMED_UNREACHABLE", false, 1.0)]
public void MapLatticeToReachable_ConfirmedStates_HighestConfidence(
string latticeState, bool expectedReachable, double expectedConfidence)
{
// Act
var (reachable, confidence) = ReachabilityGateBridge.MapLatticeToReachable(latticeState);
// Assert
Assert.Equal(expectedReachable, reachable);
Assert.Equal(expectedConfidence, confidence);
}
[Theory]
[InlineData("SR", true, 0.85)]
[InlineData("STATIC_REACHABLE", true, 0.85)]
[InlineData("SU", false, 0.85)]
[InlineData("STATIC_UNREACHABLE", false, 0.85)]
public void MapLatticeToReachable_StaticStates_HighConfidence(
string latticeState, bool expectedReachable, double expectedConfidence)
{
// Act
var (reachable, confidence) = ReachabilityGateBridge.MapLatticeToReachable(latticeState);
// Assert
Assert.Equal(expectedReachable, reachable);
Assert.Equal(expectedConfidence, confidence);
}
[Theory]
[InlineData("RO", true, 0.90)]
[InlineData("RUNTIME_OBSERVED", true, 0.90)]
[InlineData("RU", false, 0.70)]
[InlineData("RUNTIME_UNOBSERVED", false, 0.70)]
public void MapLatticeToReachable_RuntimeStates_CorrectConfidence(
string latticeState, bool expectedReachable, double expectedConfidence)
{
// Act
var (reachable, confidence) = ReachabilityGateBridge.MapLatticeToReachable(latticeState);
// Assert
Assert.Equal(expectedReachable, reachable);
Assert.Equal(expectedConfidence, confidence);
}
[Theory]
[InlineData("U")]
[InlineData("UNKNOWN")]
public void MapLatticeToReachable_UnknownState_NullWithZeroConfidence(string latticeState)
{
// Act
var (reachable, confidence) = ReachabilityGateBridge.MapLatticeToReachable(latticeState);
// Assert
Assert.Null(reachable);
Assert.Equal(0.0, confidence);
}
[Theory]
[InlineData("X")]
[InlineData("CONTESTED")]
public void MapLatticeToReachable_ContestedState_NullWithMediumConfidence(string latticeState)
{
// Act
var (reachable, confidence) = ReachabilityGateBridge.MapLatticeToReachable(latticeState);
// Assert
Assert.Null(reachable);
Assert.Equal(0.5, confidence);
}
[Fact]
public void MapLatticeToReachable_UnrecognizedState_NullWithZeroConfidence()
{
// Act
var (reachable, confidence) = ReachabilityGateBridge.MapLatticeToReachable("INVALID_STATE");
// Assert
Assert.Null(reachable);
Assert.Equal(0.0, confidence);
}
#endregion
#region FromLatticeState Tests
[Fact]
public void FromLatticeState_CreatesGateWithCorrectValues()
{
// Act
var gate = ReachabilityGateBridge.FromLatticeState("CR", configActivated: true, runningUser: false);
// Assert
Assert.True(gate.Reachable);
Assert.True(gate.ConfigActivated);
Assert.False(gate.RunningUser);
Assert.Equal(1.0, gate.Confidence);
Assert.Equal("CR", gate.LatticeState);
Assert.Contains("REACHABLE", gate.Rationale);
}
[Fact]
public void FromLatticeState_UnknownState_CreatesGateWithNulls()
{
// Act
var gate = ReachabilityGateBridge.FromLatticeState("U");
// Assert
Assert.Null(gate.Reachable);
Assert.Equal(0.0, gate.Confidence);
Assert.Contains("UNKNOWN", gate.Rationale);
}
#endregion
#region ComputeClass Tests
[Fact]
public void ComputeClass_AllFalse_ReturnsZero()
{
// Arrange
var gate = new ReachabilityGate(
Reachable: false,
ConfigActivated: false,
RunningUser: false,
Confidence: 1.0,
LatticeState: "CU",
Rationale: "test");
// Act
var gateClass = gate.ComputeClass();
// Assert
Assert.Equal(0, gateClass);
}
[Fact]
public void ComputeClass_OnlyReachable_ReturnsOne()
{
// Arrange
var gate = new ReachabilityGate(
Reachable: true,
ConfigActivated: false,
RunningUser: false,
Confidence: 1.0,
LatticeState: "CR",
Rationale: "test");
// Act
var gateClass = gate.ComputeClass();
// Assert
Assert.Equal(1, gateClass);
}
[Fact]
public void ComputeClass_ReachableAndActivated_ReturnsThree()
{
// Arrange
var gate = new ReachabilityGate(
Reachable: true,
ConfigActivated: true,
RunningUser: false,
Confidence: 1.0,
LatticeState: "CR",
Rationale: "test");
// Act
var gateClass = gate.ComputeClass();
// Assert
Assert.Equal(3, gateClass);
}
[Fact]
public void ComputeClass_AllTrue_ReturnsSeven()
{
// Arrange
var gate = new ReachabilityGate(
Reachable: true,
ConfigActivated: true,
RunningUser: true,
Confidence: 1.0,
LatticeState: "CR",
Rationale: "test");
// Act
var gateClass = gate.ComputeClass();
// Assert
Assert.Equal(7, gateClass);
}
[Fact]
public void ComputeClass_NullsAsZero()
{
// Arrange - nulls should be treated as false (0)
var gate = new ReachabilityGate(
Reachable: null,
ConfigActivated: null,
RunningUser: null,
Confidence: 0.0,
LatticeState: "U",
Rationale: "test");
// Act
var gateClass = gate.ComputeClass();
// Assert
Assert.Equal(0, gateClass);
}
#endregion
#region InterpretClass Tests
[Theory]
[InlineData(0, "LOW")]
[InlineData(7, "HIGH")]
public void InterpretClass_ExtremeCases_CorrectRiskLevel(int gateClass, string expectedRiskContains)
{
// Act
var interpretation = ReachabilityGateBridge.InterpretClass(gateClass);
// Assert
Assert.Contains(expectedRiskContains, interpretation);
}
[Fact]
public void RiskInterpretation_Property_ReturnsCorrectValue()
{
// Arrange
var gate = new ReachabilityGate(
Reachable: true,
ConfigActivated: true,
RunningUser: true,
Confidence: 1.0,
LatticeState: "CR",
Rationale: "test");
// Act
var interpretation = gate.RiskInterpretation;
// Assert
Assert.Contains("HIGH", interpretation);
}
#endregion
#region Static Unknown Gate Tests
[Fact]
public void Unknown_HasExpectedValues()
{
// Act
var gate = ReachabilityGate.Unknown;
// Assert
Assert.Null(gate.Reachable);
Assert.Null(gate.ConfigActivated);
Assert.Null(gate.RunningUser);
Assert.Equal(0.0, gate.Confidence);
Assert.Equal("U", gate.LatticeState);
}
#endregion
#region Rationale Generation Tests
[Theory]
[InlineData("CR", "Confirmed reachable")]
[InlineData("SR", "Statically reachable")]
[InlineData("RO", "Observed at runtime")]
[InlineData("U", "unknown")]
[InlineData("X", "Contested")]
public void GenerateRationale_IncludesStateDescription(string latticeState, string expectedContains)
{
// Act
var rationale = ReachabilityGateBridge.GenerateRationale(latticeState, true);
// Assert
Assert.Contains(expectedContains, rationale, StringComparison.OrdinalIgnoreCase);
}
#endregion
}

View File

@@ -0,0 +1,374 @@
using System.Collections.Immutable;
using System.Text.Json;
using StellaOps.Scanner.SmartDiff.Detection;
using Xunit;
namespace StellaOps.Scanner.SmartDiff.Tests;
/// <summary>
/// Golden fixture tests for Smart-Diff state comparison determinism.
/// Per Sprint 3500.3 - ensures stable, reproducible change detection.
/// </summary>
public class StateComparisonGoldenTests
{
private static readonly string FixturePath = Path.Combine(
AppContext.BaseDirectory,
"Fixtures",
"state-comparison.v1.json");
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private readonly MaterialRiskChangeDetector _detector;
public StateComparisonGoldenTests()
{
_detector = new MaterialRiskChangeDetector();
}
[Fact]
public void GoldenFixture_Exists()
{
Assert.True(File.Exists(FixturePath), $"Fixture file not found: {FixturePath}");
}
[Theory]
[MemberData(nameof(GetTestCases))]
public void DetectChanges_MatchesGoldenFixture(GoldenTestCase testCase)
{
// Arrange
var previous = ParseSnapshot(testCase.Previous);
var current = ParseSnapshot(testCase.Current);
// Act
var result = _detector.DetectChanges(previous, current);
// Assert
Assert.Equal(testCase.Expected.HasMaterialChange, result.HasMaterialChange);
if (testCase.Expected.ChangeCount.HasValue)
{
Assert.Equal(testCase.Expected.ChangeCount.Value, result.Changes.Length);
}
if (testCase.Expected.TotalPriorityScore.HasValue)
{
Assert.Equal(testCase.Expected.TotalPriorityScore.Value, result.PriorityScore);
}
if (testCase.Expected.ChangeType is not null && result.Changes.Length > 0)
{
var expectedType = ParseChangeType(testCase.Expected.ChangeType);
Assert.Contains(result.Changes, c => c.ChangeType == expectedType);
}
if (testCase.Expected.Direction is not null && result.Changes.Length > 0)
{
var expectedDirection = ParseDirection(testCase.Expected.Direction);
Assert.Contains(result.Changes, c => c.Direction == expectedDirection);
}
}
[Fact]
public void StateHash_IsDeterministic()
{
// Arrange
var snapshot = new RiskStateSnapshot(
FindingKey: new FindingKey("CVE-2024-1111", "pkg:npm/test@1.0.0"),
ScanId: "scan-hash-001",
CapturedAt: DateTimeOffset.Parse("2024-12-15T10:00:00Z"),
Reachable: true,
LatticeState: "CR",
VexStatus: VexStatusType.Affected,
InAffectedRange: true,
Kev: false,
EpssScore: 0.05,
PolicyFlags: ["FLAG_A", "FLAG_B"],
PolicyDecision: PolicyDecisionType.Warn);
// Act - compute hash multiple times
var hash1 = snapshot.ComputeStateHash();
var hash2 = snapshot.ComputeStateHash();
var hash3 = snapshot.ComputeStateHash();
// Assert - all hashes must be identical
Assert.Equal(hash1, hash2);
Assert.Equal(hash2, hash3);
Assert.StartsWith("sha256:", hash1);
}
[Fact]
public void StateHash_DiffersWithReachabilityChange()
{
// Arrange
var baseSnapshot = new RiskStateSnapshot(
FindingKey: new FindingKey("CVE-2024-1111", "pkg:npm/test@1.0.0"),
ScanId: "scan-hash-001",
CapturedAt: DateTimeOffset.Parse("2024-12-15T10:00:00Z"),
Reachable: true,
LatticeState: "CR",
VexStatus: VexStatusType.Affected,
InAffectedRange: true,
Kev: false,
EpssScore: 0.05,
PolicyFlags: [],
PolicyDecision: null);
var modifiedSnapshot = baseSnapshot with { Reachable = false };
// Act
var hash1 = baseSnapshot.ComputeStateHash();
var hash2 = modifiedSnapshot.ComputeStateHash();
// Assert - hashes must differ
Assert.NotEqual(hash1, hash2);
}
[Fact]
public void StateHash_DiffersWithVexStatusChange()
{
// Arrange
var baseSnapshot = new RiskStateSnapshot(
FindingKey: new FindingKey("CVE-2024-1111", "pkg:npm/test@1.0.0"),
ScanId: "scan-hash-001",
CapturedAt: DateTimeOffset.Parse("2024-12-15T10:00:00Z"),
Reachable: true,
LatticeState: "CR",
VexStatus: VexStatusType.Affected,
InAffectedRange: true,
Kev: false,
EpssScore: 0.05,
PolicyFlags: [],
PolicyDecision: null);
var modifiedSnapshot = baseSnapshot with { VexStatus = VexStatusType.NotAffected };
// Act
var hash1 = baseSnapshot.ComputeStateHash();
var hash2 = modifiedSnapshot.ComputeStateHash();
// Assert - hashes must differ
Assert.NotEqual(hash1, hash2);
}
[Fact]
public void StateHash_SameForEquivalentStates()
{
// Arrange - two snapshots with same risk-relevant fields but different scan IDs
var snapshot1 = new RiskStateSnapshot(
FindingKey: new FindingKey("CVE-2024-1111", "pkg:npm/test@1.0.0"),
ScanId: "scan-001",
CapturedAt: DateTimeOffset.Parse("2024-12-15T10:00:00Z"),
Reachable: true,
LatticeState: "CR",
VexStatus: VexStatusType.Affected,
InAffectedRange: true,
Kev: false,
EpssScore: 0.05,
PolicyFlags: [],
PolicyDecision: null);
var snapshot2 = new RiskStateSnapshot(
FindingKey: new FindingKey("CVE-2024-1111", "pkg:npm/test@1.0.0"),
ScanId: "scan-002", // Different scan ID
CapturedAt: DateTimeOffset.Parse("2024-12-16T10:00:00Z"), // Different timestamp
Reachable: true,
LatticeState: "CR",
VexStatus: VexStatusType.Affected,
InAffectedRange: true,
Kev: false,
EpssScore: 0.05,
PolicyFlags: [],
PolicyDecision: null);
// Act
var hash1 = snapshot1.ComputeStateHash();
var hash2 = snapshot2.ComputeStateHash();
// Assert - hashes should be the same (scan ID and timestamp are not part of state hash)
Assert.Equal(hash1, hash2);
}
[Fact]
public void PriorityScore_IsConsistent()
{
// Arrange - KEV flip should always produce same priority
var previous = new RiskStateSnapshot(
FindingKey: new FindingKey("CVE-2024-6666", "pkg:npm/axios@0.21.0"),
ScanId: "scan-prev",
CapturedAt: DateTimeOffset.Parse("2024-12-01T10:00:00Z"),
Reachable: true,
LatticeState: "SR",
VexStatus: VexStatusType.Affected,
InAffectedRange: true,
Kev: false,
EpssScore: 0.08,
PolicyFlags: [],
PolicyDecision: null);
var current = previous with
{
ScanId = "scan-curr",
CapturedAt = DateTimeOffset.Parse("2024-12-15T10:00:00Z"),
Kev = true
};
// Act - detect multiple times
var result1 = _detector.DetectChanges(previous, current);
var result2 = _detector.DetectChanges(previous, current);
var result3 = _detector.DetectChanges(previous, current);
// Assert - priority score should be deterministic
Assert.Equal(result1.PriorityScore, result2.PriorityScore);
Assert.Equal(result2.PriorityScore, result3.PriorityScore);
}
#region Data Loading
public static IEnumerable<object[]> GetTestCases()
{
if (!File.Exists(FixturePath))
{
yield break;
}
var json = File.ReadAllText(FixturePath);
var fixture = JsonSerializer.Deserialize<GoldenFixture>(json, JsonOptions);
if (fixture?.TestCases is null)
{
yield break;
}
foreach (var testCase in fixture.TestCases)
{
yield return new object[] { testCase };
}
}
private static RiskStateSnapshot ParseSnapshot(SnapshotData data)
{
return new RiskStateSnapshot(
FindingKey: new FindingKey(data.FindingKey.VulnId, data.FindingKey.Purl),
ScanId: data.ScanId,
CapturedAt: DateTimeOffset.Parse(data.CapturedAt),
Reachable: data.Reachable,
LatticeState: data.LatticeState,
VexStatus: ParseVexStatus(data.VexStatus),
InAffectedRange: data.InAffectedRange,
Kev: data.Kev,
EpssScore: data.EpssScore,
PolicyFlags: data.PolicyFlags?.ToImmutableArray() ?? [],
PolicyDecision: ParsePolicyDecision(data.PolicyDecision));
}
private static VexStatusType ParseVexStatus(string value)
{
return value.ToLowerInvariant() switch
{
"affected" => VexStatusType.Affected,
"not_affected" => VexStatusType.NotAffected,
"fixed" => VexStatusType.Fixed,
"under_investigation" => VexStatusType.UnderInvestigation,
_ => VexStatusType.Unknown
};
}
private static PolicyDecisionType? ParsePolicyDecision(string? value)
{
if (string.IsNullOrEmpty(value))
return null;
return value.ToLowerInvariant() switch
{
"allow" => PolicyDecisionType.Allow,
"warn" => PolicyDecisionType.Warn,
"block" => PolicyDecisionType.Block,
_ => null
};
}
private static MaterialChangeType ParseChangeType(string value)
{
return value.ToLowerInvariant() switch
{
"reachability_flip" => MaterialChangeType.ReachabilityFlip,
"vex_flip" => MaterialChangeType.VexFlip,
"range_boundary" => MaterialChangeType.RangeBoundary,
"kev_added" => MaterialChangeType.KevAdded,
"kev_removed" => MaterialChangeType.KevRemoved,
"epss_threshold" => MaterialChangeType.EpssThreshold,
"policy_flip" => MaterialChangeType.PolicyFlip,
_ => throw new ArgumentException($"Unknown change type: {value}")
};
}
private static RiskDirection ParseDirection(string value)
{
return value.ToLowerInvariant() switch
{
"increased" => RiskDirection.Increased,
"decreased" => RiskDirection.Decreased,
"neutral" => RiskDirection.Neutral,
_ => throw new ArgumentException($"Unknown direction: {value}")
};
}
#endregion
}
#region Fixture DTOs
public class GoldenFixture
{
public string? Version { get; set; }
public string? Description { get; set; }
public List<GoldenTestCase>? TestCases { get; set; }
}
public class GoldenTestCase
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public string? Rule { get; set; }
public SnapshotData Previous { get; set; } = new();
public SnapshotData Current { get; set; } = new();
public ExpectedResult Expected { get; set; } = new();
public override string ToString() => $"{Id}: {Name}";
}
public class SnapshotData
{
public FindingKeyData FindingKey { get; set; } = new();
public string ScanId { get; set; } = "";
public string CapturedAt { get; set; } = "";
public bool? Reachable { get; set; }
public string? LatticeState { get; set; }
public string VexStatus { get; set; } = "unknown";
public bool? InAffectedRange { get; set; }
public bool Kev { get; set; }
public double? EpssScore { get; set; }
public List<string>? PolicyFlags { get; set; }
public string? PolicyDecision { get; set; }
}
public class FindingKeyData
{
public string VulnId { get; set; } = "";
public string Purl { get; set; } = "";
}
public class ExpectedResult
{
public bool HasMaterialChange { get; set; }
public string? Direction { get; set; }
public string? ChangeType { get; set; }
public int? ChangeCount { get; set; }
public int? TotalPriorityScore { get; set; }
public int? PriorityScoreContribution { get; set; }
}
#endregion

View File

@@ -0,0 +1,386 @@
using System.Collections.Immutable;
using StellaOps.Scanner.SmartDiff.Detection;
using Xunit;
namespace StellaOps.Scanner.SmartDiff.Tests;
public class VexCandidateEmitterTests
{
private readonly InMemoryVexCandidateStore _store = new();
#region Basic Emission Tests
[Fact]
public async Task EmitCandidates_WithAbsentApis_EmitsCandidate()
{
// Arrange
var emitter = new VexCandidateEmitter(store: _store);
var prevCallGraph = new CallGraphSnapshot("prev-digest", ["vuln_api_1", "vuln_api_2", "safe_api"]);
var currCallGraph = new CallGraphSnapshot("curr-digest", ["safe_api"]); // vuln APIs removed
var context = new VexCandidateEmissionContext(
PreviousScanId: "scan-001",
CurrentScanId: "scan-002",
TargetImageDigest: "sha256:abc123",
PreviousFindings: [new FindingSnapshot(
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
VexStatus: VexStatusType.Affected,
VulnerableApis: ["vuln_api_1", "vuln_api_2"])],
CurrentFindings: [new FindingSnapshot(
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
VexStatus: VexStatusType.Affected,
VulnerableApis: ["vuln_api_1", "vuln_api_2"])],
PreviousCallGraph: prevCallGraph,
CurrentCallGraph: currCallGraph);
// Act
var result = await emitter.EmitCandidatesAsync(context);
// Assert
Assert.Equal(1, result.CandidatesEmitted);
Assert.Single(result.Candidates);
Assert.Equal(VexStatusType.NotAffected, result.Candidates[0].SuggestedStatus);
Assert.Equal(VexJustification.VulnerableCodeNotPresent, result.Candidates[0].Justification);
}
[Fact]
public async Task EmitCandidates_WithPresentApis_DoesNotEmit()
{
// Arrange
var emitter = new VexCandidateEmitter(store: _store);
var prevCallGraph = new CallGraphSnapshot("prev-digest", ["vuln_api_1", "safe_api"]);
var currCallGraph = new CallGraphSnapshot("curr-digest", ["vuln_api_1", "safe_api"]); // vuln API still present
var context = new VexCandidateEmissionContext(
PreviousScanId: "scan-001",
CurrentScanId: "scan-002",
TargetImageDigest: "sha256:abc123",
PreviousFindings: [new FindingSnapshot(
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
VexStatus: VexStatusType.Affected,
VulnerableApis: ["vuln_api_1"])],
CurrentFindings: [new FindingSnapshot(
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
VexStatus: VexStatusType.Affected,
VulnerableApis: ["vuln_api_1"])],
PreviousCallGraph: prevCallGraph,
CurrentCallGraph: currCallGraph);
// Act
var result = await emitter.EmitCandidatesAsync(context);
// Assert
Assert.Equal(0, result.CandidatesEmitted);
Assert.Empty(result.Candidates);
}
[Fact]
public async Task EmitCandidates_FindingAlreadyNotAffected_DoesNotEmit()
{
// Arrange
var emitter = new VexCandidateEmitter(store: _store);
var prevCallGraph = new CallGraphSnapshot("prev-digest", ["vuln_api_1"]);
var currCallGraph = new CallGraphSnapshot("curr-digest", []); // API removed
var context = new VexCandidateEmissionContext(
PreviousScanId: "scan-001",
CurrentScanId: "scan-002",
TargetImageDigest: "sha256:abc123",
PreviousFindings: [new FindingSnapshot(
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
VexStatus: VexStatusType.NotAffected, // Already not affected
VulnerableApis: ["vuln_api_1"])],
CurrentFindings: [new FindingSnapshot(
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
VexStatus: VexStatusType.NotAffected,
VulnerableApis: ["vuln_api_1"])],
PreviousCallGraph: prevCallGraph,
CurrentCallGraph: currCallGraph);
// Act
var result = await emitter.EmitCandidatesAsync(context);
// Assert
Assert.Equal(0, result.CandidatesEmitted);
}
#endregion
#region Call Graph Tests
[Fact]
public async Task EmitCandidates_NoCallGraph_DoesNotEmit()
{
// Arrange
var emitter = new VexCandidateEmitter(store: _store);
var context = new VexCandidateEmissionContext(
PreviousScanId: "scan-001",
CurrentScanId: "scan-002",
TargetImageDigest: "sha256:abc123",
PreviousFindings: [new FindingSnapshot(
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
VexStatus: VexStatusType.Affected,
VulnerableApis: ["vuln_api_1"])],
CurrentFindings: [new FindingSnapshot(
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
VexStatus: VexStatusType.Affected,
VulnerableApis: ["vuln_api_1"])],
PreviousCallGraph: null,
CurrentCallGraph: null);
// Act
var result = await emitter.EmitCandidatesAsync(context);
// Assert
Assert.Equal(0, result.CandidatesEmitted);
}
[Fact]
public async Task EmitCandidates_NoVulnerableApis_DoesNotEmit()
{
// Arrange
var emitter = new VexCandidateEmitter(store: _store);
var prevCallGraph = new CallGraphSnapshot("prev-digest", ["api_1"]);
var currCallGraph = new CallGraphSnapshot("curr-digest", []);
var context = new VexCandidateEmissionContext(
PreviousScanId: "scan-001",
CurrentScanId: "scan-002",
TargetImageDigest: "sha256:abc123",
PreviousFindings: [new FindingSnapshot(
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
VexStatus: VexStatusType.Affected,
VulnerableApis: [])], // No vulnerable APIs tracked
CurrentFindings: [new FindingSnapshot(
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
VexStatus: VexStatusType.Affected,
VulnerableApis: [])],
PreviousCallGraph: prevCallGraph,
CurrentCallGraph: currCallGraph);
// Act
var result = await emitter.EmitCandidatesAsync(context);
// Assert
Assert.Equal(0, result.CandidatesEmitted);
}
#endregion
#region Confidence Tests
[Fact]
public async Task EmitCandidates_MultipleAbsentApis_HigherConfidence()
{
// Arrange
var emitter = new VexCandidateEmitter(store: _store);
var prevCallGraph = new CallGraphSnapshot("prev-digest", ["vuln_1", "vuln_2", "vuln_3"]);
var currCallGraph = new CallGraphSnapshot("curr-digest", []); // All removed
var context = new VexCandidateEmissionContext(
PreviousScanId: "scan-001",
CurrentScanId: "scan-002",
TargetImageDigest: "sha256:abc123",
PreviousFindings: [new FindingSnapshot(
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
VexStatus: VexStatusType.Affected,
VulnerableApis: ["vuln_1", "vuln_2", "vuln_3"])],
CurrentFindings: [new FindingSnapshot(
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
VexStatus: VexStatusType.Affected,
VulnerableApis: ["vuln_1", "vuln_2", "vuln_3"])],
PreviousCallGraph: prevCallGraph,
CurrentCallGraph: currCallGraph);
// Act
var result = await emitter.EmitCandidatesAsync(context);
// Assert
Assert.Single(result.Candidates);
Assert.Equal(0.95, result.Candidates[0].Confidence); // 3+ APIs = 0.95
}
[Fact]
public async Task EmitCandidates_BelowConfidenceThreshold_DoesNotEmit()
{
// Arrange - Set high threshold
var options = new VexCandidateEmitterOptions { MinConfidence = 0.99 };
var emitter = new VexCandidateEmitter(options: options, store: _store);
var prevCallGraph = new CallGraphSnapshot("prev-digest", ["vuln_1"]);
var currCallGraph = new CallGraphSnapshot("curr-digest", []);
var context = new VexCandidateEmissionContext(
PreviousScanId: "scan-001",
CurrentScanId: "scan-002",
TargetImageDigest: "sha256:abc123",
PreviousFindings: [new FindingSnapshot(
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
VexStatus: VexStatusType.Affected,
VulnerableApis: ["vuln_1"])],
CurrentFindings: [new FindingSnapshot(
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
VexStatus: VexStatusType.Affected,
VulnerableApis: ["vuln_1"])],
PreviousCallGraph: prevCallGraph,
CurrentCallGraph: currCallGraph);
// Act
var result = await emitter.EmitCandidatesAsync(context);
// Assert - Single API = 0.75 confidence, below 0.99 threshold
Assert.Equal(0, result.CandidatesEmitted);
}
#endregion
#region Rate Limiting Tests
[Fact]
public async Task EmitCandidates_RespectsMaxCandidatesLimit()
{
// Arrange
var options = new VexCandidateEmitterOptions { MaxCandidatesPerImage = 2 };
var emitter = new VexCandidateEmitter(options: options, store: _store);
var prevCallGraph = new CallGraphSnapshot("prev-digest", ["vuln_1", "vuln_2", "vuln_3"]);
var currCallGraph = new CallGraphSnapshot("curr-digest", []);
var findings = Enumerable.Range(1, 5).Select(i => new FindingSnapshot(
FindingKey: new FindingKey($"CVE-2024-{i}", $"pkg:npm/example{i}@1.0.0"),
VexStatus: VexStatusType.Affected,
VulnerableApis: [$"vuln_{i}"])).ToList();
var context = new VexCandidateEmissionContext(
PreviousScanId: "scan-001",
CurrentScanId: "scan-002",
TargetImageDigest: "sha256:abc123",
PreviousFindings: findings,
CurrentFindings: findings,
PreviousCallGraph: prevCallGraph,
CurrentCallGraph: currCallGraph);
// Act
var result = await emitter.EmitCandidatesAsync(context);
// Assert
Assert.Equal(2, result.CandidatesEmitted);
}
#endregion
#region Storage Tests
[Fact]
public async Task EmitCandidates_StoresCandidates()
{
// Arrange
var options = new VexCandidateEmitterOptions { PersistCandidates = true };
var emitter = new VexCandidateEmitter(options: options, store: _store);
var prevCallGraph = new CallGraphSnapshot("prev-digest", ["vuln_api"]);
var currCallGraph = new CallGraphSnapshot("curr-digest", []);
var context = new VexCandidateEmissionContext(
PreviousScanId: "scan-001",
CurrentScanId: "scan-002",
TargetImageDigest: "sha256:abc123",
PreviousFindings: [new FindingSnapshot(
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
VexStatus: VexStatusType.Affected,
VulnerableApis: ["vuln_api"])],
CurrentFindings: [new FindingSnapshot(
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
VexStatus: VexStatusType.Affected,
VulnerableApis: ["vuln_api"])],
PreviousCallGraph: prevCallGraph,
CurrentCallGraph: currCallGraph);
// Act
await emitter.EmitCandidatesAsync(context);
// Assert
var stored = await _store.GetCandidatesAsync("sha256:abc123");
Assert.Single(stored);
}
[Fact]
public async Task EmitCandidates_NoPersist_DoesNotStore()
{
// Arrange
var options = new VexCandidateEmitterOptions { PersistCandidates = false };
var emitter = new VexCandidateEmitter(options: options, store: _store);
var prevCallGraph = new CallGraphSnapshot("prev-digest", ["vuln_api"]);
var currCallGraph = new CallGraphSnapshot("curr-digest", []);
var context = new VexCandidateEmissionContext(
PreviousScanId: "scan-001",
CurrentScanId: "scan-002",
TargetImageDigest: "sha256:abc123",
PreviousFindings: [new FindingSnapshot(
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
VexStatus: VexStatusType.Affected,
VulnerableApis: ["vuln_api"])],
CurrentFindings: [new FindingSnapshot(
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
VexStatus: VexStatusType.Affected,
VulnerableApis: ["vuln_api"])],
PreviousCallGraph: prevCallGraph,
CurrentCallGraph: currCallGraph);
// Act
var result = await emitter.EmitCandidatesAsync(context);
// Assert - Candidate emitted but not stored
Assert.Equal(1, result.CandidatesEmitted);
var stored = await _store.GetCandidatesAsync("sha256:abc123");
Assert.Empty(stored);
}
#endregion
#region Evidence Link Tests
[Fact]
public async Task EmitCandidates_IncludesEvidenceLinks()
{
// Arrange
var emitter = new VexCandidateEmitter(store: _store);
var prevCallGraph = new CallGraphSnapshot("prev-digest", ["vuln_api_1", "vuln_api_2"]);
var currCallGraph = new CallGraphSnapshot("curr-digest", []);
var context = new VexCandidateEmissionContext(
PreviousScanId: "scan-001",
CurrentScanId: "scan-002",
TargetImageDigest: "sha256:abc123",
PreviousFindings: [new FindingSnapshot(
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
VexStatus: VexStatusType.Affected,
VulnerableApis: ["vuln_api_1", "vuln_api_2"])],
CurrentFindings: [new FindingSnapshot(
FindingKey: new FindingKey("CVE-2024-1234", "pkg:npm/example@1.0.0"),
VexStatus: VexStatusType.Affected,
VulnerableApis: ["vuln_api_1", "vuln_api_2"])],
PreviousCallGraph: prevCallGraph,
CurrentCallGraph: currCallGraph);
// Act
var result = await emitter.EmitCandidatesAsync(context);
// Assert
var candidate = result.Candidates[0];
Assert.Contains(candidate.EvidenceLinks, e => e.Type == "callgraph_diff");
Assert.Contains(candidate.EvidenceLinks, e => e.Type == "absent_api" && e.Uri.Contains("vuln_api_1"));
Assert.Contains(candidate.EvidenceLinks, e => e.Type == "absent_api" && e.Uri.Contains("vuln_api_2"));
}
#endregion
}

View File

@@ -0,0 +1,368 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.SmartDiff.Detection;
using StellaOps.Scanner.Storage.Postgres;
using Xunit;
namespace StellaOps.Scanner.Storage.Tests;
/// <summary>
/// Integration tests for Smart-Diff PostgreSQL repositories.
/// Per Sprint 3500.3 - SDIFF-DET-026.
/// </summary>
[Collection("scanner-postgres")]
public class SmartDiffRepositoryIntegrationTests : IAsyncLifetime
{
private readonly ScannerPostgresFixture _fixture;
private PostgresRiskStateRepository _riskStateRepo = null!;
private PostgresVexCandidateStore _vexCandidateStore = null!;
private PostgresMaterialRiskChangeRepository _changeRepo = null!;
public SmartDiffRepositoryIntegrationTests(ScannerPostgresFixture fixture)
{
_fixture = fixture;
}
public async Task InitializeAsync()
{
await _fixture.TruncateAllTablesAsync();
var dataSource = CreateDataSource();
var logger = NullLoggerFactory.Instance;
_riskStateRepo = new PostgresRiskStateRepository(
dataSource,
logger.CreateLogger<PostgresRiskStateRepository>());
_vexCandidateStore = new PostgresVexCandidateStore(
dataSource,
logger.CreateLogger<PostgresVexCandidateStore>());
_changeRepo = new PostgresMaterialRiskChangeRepository(
dataSource,
logger.CreateLogger<PostgresMaterialRiskChangeRepository>());
}
public Task DisposeAsync() => Task.CompletedTask;
private ScannerDataSource CreateDataSource()
{
var options = new ScannerStorageOptions
{
Postgres = new StellaOps.Infrastructure.Postgres.Options.PostgresOptions
{
ConnectionString = _fixture.ConnectionString,
SchemaName = _fixture.SchemaName
}
};
return new ScannerDataSource(
Microsoft.Extensions.Options.Options.Create(options),
NullLoggerFactory.Instance.CreateLogger<ScannerDataSource>());
}
#region RiskStateSnapshot Tests
[Fact]
public async Task StoreSnapshot_ThenRetrieve_ReturnsCorrectData()
{
// Arrange
var snapshot = CreateTestSnapshot("CVE-2024-1234", "pkg:npm/lodash@4.17.21", "scan-001");
// Act
await _riskStateRepo.StoreSnapshotAsync(snapshot);
var retrieved = await _riskStateRepo.GetLatestSnapshotAsync(snapshot.FindingKey);
// Assert
Assert.NotNull(retrieved);
Assert.Equal(snapshot.FindingKey.VulnId, retrieved.FindingKey.VulnId);
Assert.Equal(snapshot.FindingKey.Purl, retrieved.FindingKey.Purl);
Assert.Equal(snapshot.Reachable, retrieved.Reachable);
Assert.Equal(snapshot.VexStatus, retrieved.VexStatus);
Assert.Equal(snapshot.Kev, retrieved.Kev);
}
[Fact]
public async Task StoreMultipleSnapshots_GetHistory_ReturnsInOrder()
{
// Arrange
var findingKey = new FindingKey("CVE-2024-5678", "pkg:pypi/requests@2.28.0");
var snapshot1 = CreateTestSnapshot(findingKey.VulnId, findingKey.Purl, "scan-001",
capturedAt: DateTimeOffset.UtcNow.AddHours(-2));
var snapshot2 = CreateTestSnapshot(findingKey.VulnId, findingKey.Purl, "scan-002",
capturedAt: DateTimeOffset.UtcNow.AddHours(-1));
var snapshot3 = CreateTestSnapshot(findingKey.VulnId, findingKey.Purl, "scan-003",
capturedAt: DateTimeOffset.UtcNow);
// Act
await _riskStateRepo.StoreSnapshotsAsync([snapshot1, snapshot2, snapshot3]);
var history = await _riskStateRepo.GetSnapshotHistoryAsync(findingKey, limit: 10);
// Assert
Assert.Equal(3, history.Count);
Assert.Equal("scan-003", history[0].ScanId); // Most recent first
Assert.Equal("scan-002", history[1].ScanId);
Assert.Equal("scan-001", history[2].ScanId);
}
[Fact]
public async Task GetSnapshotsForScan_ReturnsAllForScan()
{
// Arrange
var scanId = "scan-bulk-001";
var snapshot1 = CreateTestSnapshot("CVE-2024-1111", "pkg:npm/a@1.0.0", scanId);
var snapshot2 = CreateTestSnapshot("CVE-2024-2222", "pkg:npm/b@2.0.0", scanId);
var snapshot3 = CreateTestSnapshot("CVE-2024-3333", "pkg:npm/c@3.0.0", "other-scan");
await _riskStateRepo.StoreSnapshotsAsync([snapshot1, snapshot2, snapshot3]);
// Act
var results = await _riskStateRepo.GetSnapshotsForScanAsync(scanId);
// Assert
Assert.Equal(2, results.Count);
Assert.All(results, r => Assert.Equal(scanId, r.ScanId));
}
[Fact]
public async Task StateHash_IsDeterministic()
{
// Arrange
var snapshot = CreateTestSnapshot("CVE-2024-HASH", "pkg:npm/hash-test@1.0.0", "scan-hash");
// Act
await _riskStateRepo.StoreSnapshotAsync(snapshot);
var hash1 = snapshot.ComputeStateHash();
var retrieved = await _riskStateRepo.GetLatestSnapshotAsync(snapshot.FindingKey);
var hash2 = retrieved!.ComputeStateHash();
// Assert
Assert.Equal(hash1, hash2);
}
#endregion
#region VexCandidate Tests
[Fact]
public async Task StoreCandidates_ThenRetrieve_ReturnsCorrectData()
{
// Arrange
var candidate = CreateTestCandidate("CVE-2024-VEX1", "pkg:npm/vex-test@1.0.0", "sha256:abc123");
// Act
await _vexCandidateStore.StoreCandidatesAsync([candidate]);
var retrieved = await _vexCandidateStore.GetCandidateAsync(candidate.CandidateId);
// Assert
Assert.NotNull(retrieved);
Assert.Equal(candidate.CandidateId, retrieved.CandidateId);
Assert.Equal(candidate.SuggestedStatus, retrieved.SuggestedStatus);
Assert.Equal(candidate.Justification, retrieved.Justification);
Assert.Equal(candidate.Confidence, retrieved.Confidence, precision: 2);
}
[Fact]
public async Task GetCandidatesForImage_ReturnsFilteredResults()
{
// Arrange
var imageDigest = "sha256:image123";
var candidate1 = CreateTestCandidate("CVE-2024-A", "pkg:npm/a@1.0.0", imageDigest);
var candidate2 = CreateTestCandidate("CVE-2024-B", "pkg:npm/b@1.0.0", imageDigest);
var candidate3 = CreateTestCandidate("CVE-2024-C", "pkg:npm/c@1.0.0", "sha256:other");
await _vexCandidateStore.StoreCandidatesAsync([candidate1, candidate2, candidate3]);
// Act
var results = await _vexCandidateStore.GetCandidatesAsync(imageDigest);
// Assert
Assert.Equal(2, results.Count);
Assert.All(results, r => Assert.Equal(imageDigest, r.ImageDigest));
}
[Fact]
public async Task ReviewCandidate_UpdatesReviewStatus()
{
// Arrange
var candidate = CreateTestCandidate("CVE-2024-REVIEW", "pkg:npm/review@1.0.0", "sha256:review");
await _vexCandidateStore.StoreCandidatesAsync([candidate]);
var review = new VexCandidateReview(
Action: VexReviewAction.Accept,
Reviewer: "test-user@example.com",
ReviewedAt: DateTimeOffset.UtcNow,
Comment: "Verified via manual code review");
// Act
var success = await _vexCandidateStore.ReviewCandidateAsync(candidate.CandidateId, review);
var retrieved = await _vexCandidateStore.GetCandidateAsync(candidate.CandidateId);
// Assert
Assert.True(success);
Assert.NotNull(retrieved);
Assert.False(retrieved.RequiresReview);
}
[Fact]
public async Task ReviewCandidate_NonExistent_ReturnsFalse()
{
// Arrange
var review = new VexCandidateReview(
Action: VexReviewAction.Reject,
Reviewer: "test@example.com",
ReviewedAt: DateTimeOffset.UtcNow,
Comment: "Test");
// Act
var success = await _vexCandidateStore.ReviewCandidateAsync("non-existent-id", review);
// Assert
Assert.False(success);
}
#endregion
#region MaterialRiskChange Tests
[Fact]
public async Task StoreChange_ThenRetrieve_ReturnsCorrectData()
{
// Arrange
var change = CreateTestChange("CVE-2024-CHG1", "pkg:npm/change@1.0.0", hasMaterialChange: true);
var scanId = "scan-change-001";
// Act
await _changeRepo.StoreChangeAsync(change, scanId);
var results = await _changeRepo.GetChangesForScanAsync(scanId);
// Assert
Assert.Single(results);
Assert.Equal(change.FindingKey.VulnId, results[0].FindingKey.VulnId);
Assert.Equal(change.HasMaterialChange, results[0].HasMaterialChange);
Assert.Equal(change.PriorityScore, results[0].PriorityScore);
}
[Fact]
public async Task StoreMultipleChanges_QueryByFinding_ReturnsHistory()
{
// Arrange
var findingKey = new FindingKey("CVE-2024-HIST", "pkg:npm/history@1.0.0");
var change1 = CreateTestChange(findingKey.VulnId, findingKey.Purl, hasMaterialChange: true, priority: 100);
var change2 = CreateTestChange(findingKey.VulnId, findingKey.Purl, hasMaterialChange: true, priority: 200);
await _changeRepo.StoreChangeAsync(change1, "scan-h1");
await _changeRepo.StoreChangeAsync(change2, "scan-h2");
// Act
var history = await _changeRepo.GetChangesForFindingAsync(findingKey, limit: 10);
// Assert
Assert.Equal(2, history.Count);
}
[Fact]
public async Task QueryChanges_WithMinPriority_FiltersCorrectly()
{
// Arrange
var change1 = CreateTestChange("CVE-2024-P1", "pkg:npm/p1@1.0.0", hasMaterialChange: true, priority: 50);
var change2 = CreateTestChange("CVE-2024-P2", "pkg:npm/p2@1.0.0", hasMaterialChange: true, priority: 150);
var change3 = CreateTestChange("CVE-2024-P3", "pkg:npm/p3@1.0.0", hasMaterialChange: true, priority: 250);
await _changeRepo.StoreChangesAsync([change1, change2, change3], "scan-priority");
var query = new MaterialRiskChangeQuery
{
MinPriorityScore = 100,
Offset = 0,
Limit = 100
};
// Act
var result = await _changeRepo.QueryChangesAsync(query);
// Assert
Assert.Equal(2, result.Changes.Length);
Assert.All(result.Changes, c => Assert.True(c.PriorityScore >= 100));
}
#endregion
#region Test Data Factories
private static RiskStateSnapshot CreateTestSnapshot(
string vulnId,
string purl,
string scanId,
DateTimeOffset? capturedAt = null)
{
return new RiskStateSnapshot(
FindingKey: new FindingKey(vulnId, purl),
ScanId: scanId,
CapturedAt: capturedAt ?? DateTimeOffset.UtcNow,
Reachable: true,
LatticeState: "CR",
VexStatus: VexStatusType.Affected,
InAffectedRange: true,
Kev: false,
EpssScore: 0.05,
PolicyFlags: ["TEST_FLAG"],
PolicyDecision: PolicyDecisionType.Warn);
}
private static VexCandidate CreateTestCandidate(
string vulnId,
string purl,
string imageDigest)
{
return new VexCandidate(
CandidateId: $"cand-{Guid.NewGuid():N}",
FindingKey: new FindingKey(vulnId, purl),
SuggestedStatus: VexStatusType.NotAffected,
Justification: VexJustification.VulnerableCodeNotInExecutePath,
Rationale: "Test rationale - vulnerable code path not executed",
EvidenceLinks:
[
new EvidenceLink("call_graph", "stellaops://graph/test", "sha256:evidence123")
],
Confidence: 0.85,
ImageDigest: imageDigest,
GeneratedAt: DateTimeOffset.UtcNow,
ExpiresAt: DateTimeOffset.UtcNow.AddDays(30),
RequiresReview: true);
}
private static MaterialRiskChangeResult CreateTestChange(
string vulnId,
string purl,
bool hasMaterialChange,
int priority = 100)
{
var changes = hasMaterialChange
?
[
new DetectedChange(
Rule: DetectionRule.R1_ReachabilityFlip,
ChangeType: MaterialChangeType.ReachabilityFlip,
Direction: RiskDirection.Increased,
Reason: "Test reachability flip",
PreviousValue: "false",
CurrentValue: "true",
Weight: 1.0)
]
: ImmutableArray<DetectedChange>.Empty;
return new MaterialRiskChangeResult(
FindingKey: new FindingKey(vulnId, purl),
HasMaterialChange: hasMaterialChange,
Changes: changes,
PriorityScore: priority,
PreviousStateHash: "sha256:prev",
CurrentStateHash: "sha256:curr");
}
#endregion
}