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
|
||||
}
|
||||
Reference in New Issue
Block a user