release orchestrator v1 draft and build fixes

This commit is contained in:
master
2026-01-12 12:24:17 +02:00
parent f3de858c59
commit 9873f80830
1598 changed files with 240385 additions and 5944 deletions

View File

@@ -0,0 +1,510 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
// Sprint: EVID-001-004 - Binary Patch Verification Implementation
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using StellaOps.BinaryIndex.Decompiler;
using StellaOps.BinaryIndex.Ghidra;
using StellaOps.Scanner.Explainability.Assumptions;
using StellaOps.Scanner.Reachability.Stack;
namespace StellaOps.Scanner.Reachability.Binary;
/// <summary>
/// Verifies binary patches using Ghidra decompilation and AST comparison.
/// Bridges the existing decompiler infrastructure to L2 reachability analysis.
/// </summary>
public sealed class BinaryPatchVerifier : IBinaryPatchVerifier
{
private readonly IGhidraService _ghidraService;
private readonly IDecompilerService _decompilerService;
private readonly ILogger<BinaryPatchVerifier> _logger;
private readonly TimeProvider _timeProvider;
// Supported binary formats
private static readonly HashSet<string> SupportedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".so", ".dll", ".exe", ".dylib", ".bin", ".elf", ""
};
public BinaryPatchVerifier(
IGhidraService ghidraService,
IDecompilerService decompilerService,
ILogger<BinaryPatchVerifier> logger,
TimeProvider? timeProvider = null)
{
_ghidraService = ghidraService ?? throw new ArgumentNullException(nameof(ghidraService));
_decompilerService = decompilerService ?? throw new ArgumentNullException(nameof(decompilerService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public bool IsSupported(string binaryPath)
{
if (string.IsNullOrWhiteSpace(binaryPath))
return false;
var extension = Path.GetExtension(binaryPath);
return SupportedExtensions.Contains(extension);
}
/// <inheritdoc />
public async Task<PatchVerificationResult> VerifyPatchAsync(
PatchVerificationRequest request,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(request);
var startTime = _timeProvider.GetUtcNow();
var sw = Stopwatch.StartNew();
_logger.LogInformation(
"Starting patch verification for CVE {CveId} with {SymbolCount} target symbols",
request.CveId, request.TargetSymbols.Count);
try
{
// Analyze both binaries in parallel
var vulnerableAnalysisTask = AnalyzeBinaryAsync(
request.VulnerableBinaryReference, true, ct);
var targetAnalysisTask = AnalyzeBinaryAsync(
request.TargetBinaryPath, true, ct);
await Task.WhenAll(vulnerableAnalysisTask, targetAnalysisTask);
var vulnerableAnalysis = await vulnerableAnalysisTask;
var targetAnalysis = await targetAnalysisTask;
if (vulnerableAnalysis is null)
{
return CreateFailedResult(request, sw.Elapsed,
"Failed to analyze vulnerable reference binary");
}
if (targetAnalysis is null)
{
return CreateFailedResult(request, sw.Elapsed,
"Failed to analyze target binary");
}
// Compare each target symbol
var results = new ConcurrentBag<FunctionPatchResult>();
var semaphore = new SemaphoreSlim(request.Options.MaxParallelism);
var tasks = request.TargetSymbols.Select(async symbol =>
{
await semaphore.WaitAsync(ct);
try
{
var result = await CompareFunctionInAnalysesAsync(
vulnerableAnalysis,
targetAnalysis,
symbol,
request.Options,
ct);
results.Add(result);
}
finally
{
semaphore.Release();
}
});
await Task.WhenAll(tasks);
var functionResults = results.ToList();
var status = DeterminePatchStatus(functionResults, request.Options);
var layer2 = BuildLayer2(functionResults, status, request.CveId);
var confidence = CalculateOverallConfidence(functionResults);
sw.Stop();
_logger.LogInformation(
"Patch verification completed: {Status} with confidence {Confidence:P1} in {Duration}ms",
status, confidence, sw.ElapsedMilliseconds);
return new PatchVerificationResult
{
Success = true,
Status = status,
FunctionResults = functionResults,
Layer2 = layer2,
Confidence = confidence,
Duration = sw.Elapsed
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Patch verification failed for CVE {CveId}", request.CveId);
sw.Stop();
return CreateFailedResult(request, sw.Elapsed, ex.Message);
}
}
/// <inheritdoc />
public async Task<FunctionPatchResult> CompareFunctionAsync(
string vulnerableBinaryPath,
string targetBinaryPath,
string symbolName,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(vulnerableBinaryPath);
ArgumentException.ThrowIfNullOrWhiteSpace(targetBinaryPath);
ArgumentException.ThrowIfNullOrWhiteSpace(symbolName);
var vulnerableAnalysis = await AnalyzeBinaryAsync(vulnerableBinaryPath, true, ct);
var targetAnalysis = await AnalyzeBinaryAsync(targetBinaryPath, true, ct);
if (vulnerableAnalysis is null || targetAnalysis is null)
{
return new FunctionPatchResult
{
SymbolName = symbolName,
Success = false,
IsPatched = false,
Similarity = 0,
Confidence = 0,
Error = "Failed to analyze one or both binaries"
};
}
var symbol = new VulnerableSymbol { Name = symbolName };
return await CompareFunctionInAnalysesAsync(
vulnerableAnalysis,
targetAnalysis,
symbol,
new PatchVerificationOptions(),
ct);
}
private async Task<GhidraAnalysisResult?> AnalyzeBinaryAsync(
string binaryPath,
bool includeDecompilation,
CancellationToken ct)
{
try
{
_logger.LogDebug("Analyzing binary: {Path}", binaryPath);
return await _ghidraService.AnalyzeAsync(
binaryPath,
new GhidraAnalysisOptions
{
IncludeDecompilation = includeDecompilation,
ExtractDecompilation = includeDecompilation,
RunFullAnalysis = true,
GeneratePCodeHashes = true,
ExtractFunctions = true
},
ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to analyze binary: {Path}", binaryPath);
return null;
}
}
private async Task<FunctionPatchResult> CompareFunctionInAnalysesAsync(
GhidraAnalysisResult vulnerableAnalysis,
GhidraAnalysisResult targetAnalysis,
VulnerableSymbol symbol,
PatchVerificationOptions options,
CancellationToken ct)
{
try
{
// Find the function in both analyses
var vulnerableFunc = FindFunction(vulnerableAnalysis, symbol);
var targetFunc = FindFunction(targetAnalysis, symbol);
if (vulnerableFunc is null)
{
_logger.LogWarning(
"Symbol {Symbol} not found in vulnerable binary",
symbol.Name);
return new FunctionPatchResult
{
SymbolName = symbol.Name,
Success = false,
IsPatched = false,
Similarity = 0,
Confidence = 0,
Error = "Symbol not found in vulnerable binary"
};
}
if (targetFunc is null)
{
// Function removed from target - likely patched by removal
_logger.LogDebug(
"Symbol {Symbol} not found in target binary - possibly removed",
symbol.Name);
return new FunctionPatchResult
{
SymbolName = symbol.Name,
Success = true,
IsPatched = true,
Similarity = 0,
Confidence = 0.7m,
Differences = ["Function removed from target binary"]
};
}
// Quick check: if P-Code hashes match, functions are identical
if (vulnerableFunc.PCodeHash is not null &&
targetFunc.PCodeHash is not null &&
vulnerableFunc.PCodeHash.SequenceEqual(targetFunc.PCodeHash))
{
_logger.LogDebug(
"Symbol {Symbol} has identical P-Code hash - not patched",
symbol.Name);
return new FunctionPatchResult
{
SymbolName = symbol.Name,
Success = true,
IsPatched = false,
Similarity = 1.0m,
StructuralSimilarity = 1.0m,
SemanticSimilarity = 1.0m,
Confidence = 0.99m,
VulnerableCode = options.IncludeDecompiledCode ? vulnerableFunc.DecompiledCode : null,
TargetCode = options.IncludeDecompiledCode ? targetFunc.DecompiledCode : null
};
}
// Decompile and compare
var vulnerableDecompiled = await _decompilerService.DecompileAsync(
vulnerableFunc,
new DecompileOptions { Timeout = options.FunctionTimeout },
ct);
var targetDecompiled = await _decompilerService.DecompileAsync(
targetFunc,
new DecompileOptions { Timeout = options.FunctionTimeout },
ct);
var comparison = await _decompilerService.CompareAsync(
vulnerableDecompiled,
targetDecompiled,
new ComparisonOptions
{
IgnoreVariableNames = true,
DetectOptimizations = true
},
ct);
// Determine if patched based on similarity
var isPatched = comparison.Similarity < options.PatchedThreshold;
var isIdentical = comparison.Similarity >= options.IdenticalThreshold;
// Build difference descriptions
var differences = comparison.Differences
.Take(10)
.Select(d => $"{d.Type}: {d.Description}")
.ToList();
// Map comparison confidence to our confidence
var confidence = MapConfidence(comparison.Confidence, comparison.Similarity);
return new FunctionPatchResult
{
SymbolName = symbol.Name,
Success = true,
IsPatched = isPatched,
Similarity = comparison.Similarity,
StructuralSimilarity = comparison.StructuralSimilarity,
SemanticSimilarity = comparison.SemanticSimilarity,
Confidence = confidence,
Differences = differences,
VulnerableCode = options.IncludeDecompiledCode ? vulnerableDecompiled.Code : null,
TargetCode = options.IncludeDecompiledCode ? targetDecompiled.Code : null
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to compare function {Symbol}", symbol.Name);
return new FunctionPatchResult
{
SymbolName = symbol.Name,
Success = false,
IsPatched = false,
Similarity = 0,
Confidence = 0,
Error = ex.Message
};
}
}
private static GhidraFunction? FindFunction(
GhidraAnalysisResult analysis,
VulnerableSymbol symbol)
{
// Try exact name match first
var func = analysis.Functions.FirstOrDefault(f =>
f.Name.Equals(symbol.Name, StringComparison.Ordinal));
if (func is not null)
return func;
// Try case-insensitive match
func = analysis.Functions.FirstOrDefault(f =>
f.Name.Equals(symbol.Name, StringComparison.OrdinalIgnoreCase));
if (func is not null)
return func;
// Try by address if specified
if (symbol.VulnerableAddress.HasValue)
{
func = analysis.Functions.FirstOrDefault(f =>
f.Address == symbol.VulnerableAddress.Value);
}
return func;
}
private static PatchStatus DeterminePatchStatus(
IReadOnlyList<FunctionPatchResult> results,
PatchVerificationOptions options)
{
if (results.Count == 0)
return PatchStatus.Unknown;
var successfulResults = results.Where(r => r.Success).ToList();
if (successfulResults.Count == 0)
return PatchStatus.Unknown;
var patchedCount = successfulResults.Count(r => r.IsPatched);
var vulnerableCount = successfulResults.Count(r => !r.IsPatched);
if (patchedCount == successfulResults.Count)
return PatchStatus.Patched;
if (vulnerableCount == successfulResults.Count)
return PatchStatus.Vulnerable;
if (patchedCount > 0 && vulnerableCount > 0)
return PatchStatus.PartiallyPatched;
return PatchStatus.Unknown;
}
private static ReachabilityLayer2 BuildLayer2(
IReadOnlyList<FunctionPatchResult> results,
PatchStatus status,
string cveId)
{
var successfulResults = results.Where(r => r.Success).ToList();
var avgConfidence = successfulResults.Count > 0
? successfulResults.Average(r => r.Confidence)
: 0m;
var confidenceLevel = avgConfidence switch
{
>= 0.8m => ConfidenceLevel.High,
>= 0.5m => ConfidenceLevel.Medium,
_ => ConfidenceLevel.Low
};
// Determine if the vulnerable symbol is resolved (linked) in the binary
var isResolved = status switch
{
PatchStatus.Vulnerable => true,
PatchStatus.PartiallyPatched => true,
PatchStatus.Patched => false, // Patched means different code
PatchStatus.Unknown => true, // Assume resolved if unknown
_ => true // Default: assume resolved
};
var reason = status switch
{
PatchStatus.Patched => $"All {results.Count} vulnerable function(s) have been patched",
PatchStatus.Vulnerable => $"All {results.Count} vulnerable function(s) are identical to known-vulnerable version",
PatchStatus.PartiallyPatched => $"{results.Count(r => r.IsPatched)} of {results.Count} functions patched",
PatchStatus.Unknown => "Unable to determine patch status",
_ => "Unknown status"
};
var symbols = successfulResults
.Select(r => new ResolvedSymbol(
Name: r.SymbolName,
Address: 0, // We don't track this
IsWeak: false,
BindingType: SymbolBinding.Global))
.ToImmutableArray();
return new ReachabilityLayer2
{
IsResolved = isResolved,
Confidence = confidenceLevel,
Reason = reason,
ResolvedSymbols = symbols
};
}
private static decimal CalculateOverallConfidence(
IReadOnlyList<FunctionPatchResult> results)
{
var successfulResults = results.Where(r => r.Success).ToList();
if (successfulResults.Count == 0)
return 0;
// Weighted by individual confidence
return successfulResults.Average(r => r.Confidence);
}
private static decimal MapConfidence(
ComparisonConfidence comparisonConfidence,
decimal similarity)
{
var baseConfidence = comparisonConfidence switch
{
ComparisonConfidence.VeryHigh => 0.95m,
ComparisonConfidence.High => 0.85m,
ComparisonConfidence.Medium => 0.7m,
ComparisonConfidence.Low => 0.5m,
_ => 0.5m
};
// Adjust based on similarity - extreme values are more confident
if (similarity > 0.95m || similarity < 0.3m)
{
baseConfidence = Math.Min(1.0m, baseConfidence + 0.1m);
}
return baseConfidence;
}
private PatchVerificationResult CreateFailedResult(
PatchVerificationRequest request,
TimeSpan duration,
string error)
{
return new PatchVerificationResult
{
Success = false,
Status = PatchStatus.Unknown,
FunctionResults = [],
Layer2 = new ReachabilityLayer2
{
IsResolved = true, // Assume resolved if we can't verify
Confidence = ConfidenceLevel.Low,
Reason = error
},
Confidence = 0,
Error = error,
Duration = duration
};
}
}

View File

@@ -0,0 +1,248 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
// Sprint: EVID-001-004 - Binary Patch Verification
using StellaOps.Scanner.Reachability.Stack;
namespace StellaOps.Scanner.Reachability.Binary;
/// <summary>
/// Verifies whether a vulnerable binary has been patched by comparing
/// decompiled functions between vulnerable and patched versions.
/// </summary>
public interface IBinaryPatchVerifier
{
/// <summary>
/// Verifies if specific vulnerable functions have been patched in a binary.
/// </summary>
/// <param name="request">Verification request containing binary references and target symbols.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Patch verification result for L2 reachability analysis.</returns>
Task<PatchVerificationResult> VerifyPatchAsync(
PatchVerificationRequest request,
CancellationToken ct = default);
/// <summary>
/// Compares a single function between vulnerable and potentially patched versions.
/// </summary>
/// <param name="vulnerableBinaryPath">Path to known-vulnerable binary.</param>
/// <param name="targetBinaryPath">Path to target binary to verify.</param>
/// <param name="symbolName">Name of the vulnerable function.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Comparison result for the specific function.</returns>
Task<FunctionPatchResult> CompareFunctionAsync(
string vulnerableBinaryPath,
string targetBinaryPath,
string symbolName,
CancellationToken ct = default);
/// <summary>
/// Checks if the verifier supports the given binary format.
/// </summary>
/// <param name="binaryPath">Path to the binary.</param>
/// <returns>True if the binary format is supported.</returns>
bool IsSupported(string binaryPath);
}
/// <summary>
/// Request for patch verification.
/// </summary>
public sealed record PatchVerificationRequest
{
/// <summary>
/// Path to a known-vulnerable reference binary (or registry key).
/// </summary>
public required string VulnerableBinaryReference { get; init; }
/// <summary>
/// Path to the target binary to verify.
/// </summary>
public required string TargetBinaryPath { get; init; }
/// <summary>
/// CVE identifier for context.
/// </summary>
public required string CveId { get; init; }
/// <summary>
/// Target vulnerable symbols/functions to compare.
/// </summary>
public required IReadOnlyList<VulnerableSymbol> TargetSymbols { get; init; }
/// <summary>
/// Options for verification.
/// </summary>
public PatchVerificationOptions Options { get; init; } = new();
}
/// <summary>
/// A vulnerable symbol to verify.
/// </summary>
public sealed record VulnerableSymbol
{
/// <summary>
/// Symbol name (function name).
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Optional address in the vulnerable binary.
/// </summary>
public ulong? VulnerableAddress { get; init; }
/// <summary>
/// Expected signature change in patched version.
/// </summary>
public string? ExpectedPatchPattern { get; init; }
}
/// <summary>
/// Options for patch verification.
/// </summary>
public sealed record PatchVerificationOptions
{
/// <summary>
/// Similarity threshold below which functions are considered different (patched).
/// </summary>
public decimal PatchedThreshold { get; init; } = 0.85m;
/// <summary>
/// Similarity threshold above which functions are considered identical.
/// </summary>
public decimal IdenticalThreshold { get; init; } = 0.98m;
/// <summary>
/// Timeout for each function comparison.
/// </summary>
public TimeSpan FunctionTimeout { get; init; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Whether to include decompiled code in results.
/// </summary>
public bool IncludeDecompiledCode { get; init; } = false;
/// <summary>
/// Maximum number of functions to compare in parallel.
/// </summary>
public int MaxParallelism { get; init; } = 4;
}
/// <summary>
/// Result of patch verification.
/// </summary>
public sealed record PatchVerificationResult
{
/// <summary>
/// Whether verification completed successfully.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// Overall patch status determination.
/// </summary>
public required PatchStatus Status { get; init; }
/// <summary>
/// Results for each verified function.
/// </summary>
public required IReadOnlyList<FunctionPatchResult> FunctionResults { get; init; }
/// <summary>
/// L2 reachability layer from verification.
/// </summary>
public required ReachabilityLayer2 Layer2 { get; init; }
/// <summary>
/// Overall confidence in the determination.
/// </summary>
public required decimal Confidence { get; init; }
/// <summary>
/// Error message if verification failed.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Duration of verification.
/// </summary>
public TimeSpan Duration { get; init; }
}
/// <summary>
/// Overall patch status.
/// </summary>
public enum PatchStatus
{
/// <summary>All vulnerable functions appear patched.</summary>
Patched,
/// <summary>All vulnerable functions appear identical to vulnerable version.</summary>
Vulnerable,
/// <summary>Some functions patched, some not.</summary>
PartiallyPatched,
/// <summary>Unable to determine patch status.</summary>
Unknown
}
/// <summary>
/// Result for a single function comparison.
/// </summary>
public sealed record FunctionPatchResult
{
/// <summary>
/// Symbol name that was compared.
/// </summary>
public required string SymbolName { get; init; }
/// <summary>
/// Whether comparison was successful.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// Whether the function appears to be patched.
/// </summary>
public required bool IsPatched { get; init; }
/// <summary>
/// Similarity score (0.0 to 1.0).
/// </summary>
public required decimal Similarity { get; init; }
/// <summary>
/// Structural similarity.
/// </summary>
public decimal StructuralSimilarity { get; init; }
/// <summary>
/// Semantic similarity.
/// </summary>
public decimal SemanticSimilarity { get; init; }
/// <summary>
/// Confidence in the determination.
/// </summary>
public required decimal Confidence { get; init; }
/// <summary>
/// Description of differences found.
/// </summary>
public IReadOnlyList<string> Differences { get; init; } = [];
/// <summary>
/// Decompiled code from vulnerable binary (if requested).
/// </summary>
public string? VulnerableCode { get; init; }
/// <summary>
/// Decompiled code from target binary (if requested).
/// </summary>
public string? TargetCode { get; init; }
/// <summary>
/// Error message if comparison failed.
/// </summary>
public string? Error { get; init; }
}

View File

@@ -0,0 +1,23 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
// Sprint: EVID-001-002 - Reachability Evidence Job Executor
using StellaOps.Scanner.Reachability.Stack;
namespace StellaOps.Scanner.Reachability.Jobs;
/// <summary>
/// Executor for reachability evidence jobs.
/// </summary>
public interface IReachabilityEvidenceJobExecutor
{
/// <summary>
/// Executes a reachability evidence job and returns the result.
/// </summary>
/// <param name="job">The job to execute.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The job result containing the reachability stack.</returns>
Task<ReachabilityEvidenceJobResult> ExecuteAsync(
ReachabilityEvidenceJob job,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,231 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
// Sprint: EVID-001-002 - Reachability Evidence Job
namespace StellaOps.Scanner.Reachability.Jobs;
/// <summary>
/// A job request for reachability evidence analysis.
/// </summary>
public sealed record ReachabilityEvidenceJob
{
/// <summary>
/// Unique job identifier. Deterministic from inputs hash if not specified.
/// </summary>
public required string JobId { get; init; }
/// <summary>
/// Image digest to analyze (sha256:...).
/// </summary>
public required string ImageDigest { get; init; }
/// <summary>
/// CVE identifier to check reachability for.
/// </summary>
public required string CveId { get; init; }
/// <summary>
/// Package URL of the affected component.
/// </summary>
public required string Purl { get; init; }
/// <summary>
/// Optional source commit for the image.
/// </summary>
public string? SourceCommit { get; init; }
/// <summary>
/// Job options controlling analysis behavior.
/// </summary>
public required ReachabilityJobOptions Options { get; init; }
/// <summary>
/// When the job was queued.
/// </summary>
public required DateTimeOffset QueuedAt { get; init; }
/// <summary>
/// Tenant ID for multi-tenant deployments.
/// </summary>
public string TenantId { get; init; } = "default";
/// <summary>
/// Priority of the job (lower = higher priority).
/// </summary>
public int Priority { get; init; } = 100;
/// <summary>
/// Creates a deterministic job ID from inputs.
/// </summary>
public static string ComputeJobId(string imageDigest, string cveId, string purl)
{
var input = $"{imageDigest}:{cveId}:{purl}";
var hash = System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(hash)[..32].ToLowerInvariant();
}
}
/// <summary>
/// Options for reachability evidence analysis.
/// </summary>
public sealed record ReachabilityJobOptions
{
/// <summary>
/// Include Layer 2 (binary resolution) analysis.
/// </summary>
public bool IncludeL2 { get; init; } = false;
/// <summary>
/// Include Layer 3 (runtime observation) analysis if available.
/// </summary>
public bool IncludeL3 { get; init; } = false;
/// <summary>
/// Maximum number of paths to return per sink.
/// </summary>
public int MaxPathsPerSink { get; init; } = 3;
/// <summary>
/// Maximum total paths across all sinks.
/// </summary>
public int MaxTotalPaths { get; init; } = 10;
/// <summary>
/// Maximum depth for call graph traversal.
/// </summary>
public int MaxDepth { get; init; } = 256;
/// <summary>
/// Timeout for the job in seconds.
/// </summary>
public int TimeoutSeconds { get; init; } = 300;
/// <summary>
/// Whether to force recomputation even if cached.
/// </summary>
public bool ForceRecompute { get; init; } = false;
// --- Layer 2 (Binary Patch Verification) Options ---
/// <summary>
/// Path to known-vulnerable reference binary for L2 analysis.
/// </summary>
public string? VulnerableBinaryPath { get; init; }
/// <summary>
/// Path to target binary to verify for L2 analysis.
/// </summary>
public string? TargetBinaryPath { get; init; }
// --- Layer 3 (Runtime Observation) Options ---
/// <summary>
/// Container ID for runtime observation (L3 analysis).
/// </summary>
public string? ContainerId { get; init; }
/// <summary>
/// Duration for runtime observation. Defaults to 5 minutes.
/// </summary>
public TimeSpan? RuntimeObservationDuration { get; init; }
/// <summary>
/// Use historical runtime observation data if available.
/// </summary>
public bool UseHistoricalRuntimeData { get; init; } = true;
/// <summary>
/// Default options for standard analysis.
/// </summary>
public static ReachabilityJobOptions Default => new();
/// <summary>
/// Options for thorough analysis including all layers.
/// </summary>
public static ReachabilityJobOptions Thorough => new()
{
IncludeL2 = true,
IncludeL3 = true,
MaxPathsPerSink = 5,
MaxTotalPaths = 25,
MaxDepth = 512,
TimeoutSeconds = 600,
RuntimeObservationDuration = TimeSpan.FromMinutes(5)
};
}
/// <summary>
/// Result of a reachability evidence job.
/// </summary>
public sealed record ReachabilityEvidenceJobResult
{
/// <summary>
/// The job that was executed.
/// </summary>
public required string JobId { get; init; }
/// <summary>
/// Job execution status.
/// </summary>
public required JobStatus Status { get; init; }
/// <summary>
/// The reachability stack result (null if failed).
/// </summary>
public Stack.ReachabilityStack? Stack { get; init; }
/// <summary>
/// Evidence bundle ID where results are stored.
/// </summary>
public string? EvidenceBundleId { get; init; }
/// <summary>
/// Evidence bundle URI (stella:// scheme).
/// </summary>
public string? EvidenceUri { get; init; }
/// <summary>
/// Error message if failed.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// When the job started.
/// </summary>
public DateTimeOffset? StartedAt { get; init; }
/// <summary>
/// When the job completed.
/// </summary>
public DateTimeOffset? CompletedAt { get; init; }
/// <summary>
/// Duration in milliseconds.
/// </summary>
public long? DurationMs { get; init; }
}
/// <summary>
/// Status of a reachability job.
/// </summary>
public enum JobStatus
{
/// <summary>Job is queued.</summary>
Queued,
/// <summary>Job is running.</summary>
Running,
/// <summary>Job completed successfully.</summary>
Completed,
/// <summary>Job failed.</summary>
Failed,
/// <summary>Job was cancelled.</summary>
Cancelled,
/// <summary>Job timed out.</summary>
TimedOut
}

View File

@@ -0,0 +1,473 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
// Sprint: EVID-001-002 - Reachability Evidence Job Executor
using System.Collections.Immutable;
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.CallGraph;
using StellaOps.Scanner.Explainability.Assumptions;
using StellaOps.Scanner.Reachability.Binary;
using StellaOps.Scanner.Reachability.Runtime;
using StellaOps.Scanner.Reachability.Services;
using StellaOps.Scanner.Reachability.Stack;
// Aliases to disambiguate types with same name in different namespaces
using StackEntrypointType = StellaOps.Scanner.Reachability.Stack.EntrypointType;
using StackVulnerableSymbol = StellaOps.Scanner.Reachability.Stack.VulnerableSymbol;
using BinaryVulnerableSymbol = StellaOps.Scanner.Reachability.Binary.VulnerableSymbol;
using StackCallPath = StellaOps.Scanner.Reachability.Stack.CallPath;
namespace StellaOps.Scanner.Reachability.Jobs;
/// <summary>
/// Executes reachability evidence jobs by orchestrating call graph analysis.
/// </summary>
public sealed class ReachabilityEvidenceJobExecutor : IReachabilityEvidenceJobExecutor
{
private readonly ICveSymbolMappingService _cveSymbolService;
private readonly ICallGraphSnapshotProvider _callGraphProvider;
private readonly IReachabilityStackEvaluator _stackEvaluator;
private readonly IEvidenceStorageService _evidenceStorage;
private readonly IBinaryPatchVerifier? _binaryPatchVerifier;
private readonly IRuntimeReachabilityCollector? _runtimeCollector;
private readonly ILogger<ReachabilityEvidenceJobExecutor> _logger;
private readonly TimeProvider _timeProvider;
public ReachabilityEvidenceJobExecutor(
ICveSymbolMappingService cveSymbolService,
ICallGraphSnapshotProvider callGraphProvider,
IReachabilityStackEvaluator stackEvaluator,
IEvidenceStorageService evidenceStorage,
ILogger<ReachabilityEvidenceJobExecutor> logger,
IBinaryPatchVerifier? binaryPatchVerifier = null,
IRuntimeReachabilityCollector? runtimeCollector = null,
TimeProvider? timeProvider = null)
{
_cveSymbolService = cveSymbolService ?? throw new ArgumentNullException(nameof(cveSymbolService));
_callGraphProvider = callGraphProvider ?? throw new ArgumentNullException(nameof(callGraphProvider));
_stackEvaluator = stackEvaluator ?? throw new ArgumentNullException(nameof(stackEvaluator));
_evidenceStorage = evidenceStorage ?? throw new ArgumentNullException(nameof(evidenceStorage));
_binaryPatchVerifier = binaryPatchVerifier;
_runtimeCollector = runtimeCollector;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public async Task<ReachabilityEvidenceJobResult> ExecuteAsync(
ReachabilityEvidenceJob job,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(job);
var startedAt = _timeProvider.GetUtcNow();
var sw = Stopwatch.StartNew();
_logger.LogInformation(
"Starting reachability evidence job: JobId={JobId} CVE={CveId} Image={ImageDigest}",
job.JobId, job.CveId, job.ImageDigest);
try
{
// 1. Get vulnerable sinks from CVE mapping
var sinks = await _cveSymbolService.GetSinksForCveAsync(
job.CveId, job.Purl, ct);
if (sinks.Count == 0)
{
_logger.LogWarning(
"No sink mappings found for CVE {CveId} and PURL {Purl}",
job.CveId, job.Purl);
return CreateUnknownResult(job, startedAt, sw,
$"No sink mappings found for CVE {job.CveId}");
}
_logger.LogDebug(
"Found {SinkCount} sinks for CVE {CveId}: {Sinks}",
sinks.Count, job.CveId,
string.Join(", ", sinks.Select(s => s.SymbolName)));
// 2. Get or compute call graph snapshot
var snapshot = await _callGraphProvider.GetOrComputeAsync(
job.ImageDigest, job.Options.ForceRecompute, ct);
if (snapshot is null || snapshot.Nodes.IsDefaultOrEmpty)
{
_logger.LogWarning(
"No call graph available for image {ImageDigest}",
job.ImageDigest);
return CreateUnknownResult(job, startedAt, sw,
"No call graph available for image");
}
// 3. Run reachability analysis with explicit sinks
var sinkIds = sinks
.Select(s => s.CanonicalId ?? s.SymbolName)
.ToImmutableArray();
var analysisOptions = new ReachabilityAnalysisOptions
{
ExplicitSinks = sinkIds,
MaxPathsPerSink = job.Options.MaxPathsPerSink,
MaxTotalPaths = job.Options.MaxTotalPaths,
MaxDepth = job.Options.MaxDepth
};
var analyzer = new ReachabilityAnalyzer(_timeProvider, analysisOptions);
var analysisResult = analyzer.Analyze(snapshot, analysisOptions);
_logger.LogInformation(
"Reachability analysis complete: ReachableNodes={NodeCount} ReachableSinks={SinkCount} Paths={PathCount}",
analysisResult.ReachableNodeIds.Length,
analysisResult.ReachableSinkIds.Length,
analysisResult.Paths.Length);
// 4. Build Layer 1 from analysis result
var layer1 = BuildLayer1(analysisResult, sinks);
// 5. Build Layer 2 (binary patch verification via Ghidra)
var layer2 = job.Options.IncludeL2
? await BuildLayer2Async(job, sinks, ct)
: CreateUnknownLayer2();
// 6. Build Layer 3 (runtime observation via eBPF)
var layer3 = job.Options.IncludeL3
? await BuildLayer3Async(job, sinks, ct)
: CreateUnknownLayer3();
// 7. Evaluate the stack
var primarySink = sinks.First();
var vulnerableSymbol = primarySink.ToVulnerableSymbol();
var stack = _stackEvaluator.Evaluate(
findingId: $"{job.CveId}:{job.Purl}",
symbol: vulnerableSymbol,
layer1: layer1,
layer2: layer2,
layer3: layer3,
_timeProvider);
_logger.LogInformation(
"Reachability verdict: Verdict={Verdict} FindingId={FindingId}",
stack.Verdict, stack.FindingId);
// 8. Store evidence
var (bundleId, evidenceUri) = await _evidenceStorage.StoreReachabilityStackAsync(
stack, job, ct);
sw.Stop();
return new ReachabilityEvidenceJobResult
{
JobId = job.JobId,
Status = JobStatus.Completed,
Stack = stack,
EvidenceBundleId = bundleId,
EvidenceUri = evidenceUri,
StartedAt = startedAt,
CompletedAt = _timeProvider.GetUtcNow(),
DurationMs = sw.ElapsedMilliseconds
};
}
catch (OperationCanceledException)
{
_logger.LogWarning("Reachability job cancelled: JobId={JobId}", job.JobId);
sw.Stop();
return new ReachabilityEvidenceJobResult
{
JobId = job.JobId,
Status = JobStatus.Cancelled,
Error = "Job was cancelled",
StartedAt = startedAt,
CompletedAt = _timeProvider.GetUtcNow(),
DurationMs = sw.ElapsedMilliseconds
};
}
catch (Exception ex)
{
_logger.LogError(ex,
"Reachability job failed: JobId={JobId} Error={Error}",
job.JobId, ex.Message);
sw.Stop();
return new ReachabilityEvidenceJobResult
{
JobId = job.JobId,
Status = JobStatus.Failed,
Error = ex.Message,
StartedAt = startedAt,
CompletedAt = _timeProvider.GetUtcNow(),
DurationMs = sw.ElapsedMilliseconds
};
}
}
private ReachabilityLayer1 BuildLayer1(
ReachabilityAnalysisResult analysisResult,
IReadOnlyList<CveSinkMapping> sinks)
{
var isReachable = analysisResult.ReachableSinkIds.Length > 0;
var paths = analysisResult.Paths
.Select(p => new StackCallPath
{
Sites = p.NodeIds.Select((id, idx) => new CallSite(
MethodName: id,
ClassName: null,
FileName: null,
LineNumber: null,
Type: CallSiteType.Direct)).ToImmutableArray(),
Entrypoint = new Entrypoint(
Name: p.EntrypointId,
Type: StackEntrypointType.HttpEndpoint,
Location: null,
Description: null),
Confidence = 1.0,
HasConditionals = false
})
.ToImmutableArray();
var reachingEntrypoints = analysisResult.Paths
.Select(p => p.EntrypointId)
.Distinct()
.Select(ep => new Entrypoint(
Name: ep,
Type: StackEntrypointType.HttpEndpoint,
Location: null,
Description: null))
.ToImmutableArray();
var limitations = new List<string>
{
"Best-effort within analyzed call graph only",
"Reflection and dynamic invocation not modeled",
"Virtual dispatch expanded to known implementations only"
};
return new ReachabilityLayer1
{
IsReachable = isReachable,
Confidence = isReachable ? ConfidenceLevel.High : ConfidenceLevel.Medium,
Paths = paths,
ReachingEntrypoints = reachingEntrypoints,
AnalysisMethod = "BFS",
Limitations = limitations.ToImmutableArray()
};
}
private static ReachabilityLayer2 CreateUnknownLayer2()
{
return new ReachabilityLayer2
{
IsResolved = false,
Confidence = ConfidenceLevel.Low,
Reason = "Binary resolution analysis not performed"
};
}
private static ReachabilityLayer3 CreateUnknownLayer3()
{
return new ReachabilityLayer3
{
IsGated = false,
Outcome = GatingOutcome.Unknown,
Confidence = ConfidenceLevel.Low,
Description = "Runtime gating analysis not performed"
};
}
private async Task<ReachabilityLayer2> BuildLayer2Async(
ReachabilityEvidenceJob job,
IReadOnlyList<CveSinkMapping> sinks,
CancellationToken ct)
{
if (_binaryPatchVerifier is null)
{
_logger.LogDebug("Binary patch verifier not available, skipping L2 analysis");
return CreateUnknownLayer2();
}
// Check if we have binary paths to compare
if (string.IsNullOrWhiteSpace(job.Options.VulnerableBinaryPath) ||
string.IsNullOrWhiteSpace(job.Options.TargetBinaryPath))
{
_logger.LogDebug("Binary paths not provided, skipping L2 analysis");
return CreateUnknownLayer2();
}
try
{
var targetSymbols = sinks.Select(s => new BinaryVulnerableSymbol
{
Name = s.SymbolName
}).ToList();
var request = new PatchVerificationRequest
{
VulnerableBinaryReference = job.Options.VulnerableBinaryPath,
TargetBinaryPath = job.Options.TargetBinaryPath,
CveId = job.CveId,
TargetSymbols = targetSymbols,
Options = new PatchVerificationOptions
{
IncludeDecompiledCode = false,
PatchedThreshold = 0.85m
}
};
var result = await _binaryPatchVerifier.VerifyPatchAsync(request, ct);
if (!result.Success)
{
_logger.LogWarning("Binary patch verification failed: {Error}", result.Error);
return CreateUnknownLayer2();
}
_logger.LogInformation(
"Binary patch verification completed: Status={Status} Confidence={Confidence:P1}",
result.Status, result.Confidence);
return result.Layer2;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during L2 binary analysis");
return CreateUnknownLayer2();
}
}
private async Task<ReachabilityLayer3> BuildLayer3Async(
ReachabilityEvidenceJob job,
IReadOnlyList<CveSinkMapping> sinks,
CancellationToken ct)
{
if (_runtimeCollector is null || !_runtimeCollector.IsAvailable)
{
_logger.LogDebug(
"Runtime collector not available (available={Available}), skipping L3 analysis",
_runtimeCollector?.IsAvailable ?? false);
return CreateUnknownLayer3();
}
// Check if we have container info for runtime observation
if (string.IsNullOrWhiteSpace(job.Options.ContainerId))
{
_logger.LogDebug("Container ID not provided, skipping L3 analysis");
return CreateUnknownLayer3();
}
try
{
var targetSymbols = sinks
.Select(s => s.SymbolName)
.ToList();
var request = new RuntimeObservationRequest
{
ContainerId = job.Options.ContainerId,
ImageDigest = job.ImageDigest,
TargetSymbols = targetSymbols,
Duration = job.Options.RuntimeObservationDuration ?? TimeSpan.FromMinutes(5),
UseHistoricalData = true
};
var result = await _runtimeCollector.ObserveAsync(request, ct);
if (!result.Success)
{
_logger.LogWarning("Runtime observation failed: {Error}", result.Error);
return CreateUnknownLayer3();
}
_logger.LogInformation(
"Runtime observation completed: Outcome={Outcome} Source={Source} ObservedSymbols={Count}",
result.Layer3.Outcome,
result.Source,
result.Observations.Count(o => o.WasObserved));
return result.Layer3;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during L3 runtime analysis");
return CreateUnknownLayer3();
}
}
private ReachabilityEvidenceJobResult CreateUnknownResult(
ReachabilityEvidenceJob job,
DateTimeOffset startedAt,
Stopwatch sw,
string reason)
{
sw.Stop();
// Create a minimal stack with Unknown verdict
var unknownStack = new ReachabilityStack
{
Id = Guid.NewGuid().ToString("N"),
FindingId = $"{job.CveId}:{job.Purl}",
Symbol = new StackVulnerableSymbol(
Name: "unknown",
Library: null,
Version: null,
VulnerabilityId: job.CveId,
Type: SymbolType.Function),
StaticCallGraph = new ReachabilityLayer1
{
IsReachable = false,
Confidence = ConfidenceLevel.Low,
AnalysisMethod = "none",
Limitations = [reason]
},
BinaryResolution = CreateUnknownLayer2(),
RuntimeGating = CreateUnknownLayer3(),
Verdict = ReachabilityVerdict.Unknown,
AnalyzedAt = _timeProvider.GetUtcNow(),
Explanation = reason
};
return new ReachabilityEvidenceJobResult
{
JobId = job.JobId,
Status = JobStatus.Completed,
Stack = unknownStack,
StartedAt = startedAt,
CompletedAt = _timeProvider.GetUtcNow(),
DurationMs = sw.ElapsedMilliseconds
};
}
}
/// <summary>
/// Provider for call graph snapshots.
/// </summary>
public interface ICallGraphSnapshotProvider
{
/// <summary>
/// Gets or computes a call graph snapshot for an image.
/// </summary>
Task<CallGraphSnapshot?> GetOrComputeAsync(
string imageDigest,
bool forceRecompute,
CancellationToken ct);
}
/// <summary>
/// Service for storing reachability evidence.
/// </summary>
public interface IEvidenceStorageService
{
/// <summary>
/// Stores a reachability stack as an evidence bundle.
/// </summary>
/// <returns>Tuple of (bundleId, evidenceUri).</returns>
Task<(string BundleId, string EvidenceUri)> StoreReachabilityStackAsync(
ReachabilityStack stack,
ReachabilityEvidenceJob job,
CancellationToken ct);
}

View File

@@ -0,0 +1,302 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
// Sprint: EVID-001-004 - eBPF Runtime Reachability Collector
using System.Collections.Immutable;
using System.Runtime.InteropServices;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Explainability.Assumptions;
using StellaOps.Scanner.Reachability.Stack;
using StellaOps.Signals.Ebpf.Schema;
using StellaOps.Signals.Ebpf.Services;
namespace StellaOps.Scanner.Reachability.Runtime;
/// <summary>
/// Runtime reachability collector using the existing eBPF signal infrastructure.
/// </summary>
public sealed class EbpfRuntimeReachabilityCollector : IRuntimeReachabilityCollector
{
private readonly IRuntimeSignalCollector _signalCollector;
private readonly IRuntimeObservationStore _observationStore;
private readonly ILogger<EbpfRuntimeReachabilityCollector> _logger;
private readonly TimeProvider _timeProvider;
public EbpfRuntimeReachabilityCollector(
IRuntimeSignalCollector signalCollector,
IRuntimeObservationStore observationStore,
ILogger<EbpfRuntimeReachabilityCollector> logger,
TimeProvider? timeProvider = null)
{
_signalCollector = signalCollector ?? throw new ArgumentNullException(nameof(signalCollector));
_observationStore = observationStore ?? throw new ArgumentNullException(nameof(observationStore));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public bool IsAvailable => _signalCollector.IsSupported() && RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
/// <inheritdoc />
public string Platform => IsAvailable ? "linux/ebpf" : "unsupported";
/// <inheritdoc />
public async Task<RuntimeReachabilityResult> ObserveAsync(
RuntimeObservationRequest request,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(request);
var startTime = _timeProvider.GetUtcNow();
_logger.LogInformation(
"Starting runtime observation for container {ContainerId} with {SymbolCount} target symbols",
request.ContainerId, request.TargetSymbols.Count);
try
{
// Try historical data first if requested
if (request.UseHistoricalData)
{
var historical = await TryGetHistoricalObservationsAsync(request, ct);
if (historical is not null)
{
_logger.LogDebug(
"Using historical observation data for container {ContainerId}",
request.ContainerId);
return historical;
}
}
// Fall back to live observation if available
if (!IsAvailable)
{
return CreateUnavailableResult(request, startTime,
"eBPF runtime observation not available on this platform");
}
return await PerformLiveObservationAsync(request, startTime, ct);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Runtime observation failed for container {ContainerId}",
request.ContainerId);
return CreateFailedResult(request, startTime, ex.Message);
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<SymbolObservation>> CheckObservationsAsync(
string containerId,
IReadOnlyList<string> symbols,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(containerId);
ArgumentNullException.ThrowIfNull(symbols);
return await _observationStore.GetObservationsAsync(containerId, symbols, ct);
}
private async Task<RuntimeReachabilityResult?> TryGetHistoricalObservationsAsync(
RuntimeObservationRequest request,
CancellationToken ct)
{
var observations = await _observationStore.GetObservationsAsync(
request.ContainerId,
request.TargetSymbols,
ct);
if (observations.Count == 0)
{
return null;
}
var anyObserved = observations.Any(o => o.WasObserved);
var layer3 = BuildLayer3FromObservations(observations, ObservationSource.Historical);
return new RuntimeReachabilityResult
{
Success = true,
Layer3 = layer3,
Observations = observations,
ObservedAt = _timeProvider.GetUtcNow(),
Duration = TimeSpan.Zero,
Source = ObservationSource.Historical
};
}
private async Task<RuntimeReachabilityResult> PerformLiveObservationAsync(
RuntimeObservationRequest request,
DateTimeOffset startTime,
CancellationToken ct)
{
var options = new RuntimeSignalOptions
{
TargetSymbols = request.TargetSymbols.ToList(),
MaxDuration = request.Duration,
SampleRate = request.SampleRate,
ResolveSymbols = true,
MaxEventsPerSecond = 5000
};
var handle = await _signalCollector.StartCollectionAsync(
request.ContainerId, options, ct);
_logger.LogDebug(
"Started eBPF signal collection session {SessionId} for container {ContainerId}",
handle.SessionId, request.ContainerId);
try
{
// Wait for collection duration or cancellation
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
linkedCts.CancelAfter(request.Duration);
try
{
await Task.Delay(request.Duration, linkedCts.Token);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
// Duration elapsed, this is expected
}
}
finally
{
// Always stop collection
var summary = await _signalCollector.StopCollectionAsync(handle, ct);
_logger.LogInformation(
"Stopped eBPF signal collection: {TotalEvents} events, {UniqueSymbols} symbols observed",
summary.TotalEvents, summary.ObservedSymbols.Count);
}
// Get final observations
var observations = await _observationStore.GetObservationsAsync(
request.ContainerId,
request.TargetSymbols,
ct);
var layer3 = BuildLayer3FromObservations(observations, ObservationSource.Live);
var duration = _timeProvider.GetUtcNow() - startTime;
return new RuntimeReachabilityResult
{
Success = true,
Layer3 = layer3,
Observations = observations,
ObservedAt = startTime,
Duration = duration,
Source = ObservationSource.Live
};
}
private ReachabilityLayer3 BuildLayer3FromObservations(
IReadOnlyList<SymbolObservation> observations,
ObservationSource source)
{
var observedSymbols = observations.Where(o => o.WasObserved).ToList();
var anyTargetObserved = observedSymbols.Count > 0;
if (!anyTargetObserved)
{
// No target symbols observed - potentially gated
return new ReachabilityLayer3
{
IsGated = false, // We can't confirm it's gated, just not observed
Outcome = GatingOutcome.Unknown,
Confidence = ConfidenceLevel.Medium,
Description = $"No target symbols observed during {source.ToString().ToLowerInvariant()} monitoring"
};
}
// Target symbols were observed - definitely not gated
var conditions = observedSymbols
.Select(o => new GatingCondition(
Type: GatingType.EnvironmentVariable, // Best approximation
Description: $"Symbol '{o.Symbol}' was executed {o.ObservationCount} time(s)",
ConfigKey: null,
EnvVar: null,
IsBlocking: false,
Status: GatingStatus.Enabled))
.ToImmutableArray();
return new ReachabilityLayer3
{
IsGated = false,
Outcome = GatingOutcome.NotGated,
Confidence = ConfidenceLevel.High,
Conditions = conditions,
Description = $"{observedSymbols.Count} target symbol(s) observed executing at runtime"
};
}
private RuntimeReachabilityResult CreateUnavailableResult(
RuntimeObservationRequest request,
DateTimeOffset startTime,
string reason)
{
return new RuntimeReachabilityResult
{
Success = false,
Layer3 = new ReachabilityLayer3
{
IsGated = false,
Outcome = GatingOutcome.Unknown,
Confidence = ConfidenceLevel.Low,
Description = reason
},
ObservedAt = startTime,
Duration = TimeSpan.Zero,
Error = reason,
Source = ObservationSource.None
};
}
private RuntimeReachabilityResult CreateFailedResult(
RuntimeObservationRequest request,
DateTimeOffset startTime,
string error)
{
return new RuntimeReachabilityResult
{
Success = false,
Layer3 = new ReachabilityLayer3
{
IsGated = false,
Outcome = GatingOutcome.Unknown,
Confidence = ConfidenceLevel.Low,
Description = $"Runtime observation failed: {error}"
},
ObservedAt = startTime,
Duration = _timeProvider.GetUtcNow() - startTime,
Error = error,
Source = ObservationSource.None
};
}
}
/// <summary>
/// Store for persisting and retrieving runtime observations.
/// </summary>
public interface IRuntimeObservationStore
{
/// <summary>
/// Gets observations for specific symbols in a container.
/// </summary>
Task<IReadOnlyList<SymbolObservation>> GetObservationsAsync(
string containerId,
IReadOnlyList<string> symbols,
CancellationToken ct = default);
/// <summary>
/// Stores a symbol observation.
/// </summary>
Task StoreObservationAsync(
string containerId,
string imageDigest,
SymbolObservation observation,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,195 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
// Sprint: EVID-001-004 - Runtime Reachability Collection
using StellaOps.Scanner.Reachability.Stack;
namespace StellaOps.Scanner.Reachability.Runtime;
/// <summary>
/// Collects runtime reachability evidence by observing function calls.
/// Bridges the eBPF signal infrastructure with reachability Layer 3.
/// </summary>
public interface IRuntimeReachabilityCollector
{
/// <summary>
/// Checks if runtime collection is available on this system.
/// </summary>
bool IsAvailable { get; }
/// <summary>
/// Gets the platform and collection method.
/// </summary>
string Platform { get; }
/// <summary>
/// Observes runtime execution and builds Layer 3 evidence.
/// </summary>
/// <param name="request">Observation request with targets.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Layer 3 reachability result.</returns>
Task<RuntimeReachabilityResult> ObserveAsync(
RuntimeObservationRequest request,
CancellationToken ct = default);
/// <summary>
/// Checks if specific symbols were observed in past runtime data.
/// </summary>
/// <param name="containerId">Container to check.</param>
/// <param name="symbols">Symbols to look for.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Observation results for each symbol.</returns>
Task<IReadOnlyList<SymbolObservation>> CheckObservationsAsync(
string containerId,
IReadOnlyList<string> symbols,
CancellationToken ct = default);
}
/// <summary>
/// Request to observe runtime reachability.
/// </summary>
public sealed record RuntimeObservationRequest
{
/// <summary>
/// Container ID to observe.
/// </summary>
public required string ContainerId { get; init; }
/// <summary>
/// Image digest the container is running.
/// </summary>
public required string ImageDigest { get; init; }
/// <summary>
/// Target symbols (vulnerable sinks) to watch for.
/// </summary>
public required IReadOnlyList<string> TargetSymbols { get; init; }
/// <summary>
/// Observation duration.
/// </summary>
public TimeSpan Duration { get; init; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Sample rate (1 = every call).
/// </summary>
public int SampleRate { get; init; } = 1;
/// <summary>
/// Use historical data if available instead of live observation.
/// </summary>
public bool UseHistoricalData { get; init; } = true;
/// <summary>
/// Time window for historical data lookup.
/// </summary>
public TimeSpan HistoricalWindow { get; init; } = TimeSpan.FromDays(7);
}
/// <summary>
/// Result of runtime reachability observation.
/// </summary>
public sealed record RuntimeReachabilityResult
{
/// <summary>
/// Whether the observation was successful.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// Layer 3 model populated from observations.
/// </summary>
public required ReachabilityLayer3 Layer3 { get; init; }
/// <summary>
/// Details about each observed target symbol.
/// </summary>
public IReadOnlyList<SymbolObservation> Observations { get; init; } = [];
/// <summary>
/// When observation started.
/// </summary>
public DateTimeOffset ObservedAt { get; init; }
/// <summary>
/// Observation duration.
/// </summary>
public TimeSpan Duration { get; init; }
/// <summary>
/// Error message if observation failed.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Source of the data (live, historical, none).
/// </summary>
public ObservationSource Source { get; init; }
}
/// <summary>
/// Observation result for a specific symbol.
/// </summary>
public sealed record SymbolObservation
{
/// <summary>
/// Symbol name that was searched for.
/// </summary>
public required string Symbol { get; init; }
/// <summary>
/// Whether this symbol was observed executing.
/// </summary>
public required bool WasObserved { get; init; }
/// <summary>
/// Number of times observed.
/// </summary>
public int ObservationCount { get; init; }
/// <summary>
/// First observation timestamp.
/// </summary>
public DateTimeOffset? FirstObservedAt { get; init; }
/// <summary>
/// Last observation timestamp.
/// </summary>
public DateTimeOffset? LastObservedAt { get; init; }
/// <summary>
/// Call paths that led to this symbol.
/// </summary>
public IReadOnlyList<ObservedPath> Paths { get; init; } = [];
}
/// <summary>
/// A call path observed at runtime.
/// </summary>
public sealed record ObservedPath
{
/// <summary>
/// Symbols in the path from entry to target.
/// </summary>
public required IReadOnlyList<string> Symbols { get; init; }
/// <summary>
/// Number of times this specific path was observed.
/// </summary>
public int Count { get; init; }
}
/// <summary>
/// Source of observation data.
/// </summary>
public enum ObservationSource
{
/// <summary>Live observation via eBPF.</summary>
Live,
/// <summary>Historical data from past observations.</summary>
Historical,
/// <summary>No observation data available.</summary>
None
}

View File

@@ -0,0 +1,142 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
// Sprint: EVID-001 - Evidence Pipeline DI Registration
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Scanner.Reachability.Binary;
using StellaOps.Scanner.Reachability.Jobs;
using StellaOps.Scanner.Reachability.Runtime;
using StellaOps.Scanner.Reachability.Services;
using StellaOps.Scanner.Reachability.Stack;
using StellaOps.Scanner.Reachability.Vex;
namespace StellaOps.Scanner.Reachability;
/// <summary>
/// Extension methods for registering reachability evidence services.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds reachability evidence pipeline services.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="connectionString">PostgreSQL connection string for CVE-symbol mappings.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddReachabilityEvidence(
this IServiceCollection services,
string connectionString)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
// CVE-Symbol Mapping Service
services.TryAddSingleton<ICveSymbolMappingService>(sp =>
new PostgresCveSymbolMappingRepository(
connectionString,
sp.GetRequiredService<Microsoft.Extensions.Logging.ILogger<PostgresCveSymbolMappingRepository>>()));
// Stack Evaluator (already exists, ensure registered)
services.TryAddSingleton<IReachabilityStackEvaluator, ReachabilityStackEvaluator>();
// VEX Integration
services.TryAddSingleton<IVexStatusDeterminer, VexStatusDeterminer>();
// Job Executor
services.TryAddScoped<IReachabilityEvidenceJobExecutor, ReachabilityEvidenceJobExecutor>();
// Runtime Collection (optional - requires eBPF infrastructure)
services.TryAddSingleton<IRuntimeReachabilityCollector, EbpfRuntimeReachabilityCollector>();
// Binary Patch Verification (requires Ghidra infrastructure)
services.TryAddSingleton<IBinaryPatchVerifier, BinaryPatchVerifier>();
return services;
}
/// <summary>
/// Adds reachability evidence pipeline services with custom CVE mapping service.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="cveSymbolMappingFactory">Factory for CVE mapping service.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddReachabilityEvidence(
this IServiceCollection services,
Func<IServiceProvider, ICveSymbolMappingService> cveSymbolMappingFactory)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(cveSymbolMappingFactory);
services.TryAddSingleton(cveSymbolMappingFactory);
services.TryAddSingleton<IReachabilityStackEvaluator, ReachabilityStackEvaluator>();
services.TryAddSingleton<IVexStatusDeterminer, VexStatusDeterminer>();
services.TryAddScoped<IReachabilityEvidenceJobExecutor, ReachabilityEvidenceJobExecutor>();
services.TryAddSingleton<IRuntimeReachabilityCollector, EbpfRuntimeReachabilityCollector>();
services.TryAddSingleton<IBinaryPatchVerifier, BinaryPatchVerifier>();
return services;
}
/// <summary>
/// Adds reachability evidence pipeline services with all dependencies.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configure">Configuration callback.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddReachabilityEvidence(
this IServiceCollection services,
Action<ReachabilityEvidenceOptions> configure)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
var options = new ReachabilityEvidenceOptions();
configure(options);
if (string.IsNullOrWhiteSpace(options.ConnectionString))
{
throw new InvalidOperationException("ConnectionString is required");
}
services.AddReachabilityEvidence(options.ConnectionString);
if (options.TimeProvider is not null)
{
services.AddSingleton(options.TimeProvider);
}
return services;
}
}
/// <summary>
/// Options for configuring reachability evidence services.
/// </summary>
public sealed class ReachabilityEvidenceOptions
{
/// <summary>
/// PostgreSQL connection string.
/// </summary>
public string ConnectionString { get; set; } = string.Empty;
/// <summary>
/// Optional time provider (for testing).
/// </summary>
public TimeProvider? TimeProvider { get; set; }
/// <summary>
/// Enable runtime observation via eBPF.
/// </summary>
public bool EnableRuntimeObservation { get; set; } = false;
/// <summary>
/// Enable binary patch verification.
/// </summary>
public bool EnableBinaryPatchVerification { get; set; } = false;
/// <summary>
/// Maximum job timeout in seconds.
/// </summary>
public int MaxJobTimeoutSeconds { get; set; } = 300;
}

View File

@@ -0,0 +1,215 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
// Sprint: EVID-001-001 - CVE-Symbol Mapping Service
using StellaOps.Scanner.Reachability.Stack;
namespace StellaOps.Scanner.Reachability.Services;
/// <summary>
/// Service for looking up vulnerable symbols (sinks) associated with CVEs.
/// Used to target reachability analysis at specific vulnerable code paths.
/// </summary>
public interface ICveSymbolMappingService
{
/// <summary>
/// Gets vulnerable symbols (sinks) for a given CVE and package.
/// </summary>
/// <param name="cveId">The CVE identifier (e.g., "CVE-2021-44228").</param>
/// <param name="purl">Package URL of the affected component.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of vulnerable symbols that should be treated as sinks.</returns>
Task<IReadOnlyList<CveSinkMapping>> GetSinksForCveAsync(
string cveId,
string purl,
CancellationToken ct = default);
/// <summary>
/// Checks if any sink mappings exist for a CVE.
/// </summary>
/// <param name="cveId">The CVE identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>True if mappings exist.</returns>
Task<bool> HasMappingAsync(string cveId, CancellationToken ct = default);
/// <summary>
/// Gets all sink mappings for a CVE regardless of package.
/// </summary>
/// <param name="cveId">The CVE identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>All sink mappings for the CVE.</returns>
Task<IReadOnlyList<CveSinkMapping>> GetAllMappingsForCveAsync(
string cveId,
CancellationToken ct = default);
/// <summary>
/// Adds or updates a CVE-to-sink mapping.
/// </summary>
/// <param name="mapping">The mapping to upsert.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The stored mapping with generated ID.</returns>
Task<CveSinkMapping> UpsertMappingAsync(
CveSinkMapping mapping,
CancellationToken ct = default);
/// <summary>
/// Gets the total count of mappings in the database.
/// </summary>
Task<int> GetMappingCountAsync(CancellationToken ct = default);
}
/// <summary>
/// A mapping from a CVE to a vulnerable symbol (sink).
/// </summary>
public sealed record CveSinkMapping
{
/// <summary>Unique mapping ID.</summary>
public Guid? MappingId { get; init; }
/// <summary>CVE identifier (e.g., "CVE-2021-44228").</summary>
public required string CveId { get; init; }
/// <summary>Package URL of the affected component.</summary>
public required string Purl { get; init; }
/// <summary>Name of the vulnerable symbol/function.</summary>
public required string SymbolName { get; init; }
/// <summary>Canonical symbol ID for cross-language matching.</summary>
public string? CanonicalId { get; init; }
/// <summary>File path where the symbol is defined.</summary>
public string? FilePath { get; init; }
/// <summary>Start line number in the file.</summary>
public int? StartLine { get; init; }
/// <summary>End line number in the file.</summary>
public int? EndLine { get; init; }
/// <summary>Source of this mapping.</summary>
public required MappingSource Source { get; init; }
/// <summary>Type of vulnerability (source, sink, gadget).</summary>
public required VulnerabilityType VulnType { get; init; }
/// <summary>Confidence score (0.0 to 1.0).</summary>
public required decimal Confidence { get; init; }
/// <summary>URI to evidence supporting this mapping.</summary>
public string? EvidenceUri { get; init; }
/// <summary>URL to the source commit (if from patch analysis).</summary>
public string? SourceCommitUrl { get; init; }
/// <summary>URL to the patch.</summary>
public string? PatchUrl { get; init; }
/// <summary>Affected version ranges.</summary>
public IReadOnlyList<string>? AffectedVersions { get; init; }
/// <summary>Fixed versions.</summary>
public IReadOnlyList<string>? FixedVersions { get; init; }
/// <summary>When this mapping was created.</summary>
public DateTimeOffset CreatedAt { get; init; }
/// <summary>When this mapping was last updated.</summary>
public DateTimeOffset UpdatedAt { get; init; }
/// <summary>
/// Converts this mapping to a VulnerableSymbol for use in reachability analysis.
/// </summary>
public VulnerableSymbol ToVulnerableSymbol()
{
var symbolType = InferSymbolType(SymbolName, Purl);
return new VulnerableSymbol(
Name: CanonicalId ?? SymbolName,
Library: ExtractLibraryFromPurl(Purl),
Version: ExtractVersionFromPurl(Purl),
VulnerabilityId: CveId,
Type: symbolType);
}
private static SymbolType InferSymbolType(string symbolName, string purl)
{
if (purl.StartsWith("pkg:maven/", StringComparison.OrdinalIgnoreCase))
return SymbolType.JavaMethod;
if (purl.StartsWith("pkg:npm/", StringComparison.OrdinalIgnoreCase) ||
purl.StartsWith("pkg:deno/", StringComparison.OrdinalIgnoreCase))
return SymbolType.JsFunction;
if (purl.StartsWith("pkg:pypi/", StringComparison.OrdinalIgnoreCase))
return SymbolType.PyFunction;
if (purl.StartsWith("pkg:nuget/", StringComparison.OrdinalIgnoreCase))
return SymbolType.Method;
if (purl.StartsWith("pkg:golang/", StringComparison.OrdinalIgnoreCase))
return SymbolType.GoFunction;
if (purl.StartsWith("pkg:cargo/", StringComparison.OrdinalIgnoreCase))
return SymbolType.RustFunction;
return SymbolType.Function;
}
private static string? ExtractLibraryFromPurl(string purl)
{
// pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1
var atIndex = purl.IndexOf('@');
var path = atIndex > 0 ? purl[..atIndex] : purl;
var lastSlash = path.LastIndexOf('/');
return lastSlash > 0 ? path[(lastSlash + 1)..] : null;
}
private static string? ExtractVersionFromPurl(string purl)
{
var atIndex = purl.IndexOf('@');
return atIndex > 0 ? purl[(atIndex + 1)..] : null;
}
}
/// <summary>
/// Source of a CVE-symbol mapping.
/// </summary>
public enum MappingSource
{
/// <summary>Derived from patch/commit analysis.</summary>
PatchAnalysis,
/// <summary>From OSV advisory.</summary>
OsvAdvisory,
/// <summary>From NVD CPE data.</summary>
NvdCpe,
/// <summary>Manually curated.</summary>
ManualCuration,
/// <summary>From fuzzing corpus.</summary>
FuzzingCorpus,
/// <summary>From exploit database.</summary>
ExploitDatabase,
/// <summary>Unknown source.</summary>
Unknown
}
/// <summary>
/// Type of vulnerability for taint analysis.
/// </summary>
public enum VulnerabilityType
{
/// <summary>Taint source (user input).</summary>
Source,
/// <summary>Dangerous sink (vulnerable function).</summary>
Sink,
/// <summary>Gadget in an exploit chain.</summary>
Gadget,
/// <summary>Both source and sink.</summary>
BothSourceAndSink,
/// <summary>Unknown type.</summary>
Unknown
}

View File

@@ -0,0 +1,277 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
// Sprint: EVID-001-001 - CVE-Symbol Mapping Repository
using System.Data;
using Microsoft.Extensions.Logging;
using Npgsql;
namespace StellaOps.Scanner.Reachability.Services;
/// <summary>
/// PostgreSQL implementation of <see cref="ICveSymbolMappingService"/>.
/// Uses the reachability.cve_symbol_mappings schema.
/// </summary>
public sealed class PostgresCveSymbolMappingRepository : ICveSymbolMappingService
{
private readonly string _connectionString;
private readonly ILogger<PostgresCveSymbolMappingRepository> _logger;
public PostgresCveSymbolMappingRepository(
string connectionString,
ILogger<PostgresCveSymbolMappingRepository> logger)
{
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<IReadOnlyList<CveSinkMapping>> GetSinksForCveAsync(
string cveId,
string purl,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
ArgumentException.ThrowIfNullOrWhiteSpace(purl);
const string sql = """
SELECT
mapping_id, cve_id, purl, symbol_name, canonical_id,
file_path, start_line, end_line, source, vulnerability_type,
confidence, evidence_uri, source_commit_url, patch_url,
affected_versions, fixed_versions, created_at, updated_at
FROM reachability.cve_symbol_mappings
WHERE UPPER(cve_id) = UPPER(@cveId)
AND purl = @purl
AND vulnerability_type IN ('sink', 'both_source_and_sink')
ORDER BY confidence DESC, symbol_name ASC
""";
await using var conn = await OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@cveId", cveId);
cmd.Parameters.AddWithValue("@purl", purl);
var results = new List<CveSinkMapping>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
results.Add(MapFromReader(reader));
}
_logger.LogDebug(
"Found {Count} sink mappings for CVE {CveId} and PURL {Purl}",
results.Count, cveId, purl);
return results;
}
/// <inheritdoc />
public async Task<bool> HasMappingAsync(string cveId, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
const string sql = """
SELECT EXISTS(
SELECT 1 FROM reachability.cve_symbol_mappings
WHERE UPPER(cve_id) = UPPER(@cveId)
)
""";
await using var conn = await OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@cveId", cveId);
var result = await cmd.ExecuteScalarAsync(ct);
return result is true;
}
/// <inheritdoc />
public async Task<IReadOnlyList<CveSinkMapping>> GetAllMappingsForCveAsync(
string cveId,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
const string sql = """
SELECT
mapping_id, cve_id, purl, symbol_name, canonical_id,
file_path, start_line, end_line, source, vulnerability_type,
confidence, evidence_uri, source_commit_url, patch_url,
affected_versions, fixed_versions, created_at, updated_at
FROM reachability.cve_symbol_mappings
WHERE UPPER(cve_id) = UPPER(@cveId)
ORDER BY purl, confidence DESC, symbol_name ASC
""";
await using var conn = await OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@cveId", cveId);
var results = new List<CveSinkMapping>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
results.Add(MapFromReader(reader));
}
return results;
}
/// <inheritdoc />
public async Task<CveSinkMapping> UpsertMappingAsync(
CveSinkMapping mapping,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(mapping);
const string sql = """
INSERT INTO reachability.cve_symbol_mappings (
cve_id, purl, symbol_name, canonical_id, file_path,
start_line, end_line, source, vulnerability_type, confidence,
evidence_uri, source_commit_url, patch_url,
affected_versions, fixed_versions
) VALUES (
@cveId, @purl, @symbolName, @canonicalId, @filePath,
@startLine, @endLine, @source::reachability.mapping_source,
@vulnType::reachability.vulnerability_type, @confidence,
@evidenceUri, @sourceCommitUrl, @patchUrl,
@affectedVersions, @fixedVersions
)
ON CONFLICT (cve_id_normalized, purl, symbol_name)
DO UPDATE SET
canonical_id = EXCLUDED.canonical_id,
file_path = EXCLUDED.file_path,
start_line = EXCLUDED.start_line,
end_line = EXCLUDED.end_line,
source = EXCLUDED.source,
vulnerability_type = EXCLUDED.vulnerability_type,
confidence = EXCLUDED.confidence,
evidence_uri = EXCLUDED.evidence_uri,
source_commit_url = EXCLUDED.source_commit_url,
patch_url = EXCLUDED.patch_url,
affected_versions = EXCLUDED.affected_versions,
fixed_versions = EXCLUDED.fixed_versions,
updated_at = NOW()
RETURNING mapping_id, created_at, updated_at
""";
await using var conn = await OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@cveId", mapping.CveId);
cmd.Parameters.AddWithValue("@purl", mapping.Purl);
cmd.Parameters.AddWithValue("@symbolName", mapping.SymbolName);
cmd.Parameters.AddWithValue("@canonicalId", (object?)mapping.CanonicalId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@filePath", (object?)mapping.FilePath ?? DBNull.Value);
cmd.Parameters.AddWithValue("@startLine", (object?)mapping.StartLine ?? DBNull.Value);
cmd.Parameters.AddWithValue("@endLine", (object?)mapping.EndLine ?? DBNull.Value);
cmd.Parameters.AddWithValue("@source", MapSourceToString(mapping.Source));
cmd.Parameters.AddWithValue("@vulnType", MapVulnTypeToString(mapping.VulnType));
cmd.Parameters.AddWithValue("@confidence", mapping.Confidence);
cmd.Parameters.AddWithValue("@evidenceUri", (object?)mapping.EvidenceUri ?? DBNull.Value);
cmd.Parameters.AddWithValue("@sourceCommitUrl", (object?)mapping.SourceCommitUrl ?? DBNull.Value);
cmd.Parameters.AddWithValue("@patchUrl", (object?)mapping.PatchUrl ?? DBNull.Value);
cmd.Parameters.AddWithValue("@affectedVersions", (object?)mapping.AffectedVersions?.ToArray() ?? DBNull.Value);
cmd.Parameters.AddWithValue("@fixedVersions", (object?)mapping.FixedVersions?.ToArray() ?? DBNull.Value);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (await reader.ReadAsync(ct))
{
return mapping with
{
MappingId = reader.GetGuid(0),
CreatedAt = reader.GetDateTime(1),
UpdatedAt = reader.GetDateTime(2)
};
}
throw new InvalidOperationException("Upsert did not return a result");
}
/// <inheritdoc />
public async Task<int> GetMappingCountAsync(CancellationToken ct = default)
{
const string sql = "SELECT COUNT(*) FROM reachability.cve_symbol_mappings";
await using var conn = await OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand(sql, conn);
var result = await cmd.ExecuteScalarAsync(ct);
return Convert.ToInt32(result);
}
private async Task<NpgsqlConnection> OpenConnectionAsync(CancellationToken ct)
{
var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync(ct);
return conn;
}
private static CveSinkMapping MapFromReader(NpgsqlDataReader reader)
{
return new CveSinkMapping
{
MappingId = reader.GetGuid(0),
CveId = reader.GetString(1),
Purl = reader.GetString(2),
SymbolName = reader.GetString(3),
CanonicalId = reader.IsDBNull(4) ? null : reader.GetString(4),
FilePath = reader.IsDBNull(5) ? null : reader.GetString(5),
StartLine = reader.IsDBNull(6) ? null : reader.GetInt32(6),
EndLine = reader.IsDBNull(7) ? null : reader.GetInt32(7),
Source = MapStringToSource(reader.GetString(8)),
VulnType = MapStringToVulnType(reader.GetString(9)),
Confidence = reader.GetDecimal(10),
EvidenceUri = reader.IsDBNull(11) ? null : reader.GetString(11),
SourceCommitUrl = reader.IsDBNull(12) ? null : reader.GetString(12),
PatchUrl = reader.IsDBNull(13) ? null : reader.GetString(13),
AffectedVersions = reader.IsDBNull(14) ? null : (string[])reader.GetValue(14),
FixedVersions = reader.IsDBNull(15) ? null : (string[])reader.GetValue(15),
CreatedAt = reader.GetDateTime(16),
UpdatedAt = reader.GetDateTime(17)
};
}
private static string MapSourceToString(MappingSource source) => source switch
{
MappingSource.PatchAnalysis => "patch_analysis",
MappingSource.OsvAdvisory => "osv_advisory",
MappingSource.NvdCpe => "nvd_cpe",
MappingSource.ManualCuration => "manual_curation",
MappingSource.FuzzingCorpus => "fuzzing_corpus",
MappingSource.ExploitDatabase => "exploit_database",
_ => "unknown"
};
private static MappingSource MapStringToSource(string source) => source switch
{
"patch_analysis" => MappingSource.PatchAnalysis,
"osv_advisory" => MappingSource.OsvAdvisory,
"nvd_cpe" => MappingSource.NvdCpe,
"manual_curation" => MappingSource.ManualCuration,
"fuzzing_corpus" => MappingSource.FuzzingCorpus,
"exploit_database" => MappingSource.ExploitDatabase,
_ => MappingSource.Unknown
};
private static string MapVulnTypeToString(VulnerabilityType vulnType) => vulnType switch
{
VulnerabilityType.Source => "source",
VulnerabilityType.Sink => "sink",
VulnerabilityType.Gadget => "gadget",
VulnerabilityType.BothSourceAndSink => "both_source_and_sink",
_ => "unknown"
};
private static VulnerabilityType MapStringToVulnType(string vulnType) => vulnType switch
{
"source" => VulnerabilityType.Source,
"sink" => VulnerabilityType.Sink,
"gadget" => VulnerabilityType.Gadget,
"both_source_and_sink" => VulnerabilityType.BothSourceAndSink,
_ => VulnerabilityType.Unknown
};
}

View File

@@ -1,202 +1,6 @@
namespace StellaOps.Scanner.Reachability;
using System.Collections.Frozen;
using System.Collections.Immutable;
using System.Text.Json.Serialization;
/// <summary>
/// Security-relevant sink categories for reachability analysis.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<SinkCategory>))]
public enum SinkCategory
{
/// <summary>Command/process execution (e.g., Runtime.exec, Process.Start)</summary>
[JsonStringEnumMemberName("CMD_EXEC")]
CmdExec,
/// <summary>Unsafe deserialization (e.g., BinaryFormatter, pickle.loads)</summary>
[JsonStringEnumMemberName("UNSAFE_DESER")]
UnsafeDeser,
/// <summary>Raw SQL execution (e.g., SqlCommand with string concat)</summary>
[JsonStringEnumMemberName("SQL_RAW")]
SqlRaw,
/// <summary>SQL injection (e.g., unparameterized queries with user input)</summary>
[JsonStringEnumMemberName("SQL_INJECTION")]
SqlInjection,
/// <summary>Server-side request forgery (e.g., HttpClient with user input)</summary>
[JsonStringEnumMemberName("SSRF")]
Ssrf,
/// <summary>Arbitrary file write (e.g., File.WriteAllBytes with user path)</summary>
[JsonStringEnumMemberName("FILE_WRITE")]
FileWrite,
/// <summary>Path traversal (e.g., Path.Combine with ../)</summary>
[JsonStringEnumMemberName("PATH_TRAVERSAL")]
PathTraversal,
/// <summary>Template/expression injection (e.g., Razor, JEXL)</summary>
[JsonStringEnumMemberName("TEMPLATE_INJECTION")]
TemplateInjection,
/// <summary>Weak cryptography (e.g., MD5, DES, ECB mode)</summary>
[JsonStringEnumMemberName("CRYPTO_WEAK")]
CryptoWeak,
/// <summary>Authorization bypass (e.g., JWT none alg, missing authz check)</summary>
[JsonStringEnumMemberName("AUTHZ_BYPASS")]
AuthzBypass,
/// <summary>LDAP injection (e.g., DirContext.search with user input)</summary>
[JsonStringEnumMemberName("LDAP_INJECTION")]
LdapInjection,
/// <summary>XPath injection (e.g., XPath.evaluate with user input)</summary>
[JsonStringEnumMemberName("XPATH_INJECTION")]
XPathInjection,
/// <summary>XML External Entity injection (XXE)</summary>
[JsonStringEnumMemberName("XXE")]
XxeInjection,
/// <summary>Code/expression injection (e.g., eval, ScriptEngine)</summary>
[JsonStringEnumMemberName("CODE_INJECTION")]
CodeInjection,
/// <summary>Log injection (e.g., unvalidated user input in logs)</summary>
[JsonStringEnumMemberName("LOG_INJECTION")]
LogInjection,
/// <summary>Reflection-based attacks (e.g., Class.forName with user input)</summary>
[JsonStringEnumMemberName("REFLECTION")]
Reflection,
/// <summary>Open redirect (e.g., sendRedirect with user-controlled URL)</summary>
[JsonStringEnumMemberName("OPEN_REDIRECT")]
OpenRedirect
}
/// <summary>
/// A known dangerous sink with its metadata.
/// </summary>
public sealed record SinkDefinition(
SinkCategory Category,
string SymbolPattern,
string Language,
string? Framework = null,
string? Description = null,
string? CweId = null,
double SeverityWeight = 1.0);
/// <summary>
/// Registry of known dangerous sinks per language.
/// </summary>
public static class SinkRegistry
{
private static readonly FrozenDictionary<string, ImmutableArray<SinkDefinition>> SinksByLanguage = BuildRegistry();
private static FrozenDictionary<string, ImmutableArray<SinkDefinition>> BuildRegistry()
{
var builder = new Dictionary<string, List<SinkDefinition>>(StringComparer.Ordinal);
// .NET sinks
AddSink(builder, "dotnet", SinkCategory.CmdExec, "System.Diagnostics.Process.Start", cweId: "CWE-78");
AddSink(builder, "dotnet", SinkCategory.CmdExec, "System.Diagnostics.ProcessStartInfo", cweId: "CWE-78");
AddSink(builder, "dotnet", SinkCategory.UnsafeDeser, "System.Runtime.Serialization.Formatters.Binary.BinaryFormatter.Deserialize", cweId: "CWE-502");
AddSink(builder, "dotnet", SinkCategory.UnsafeDeser, "Newtonsoft.Json.JsonConvert.DeserializeObject", cweId: "CWE-502", framework: "Newtonsoft.Json");
AddSink(builder, "dotnet", SinkCategory.SqlRaw, "System.Data.SqlClient.SqlCommand.ExecuteReader", cweId: "CWE-89");
AddSink(builder, "dotnet", SinkCategory.SqlRaw, "Microsoft.EntityFrameworkCore.RelationalQueryableExtensions.FromSqlRaw", cweId: "CWE-89", framework: "EFCore");
AddSink(builder, "dotnet", SinkCategory.Ssrf, "System.Net.Http.HttpClient.GetAsync", cweId: "CWE-918");
AddSink(builder, "dotnet", SinkCategory.FileWrite, "System.IO.File.WriteAllBytes", cweId: "CWE-73");
AddSink(builder, "dotnet", SinkCategory.PathTraversal, "System.IO.Path.Combine", cweId: "CWE-22");
AddSink(builder, "dotnet", SinkCategory.CryptoWeak, "System.Security.Cryptography.MD5.Create", cweId: "CWE-327");
AddSink(builder, "dotnet", SinkCategory.CryptoWeak, "System.Security.Cryptography.DES.Create", cweId: "CWE-327");
// Java sinks
AddSink(builder, "java", SinkCategory.CmdExec, "java.lang.Runtime.exec", cweId: "CWE-78");
AddSink(builder, "java", SinkCategory.CmdExec, "java.lang.ProcessBuilder.start", cweId: "CWE-78");
AddSink(builder, "java", SinkCategory.UnsafeDeser, "java.io.ObjectInputStream.readObject", cweId: "CWE-502");
AddSink(builder, "java", SinkCategory.SqlRaw, "java.sql.Statement.executeQuery", cweId: "CWE-89");
AddSink(builder, "java", SinkCategory.Ssrf, "java.net.URL.openConnection", cweId: "CWE-918");
AddSink(builder, "java", SinkCategory.TemplateInjection, "org.springframework.expression.ExpressionParser.parseExpression", cweId: "CWE-917", framework: "Spring");
// Node.js sinks
AddSink(builder, "node", SinkCategory.CmdExec, "child_process.exec", cweId: "CWE-78");
AddSink(builder, "node", SinkCategory.CmdExec, "child_process.spawn", cweId: "CWE-78");
AddSink(builder, "node", SinkCategory.UnsafeDeser, "node-serialize.unserialize", cweId: "CWE-502");
AddSink(builder, "node", SinkCategory.SqlRaw, "mysql.query", cweId: "CWE-89");
AddSink(builder, "node", SinkCategory.PathTraversal, "path.join", cweId: "CWE-22");
AddSink(builder, "node", SinkCategory.TemplateInjection, "eval", cweId: "CWE-94");
// Python sinks
AddSink(builder, "python", SinkCategory.CmdExec, "os.system", cweId: "CWE-78");
AddSink(builder, "python", SinkCategory.CmdExec, "subprocess.call", cweId: "CWE-78");
AddSink(builder, "python", SinkCategory.UnsafeDeser, "pickle.loads", cweId: "CWE-502");
AddSink(builder, "python", SinkCategory.UnsafeDeser, "yaml.load", cweId: "CWE-502");
AddSink(builder, "python", SinkCategory.SqlRaw, "sqlite3.Cursor.execute", cweId: "CWE-89");
AddSink(builder, "python", SinkCategory.TemplateInjection, "jinja2.Template.render", cweId: "CWE-1336", framework: "Jinja2");
return builder.ToFrozenDictionary(
kvp => kvp.Key,
kvp => kvp.Value.ToImmutableArray(),
StringComparer.Ordinal);
}
private static void AddSink(
Dictionary<string, List<SinkDefinition>> builder,
string language,
SinkCategory category,
string symbolPattern,
string? cweId = null,
string? framework = null)
{
if (!builder.TryGetValue(language, out var list))
{
list = new List<SinkDefinition>();
builder[language] = list;
}
list.Add(new SinkDefinition(
Category: category,
SymbolPattern: symbolPattern,
Language: language,
Framework: framework,
CweId: cweId));
}
/// <summary>
/// Gets all sink definitions for a language.
/// </summary>
public static ImmutableArray<SinkDefinition> GetSinksForLanguage(string language)
{
if (string.IsNullOrWhiteSpace(language))
{
return ImmutableArray<SinkDefinition>.Empty;
}
return SinksByLanguage.GetValueOrDefault(language.Trim().ToLowerInvariant(), ImmutableArray<SinkDefinition>.Empty);
}
/// <summary>
/// Gets all registered languages.
/// </summary>
public static IEnumerable<string> GetRegisteredLanguages() => SinksByLanguage.Keys;
/// <summary>
/// Checks if a symbol matches any known sink.
/// </summary>
public static SinkDefinition? MatchSink(string language, string symbol)
{
if (string.IsNullOrWhiteSpace(language) || string.IsNullOrWhiteSpace(symbol))
{
return null;
}
var sinks = GetSinksForLanguage(language);
return sinks.FirstOrDefault(sink => symbol.Contains(sink.SymbolPattern, StringComparison.OrdinalIgnoreCase));
}
}
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
// SinkCategory, SinkDefinition, and SinkRegistry are now defined in StellaOps.Scanner.Contracts.
// This file is kept for backward compatibility - all types are imported via global using.
global using StellaOps.Scanner.Contracts;

View File

@@ -193,6 +193,34 @@ public sealed record ReachabilityLayer2
/// <summary>Alternative symbols that could be loaded instead</summary>
public ImmutableArray<string> Alternatives { get; init; } = [];
/// <summary>Resolved symbols from binary analysis</summary>
public ImmutableArray<ResolvedSymbol> ResolvedSymbols { get; init; } = [];
}
/// <summary>
/// A symbol that was resolved during binary analysis.
/// </summary>
public sealed record ResolvedSymbol(
string Name,
ulong Address,
bool IsWeak,
SymbolBinding BindingType
);
/// <summary>
/// Symbol binding type in ELF/PE binaries.
/// </summary>
public enum SymbolBinding
{
/// <summary>Local symbol (STB_LOCAL)</summary>
Local,
/// <summary>Global symbol (STB_GLOBAL)</summary>
Global,
/// <summary>Weak symbol (STB_WEAK)</summary>
Weak
}
/// <summary>

View File

@@ -11,12 +11,14 @@
<PackageReference Include="Npgsql" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Contracts\StellaOps.Scanner.Contracts.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Core\StellaOps.Scanner.Core.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Explainability\StellaOps.Scanner.Explainability.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Cache\StellaOps.Scanner.Cache.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.ProofSpine\StellaOps.Scanner.ProofSpine.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Surface.Env\StellaOps.Scanner.Surface.Env.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.SmartDiff\StellaOps.Scanner.SmartDiff.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.CallGraph\StellaOps.Scanner.CallGraph.csproj" />
<ProjectReference Include="..\..\StellaOps.Scanner.Analyzers.Native\StellaOps.Scanner.Analyzers.Native.csproj" />
<ProjectReference Include="..\..\..\Attestor\StellaOps.Attestor\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
<ProjectReference Include="..\..\..\Attestor\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
@@ -25,5 +27,7 @@
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Replay.Core\StellaOps.Replay.Core.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\..\..\Signals\__Libraries\StellaOps.Signals.Ebpf\StellaOps.Signals.Ebpf.csproj" />
<ProjectReference Include="..\..\..\BinaryIndex\__Libraries\StellaOps.BinaryIndex.Ghidra\StellaOps.BinaryIndex.Ghidra.csproj" />
<ProjectReference Include="..\..\..\BinaryIndex\__Libraries\StellaOps.BinaryIndex.Decompiler\StellaOps.BinaryIndex.Decompiler.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,147 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
// Sprint: EVID-001-003 - VEX Integration
using StellaOps.Scanner.Reachability.Stack;
namespace StellaOps.Scanner.Reachability.Vex;
/// <summary>
/// Determines VEX status from reachability verdicts.
/// </summary>
public interface IVexStatusDeterminer
{
/// <summary>
/// Maps a reachability verdict to a VEX status.
/// </summary>
/// <param name="verdict">The reachability verdict.</param>
/// <returns>The corresponding VEX status.</returns>
VexStatus DetermineStatus(ReachabilityVerdict verdict);
/// <summary>
/// Builds a VEX justification from a reachability stack.
/// </summary>
/// <param name="stack">The reachability stack with analysis results.</param>
/// <param name="evidenceUris">URIs to evidence bundles.</param>
/// <returns>A VEX justification object.</returns>
VexJustification BuildJustification(
ReachabilityStack stack,
IReadOnlyList<string> evidenceUris);
/// <summary>
/// Creates a complete VEX statement from a reachability stack.
/// </summary>
/// <param name="stack">The reachability stack.</param>
/// <param name="productId">Product identifier.</param>
/// <param name="evidenceUris">Evidence URIs.</param>
/// <returns>A complete VEX statement.</returns>
VexStatement CreateStatement(
ReachabilityStack stack,
string productId,
IReadOnlyList<string> evidenceUris);
}
/// <summary>
/// VEX status per CycloneDX/OpenVEX specification.
/// </summary>
public enum VexStatus
{
/// <summary>The vulnerability affects the product.</summary>
Affected,
/// <summary>The vulnerability does not affect the product.</summary>
NotAffected,
/// <summary>A fix is available.</summary>
Fixed,
/// <summary>Under investigation.</summary>
UnderInvestigation
}
/// <summary>
/// VEX justification category for not_affected status.
/// </summary>
public enum VexJustificationCategory
{
/// <summary>Component is not present.</summary>
ComponentNotPresent,
/// <summary>Vulnerable code is not present.</summary>
VulnerableCodeNotPresent,
/// <summary>Vulnerable code is not reachable.</summary>
VulnerableCodeNotReachable,
/// <summary>Vulnerable code cannot be controlled by an attacker.</summary>
VulnerableCodeCannotBeControlledByAdversary,
/// <summary>Inline mitigations exist.</summary>
InlineMitigationsAlreadyExist,
/// <summary>The code requires a specific configuration.</summary>
RequiresConfiguration,
/// <summary>The code requires specific dependencies.</summary>
RequiresDependency,
/// <summary>The code requires specific environment.</summary>
RequiresEnvironment
}
/// <summary>
/// VEX justification with evidence references.
/// </summary>
public sealed record VexJustification
{
/// <summary>Justification category.</summary>
public required VexJustificationCategory Category { get; init; }
/// <summary>Human-readable detail explaining the justification.</summary>
public required string Detail { get; init; }
/// <summary>References to supporting evidence.</summary>
public IReadOnlyList<string> EvidenceReferences { get; init; } = [];
/// <summary>Technical details about the analysis.</summary>
public string? TechnicalDetail { get; init; }
/// <summary>Confidence level in the justification.</summary>
public decimal Confidence { get; init; } = 1.0m;
}
/// <summary>
/// A complete VEX statement.
/// </summary>
public sealed record VexStatement
{
/// <summary>Unique statement ID.</summary>
public required string StatementId { get; init; }
/// <summary>Vulnerability ID (CVE).</summary>
public required string VulnerabilityId { get; init; }
/// <summary>Product identifier.</summary>
public required string ProductId { get; init; }
/// <summary>VEX status.</summary>
public required VexStatus Status { get; init; }
/// <summary>Justification (required when status is NotAffected).</summary>
public VexJustification? Justification { get; init; }
/// <summary>Action statement (when status is Affected).</summary>
public string? ActionStatement { get; init; }
/// <summary>Impact statement.</summary>
public string? ImpactStatement { get; init; }
/// <summary>When this statement was generated.</summary>
public required DateTimeOffset Timestamp { get; init; }
/// <summary>Source of this statement.</summary>
public string Source { get; init; } = "stella-ops-reachability";
/// <summary>Version of the analysis that produced this statement.</summary>
public string? AnalysisVersion { get; init; }
}

View File

@@ -0,0 +1,246 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
// Sprint: EVID-001-003 - VEX Status Determiner Implementation
using System.Text;
using StellaOps.Scanner.Reachability.Stack;
namespace StellaOps.Scanner.Reachability.Vex;
/// <summary>
/// Default implementation of <see cref="IVexStatusDeterminer"/>.
/// Maps reachability verdicts to VEX statuses following CycloneDX/OpenVEX conventions.
/// </summary>
public sealed class VexStatusDeterminer : IVexStatusDeterminer
{
private readonly TimeProvider _timeProvider;
public VexStatusDeterminer(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public VexStatus DetermineStatus(ReachabilityVerdict verdict)
{
return verdict switch
{
ReachabilityVerdict.Exploitable => VexStatus.Affected,
ReachabilityVerdict.LikelyExploitable => VexStatus.Affected,
ReachabilityVerdict.PossiblyExploitable => VexStatus.UnderInvestigation,
ReachabilityVerdict.Unreachable => VexStatus.NotAffected,
ReachabilityVerdict.Unknown => VexStatus.UnderInvestigation,
_ => VexStatus.UnderInvestigation
};
}
/// <inheritdoc />
public VexJustification BuildJustification(
ReachabilityStack stack,
IReadOnlyList<string> evidenceUris)
{
ArgumentNullException.ThrowIfNull(stack);
var (category, detail, technicalDetail) = stack.Verdict switch
{
ReachabilityVerdict.Unreachable => BuildUnreachableJustification(stack),
ReachabilityVerdict.Exploitable => BuildExploitableJustification(stack),
ReachabilityVerdict.LikelyExploitable => BuildLikelyExploitableJustification(stack),
ReachabilityVerdict.PossiblyExploitable => BuildPossiblyExploitableJustification(stack),
ReachabilityVerdict.Unknown => BuildUnknownJustification(stack),
_ => (VexJustificationCategory.RequiresConfiguration, "Analysis inconclusive", null)
};
// Derive confidence from the stack's layer confidences
var confidence = CalculateConfidence(stack);
return new VexJustification
{
Category = category,
Detail = detail,
TechnicalDetail = technicalDetail,
EvidenceReferences = evidenceUris.ToList(),
Confidence = confidence
};
}
/// <inheritdoc />
public VexStatement CreateStatement(
ReachabilityStack stack,
string productId,
IReadOnlyList<string> evidenceUris)
{
ArgumentNullException.ThrowIfNull(stack);
ArgumentException.ThrowIfNullOrWhiteSpace(productId);
var status = DetermineStatus(stack.Verdict);
var justification = BuildJustification(stack, evidenceUris);
var statementId = GenerateStatementId(stack, productId);
return new VexStatement
{
StatementId = statementId,
VulnerabilityId = stack.Symbol.VulnerabilityId,
ProductId = productId,
Status = status,
Justification = status == VexStatus.NotAffected ? justification : null,
ActionStatement = status == VexStatus.Affected
? BuildActionStatement(stack)
: null,
ImpactStatement = BuildImpactStatement(stack),
Timestamp = _timeProvider.GetUtcNow(),
AnalysisVersion = "1.0.0"
};
}
private static (VexJustificationCategory, string, string?) BuildUnreachableJustification(
ReachabilityStack stack)
{
var sb = new StringBuilder();
sb.Append("Vulnerable code is not reachable from application entrypoints.");
string? technicalDetail = null;
// Determine which layer blocked
if (!stack.StaticCallGraph.IsReachable)
{
sb.Append(" Static call graph analysis found no path from entrypoints to the vulnerable symbol.");
technicalDetail = $"L1 Analysis: {stack.StaticCallGraph.AnalysisMethod}, " +
$"Limitations: {string.Join("; ", stack.StaticCallGraph.Limitations)}";
}
else if (!stack.BinaryResolution.IsResolved)
{
sb.Append(" The vulnerable symbol is not linked in the deployed binary.");
technicalDetail = $"L2 Reason: {stack.BinaryResolution.Reason}";
}
else if (stack.RuntimeGating.IsGated &&
stack.RuntimeGating.Outcome == GatingOutcome.Blocked)
{
sb.Append(" Runtime gating conditions prevent execution of the vulnerable code path.");
var conditions = stack.RuntimeGating.Conditions
.Where(c => c.IsBlocking)
.Select(c => $"{c.Type}: {c.Description}");
technicalDetail = $"L3 Blocking conditions: {string.Join("; ", conditions)}";
}
return (VexJustificationCategory.VulnerableCodeNotReachable, sb.ToString(), technicalDetail);
}
private static (VexJustificationCategory, string, string?) BuildExploitableJustification(
ReachabilityStack stack)
{
var pathCount = stack.StaticCallGraph.Paths.Length;
var entrypointCount = stack.StaticCallGraph.ReachingEntrypoints.Length;
var detail = $"Vulnerable code is reachable via {pathCount} call path(s) " +
$"from {entrypointCount} entrypoint(s). All three analysis layers confirm exploitability.";
var entrypoints = string.Join(", ",
stack.StaticCallGraph.ReachingEntrypoints.Take(5).Select(e => e.Name));
var technicalDetail = $"Entrypoints: {entrypoints}" +
(entrypointCount > 5 ? $" (+{entrypointCount - 5} more)" : "");
return (VexJustificationCategory.RequiresDependency, detail, technicalDetail);
}
private static (VexJustificationCategory, string, string?) BuildLikelyExploitableJustification(
ReachabilityStack stack)
{
var detail = "Vulnerable code is likely exploitable. Static and binary analysis confirm reachability, " +
"but runtime gating analysis was inconclusive.";
var technicalDetail = $"L1 Reachable: {stack.StaticCallGraph.IsReachable}, " +
$"L2 Resolved: {stack.BinaryResolution.IsResolved}, " +
$"L3 Outcome: {stack.RuntimeGating.Outcome}";
return (VexJustificationCategory.RequiresConfiguration, detail, technicalDetail);
}
private static (VexJustificationCategory, string, string?) BuildPossiblyExploitableJustification(
ReachabilityStack stack)
{
var detail = "Vulnerable code may be exploitable. Static analysis shows reachability, " +
"but binary resolution or runtime gating analysis was inconclusive. " +
"Further investigation recommended.";
return (VexJustificationCategory.RequiresConfiguration, detail, null);
}
private static (VexJustificationCategory, string, string?) BuildUnknownJustification(
ReachabilityStack stack)
{
var reason = stack.Explanation ?? "Insufficient data to determine reachability.";
return (VexJustificationCategory.RequiresConfiguration, reason, null);
}
private static decimal CalculateConfidence(ReachabilityStack stack)
{
var l1Confidence = MapConfidenceLevel(stack.StaticCallGraph.Confidence);
var l2Confidence = MapConfidenceLevel(stack.BinaryResolution.Confidence);
var l3Confidence = MapConfidenceLevel(stack.RuntimeGating.Confidence);
// Weight L1 highest since it's most reliable
var weightedAvg = (l1Confidence * 0.5m) + (l2Confidence * 0.25m) + (l3Confidence * 0.25m);
return Math.Round(weightedAvg, 2);
}
private static decimal MapConfidenceLevel(Explainability.Assumptions.ConfidenceLevel level)
{
return level switch
{
Explainability.Assumptions.ConfidenceLevel.High => 1.0m,
Explainability.Assumptions.ConfidenceLevel.Medium => 0.7m,
Explainability.Assumptions.ConfidenceLevel.Low => 0.4m,
_ => 0.5m
};
}
private static string BuildActionStatement(ReachabilityStack stack)
{
var sb = new StringBuilder();
sb.Append($"Vulnerability {stack.Symbol.VulnerabilityId} affects this product. ");
if (stack.Symbol.Library is not null)
{
sb.Append($"Update {stack.Symbol.Library}");
if (stack.Symbol.Version is not null)
{
sb.Append($" from version {stack.Symbol.Version}");
}
sb.Append(" to a patched version.");
}
else
{
sb.Append("Apply vendor patches or implement mitigating controls.");
}
return sb.ToString();
}
private static string BuildImpactStatement(ReachabilityStack stack)
{
return stack.Verdict switch
{
ReachabilityVerdict.Exploitable =>
$"The vulnerable function {stack.Symbol.Name} can be reached from {stack.StaticCallGraph.ReachingEntrypoints.Length} application entrypoint(s).",
ReachabilityVerdict.LikelyExploitable =>
$"The vulnerable function {stack.Symbol.Name} is likely reachable. Impact assessment may be incomplete.",
ReachabilityVerdict.PossiblyExploitable =>
$"The vulnerable function {stack.Symbol.Name} may be reachable. Further investigation needed.",
ReachabilityVerdict.Unreachable =>
$"The vulnerable function {stack.Symbol.Name} cannot be reached from application entrypoints.",
ReachabilityVerdict.Unknown =>
"Impact could not be determined due to insufficient analysis data.",
_ => "Impact assessment unavailable."
};
}
private static string GenerateStatementId(ReachabilityStack stack, string productId)
{
var input = $"{stack.Symbol.VulnerabilityId}:{productId}:{stack.AnalyzedAt:O}";
var hash = System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(input));
return $"stmt-{Convert.ToHexString(hash)[..16].ToLowerInvariant()}";
}
}