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:
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user