release orchestrator v1 draft and build fixes
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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()}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user