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