feat(telemetry): add telemetry client and services for tracking events

- Implemented TelemetryClient to handle event queuing and flushing to the telemetry endpoint.
- Created TtfsTelemetryService for emitting specific telemetry events related to TTFS.
- Added tests for TelemetryClient to ensure event queuing and flushing functionality.
- Introduced models for reachability drift detection, including DriftResult and DriftedSink.
- Developed DriftApiService for interacting with the drift detection API.
- Updated FirstSignalCardComponent to emit telemetry events on signal appearance.
- Enhanced localization support for first signal component with i18n strings.
This commit is contained in:
master
2025-12-18 16:19:16 +02:00
parent 00d2c99af9
commit 811f35cba7
114 changed files with 13702 additions and 268 deletions

View File

@@ -0,0 +1,320 @@
// -----------------------------------------------------------------------------
// EpssEndpoints.cs
// Sprint: SPRINT_3410_0002_0001_epss_scanner_integration
// Task: EPSS-SCAN-008, EPSS-SCAN-009
// Description: EPSS lookup API endpoints.
// -----------------------------------------------------------------------------
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using StellaOps.Scanner.Core.Epss;
namespace StellaOps.Scanner.WebService.Endpoints;
/// <summary>
/// EPSS lookup API endpoints.
/// Provides bulk lookup and history APIs for EPSS scores.
/// </summary>
public static class EpssEndpoints
{
/// <summary>
/// Maps EPSS endpoints to the route builder.
/// </summary>
public static IEndpointRouteBuilder MapEpssEndpoints(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/epss")
.WithTags("EPSS")
.WithOpenApi();
group.MapPost("/current", GetCurrentBatch)
.WithName("GetCurrentEpss")
.WithSummary("Get current EPSS scores for multiple CVEs")
.WithDescription("Returns the latest EPSS scores and percentiles for the specified CVE IDs. " +
"Maximum batch size is 1000 CVEs per request.")
.Produces<EpssBatchResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces<ProblemDetails>(StatusCodes.Status503ServiceUnavailable);
group.MapGet("/current/{cveId}", GetCurrent)
.WithName("GetCurrentEpssSingle")
.WithSummary("Get current EPSS score for a single CVE")
.WithDescription("Returns the latest EPSS score and percentile for the specified CVE ID.")
.Produces<EpssEvidence>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
group.MapGet("/history/{cveId}", GetHistory)
.WithName("GetEpssHistory")
.WithSummary("Get EPSS score history for a CVE")
.WithDescription("Returns the EPSS score time series for the specified CVE ID and date range.")
.Produces<EpssHistoryResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
group.MapGet("/status", GetStatus)
.WithName("GetEpssStatus")
.WithSummary("Get EPSS data availability status")
.WithDescription("Returns the current status of the EPSS data provider.")
.Produces<EpssStatusResponse>(StatusCodes.Status200OK);
return endpoints;
}
/// <summary>
/// POST /epss/current - Bulk lookup of current EPSS scores.
/// </summary>
private static async Task<IResult> GetCurrentBatch(
[FromBody] EpssBatchRequest request,
[FromServices] IEpssProvider epssProvider,
CancellationToken cancellationToken)
{
if (request.CveIds is null || request.CveIds.Count == 0)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid request",
Detail = "At least one CVE ID is required.",
Status = StatusCodes.Status400BadRequest
});
}
if (request.CveIds.Count > 1000)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Batch size exceeded",
Detail = "Maximum batch size is 1000 CVE IDs.",
Status = StatusCodes.Status400BadRequest
});
}
var isAvailable = await epssProvider.IsAvailableAsync(cancellationToken);
if (!isAvailable)
{
return Results.Problem(
detail: "EPSS data is not available. Please ensure EPSS data has been ingested.",
statusCode: StatusCodes.Status503ServiceUnavailable);
}
var result = await epssProvider.GetCurrentBatchAsync(request.CveIds, cancellationToken);
return Results.Ok(new EpssBatchResponse
{
Found = result.Found,
NotFound = result.NotFound,
ModelDate = result.ModelDate.ToString("yyyy-MM-dd"),
LookupTimeMs = result.LookupTimeMs,
PartiallyFromCache = result.PartiallyFromCache
});
}
/// <summary>
/// GET /epss/current/{cveId} - Get current EPSS score for a single CVE.
/// </summary>
private static async Task<IResult> GetCurrent(
[FromRoute] string cveId,
[FromServices] IEpssProvider epssProvider,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(cveId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid CVE ID",
Detail = "CVE ID is required.",
Status = StatusCodes.Status400BadRequest
});
}
var evidence = await epssProvider.GetCurrentAsync(cveId, cancellationToken);
if (evidence is null)
{
return Results.NotFound(new ProblemDetails
{
Title = "CVE not found",
Detail = $"No EPSS score found for {cveId}.",
Status = StatusCodes.Status404NotFound
});
}
return Results.Ok(evidence);
}
/// <summary>
/// GET /epss/history/{cveId} - Get EPSS score history for a CVE.
/// </summary>
private static async Task<IResult> GetHistory(
[FromRoute] string cveId,
[FromServices] IEpssProvider epssProvider,
[FromQuery] string? startDate = null,
[FromQuery] string? endDate = null,
[FromQuery] int days = 30,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(cveId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid CVE ID",
Detail = "CVE ID is required.",
Status = StatusCodes.Status400BadRequest
});
}
DateOnly start, end;
if (!string.IsNullOrEmpty(startDate) && !string.IsNullOrEmpty(endDate))
{
if (!DateOnly.TryParse(startDate, out start) || !DateOnly.TryParse(endDate, out end))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid date format",
Detail = "Dates must be in yyyy-MM-dd format.",
Status = StatusCodes.Status400BadRequest
});
}
}
else
{
// Default to last N days
end = DateOnly.FromDateTime(DateTime.UtcNow);
start = end.AddDays(-days);
}
var history = await epssProvider.GetHistoryAsync(cveId, start, end, cancellationToken);
if (history.Count == 0)
{
return Results.NotFound(new ProblemDetails
{
Title = "No history found",
Detail = $"No EPSS history found for {cveId} in the specified date range.",
Status = StatusCodes.Status404NotFound
});
}
return Results.Ok(new EpssHistoryResponse
{
CveId = cveId,
StartDate = start.ToString("yyyy-MM-dd"),
EndDate = end.ToString("yyyy-MM-dd"),
History = history
});
}
/// <summary>
/// GET /epss/status - Get EPSS data availability status.
/// </summary>
private static async Task<IResult> GetStatus(
[FromServices] IEpssProvider epssProvider,
CancellationToken cancellationToken)
{
var isAvailable = await epssProvider.IsAvailableAsync(cancellationToken);
var modelDate = await epssProvider.GetLatestModelDateAsync(cancellationToken);
return Results.Ok(new EpssStatusResponse
{
Available = isAvailable,
LatestModelDate = modelDate?.ToString("yyyy-MM-dd"),
LastCheckedUtc = DateTimeOffset.UtcNow
});
}
}
#region Request/Response Models
/// <summary>
/// Request for bulk EPSS lookup.
/// </summary>
public sealed record EpssBatchRequest
{
/// <summary>
/// List of CVE IDs to look up (max 1000).
/// </summary>
[Required]
public required IReadOnlyList<string> CveIds { get; init; }
}
/// <summary>
/// Response for bulk EPSS lookup.
/// </summary>
public sealed record EpssBatchResponse
{
/// <summary>
/// EPSS evidence for found CVEs.
/// </summary>
public required IReadOnlyList<EpssEvidence> Found { get; init; }
/// <summary>
/// CVE IDs that were not found in the EPSS dataset.
/// </summary>
public required IReadOnlyList<string> NotFound { get; init; }
/// <summary>
/// EPSS model date used for this lookup.
/// </summary>
public required string ModelDate { get; init; }
/// <summary>
/// Total lookup time in milliseconds.
/// </summary>
public long LookupTimeMs { get; init; }
/// <summary>
/// Whether any results came from cache.
/// </summary>
public bool PartiallyFromCache { get; init; }
}
/// <summary>
/// Response for EPSS history lookup.
/// </summary>
public sealed record EpssHistoryResponse
{
/// <summary>
/// CVE identifier.
/// </summary>
public required string CveId { get; init; }
/// <summary>
/// Start of date range.
/// </summary>
public required string StartDate { get; init; }
/// <summary>
/// End of date range.
/// </summary>
public required string EndDate { get; init; }
/// <summary>
/// Historical EPSS evidence records.
/// </summary>
public required IReadOnlyList<EpssEvidence> History { get; init; }
}
/// <summary>
/// Response for EPSS status check.
/// </summary>
public sealed record EpssStatusResponse
{
/// <summary>
/// Whether EPSS data is available.
/// </summary>
public bool Available { get; init; }
/// <summary>
/// Latest EPSS model date available.
/// </summary>
public string? LatestModelDate { get; init; }
/// <summary>
/// When this status was checked.
/// </summary>
public DateTimeOffset LastCheckedUtc { get; init; }
}
#endregion

View File

@@ -334,4 +334,13 @@ public sealed class ScannerWorkerMetrics
return tags.ToArray();
}
/// <summary>
/// Records native binary analysis metrics.
/// </summary>
public void RecordNativeAnalysis(NativeAnalysisResult result)
{
// Native analysis metrics are tracked via counters/histograms
// This is a placeholder for when we add dedicated native analysis metrics
}
}

View File

@@ -0,0 +1,110 @@
// -----------------------------------------------------------------------------
// NativeAnalyzerOptions.cs
// Sprint: SPRINT_3500_0014_0001_native_analyzer_integration
// Task: NAI-004
// Description: Configuration options for native binary analysis.
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Worker.Options;
/// <summary>
/// Configuration options for native binary analysis during container scans.
/// </summary>
public sealed class NativeAnalyzerOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Scanner:Worker:NativeAnalyzers";
/// <summary>
/// Whether native binary analysis is enabled.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Directories to search for native analyzer plugins.
/// </summary>
public IList<string> PluginDirectories { get; } = new List<string>();
/// <summary>
/// Paths to exclude from binary discovery.
/// Common system paths that contain kernel interfaces or virtual filesystems.
/// </summary>
public IList<string> ExcludePaths { get; } = new List<string>
{
"/proc",
"/sys",
"/dev",
"/run"
};
/// <summary>
/// Maximum number of binaries to analyze per container layer.
/// Prevents performance issues with containers containing many binaries.
/// </summary>
public int MaxBinariesPerLayer { get; set; } = 1000;
/// <summary>
/// Maximum total binaries to analyze per scan.
/// </summary>
public int MaxBinariesPerScan { get; set; } = 5000;
/// <summary>
/// Whether to enable heuristic detection for binaries without file extensions.
/// </summary>
public bool EnableHeuristics { get; set; } = true;
/// <summary>
/// Whether to extract hardening flags from binaries.
/// </summary>
public bool ExtractHardeningFlags { get; set; } = true;
/// <summary>
/// Whether to look up Build-IDs in the index for package correlation.
/// </summary>
public bool EnableBuildIdLookup { get; set; } = true;
/// <summary>
/// File extensions to consider as potential binaries.
/// </summary>
public IList<string> BinaryExtensions { get; } = new List<string>
{
".so",
".dll",
".exe",
".dylib",
".a",
".o"
};
/// <summary>
/// Timeout for analyzing a single binary.
/// </summary>
public TimeSpan SingleBinaryTimeout { get; set; } = TimeSpan.FromSeconds(10);
/// <summary>
/// Timeout for the entire native analysis phase.
/// </summary>
public TimeSpan TotalAnalysisTimeout { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Minimum file size to consider as a binary (bytes).
/// </summary>
public long MinFileSizeBytes { get; set; } = 1024;
/// <summary>
/// Maximum file size to analyze (bytes). Larger files are skipped.
/// </summary>
public long MaxFileSizeBytes { get; set; } = 500 * 1024 * 1024; // 500 MB
/// <summary>
/// Whether to include unresolved binaries (no Build-ID match) in SBOM output.
/// </summary>
public bool IncludeUnresolvedInSbom { get; set; } = true;
/// <summary>
/// Degree of parallelism for binary analysis.
/// </summary>
public int MaxDegreeOfParallelism { get; set; } = 4;
}

View File

@@ -28,6 +28,8 @@ public sealed class ScannerWorkerOptions
public AnalyzerOptions Analyzers { get; } = new();
public NativeAnalyzerOptions NativeAnalyzers { get; } = new();
public StellaOpsCryptoOptions Crypto { get; } = new();
public SigningOptions Signing { get; } = new();

View File

@@ -152,19 +152,23 @@ public sealed class EpssIngestJob : BackgroundService
: _onlineSource;
// Retrieve the EPSS file
var sourceFile = await source.GetAsync(modelDate, cancellationToken).ConfigureAwait(false);
await using var sourceFile = await source.GetAsync(modelDate, cancellationToken).ConfigureAwait(false);
// Read file content and compute hash
var fileContent = await File.ReadAllBytesAsync(sourceFile.LocalPath, cancellationToken).ConfigureAwait(false);
var fileSha256 = ComputeSha256(fileContent);
_logger.LogInformation(
"Retrieved EPSS file from {SourceUri}, size={Size}",
sourceFile.SourceUri,
sourceFile.Content.Length);
fileContent.Length);
// Begin import run
var importRun = await _repository.BeginImportAsync(
modelDate,
sourceFile.SourceUri,
_timeProvider.GetUtcNow(),
sourceFile.FileSha256,
fileSha256,
cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Created import run {ImportRunId}", importRun.ImportRunId);
@@ -172,7 +176,7 @@ public sealed class EpssIngestJob : BackgroundService
try
{
// Parse and write snapshot
await using var stream = new MemoryStream(sourceFile.Content);
await using var stream = new MemoryStream(fileContent);
var session = _parser.ParseGzip(stream);
var writeResult = await _repository.WriteSnapshotAsync(
@@ -269,4 +273,10 @@ public sealed class EpssIngestJob : BackgroundService
return new DateTimeOffset(scheduledTime, TimeSpan.Zero);
}
private static string ComputeSha256(byte[] content)
{
var hash = System.Security.Cryptography.SHA256.HashData(content);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,284 @@
// -----------------------------------------------------------------------------
// NativeAnalyzerExecutor.cs
// Sprint: SPRINT_3500_0014_0001_native_analyzer_integration
// Task: NAI-001
// Description: Executes native binary analysis during container scans.
// -----------------------------------------------------------------------------
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Emit.Native;
using StellaOps.Scanner.Worker.Diagnostics;
using StellaOps.Scanner.Worker.Options;
namespace StellaOps.Scanner.Worker.Processing;
/// <summary>
/// Executes native binary analysis during container scans.
/// Discovers binaries, extracts metadata, correlates with Build-ID index,
/// and emits SBOM components.
/// </summary>
public sealed class NativeAnalyzerExecutor
{
private readonly NativeBinaryDiscovery _discovery;
private readonly INativeComponentEmitter _emitter;
private readonly NativeAnalyzerOptions _options;
private readonly ILogger<NativeAnalyzerExecutor> _logger;
private readonly ScannerWorkerMetrics _metrics;
public NativeAnalyzerExecutor(
NativeBinaryDiscovery discovery,
INativeComponentEmitter emitter,
IOptions<NativeAnalyzerOptions> options,
ILogger<NativeAnalyzerExecutor> logger,
ScannerWorkerMetrics metrics)
{
_discovery = discovery ?? throw new ArgumentNullException(nameof(discovery));
_emitter = emitter ?? throw new ArgumentNullException(nameof(emitter));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
}
/// <summary>
/// Analyzes native binaries in the container filesystem.
/// </summary>
/// <param name="rootPath">Path to the extracted container filesystem.</param>
/// <param name="context">Scan job context.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Analysis result with discovered components.</returns>
public async Task<NativeAnalysisResult> ExecuteAsync(
string rootPath,
ScanJobContext context,
CancellationToken cancellationToken = default)
{
if (!_options.Enabled)
{
_logger.LogDebug("Native analyzer is disabled");
return NativeAnalysisResult.Empty;
}
var sw = Stopwatch.StartNew();
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(_options.TotalAnalysisTimeout);
// Discover binaries
var discovered = await _discovery.DiscoverAsync(rootPath, cts.Token).ConfigureAwait(false);
if (discovered.Count == 0)
{
_logger.LogDebug("No native binaries discovered in {RootPath}", rootPath);
return NativeAnalysisResult.Empty;
}
_logger.LogInformation(
"Starting native analysis of {Count} binaries for job {JobId}",
discovered.Count,
context.JobId);
// Convert to metadata and emit
var metadataList = new List<NativeBinaryMetadata>(discovered.Count);
foreach (var binary in discovered)
{
var metadata = await ExtractMetadataAsync(binary, cts.Token).ConfigureAwait(false);
if (metadata is not null)
{
metadataList.Add(metadata);
}
}
// Batch emit components
var emitResults = await _emitter.EmitBatchAsync(metadataList, cts.Token).ConfigureAwait(false);
sw.Stop();
var result = new NativeAnalysisResult
{
DiscoveredCount = discovered.Count,
AnalyzedCount = metadataList.Count,
ResolvedCount = emitResults.Count(r => r.IndexMatch),
UnresolvedCount = emitResults.Count(r => !r.IndexMatch),
Components = emitResults,
ElapsedMs = sw.ElapsedMilliseconds
};
_metrics.RecordNativeAnalysis(result);
_logger.LogInformation(
"Native analysis complete for job {JobId}: {Resolved}/{Analyzed} resolved in {ElapsedMs}ms",
context.JobId,
result.ResolvedCount,
result.AnalyzedCount,
result.ElapsedMs);
return result;
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
_logger.LogWarning(
"Native analysis timed out for job {JobId} after {ElapsedMs}ms",
context.JobId,
sw.ElapsedMilliseconds);
return new NativeAnalysisResult
{
TimedOut = true,
ElapsedMs = sw.ElapsedMilliseconds
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Native analysis failed for job {JobId}", context.JobId);
throw;
}
}
private async Task<NativeBinaryMetadata?> ExtractMetadataAsync(
DiscoveredBinary binary,
CancellationToken cancellationToken)
{
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(_options.SingleBinaryTimeout);
return await Task.Run(() =>
{
// Read binary header to extract Build-ID and other metadata
var buildId = ExtractBuildId(binary);
return new NativeBinaryMetadata
{
Format = binary.Format.ToString().ToLowerInvariant(),
FilePath = binary.RelativePath,
BuildId = buildId,
Architecture = DetectArchitecture(binary),
Platform = DetectPlatform(binary)
};
}, cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
_logger.LogDebug("Extraction timed out for binary: {Path}", binary.RelativePath);
return null;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to extract metadata from: {Path}", binary.RelativePath);
return null;
}
}
private string? ExtractBuildId(DiscoveredBinary binary)
{
if (binary.Format != BinaryFormat.Elf)
{
return null;
}
try
{
// Read ELF to find .note.gnu.build-id section
using var fs = File.OpenRead(binary.AbsolutePath);
using var reader = new BinaryReader(fs);
// Skip to ELF header
var magic = reader.ReadBytes(4);
if (magic.Length < 4 ||
magic[0] != 0x7F || magic[1] != 0x45 || magic[2] != 0x4C || magic[3] != 0x46)
{
return null;
}
var elfClass = reader.ReadByte(); // 1 = 32-bit, 2 = 64-bit
var is64Bit = elfClass == 2;
// Skip to section headers (simplified - real implementation would parse properly)
// For now, return null - full implementation is in the Analyzers.Native project
return null;
}
catch
{
return null;
}
}
private static string? DetectArchitecture(DiscoveredBinary binary)
{
if (binary.Format != BinaryFormat.Elf)
{
return null;
}
try
{
using var fs = File.OpenRead(binary.AbsolutePath);
Span<byte> header = stackalloc byte[20];
if (fs.Read(header) < 20)
{
return null;
}
// e_machine is at offset 18 (2 bytes, little-endian typically)
var machine = BitConverter.ToUInt16(header[18..20]);
return machine switch
{
0x03 => "i386",
0x3E => "x86_64",
0x28 => "arm",
0xB7 => "aarch64",
0xF3 => "riscv",
_ => null
};
}
catch
{
return null;
}
}
private static string? DetectPlatform(DiscoveredBinary binary)
{
return binary.Format switch
{
BinaryFormat.Elf => "linux",
BinaryFormat.Pe => "windows",
BinaryFormat.MachO => "darwin",
_ => null
};
}
}
/// <summary>
/// Result of native binary analysis.
/// </summary>
public sealed record NativeAnalysisResult
{
public static readonly NativeAnalysisResult Empty = new();
/// <summary>Number of binaries discovered in filesystem.</summary>
public int DiscoveredCount { get; init; }
/// <summary>Number of binaries successfully analyzed.</summary>
public int AnalyzedCount { get; init; }
/// <summary>Number of binaries resolved via Build-ID index.</summary>
public int ResolvedCount { get; init; }
/// <summary>Number of binaries not found in Build-ID index.</summary>
public int UnresolvedCount { get; init; }
/// <summary>Whether the analysis timed out.</summary>
public bool TimedOut { get; init; }
/// <summary>Total elapsed time in milliseconds.</summary>
public long ElapsedMs { get; init; }
/// <summary>Emitted component results.</summary>
public IReadOnlyList<NativeComponentEmitResult> Components { get; init; } = Array.Empty<NativeComponentEmitResult>();
}

View File

@@ -0,0 +1,294 @@
// -----------------------------------------------------------------------------
// NativeBinaryDiscovery.cs
// Sprint: SPRINT_3500_0014_0001_native_analyzer_integration
// Task: NAI-002
// Description: Discovers native binaries in container filesystem layers.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Worker.Options;
namespace StellaOps.Scanner.Worker.Processing;
/// <summary>
/// Discovers native binaries in container filesystem layers for analysis.
/// </summary>
public sealed class NativeBinaryDiscovery
{
private readonly NativeAnalyzerOptions _options;
private readonly ILogger<NativeBinaryDiscovery> _logger;
private static readonly byte[] ElfMagic = [0x7F, 0x45, 0x4C, 0x46]; // \x7FELF
private static readonly byte[] PeMagic = [0x4D, 0x5A]; // MZ
private static readonly byte[] MachO32Magic = [0xFE, 0xED, 0xFA, 0xCE];
private static readonly byte[] MachO64Magic = [0xFE, 0xED, 0xFA, 0xCF];
private static readonly byte[] MachO32MagicReverse = [0xCE, 0xFA, 0xED, 0xFE];
private static readonly byte[] MachO64MagicReverse = [0xCF, 0xFA, 0xED, 0xFE];
private static readonly byte[] FatMachOMagic = [0xCA, 0xFE, 0xBA, 0xBE];
public NativeBinaryDiscovery(
IOptions<NativeAnalyzerOptions> options,
ILogger<NativeBinaryDiscovery> logger)
{
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Discovers binaries in the specified root filesystem path.
/// </summary>
public async Task<IReadOnlyList<DiscoveredBinary>> DiscoverAsync(
string rootPath,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
if (!Directory.Exists(rootPath))
{
_logger.LogWarning("Root path does not exist: {RootPath}", rootPath);
return Array.Empty<DiscoveredBinary>();
}
var discovered = new List<DiscoveredBinary>();
var excludeSet = new HashSet<string>(_options.ExcludePaths, StringComparer.OrdinalIgnoreCase);
var extensionSet = new HashSet<string>(
_options.BinaryExtensions.Select(e => e.StartsWith('.') ? e : "." + e),
StringComparer.OrdinalIgnoreCase);
await Task.Run(() =>
{
DiscoverRecursive(
rootPath,
rootPath,
discovered,
excludeSet,
extensionSet,
cancellationToken);
}, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Discovered {Count} native binaries in {RootPath}",
discovered.Count,
rootPath);
return discovered;
}
private void DiscoverRecursive(
string basePath,
string currentPath,
List<DiscoveredBinary> discovered,
HashSet<string> excludeSet,
HashSet<string> extensionSet,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
// Check if we've hit the limit
if (discovered.Count >= _options.MaxBinariesPerScan)
{
_logger.LogDebug("Reached max binaries per scan limit ({Limit})", _options.MaxBinariesPerScan);
return;
}
// Get relative path for exclusion check
var relativePath = GetRelativePath(basePath, currentPath);
if (IsExcluded(relativePath, excludeSet))
{
_logger.LogDebug("Skipping excluded path: {Path}", relativePath);
return;
}
// Enumerate files
IEnumerable<string> files;
try
{
files = Directory.EnumerateFiles(currentPath);
}
catch (UnauthorizedAccessException)
{
_logger.LogDebug("Access denied to directory: {Path}", currentPath);
return;
}
catch (DirectoryNotFoundException)
{
return;
}
foreach (var filePath in files)
{
cancellationToken.ThrowIfCancellationRequested();
if (discovered.Count >= _options.MaxBinariesPerScan)
{
break;
}
try
{
var binary = TryDiscoverBinary(basePath, filePath, extensionSet);
if (binary is not null)
{
discovered.Add(binary);
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_logger.LogDebug(ex, "Could not analyze file: {FilePath}", filePath);
}
}
// Recurse into subdirectories
IEnumerable<string> directories;
try
{
directories = Directory.EnumerateDirectories(currentPath);
}
catch (UnauthorizedAccessException)
{
return;
}
catch (DirectoryNotFoundException)
{
return;
}
foreach (var directory in directories)
{
DiscoverRecursive(basePath, directory, discovered, excludeSet, extensionSet, cancellationToken);
}
}
private DiscoveredBinary? TryDiscoverBinary(
string basePath,
string filePath,
HashSet<string> extensionSet)
{
var fileInfo = new FileInfo(filePath);
// Size checks
if (fileInfo.Length < _options.MinFileSizeBytes)
{
return null;
}
if (fileInfo.Length > _options.MaxFileSizeBytes)
{
_logger.LogDebug("File too large ({Size} bytes): {FilePath}", fileInfo.Length, filePath);
return null;
}
// Extension check (if heuristics disabled)
var extension = Path.GetExtension(filePath);
var hasKnownExtension = !string.IsNullOrEmpty(extension) && extensionSet.Contains(extension);
if (!_options.EnableHeuristics && !hasKnownExtension)
{
return null;
}
// Magic byte check
var format = DetectBinaryFormat(filePath);
if (format == BinaryFormat.Unknown)
{
return null;
}
var relativePath = GetRelativePath(basePath, filePath);
return new DiscoveredBinary(
AbsolutePath: filePath,
RelativePath: relativePath,
Format: format,
SizeBytes: fileInfo.Length,
FileName: fileInfo.Name);
}
private BinaryFormat DetectBinaryFormat(string filePath)
{
try
{
Span<byte> header = stackalloc byte[4];
using var fs = File.OpenRead(filePath);
if (fs.Read(header) < 4)
{
return BinaryFormat.Unknown;
}
if (header.SequenceEqual(ElfMagic))
{
return BinaryFormat.Elf;
}
if (header[..2].SequenceEqual(PeMagic))
{
return BinaryFormat.Pe;
}
if (header.SequenceEqual(MachO32Magic) ||
header.SequenceEqual(MachO64Magic) ||
header.SequenceEqual(MachO32MagicReverse) ||
header.SequenceEqual(MachO64MagicReverse) ||
header.SequenceEqual(FatMachOMagic))
{
return BinaryFormat.MachO;
}
return BinaryFormat.Unknown;
}
catch
{
return BinaryFormat.Unknown;
}
}
private static string GetRelativePath(string basePath, string fullPath)
{
if (fullPath.StartsWith(basePath, StringComparison.OrdinalIgnoreCase))
{
var relative = fullPath[basePath.Length..].TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
return "/" + relative.Replace('\\', '/');
}
return fullPath;
}
private static bool IsExcluded(string relativePath, HashSet<string> excludeSet)
{
foreach (var exclude in excludeSet)
{
if (relativePath.StartsWith(exclude, StringComparison.OrdinalIgnoreCase) ||
relativePath.StartsWith("/" + exclude.TrimStart('/'), StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
}
/// <summary>
/// A discovered binary file ready for analysis.
/// </summary>
/// <param name="AbsolutePath">Full path to the binary.</param>
/// <param name="RelativePath">Path relative to the container root.</param>
/// <param name="Format">Detected binary format.</param>
/// <param name="SizeBytes">File size in bytes.</param>
/// <param name="FileName">File name only.</param>
public sealed record DiscoveredBinary(
string AbsolutePath,
string RelativePath,
BinaryFormat Format,
long SizeBytes,
string FileName);
/// <summary>
/// Binary format types.
/// </summary>
public enum BinaryFormat
{
Unknown,
Elf,
Pe,
MachO
}

View File

@@ -29,5 +29,7 @@
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Surface.Secrets/StellaOps.Scanner.Surface.Secrets.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Surface.FS/StellaOps.Scanner.Surface.FS.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Storage/StellaOps.Scanner.Storage.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Emit/StellaOps.Scanner.Emit.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Analyzers.Native/StellaOps.Scanner.Analyzers.Native.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,143 @@
// -----------------------------------------------------------------------------
// EpssEnrichmentOptions.cs
// Sprint: SPRINT_3413_0001_0001_epss_live_enrichment
// Task: 9
// Description: Configuration options for EPSS live enrichment.
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Core.Configuration;
/// <summary>
/// Configuration for EPSS live enrichment jobs.
/// Bound from "Scanner:EpssEnrichment" section.
/// </summary>
public sealed class EpssEnrichmentOptions
{
public const string SectionName = "Scanner:EpssEnrichment";
/// <summary>
/// Enables EPSS enrichment jobs.
/// Default: true
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// EPSS percentile threshold for HIGH priority band.
/// Vulnerabilities at or above this percentile are considered high priority.
/// Range: [0, 1]. Default: 0.95 (top 5%)
/// </summary>
public double HighPercentile { get; set; } = 0.95;
/// <summary>
/// EPSS score threshold for HIGH priority (alternative trigger).
/// If score exceeds this, vulnerability is high priority regardless of percentile.
/// Range: [0, 1]. Default: 0.5
/// </summary>
public double HighScore { get; set; } = 0.5;
/// <summary>
/// EPSS percentile threshold for CRITICAL priority band.
/// Range: [0, 1]. Default: 0.99 (top 1%)
/// </summary>
public double CriticalPercentile { get; set; } = 0.99;
/// <summary>
/// EPSS score threshold for CRITICAL priority (alternative trigger).
/// Range: [0, 1]. Default: 0.8
/// </summary>
public double CriticalScore { get; set; } = 0.8;
/// <summary>
/// EPSS percentile threshold for MEDIUM priority band.
/// Range: [0, 1]. Default: 0.75 (top 25%)
/// </summary>
public double MediumPercentile { get; set; } = 0.75;
/// <summary>
/// Delta threshold for BIG_JUMP flag.
/// Triggers when EPSS score increases by more than this amount.
/// Range: [0, 1]. Default: 0.15
/// </summary>
public double BigJumpDelta { get; set; } = 0.15;
/// <summary>
/// Delta threshold for DROPPED_LOW flag.
/// Triggers when EPSS score decreases by more than this amount.
/// Range: [0, 1]. Default: 0.1
/// </summary>
public double DroppedLowDelta { get; set; } = 0.1;
/// <summary>
/// Batch size for bulk updates.
/// Default: 5000
/// </summary>
public int BatchSize { get; set; } = 5000;
/// <summary>
/// Maximum number of instances to process per job run.
/// 0 = unlimited. Default: 0
/// </summary>
public int MaxInstancesPerRun { get; set; } = 0;
/// <summary>
/// Minimum delay between enrichment jobs (prevents rapid re-runs).
/// Default: 1 hour
/// </summary>
public TimeSpan MinJobInterval { get; set; } = TimeSpan.FromHours(1);
/// <summary>
/// Whether to emit priority change events.
/// Default: true
/// </summary>
public bool EmitPriorityChangeEvents { get; set; } = true;
/// <summary>
/// Whether to skip enrichment when EPSS model version changes.
/// This prevents false positive delta events from model retraining.
/// Default: true
/// </summary>
public bool SkipOnModelVersionChange { get; set; } = true;
/// <summary>
/// Number of days to retain raw EPSS data.
/// Default: 365
/// </summary>
public int RawDataRetentionDays { get; set; } = 365;
/// <summary>
/// Validates the options.
/// </summary>
public void Validate()
{
EnsurePercentage(nameof(HighPercentile), HighPercentile);
EnsurePercentage(nameof(HighScore), HighScore);
EnsurePercentage(nameof(CriticalPercentile), CriticalPercentile);
EnsurePercentage(nameof(CriticalScore), CriticalScore);
EnsurePercentage(nameof(MediumPercentile), MediumPercentile);
EnsurePercentage(nameof(BigJumpDelta), BigJumpDelta);
EnsurePercentage(nameof(DroppedLowDelta), DroppedLowDelta);
if (BatchSize < 1)
{
throw new ArgumentOutOfRangeException(nameof(BatchSize), BatchSize, "Must be at least 1.");
}
if (MinJobInterval < TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(MinJobInterval), MinJobInterval, "Cannot be negative.");
}
if (RawDataRetentionDays < 1)
{
throw new ArgumentOutOfRangeException(nameof(RawDataRetentionDays), RawDataRetentionDays, "Must be at least 1.");
}
}
private static void EnsurePercentage(string name, double value)
{
if (double.IsNaN(value) || value < 0.0 || value > 1.0)
{
throw new ArgumentOutOfRangeException(name, value, "Must be between 0 and 1.");
}
}
}

View File

@@ -53,4 +53,17 @@ public sealed class OfflineKitOptions
/// Contains checkpoint.sig and entries/*.jsonl
/// </summary>
public string? RekorSnapshotDirectory { get; set; }
/// <summary>
/// Path to the Build-ID mapping index file (NDJSON format).
/// Used to correlate native binary Build-IDs (ELF GNU build-id, PE CodeView GUID+Age, Mach-O UUID)
/// to Package URLs (PURLs) for binary identification in distroless/scratch images.
/// </summary>
public string? BuildIdIndexPath { get; set; }
/// <summary>
/// When true, Build-ID index must have valid DSSE signature.
/// Default: true
/// </summary>
public bool RequireBuildIdIndexSignature { get; set; } = true;
}

View File

@@ -0,0 +1,146 @@
// -----------------------------------------------------------------------------
// EpssEvidence.cs
// Sprint: SPRINT_3410_0002_0001_epss_scanner_integration
// Task: EPSS-SCAN-002
// Description: Immutable EPSS evidence captured at scan time.
// -----------------------------------------------------------------------------
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Core.Epss;
/// <summary>
/// Immutable EPSS evidence captured at scan time.
/// This record captures the EPSS score and percentile at the exact moment of scanning,
/// providing immutable evidence for deterministic replay and audit.
/// </summary>
public sealed record EpssEvidence
{
/// <summary>
/// EPSS probability score [0,1] at scan time.
/// Represents the probability of exploitation in the wild in the next 30 days.
/// </summary>
[JsonPropertyName("score")]
public required double Score { get; init; }
/// <summary>
/// EPSS percentile rank [0,1] at scan time.
/// Represents where this CVE ranks compared to all other CVEs.
/// </summary>
[JsonPropertyName("percentile")]
public required double Percentile { get; init; }
/// <summary>
/// EPSS model date used for this score.
/// The EPSS model is updated daily, so this records which model version was used.
/// </summary>
[JsonPropertyName("modelDate")]
public required DateOnly ModelDate { get; init; }
/// <summary>
/// Timestamp when this evidence was captured (UTC).
/// </summary>
[JsonPropertyName("capturedAt")]
public required DateTimeOffset CapturedAt { get; init; }
/// <summary>
/// CVE identifier this evidence applies to.
/// </summary>
[JsonPropertyName("cveId")]
public required string CveId { get; init; }
/// <summary>
/// Source of the EPSS data (e.g., "first.org", "offline-bundle", "cache").
/// </summary>
[JsonPropertyName("source")]
public string? Source { get; init; }
/// <summary>
/// Whether this evidence was captured from a cached value.
/// </summary>
[JsonPropertyName("fromCache")]
public bool FromCache { get; init; }
/// <summary>
/// Creates a new EPSS evidence record with current timestamp.
/// </summary>
public static EpssEvidence Create(
string cveId,
double score,
double percentile,
DateOnly modelDate,
string? source = null,
bool fromCache = false)
{
return new EpssEvidence
{
CveId = cveId,
Score = score,
Percentile = percentile,
ModelDate = modelDate,
CapturedAt = DateTimeOffset.UtcNow,
Source = source,
FromCache = fromCache
};
}
/// <summary>
/// Creates a new EPSS evidence record with explicit timestamp (for replay).
/// </summary>
public static EpssEvidence CreateWithTimestamp(
string cveId,
double score,
double percentile,
DateOnly modelDate,
DateTimeOffset capturedAt,
string? source = null,
bool fromCache = false)
{
return new EpssEvidence
{
CveId = cveId,
Score = score,
Percentile = percentile,
ModelDate = modelDate,
CapturedAt = capturedAt,
Source = source,
FromCache = fromCache
};
}
}
/// <summary>
/// Batch result for EPSS lookup operations.
/// </summary>
public sealed record EpssBatchResult
{
/// <summary>
/// Successfully retrieved EPSS evidence records.
/// </summary>
[JsonPropertyName("found")]
public required IReadOnlyList<EpssEvidence> Found { get; init; }
/// <summary>
/// CVE IDs that were not found in the EPSS dataset.
/// </summary>
[JsonPropertyName("notFound")]
public required IReadOnlyList<string> NotFound { get; init; }
/// <summary>
/// Model date used for this batch lookup.
/// </summary>
[JsonPropertyName("modelDate")]
public required DateOnly ModelDate { get; init; }
/// <summary>
/// Whether any results came from cache.
/// </summary>
[JsonPropertyName("partiallyFromCache")]
public bool PartiallyFromCache { get; init; }
/// <summary>
/// Total lookup time in milliseconds.
/// </summary>
[JsonPropertyName("lookupTimeMs")]
public long LookupTimeMs { get; init; }
}

View File

@@ -0,0 +1,187 @@
// -----------------------------------------------------------------------------
// EpssPriorityBand.cs
// Sprint: SPRINT_3413_0001_0001_epss_live_enrichment
// Task: 5
// Description: EPSS priority band calculation and models.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Core.Configuration;
namespace StellaOps.Scanner.Core.Epss;
/// <summary>
/// Priority bands derived from EPSS scores and percentiles.
/// </summary>
public enum EpssPriorityBand
{
/// <summary>Top 1% by percentile or score > 0.8 - requires immediate action.</summary>
Critical = 0,
/// <summary>Top 5% by percentile or score > 0.5 - high likelihood of exploitation.</summary>
High = 1,
/// <summary>Top 25% by percentile - moderate likelihood.</summary>
Medium = 2,
/// <summary>Below top 25% - lower immediate risk.</summary>
Low = 3,
/// <summary>No EPSS data available.</summary>
Unknown = 4
}
/// <summary>
/// Result of EPSS priority band calculation.
/// </summary>
public sealed record EpssPriorityResult(
/// <summary>Calculated priority band.</summary>
EpssPriorityBand Band,
/// <summary>Whether this priority was elevated due to score threshold.</summary>
bool ElevatedByScore,
/// <summary>The trigger condition that determined the band.</summary>
string Reason);
/// <summary>
/// Service for calculating EPSS priority bands.
/// </summary>
public sealed class EpssPriorityCalculator
{
private readonly EpssEnrichmentOptions _options;
public EpssPriorityCalculator(EpssEnrichmentOptions options)
{
ArgumentNullException.ThrowIfNull(options);
_options = options;
}
/// <summary>
/// Calculate priority band from EPSS score and percentile.
/// </summary>
/// <param name="score">EPSS probability score [0, 1].</param>
/// <param name="percentile">EPSS percentile rank [0, 1].</param>
/// <returns>Priority result with band and reasoning.</returns>
public EpssPriorityResult Calculate(double? score, double? percentile)
{
if (!score.HasValue || !percentile.HasValue)
{
return new EpssPriorityResult(EpssPriorityBand.Unknown, false, "No EPSS data available");
}
var s = score.Value;
var p = percentile.Value;
// Critical: top 1% by percentile OR score > critical threshold
if (p >= _options.CriticalPercentile)
{
return new EpssPriorityResult(EpssPriorityBand.Critical, false, $"Percentile {p:P1} >= {_options.CriticalPercentile:P0}");
}
if (s >= _options.CriticalScore)
{
return new EpssPriorityResult(EpssPriorityBand.Critical, true, $"Score {s:F3} >= {_options.CriticalScore:F2}");
}
// High: top 5% by percentile OR score > high threshold
if (p >= _options.HighPercentile)
{
return new EpssPriorityResult(EpssPriorityBand.High, false, $"Percentile {p:P1} >= {_options.HighPercentile:P0}");
}
if (s >= _options.HighScore)
{
return new EpssPriorityResult(EpssPriorityBand.High, true, $"Score {s:F3} >= {_options.HighScore:F2}");
}
// Medium: top 25% by percentile
if (p >= _options.MediumPercentile)
{
return new EpssPriorityResult(EpssPriorityBand.Medium, false, $"Percentile {p:P1} >= {_options.MediumPercentile:P0}");
}
// Low: everything else
return new EpssPriorityResult(EpssPriorityBand.Low, false, $"Percentile {p:P1} < {_options.MediumPercentile:P0}");
}
/// <summary>
/// Check if priority band has changed between two EPSS snapshots.
/// </summary>
public bool HasBandChanged(
double? oldScore, double? oldPercentile,
double? newScore, double? newPercentile)
{
var oldBand = Calculate(oldScore, oldPercentile).Band;
var newBand = Calculate(newScore, newPercentile).Band;
return oldBand != newBand;
}
/// <summary>
/// Determine change flags for an EPSS update.
/// </summary>
public EpssChangeFlags ComputeChangeFlags(
double? oldScore, double? oldPercentile,
double newScore, double newPercentile)
{
var flags = EpssChangeFlags.None;
// NEW_SCORED: first time we have EPSS data
if (!oldScore.HasValue && newScore > 0)
{
flags |= EpssChangeFlags.NewScored;
}
if (oldScore.HasValue)
{
var delta = newScore - oldScore.Value;
// BIG_JUMP: significant score increase
if (delta >= _options.BigJumpDelta)
{
flags |= EpssChangeFlags.BigJump;
}
// DROPPED_LOW: significant score decrease
if (delta <= -_options.DroppedLowDelta)
{
flags |= EpssChangeFlags.DroppedLow;
}
}
// CROSSED_HIGH: moved into or out of high priority
var oldBand = Calculate(oldScore, oldPercentile).Band;
var newBand = Calculate(newScore, newPercentile).Band;
if (oldBand != newBand)
{
// Crossed into critical or high
if ((newBand == EpssPriorityBand.Critical || newBand == EpssPriorityBand.High) &&
oldBand != EpssPriorityBand.Critical && oldBand != EpssPriorityBand.High)
{
flags |= EpssChangeFlags.CrossedHigh;
}
}
return flags;
}
}
/// <summary>
/// Flags indicating what kind of EPSS change occurred.
/// </summary>
[Flags]
public enum EpssChangeFlags
{
/// <summary>No significant change.</summary>
None = 0,
/// <summary>CVE was scored for the first time.</summary>
NewScored = 1 << 0,
/// <summary>Score crossed into high priority band.</summary>
CrossedHigh = 1 << 1,
/// <summary>Score increased significantly (above BigJumpDelta).</summary>
BigJump = 1 << 2,
/// <summary>Score dropped significantly (above DroppedLowDelta).</summary>
DroppedLow = 1 << 3
}

View File

@@ -0,0 +1,119 @@
// -----------------------------------------------------------------------------
// IEpssProvider.cs
// Sprint: SPRINT_3410_0002_0001_epss_scanner_integration
// Task: EPSS-SCAN-003
// Description: Interface for EPSS data access in the scanner.
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Core.Epss;
/// <summary>
/// Provides access to EPSS (Exploit Prediction Scoring System) data.
/// Implementations may use PostgreSQL, cache layers, or offline bundles.
/// </summary>
public interface IEpssProvider
{
/// <summary>
/// Gets the current EPSS score for a single CVE.
/// </summary>
/// <param name="cveId">CVE identifier (e.g., "CVE-2021-44228").</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>EPSS evidence if found; otherwise null.</returns>
Task<EpssEvidence?> GetCurrentAsync(string cveId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets EPSS scores for multiple CVEs in a single batch operation.
/// </summary>
/// <param name="cveIds">Collection of CVE identifiers.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Batch result with found evidence and missing CVE IDs.</returns>
Task<EpssBatchResult> GetCurrentBatchAsync(
IEnumerable<string> cveIds,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets EPSS score as of a specific date (for replay scenarios).
/// </summary>
/// <param name="cveId">CVE identifier.</param>
/// <param name="asOfDate">Date for which to retrieve the score.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>EPSS evidence if found for that date; otherwise null.</returns>
Task<EpssEvidence?> GetAsOfDateAsync(
string cveId,
DateOnly asOfDate,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets EPSS score history for a CVE over a date range.
/// </summary>
/// <param name="cveId">CVE identifier.</param>
/// <param name="startDate">Start of date range (inclusive).</param>
/// <param name="endDate">End of date range (inclusive).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of EPSS evidence records ordered by date ascending.</returns>
Task<IReadOnlyList<EpssEvidence>> GetHistoryAsync(
string cveId,
DateOnly startDate,
DateOnly endDate,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the most recent model date available in the provider.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Most recent model date, or null if no data is available.</returns>
Task<DateOnly?> GetLatestModelDateAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Checks if EPSS data is available and the provider is healthy.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if the provider can serve requests.</returns>
Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// Options for EPSS provider configuration.
/// </summary>
public sealed class EpssProviderOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Epss";
/// <summary>
/// Whether to enable Valkey/Redis cache layer.
/// </summary>
public bool EnableCache { get; set; } = true;
/// <summary>
/// Cache TTL for current EPSS scores (default: 1 hour).
/// </summary>
public TimeSpan CacheTtl { get; set; } = TimeSpan.FromHours(1);
/// <summary>
/// Maximum batch size for bulk lookups (default: 1000).
/// </summary>
public int MaxBatchSize { get; set; } = 1000;
/// <summary>
/// Timeout for individual lookups (default: 5 seconds).
/// </summary>
public TimeSpan LookupTimeout { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Whether to use offline/bundled EPSS data (air-gap mode).
/// </summary>
public bool OfflineMode { get; set; }
/// <summary>
/// Path to offline EPSS bundle (when OfflineMode is true).
/// </summary>
public string? OfflineBundlePath { get; set; }
/// <summary>
/// Source identifier for telemetry.
/// </summary>
public string SourceIdentifier { get; set; } = "postgres";
}

View File

@@ -52,4 +52,10 @@ public sealed record NativeBinaryMetadata
/// <summary>Signature details (Authenticode, codesign, etc.)</summary>
public string? SignatureDetails { get; init; }
/// <summary>Imported libraries (DLL names for PE, SO names for ELF, dylib names for Mach-O)</summary>
public IReadOnlyList<string>? Imports { get; init; }
/// <summary>Exported symbols (for dependency analysis)</summary>
public IReadOnlyList<string>? Exports { get; init; }
}

View File

@@ -0,0 +1,196 @@
// -----------------------------------------------------------------------------
// NativeComponentMapper.cs
// Sprint: SPRINT_3500_0012_0001_binary_sbom_emission
// Task: BSE-004
// Description: Maps native binaries to container layer fragments for SBOM.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Analyzers.Native.Index;
namespace StellaOps.Scanner.Emit.Native;
/// <summary>
/// Maps native binary components to container layer fragments.
/// Generates dependency relationships and layer ownership metadata.
/// </summary>
public sealed class NativeComponentMapper
{
private readonly INativeComponentEmitter _emitter;
public NativeComponentMapper(INativeComponentEmitter emitter)
{
ArgumentNullException.ThrowIfNull(emitter);
_emitter = emitter;
}
/// <summary>
/// Maps a container layer's native binaries to SBOM components.
/// </summary>
/// <param name="layerDigest">Layer digest (sha256:...)</param>
/// <param name="binaries">Native binaries discovered in the layer</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Layer mapping result</returns>
public async Task<LayerComponentMapping> MapLayerAsync(
string layerDigest,
IReadOnlyList<NativeBinaryMetadata> binaries,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(layerDigest);
ArgumentNullException.ThrowIfNull(binaries);
var components = new List<NativeComponentEmitResult>(binaries.Count);
var unresolvedCount = 0;
foreach (var binary in binaries)
{
cancellationToken.ThrowIfCancellationRequested();
var result = await _emitter.EmitAsync(binary, cancellationToken).ConfigureAwait(false);
components.Add(result);
if (!result.IndexMatch)
{
unresolvedCount++;
}
}
return new LayerComponentMapping(
LayerDigest: layerDigest,
Components: components,
TotalCount: components.Count,
ResolvedCount: components.Count - unresolvedCount,
UnresolvedCount: unresolvedCount);
}
/// <summary>
/// Maps all layers in a container image to SBOM components.
/// Deduplicates components that appear in multiple layers.
/// </summary>
/// <param name="imageLayers">Ordered list of layer digests (base to top)</param>
/// <param name="binariesByLayer">Binaries discovered per layer</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Image mapping result with deduplication</returns>
public async Task<ImageComponentMapping> MapImageAsync(
IReadOnlyList<string> imageLayers,
IReadOnlyDictionary<string, IReadOnlyList<NativeBinaryMetadata>> binariesByLayer,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(imageLayers);
ArgumentNullException.ThrowIfNull(binariesByLayer);
var layerMappings = new List<LayerComponentMapping>(imageLayers.Count);
var seenPurls = new HashSet<string>(StringComparer.Ordinal);
var uniqueComponents = new List<NativeComponentEmitResult>();
var duplicateCount = 0;
foreach (var layerDigest in imageLayers)
{
cancellationToken.ThrowIfCancellationRequested();
if (!binariesByLayer.TryGetValue(layerDigest, out var binaries))
{
// Empty layer, skip
layerMappings.Add(new LayerComponentMapping(
LayerDigest: layerDigest,
Components: Array.Empty<NativeComponentEmitResult>(),
TotalCount: 0,
ResolvedCount: 0,
UnresolvedCount: 0));
continue;
}
var layerMapping = await MapLayerAsync(layerDigest, binaries, cancellationToken).ConfigureAwait(false);
layerMappings.Add(layerMapping);
// Track unique components for the final image SBOM
foreach (var component in layerMapping.Components)
{
if (seenPurls.Add(component.Purl))
{
uniqueComponents.Add(component);
}
else
{
duplicateCount++;
}
}
}
return new ImageComponentMapping(
Layers: layerMappings,
UniqueComponents: uniqueComponents,
TotalBinaryCount: layerMappings.Sum(l => l.TotalCount),
UniqueBinaryCount: uniqueComponents.Count,
DuplicateCount: duplicateCount);
}
/// <summary>
/// Computes dependency relationships between native binaries.
/// Uses import table analysis to determine which binaries depend on which.
/// </summary>
/// <param name="components">Components to analyze</param>
/// <returns>Dependency edges (from PURL to list of dependency PURLs)</returns>
public IReadOnlyDictionary<string, IReadOnlyList<string>> ComputeDependencies(
IReadOnlyList<NativeComponentEmitResult> components)
{
ArgumentNullException.ThrowIfNull(components);
// Build lookup by filename for dependency resolution
var byFilename = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var component in components)
{
var filename = Path.GetFileName(component.Metadata.FilePath);
if (!string.IsNullOrWhiteSpace(filename))
{
byFilename.TryAdd(filename, component.Purl);
}
}
var dependencies = new Dictionary<string, IReadOnlyList<string>>();
foreach (var component in components)
{
var deps = new List<string>();
// Use imports from metadata if available
if (component.Metadata.Imports is { Count: > 0 })
{
foreach (var import in component.Metadata.Imports)
{
var importName = Path.GetFileName(import);
if (byFilename.TryGetValue(importName, out var depPurl))
{
deps.Add(depPurl);
}
}
}
if (deps.Count > 0)
{
dependencies[component.Purl] = deps;
}
}
return dependencies;
}
}
/// <summary>
/// Result of mapping a single container layer to SBOM components.
/// </summary>
public sealed record LayerComponentMapping(
string LayerDigest,
IReadOnlyList<NativeComponentEmitResult> Components,
int TotalCount,
int ResolvedCount,
int UnresolvedCount);
/// <summary>
/// Result of mapping an entire container image to SBOM components.
/// </summary>
public sealed record ImageComponentMapping(
IReadOnlyList<LayerComponentMapping> Layers,
IReadOnlyList<NativeComponentEmitResult> UniqueComponents,
int TotalBinaryCount,
int UniqueBinaryCount,
int DuplicateCount);

View File

@@ -0,0 +1,90 @@
// -----------------------------------------------------------------------------
// BoundaryExtractionContext.cs
// Sprint: SPRINT_3800_0002_0001_boundary_richgraph
// Description: Context for boundary extraction with environment hints.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using StellaOps.Scanner.Reachability.Gates;
namespace StellaOps.Scanner.Reachability.Boundary;
/// <summary>
/// Context for boundary extraction, providing environment hints and detected gates.
/// </summary>
public sealed record BoundaryExtractionContext
{
/// <summary>
/// Empty context for simple extractions.
/// </summary>
public static readonly BoundaryExtractionContext Empty = new();
/// <summary>
/// Environment identifier (e.g., "production", "staging").
/// </summary>
public string? EnvironmentId { get; init; }
/// <summary>
/// Deployment namespace or context (e.g., "default", "kube-system").
/// </summary>
public string? Namespace { get; init; }
/// <summary>
/// Additional annotations from deployment metadata.
/// </summary>
public IReadOnlyDictionary<string, string> Annotations { get; init; } =
new Dictionary<string, string>();
/// <summary>
/// Gates detected by gate detection analysis.
/// </summary>
public IReadOnlyList<DetectedGate> DetectedGates { get; init; } =
Array.Empty<DetectedGate>();
/// <summary>
/// Whether the service is known to be internet-facing.
/// </summary>
public bool? IsInternetFacing { get; init; }
/// <summary>
/// Network zone (e.g., "dmz", "internal", "trusted").
/// </summary>
public string? NetworkZone { get; init; }
/// <summary>
/// Known port bindings (port → protocol).
/// </summary>
public IReadOnlyDictionary<int, string> PortBindings { get; init; } =
new Dictionary<int, string>();
/// <summary>
/// Timestamp for the context (for cache invalidation).
/// </summary>
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
/// <summary>
/// Source of this context (e.g., "k8s", "iac", "runtime").
/// </summary>
public string? Source { get; init; }
/// <summary>
/// Creates a context from detected gates.
/// </summary>
public static BoundaryExtractionContext FromGates(IReadOnlyList<DetectedGate> gates) =>
new() { DetectedGates = gates };
/// <summary>
/// Creates a context with environment hints.
/// </summary>
public static BoundaryExtractionContext ForEnvironment(
string environmentId,
bool? isInternetFacing = null,
string? networkZone = null) =>
new()
{
EnvironmentId = environmentId,
IsInternetFacing = isInternetFacing,
NetworkZone = networkZone
};
}

View File

@@ -0,0 +1,41 @@
// -----------------------------------------------------------------------------
// BoundaryServiceCollectionExtensions.cs
// Sprint: SPRINT_3800_0002_0001_boundary_richgraph
// Description: DI registration for boundary proof extractors.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace StellaOps.Scanner.Reachability.Boundary;
/// <summary>
/// Extension methods for registering boundary proof extractors.
/// </summary>
public static class BoundaryServiceCollectionExtensions
{
/// <summary>
/// Adds boundary proof extraction services.
/// </summary>
public static IServiceCollection AddBoundaryExtractors(this IServiceCollection services)
{
// Register base extractor
services.TryAddSingleton<RichGraphBoundaryExtractor>();
services.TryAddSingleton<IBoundaryProofExtractor, RichGraphBoundaryExtractor>();
// Register composite extractor that uses all available extractors
services.TryAddSingleton<CompositeBoundaryExtractor>();
return services;
}
/// <summary>
/// Adds a custom boundary proof extractor.
/// </summary>
public static IServiceCollection AddBoundaryExtractor<TExtractor>(this IServiceCollection services)
where TExtractor : class, IBoundaryProofExtractor
{
services.AddSingleton<IBoundaryProofExtractor, TExtractor>();
return services;
}
}

View File

@@ -0,0 +1,119 @@
// -----------------------------------------------------------------------------
// CompositeBoundaryExtractor.cs
// Sprint: SPRINT_3800_0002_0001_boundary_richgraph
// Description: Composite extractor that aggregates results from multiple extractors.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.SmartDiff.Detection;
namespace StellaOps.Scanner.Reachability.Boundary;
/// <summary>
/// Composite boundary extractor that selects the best result from multiple extractors.
/// Extractors are sorted by priority and the first successful extraction is used.
/// </summary>
public sealed class CompositeBoundaryExtractor : IBoundaryProofExtractor
{
private readonly IEnumerable<IBoundaryProofExtractor> _extractors;
private readonly ILogger<CompositeBoundaryExtractor> _logger;
public CompositeBoundaryExtractor(
IEnumerable<IBoundaryProofExtractor> extractors,
ILogger<CompositeBoundaryExtractor> logger)
{
_extractors = extractors ?? throw new ArgumentNullException(nameof(extractors));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public int Priority => int.MaxValue; // Composite has highest priority
/// <inheritdoc />
public bool CanHandle(BoundaryExtractionContext context) => true;
/// <inheritdoc />
public async Task<BoundaryProof?> ExtractAsync(
RichGraphRoot root,
RichGraphNode? rootNode,
BoundaryExtractionContext context,
CancellationToken cancellationToken = default)
{
var sortedExtractors = _extractors
.Where(e => e != this) // Avoid recursion
.Where(e => e.CanHandle(context))
.OrderByDescending(e => e.Priority)
.ToList();
if (sortedExtractors.Count == 0)
{
_logger.LogDebug("No extractors available for context {Source}", context.Source);
return null;
}
foreach (var extractor in sortedExtractors)
{
try
{
cancellationToken.ThrowIfCancellationRequested();
var result = await extractor.ExtractAsync(root, rootNode, context, cancellationToken);
if (result is not null)
{
_logger.LogDebug(
"Boundary extracted by {Extractor} with confidence {Confidence:F2}",
extractor.GetType().Name,
result.Confidence);
return result;
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Extractor {Extractor} failed", extractor.GetType().Name);
// Continue to next extractor
}
}
return null;
}
/// <inheritdoc />
public BoundaryProof? Extract(
RichGraphRoot root,
RichGraphNode? rootNode,
BoundaryExtractionContext context)
{
var sortedExtractors = _extractors
.Where(e => e != this)
.Where(e => e.CanHandle(context))
.OrderByDescending(e => e.Priority)
.ToList();
foreach (var extractor in sortedExtractors)
{
try
{
var result = extractor.Extract(root, rootNode, context);
if (result is not null)
{
return result;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Extractor {Extractor} failed", extractor.GetType().Name);
}
}
return null;
}
}

View File

@@ -0,0 +1,49 @@
// -----------------------------------------------------------------------------
// IBoundaryProofExtractor.cs
// Sprint: SPRINT_3800_0002_0001_boundary_richgraph
// Description: Interface for extracting boundary proofs from various sources.
// -----------------------------------------------------------------------------
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Scanner.SmartDiff.Detection;
namespace StellaOps.Scanner.Reachability.Boundary;
/// <summary>
/// Extracts boundary proof (exposure, auth, controls) from reachability data.
/// </summary>
public interface IBoundaryProofExtractor
{
/// <summary>
/// Extracts boundary proof for a RichGraph root/entrypoint.
/// </summary>
/// <param name="root">The RichGraph root representing the entrypoint.</param>
/// <param name="rootNode">Optional root node with additional metadata.</param>
/// <param name="context">Extraction context with environment hints.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Boundary proof if extractable; otherwise null.</returns>
Task<BoundaryProof?> ExtractAsync(
RichGraphRoot root,
RichGraphNode? rootNode,
BoundaryExtractionContext context,
CancellationToken cancellationToken = default);
/// <summary>
/// Synchronous extraction for contexts where async is not needed.
/// </summary>
BoundaryProof? Extract(
RichGraphRoot root,
RichGraphNode? rootNode,
BoundaryExtractionContext context);
/// <summary>
/// Gets the priority of this extractor (higher = preferred).
/// </summary>
int Priority { get; }
/// <summary>
/// Checks if this extractor can handle the given context.
/// </summary>
bool CanHandle(BoundaryExtractionContext context);
}

View File

@@ -0,0 +1,384 @@
// -----------------------------------------------------------------------------
// RichGraphBoundaryExtractor.cs
// Sprint: SPRINT_3800_0002_0001_boundary_richgraph
// Description: Extracts boundary proof from RichGraph roots and node annotations.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Reachability.Gates;
using StellaOps.Scanner.SmartDiff.Detection;
namespace StellaOps.Scanner.Reachability.Boundary;
/// <summary>
/// Extracts boundary proof from RichGraph roots and node annotations.
/// This is the base extractor that infers exposure from static analysis data.
/// </summary>
public sealed class RichGraphBoundaryExtractor : IBoundaryProofExtractor
{
private readonly ILogger<RichGraphBoundaryExtractor> _logger;
private readonly TimeProvider _timeProvider;
public RichGraphBoundaryExtractor(
ILogger<RichGraphBoundaryExtractor> logger,
TimeProvider? timeProvider = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public int Priority => 100; // Base extractor, lowest priority
/// <inheritdoc />
public bool CanHandle(BoundaryExtractionContext context) => true; // Always handles as fallback
/// <inheritdoc />
public Task<BoundaryProof?> ExtractAsync(
RichGraphRoot root,
RichGraphNode? rootNode,
BoundaryExtractionContext context,
CancellationToken cancellationToken = default)
{
return Task.FromResult(Extract(root, rootNode, context));
}
/// <inheritdoc />
public BoundaryProof? Extract(
RichGraphRoot root,
RichGraphNode? rootNode,
BoundaryExtractionContext context)
{
ArgumentNullException.ThrowIfNull(root);
try
{
var surface = InferSurface(root, rootNode);
var exposure = InferExposure(root, rootNode, context);
var auth = InferAuth(context.DetectedGates, rootNode);
var controls = InferControls(context.DetectedGates);
var confidence = CalculateConfidence(surface, exposure, context);
return new BoundaryProof
{
Kind = InferBoundaryKind(surface),
Surface = surface,
Exposure = exposure,
Auth = auth,
Controls = controls.Count > 0 ? controls : null,
LastSeen = _timeProvider.GetUtcNow(),
Confidence = confidence,
Source = "static_analysis",
EvidenceRef = root.Id
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to extract boundary proof for root {RootId}", root.Id);
return null;
}
}
private BoundarySurface InferSurface(RichGraphRoot root, RichGraphNode? rootNode)
{
var (surfaceType, protocol) = InferSurfaceTypeAndProtocol(root, rootNode);
var port = InferPort(rootNode, protocol);
var path = InferPath(rootNode);
return new BoundarySurface
{
Type = surfaceType,
Protocol = protocol,
Port = port,
Path = path
};
}
private (string type, string? protocol) InferSurfaceTypeAndProtocol(RichGraphRoot root, RichGraphNode? rootNode)
{
var nodeKind = rootNode?.Kind?.ToLowerInvariant() ?? "";
var display = rootNode?.Display?.ToLowerInvariant() ?? "";
var phase = root.Phase?.ToLowerInvariant() ?? "runtime";
// HTTP/HTTPS detection
if (ContainsAny(nodeKind, display, "http", "rest", "api", "web", "controller", "endpoint"))
{
return ("api", "https");
}
// gRPC detection
if (ContainsAny(nodeKind, display, "grpc", "protobuf", "proto"))
{
return ("api", "grpc");
}
// GraphQL detection
if (ContainsAny(nodeKind, display, "graphql", "gql", "query", "mutation"))
{
return ("api", "https");
}
// WebSocket detection
if (ContainsAny(nodeKind, display, "websocket", "ws", "socket"))
{
return ("socket", "wss");
}
// CLI detection
if (ContainsAny(nodeKind, display, "cli", "command", "console", "main"))
{
return ("cli", null);
}
// Scheduled/background detection
if (ContainsAny(nodeKind, display, "scheduled", "cron", "timer", "background", "worker"))
{
return ("scheduled", null);
}
// Library detection
if (phase == "library" || ContainsAny(nodeKind, display, "library", "lib", "internal"))
{
return ("library", null);
}
// Default to API for runtime phase
return phase == "runtime" ? ("api", "https") : ("library", null);
}
private static int? InferPort(RichGraphNode? rootNode, string? protocol)
{
// Try to get port from node attributes
if (rootNode?.Attributes?.TryGetValue("port", out var portStr) == true &&
int.TryParse(portStr, out var port))
{
return port;
}
// Default ports by protocol
return protocol?.ToLowerInvariant() switch
{
"https" => 443,
"http" => 80,
"grpc" => 443,
"wss" => 443,
"ws" => 80,
_ => null
};
}
private static string? InferPath(RichGraphNode? rootNode)
{
// Try to get route from node attributes
if (rootNode?.Attributes?.TryGetValue("route", out var route) == true)
{
return route;
}
if (rootNode?.Attributes?.TryGetValue("path", out var path) == true)
{
return path;
}
return null;
}
private BoundaryExposure InferExposure(
RichGraphRoot root,
RichGraphNode? rootNode,
BoundaryExtractionContext context)
{
// Use context hints if available
var isInternetFacing = context.IsInternetFacing ?? InferInternetFacing(rootNode);
var level = InferExposureLevel(rootNode, isInternetFacing);
var zone = context.NetworkZone ?? InferNetworkZone(isInternetFacing, level);
return new BoundaryExposure
{
Level = level,
InternetFacing = isInternetFacing,
Zone = zone
};
}
private static bool InferInternetFacing(RichGraphNode? rootNode)
{
if (rootNode?.Attributes?.TryGetValue("internet_facing", out var value) == true)
{
return string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
}
// Assume public APIs are internet-facing unless specified otherwise
var kind = rootNode?.Kind?.ToLowerInvariant() ?? "";
return kind.Contains("public") || kind.Contains("external");
}
private static string InferExposureLevel(RichGraphNode? rootNode, bool isInternetFacing)
{
var kind = rootNode?.Kind?.ToLowerInvariant() ?? "";
if (kind.Contains("public") || isInternetFacing)
return "public";
if (kind.Contains("internal"))
return "internal";
if (kind.Contains("private") || kind.Contains("localhost"))
return "private";
// Default to internal for most services
return isInternetFacing ? "public" : "internal";
}
private static string InferNetworkZone(bool isInternetFacing, string level)
{
if (isInternetFacing || level == "public")
return "dmz";
if (level == "internal")
return "internal";
return "trusted";
}
private static BoundaryAuth? InferAuth(IReadOnlyList<DetectedGate>? gates, RichGraphNode? rootNode)
{
var authGates = gates?.Where(g =>
g.Type == GateType.AuthRequired || g.Type == GateType.AdminOnly).ToList();
if (authGates is not { Count: > 0 })
{
// Check node attributes for auth hints
if (rootNode?.Attributes?.TryGetValue("auth", out var authAttr) == true)
{
var required = !string.Equals(authAttr, "none", StringComparison.OrdinalIgnoreCase);
return new BoundaryAuth
{
Required = required,
Type = required ? authAttr : null
};
}
return null;
}
var hasAdminGate = authGates.Any(g => g.Type == GateType.AdminOnly);
var roles = hasAdminGate ? new[] { "admin" } : null;
return new BoundaryAuth
{
Required = true,
Type = InferAuthType(authGates),
Roles = roles
};
}
private static string? InferAuthType(IReadOnlyList<DetectedGate> authGates)
{
var details = authGates
.Select(g => g.Detail.ToLowerInvariant())
.ToList();
if (details.Any(d => d.Contains("jwt")))
return "jwt";
if (details.Any(d => d.Contains("oauth")))
return "oauth2";
if (details.Any(d => d.Contains("api_key") || d.Contains("apikey")))
return "api_key";
if (details.Any(d => d.Contains("basic")))
return "basic";
if (details.Any(d => d.Contains("session")))
return "session";
return "required";
}
private static IReadOnlyList<BoundaryControl> InferControls(IReadOnlyList<DetectedGate>? gates)
{
var controls = new List<BoundaryControl>();
if (gates is null)
return controls;
foreach (var gate in gates)
{
var control = gate.Type switch
{
GateType.FeatureFlag => new BoundaryControl
{
Type = "feature_flag",
Active = true,
Config = gate.Detail,
Effectiveness = "high"
},
GateType.NonDefaultConfig => new BoundaryControl
{
Type = "config_gate",
Active = true,
Config = gate.Detail,
Effectiveness = "medium"
},
_ => null
};
if (control is not null)
{
controls.Add(control);
}
}
return controls;
}
private static string InferBoundaryKind(BoundarySurface surface)
{
return surface.Type switch
{
"api" => "network",
"socket" => "network",
"cli" => "process",
"scheduled" => "process",
"library" => "library",
"file" => "file",
_ => "network"
};
}
private static double CalculateConfidence(
BoundarySurface surface,
BoundaryExposure exposure,
BoundaryExtractionContext context)
{
var baseConfidence = 0.6; // Base confidence for static analysis
// Increase confidence if we have context hints
if (context.IsInternetFacing.HasValue)
baseConfidence += 0.1;
if (!string.IsNullOrEmpty(context.NetworkZone))
baseConfidence += 0.1;
if (context.DetectedGates is { Count: > 0 })
baseConfidence += 0.1;
// Lower confidence for inferred values
if (string.IsNullOrEmpty(surface.Protocol))
baseConfidence -= 0.1;
return Math.Clamp(baseConfidence, 0.1, 0.95);
}
private static bool ContainsAny(string primary, string secondary, params string[] terms)
{
foreach (var term in terms)
{
if (primary.Contains(term, StringComparison.OrdinalIgnoreCase) ||
secondary.Contains(term, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,326 @@
// -----------------------------------------------------------------------------
// PathExplanationModels.cs
// Sprint: SPRINT_3620_0002_0001_path_explanation
// Description: Models for explained reachability paths with gate information.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
using StellaOps.Scanner.Reachability.Gates;
namespace StellaOps.Scanner.Reachability.Explanation;
/// <summary>
/// A fully explained path from entrypoint to vulnerable sink.
/// </summary>
public sealed record ExplainedPath
{
/// <summary>
/// Unique identifier for this path.
/// </summary>
[JsonPropertyName("path_id")]
public required string PathId { get; init; }
/// <summary>
/// Sink node identifier.
/// </summary>
[JsonPropertyName("sink_id")]
public required string SinkId { get; init; }
/// <summary>
/// Sink symbol name.
/// </summary>
[JsonPropertyName("sink_symbol")]
public required string SinkSymbol { get; init; }
/// <summary>
/// Sink category from taxonomy.
/// </summary>
[JsonPropertyName("sink_category")]
public required SinkCategory SinkCategory { get; init; }
/// <summary>
/// Entrypoint node identifier.
/// </summary>
[JsonPropertyName("entrypoint_id")]
public required string EntrypointId { get; init; }
/// <summary>
/// Entrypoint symbol name.
/// </summary>
[JsonPropertyName("entrypoint_symbol")]
public required string EntrypointSymbol { get; init; }
/// <summary>
/// Entrypoint type from root.
/// </summary>
[JsonPropertyName("entrypoint_type")]
public required EntrypointType EntrypointType { get; init; }
/// <summary>
/// Number of hops in the path.
/// </summary>
[JsonPropertyName("path_length")]
public required int PathLength { get; init; }
/// <summary>
/// Ordered list of hops from entrypoint to sink.
/// </summary>
[JsonPropertyName("hops")]
public required IReadOnlyList<ExplainedPathHop> Hops { get; init; }
/// <summary>
/// Gates detected along the path.
/// </summary>
[JsonPropertyName("gates")]
public required IReadOnlyList<DetectedGate> Gates { get; init; }
/// <summary>
/// Combined gate multiplier in basis points (0-10000).
/// </summary>
[JsonPropertyName("gate_multiplier_bps")]
public required int GateMultiplierBps { get; init; }
/// <summary>
/// CVE or vulnerability ID this path leads to.
/// </summary>
[JsonPropertyName("vulnerability_id")]
public string? VulnerabilityId { get; init; }
/// <summary>
/// PURL of the affected component.
/// </summary>
[JsonPropertyName("affected_purl")]
public string? AffectedPurl { get; init; }
}
/// <summary>
/// A single hop in an explained path.
/// </summary>
public sealed record ExplainedPathHop
{
/// <summary>
/// Node identifier.
/// </summary>
[JsonPropertyName("node_id")]
public required string NodeId { get; init; }
/// <summary>
/// Symbol name (method/function).
/// </summary>
[JsonPropertyName("symbol")]
public required string Symbol { get; init; }
/// <summary>
/// Source file path (if available).
/// </summary>
[JsonPropertyName("file")]
public string? File { get; init; }
/// <summary>
/// Line number in source file (if available).
/// </summary>
[JsonPropertyName("line")]
public int? Line { get; init; }
/// <summary>
/// Package name.
/// </summary>
[JsonPropertyName("package")]
public required string Package { get; init; }
/// <summary>
/// Programming language.
/// </summary>
[JsonPropertyName("language")]
public string? Language { get; init; }
/// <summary>
/// Call site information (if available).
/// </summary>
[JsonPropertyName("call_site")]
public string? CallSite { get; init; }
/// <summary>
/// Gates at this hop (edge-level).
/// </summary>
[JsonPropertyName("gates")]
public IReadOnlyList<DetectedGate>? Gates { get; init; }
/// <summary>
/// Distance from entrypoint (0 = entrypoint).
/// </summary>
[JsonPropertyName("depth")]
public int Depth { get; init; }
/// <summary>
/// Whether this is the entrypoint.
/// </summary>
[JsonPropertyName("is_entrypoint")]
public bool IsEntrypoint { get; init; }
/// <summary>
/// Whether this is the sink.
/// </summary>
[JsonPropertyName("is_sink")]
public bool IsSink { get; init; }
}
/// <summary>
/// Type of entrypoint.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<EntrypointType>))]
public enum EntrypointType
{
/// <summary>HTTP/REST endpoint.</summary>
HttpEndpoint,
/// <summary>gRPC method.</summary>
GrpcMethod,
/// <summary>GraphQL resolver.</summary>
GraphQlResolver,
/// <summary>CLI command handler.</summary>
CliCommand,
/// <summary>Message queue handler.</summary>
MessageHandler,
/// <summary>Scheduled job/cron handler.</summary>
ScheduledJob,
/// <summary>Event handler.</summary>
EventHandler,
/// <summary>WebSocket handler.</summary>
WebSocketHandler,
/// <summary>Public API method.</summary>
PublicApi,
/// <summary>Unknown entrypoint type.</summary>
Unknown
}
/// <summary>
/// Category of vulnerable sink.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<SinkCategory>))]
public enum SinkCategory
{
/// <summary>SQL query execution.</summary>
SqlRaw,
/// <summary>Command execution.</summary>
CommandExec,
/// <summary>File system access.</summary>
FileAccess,
/// <summary>Network/HTTP client.</summary>
NetworkClient,
/// <summary>Deserialization.</summary>
Deserialization,
/// <summary>Path traversal sensitive.</summary>
PathTraversal,
/// <summary>Cryptography weakness.</summary>
CryptoWeakness,
/// <summary>SSRF sensitive.</summary>
Ssrf,
/// <summary>XXE sensitive.</summary>
Xxe,
/// <summary>LDAP injection.</summary>
LdapInjection,
/// <summary>XPath injection.</summary>
XPathInjection,
/// <summary>Log injection.</summary>
LogInjection,
/// <summary>Template injection.</summary>
TemplateInjection,
/// <summary>Other sink category.</summary>
Other
}
/// <summary>
/// Path explanation query parameters.
/// </summary>
public sealed record PathExplanationQuery
{
/// <summary>
/// Filter by vulnerability ID.
/// </summary>
public string? VulnerabilityId { get; init; }
/// <summary>
/// Filter by sink ID.
/// </summary>
public string? SinkId { get; init; }
/// <summary>
/// Filter by entrypoint ID.
/// </summary>
public string? EntrypointId { get; init; }
/// <summary>
/// Maximum path length to return.
/// </summary>
public int? MaxPathLength { get; init; }
/// <summary>
/// Include only paths with gates.
/// </summary>
public bool? HasGates { get; init; }
/// <summary>
/// Maximum number of paths to return.
/// </summary>
public int MaxPaths { get; init; } = 10;
}
/// <summary>
/// Result of path explanation.
/// </summary>
public sealed record PathExplanationResult
{
/// <summary>
/// Explained paths matching the query.
/// </summary>
[JsonPropertyName("paths")]
public required IReadOnlyList<ExplainedPath> Paths { get; init; }
/// <summary>
/// Total count of paths (before limiting).
/// </summary>
[JsonPropertyName("total_count")]
public required int TotalCount { get; init; }
/// <summary>
/// Whether more paths are available.
/// </summary>
[JsonPropertyName("has_more")]
public bool HasMore { get; init; }
/// <summary>
/// Graph hash for provenance.
/// </summary>
[JsonPropertyName("graph_hash")]
public string? GraphHash { get; init; }
/// <summary>
/// When the explanation was generated.
/// </summary>
[JsonPropertyName("generated_at")]
public DateTimeOffset GeneratedAt { get; init; } = DateTimeOffset.UtcNow;
}

View File

@@ -0,0 +1,429 @@
// -----------------------------------------------------------------------------
// PathExplanationService.cs
// Sprint: SPRINT_3620_0002_0001_path_explanation
// Description: Service for reconstructing and explaining reachability paths.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Reachability.Gates;
namespace StellaOps.Scanner.Reachability.Explanation;
/// <summary>
/// Interface for path explanation service.
/// </summary>
public interface IPathExplanationService
{
/// <summary>
/// Explains paths from a RichGraph to a specific sink or vulnerability.
/// </summary>
Task<PathExplanationResult> ExplainAsync(
RichGraph graph,
PathExplanationQuery query,
CancellationToken cancellationToken = default);
/// <summary>
/// Explains a single path by its ID.
/// </summary>
Task<ExplainedPath?> ExplainPathAsync(
RichGraph graph,
string pathId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Default implementation of <see cref="IPathExplanationService"/>.
/// Reconstructs paths from RichGraph and provides user-friendly explanations.
/// </summary>
public sealed class PathExplanationService : IPathExplanationService
{
private readonly ILogger<PathExplanationService> _logger;
private readonly TimeProvider _timeProvider;
public PathExplanationService(
ILogger<PathExplanationService> logger,
TimeProvider? timeProvider = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc/>
public Task<PathExplanationResult> ExplainAsync(
RichGraph graph,
PathExplanationQuery query,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(graph);
query ??= new PathExplanationQuery();
var allPaths = new List<ExplainedPath>();
// Build node lookup
var nodeLookup = graph.Nodes.ToDictionary(n => n.Id);
var edgeLookup = BuildEdgeLookup(graph);
// Find paths from each root to sinks
foreach (var root in graph.Roots)
{
cancellationToken.ThrowIfCancellationRequested();
var rootNode = nodeLookup.GetValueOrDefault(root.Id);
if (rootNode is null) continue;
var sinkNodes = graph.Nodes.Where(n => IsSink(n)).ToList();
foreach (var sink in sinkNodes)
{
// Apply query filters
if (query.SinkId is not null && sink.Id != query.SinkId)
continue;
var paths = FindPaths(
rootNode, sink, nodeLookup, edgeLookup,
query.MaxPathLength ?? 20);
foreach (var path in paths)
{
var explained = BuildExplainedPath(
root, rootNode, sink, path, edgeLookup);
// Apply gate filter
if (query.HasGates == true && explained.Gates.Count == 0)
continue;
allPaths.Add(explained);
}
}
}
// Sort by path length, then by gate multiplier (higher = more protected)
var sortedPaths = allPaths
.OrderBy(p => p.PathLength)
.ThenByDescending(p => p.GateMultiplierBps)
.ToList();
var totalCount = sortedPaths.Count;
var limitedPaths = sortedPaths.Take(query.MaxPaths).ToList();
var result = new PathExplanationResult
{
Paths = limitedPaths,
TotalCount = totalCount,
HasMore = totalCount > query.MaxPaths,
GraphHash = null, // RichGraph does not have a Meta property; hash is computed at serialization
GeneratedAt = _timeProvider.GetUtcNow()
};
return Task.FromResult(result);
}
/// <inheritdoc/>
public Task<ExplainedPath?> ExplainPathAsync(
RichGraph graph,
string pathId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(graph);
// Path ID format: {rootId}:{sinkId}:{pathIndex}
var parts = pathId?.Split(':');
if (parts is not { Length: >= 2 })
{
return Task.FromResult<ExplainedPath?>(null);
}
var query = new PathExplanationQuery
{
EntrypointId = parts[0],
SinkId = parts[1],
MaxPaths = 100
};
var resultTask = ExplainAsync(graph, query, cancellationToken);
return resultTask.ContinueWith(t =>
{
if (t.Result.Paths.Count == 0)
return null;
// If path index specified, return that specific one
if (parts.Length >= 3 && int.TryParse(parts[2], out var idx) && idx < t.Result.Paths.Count)
{
return t.Result.Paths[idx];
}
return t.Result.Paths[0];
}, cancellationToken);
}
private static Dictionary<string, List<RichGraphEdge>> BuildEdgeLookup(RichGraph graph)
{
var lookup = new Dictionary<string, List<RichGraphEdge>>();
foreach (var edge in graph.Edges)
{
if (!lookup.TryGetValue(edge.From, out var edges))
{
edges = new List<RichGraphEdge>();
lookup[edge.From] = edges;
}
edges.Add(edge);
}
return lookup;
}
private static bool IsSink(RichGraphNode node)
{
// Check if node has sink-like characteristics
return node.Kind?.Contains("sink", StringComparison.OrdinalIgnoreCase) == true
|| node.Attributes?.ContainsKey("is_sink") == true;
}
private List<List<RichGraphNode>> FindPaths(
RichGraphNode start,
RichGraphNode end,
Dictionary<string, RichGraphNode> nodeLookup,
Dictionary<string, List<RichGraphEdge>> edgeLookup,
int maxLength)
{
var paths = new List<List<RichGraphNode>>();
var currentPath = new List<RichGraphNode> { start };
var visited = new HashSet<string> { start.Id };
FindPathsDfs(start, end, currentPath, visited, paths, nodeLookup, edgeLookup, maxLength);
return paths;
}
private void FindPathsDfs(
RichGraphNode current,
RichGraphNode target,
List<RichGraphNode> currentPath,
HashSet<string> visited,
List<List<RichGraphNode>> foundPaths,
Dictionary<string, RichGraphNode> nodeLookup,
Dictionary<string, List<RichGraphEdge>> edgeLookup,
int maxLength)
{
if (currentPath.Count > maxLength)
return;
if (current.Id == target.Id)
{
foundPaths.Add(new List<RichGraphNode>(currentPath));
return;
}
if (!edgeLookup.TryGetValue(current.Id, out var outEdges))
return;
foreach (var edge in outEdges)
{
if (visited.Contains(edge.To))
continue;
if (!nodeLookup.TryGetValue(edge.To, out var nextNode))
continue;
visited.Add(edge.To);
currentPath.Add(nextNode);
FindPathsDfs(nextNode, target, currentPath, visited, foundPaths,
nodeLookup, edgeLookup, maxLength);
currentPath.RemoveAt(currentPath.Count - 1);
visited.Remove(edge.To);
}
}
private ExplainedPath BuildExplainedPath(
RichGraphRoot root,
RichGraphNode rootNode,
RichGraphNode sinkNode,
List<RichGraphNode> path,
Dictionary<string, List<RichGraphEdge>> edgeLookup)
{
var hops = new List<ExplainedPathHop>();
var allGates = new List<DetectedGate>();
for (var i = 0; i < path.Count; i++)
{
var node = path[i];
var isFirst = i == 0;
var isLast = i == path.Count - 1;
// Get edge gates
IReadOnlyList<DetectedGate>? edgeGates = null;
if (i < path.Count - 1)
{
var edge = GetEdge(path[i].Id, path[i + 1].Id, edgeLookup);
if (edge?.Gates is not null)
{
edgeGates = edge.Gates;
allGates.AddRange(edge.Gates);
}
}
hops.Add(new ExplainedPathHop
{
NodeId = node.Id,
Symbol = node.Display ?? node.SymbolId ?? node.Id,
File = GetNodeFile(node),
Line = GetNodeLine(node),
Package = GetNodePackage(node),
Language = node.Lang,
CallSite = GetCallSite(node),
Gates = edgeGates,
Depth = i,
IsEntrypoint = isFirst,
IsSink = isLast
});
}
// Calculate combined gate multiplier
var multiplierBps = CalculateGateMultiplier(allGates);
return new ExplainedPath
{
PathId = $"{rootNode.Id}:{sinkNode.Id}:{0}",
SinkId = sinkNode.Id,
SinkSymbol = sinkNode.Display ?? sinkNode.SymbolId ?? sinkNode.Id,
SinkCategory = InferSinkCategory(sinkNode),
EntrypointId = rootNode.Id,
EntrypointSymbol = rootNode.Display ?? rootNode.SymbolId ?? rootNode.Id,
EntrypointType = InferEntrypointType(root, rootNode),
PathLength = path.Count,
Hops = hops,
Gates = allGates,
GateMultiplierBps = multiplierBps
};
}
private static RichGraphEdge? GetEdge(string from, string to, Dictionary<string, List<RichGraphEdge>> edgeLookup)
{
if (!edgeLookup.TryGetValue(from, out var edges))
return null;
return edges.FirstOrDefault(e => e.To == to);
}
private static string? GetNodeFile(RichGraphNode node)
{
if (node.Attributes?.TryGetValue("file", out var file) == true)
return file;
if (node.Attributes?.TryGetValue("source_file", out file) == true)
return file;
return null;
}
private static int? GetNodeLine(RichGraphNode node)
{
if (node.Attributes?.TryGetValue("line", out var line) == true &&
int.TryParse(line, out var lineNum))
return lineNum;
return null;
}
private static string GetNodePackage(RichGraphNode node)
{
if (node.Purl is not null)
{
// Extract package name from PURL
var purl = node.Purl;
var nameStart = purl.LastIndexOf('/') + 1;
var nameEnd = purl.IndexOf('@', nameStart);
if (nameEnd < 0) nameEnd = purl.Length;
return purl.Substring(nameStart, nameEnd - nameStart);
}
if (node.Attributes?.TryGetValue("package", out var pkg) == true)
return pkg;
return node.SymbolId?.Split('.').FirstOrDefault() ?? "unknown";
}
private static string? GetCallSite(RichGraphNode node)
{
if (node.Attributes?.TryGetValue("call_site", out var site) == true)
return site;
return null;
}
private static SinkCategory InferSinkCategory(RichGraphNode node)
{
var kind = node.Kind?.ToLowerInvariant() ?? "";
var symbol = (node.SymbolId ?? "").ToLowerInvariant();
if (kind.Contains("sql") || symbol.Contains("query") || symbol.Contains("execute"))
return SinkCategory.SqlRaw;
if (kind.Contains("exec") || symbol.Contains("command") || symbol.Contains("process"))
return SinkCategory.CommandExec;
if (kind.Contains("file") || symbol.Contains("write") || symbol.Contains("read"))
return SinkCategory.FileAccess;
if (kind.Contains("http") || symbol.Contains("request"))
return SinkCategory.NetworkClient;
if (kind.Contains("deserialize") || symbol.Contains("deserialize"))
return SinkCategory.Deserialization;
if (kind.Contains("path"))
return SinkCategory.PathTraversal;
return SinkCategory.Other;
}
private static EntrypointType InferEntrypointType(RichGraphRoot root, RichGraphNode node)
{
var phase = root.Phase?.ToLowerInvariant() ?? "";
var kind = node.Kind?.ToLowerInvariant() ?? "";
var display = (node.Display ?? "").ToLowerInvariant();
if (kind.Contains("http") || display.Contains("get ") || display.Contains("post "))
return EntrypointType.HttpEndpoint;
if (kind.Contains("grpc"))
return EntrypointType.GrpcMethod;
if (kind.Contains("graphql"))
return EntrypointType.GraphQlResolver;
if (kind.Contains("cli") || kind.Contains("command"))
return EntrypointType.CliCommand;
if (kind.Contains("message") || kind.Contains("handler"))
return EntrypointType.MessageHandler;
if (kind.Contains("scheduled") || kind.Contains("cron"))
return EntrypointType.ScheduledJob;
if (kind.Contains("websocket"))
return EntrypointType.WebSocketHandler;
if (phase == "library" || kind.Contains("public"))
return EntrypointType.PublicApi;
return EntrypointType.Unknown;
}
private static int CalculateGateMultiplier(List<DetectedGate> gates)
{
if (gates.Count == 0)
return 10000; // 100% (no reduction)
// Apply gates multiplicatively
var multiplier = 10000.0; // Start at 100% in basis points
foreach (var gate in gates.DistinctBy(g => g.Type))
{
var gateMultiplier = gate.Type switch
{
GateType.AuthRequired => 3000, // 30%
GateType.FeatureFlag => 5000, // 50%
GateType.AdminOnly => 2000, // 20%
GateType.NonDefaultConfig => 7000, // 70%
_ => 10000
};
multiplier = multiplier * gateMultiplier / 10000;
}
return (int)Math.Round(multiplier);
}
}

View File

@@ -0,0 +1,286 @@
// -----------------------------------------------------------------------------
// PathRenderer.cs
// Sprint: SPRINT_3620_0002_0001_path_explanation
// Description: Renders explained paths in various output formats.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Scanner.Reachability.Gates;
namespace StellaOps.Scanner.Reachability.Explanation;
/// <summary>
/// Output format for path rendering.
/// </summary>
public enum PathOutputFormat
{
/// <summary>Plain text format.</summary>
Text,
/// <summary>Markdown format.</summary>
Markdown,
/// <summary>JSON format.</summary>
Json
}
/// <summary>
/// Interface for path rendering.
/// </summary>
public interface IPathRenderer
{
/// <summary>
/// Renders an explained path in the specified format.
/// </summary>
string Render(ExplainedPath path, PathOutputFormat format);
/// <summary>
/// Renders multiple explained paths in the specified format.
/// </summary>
string RenderMany(IReadOnlyList<ExplainedPath> paths, PathOutputFormat format);
/// <summary>
/// Renders a path explanation result in the specified format.
/// </summary>
string RenderResult(PathExplanationResult result, PathOutputFormat format);
}
/// <summary>
/// Default implementation of <see cref="IPathRenderer"/>.
/// </summary>
public sealed class PathRenderer : IPathRenderer
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
/// <inheritdoc/>
public string Render(ExplainedPath path, PathOutputFormat format)
{
return format switch
{
PathOutputFormat.Text => RenderText(path),
PathOutputFormat.Markdown => RenderMarkdown(path),
PathOutputFormat.Json => RenderJson(path),
_ => throw new ArgumentOutOfRangeException(nameof(format))
};
}
/// <inheritdoc/>
public string RenderMany(IReadOnlyList<ExplainedPath> paths, PathOutputFormat format)
{
return format switch
{
PathOutputFormat.Text => RenderManyText(paths),
PathOutputFormat.Markdown => RenderManyMarkdown(paths),
PathOutputFormat.Json => RenderManyJson(paths),
_ => throw new ArgumentOutOfRangeException(nameof(format))
};
}
/// <inheritdoc/>
public string RenderResult(PathExplanationResult result, PathOutputFormat format)
{
return format switch
{
PathOutputFormat.Text => RenderResultText(result),
PathOutputFormat.Markdown => RenderResultMarkdown(result),
PathOutputFormat.Json => JsonSerializer.Serialize(result, JsonOptions),
_ => throw new ArgumentOutOfRangeException(nameof(format))
};
}
#region Text Rendering
private static string RenderText(ExplainedPath path)
{
var sb = new StringBuilder();
// Header
sb.AppendLine($"{path.EntrypointType}: {path.EntrypointSymbol}");
// Hops
foreach (var hop in path.Hops)
{
var prefix = hop.IsEntrypoint ? " " : " → ";
var location = hop.File is not null && hop.Line.HasValue
? $" ({hop.File}:{hop.Line})"
: "";
var sinkMarker = hop.IsSink ? $" [SINK: {path.SinkCategory}]" : "";
sb.AppendLine($"{prefix}{hop.Symbol}{location}{sinkMarker}");
}
// Gates summary
if (path.Gates.Count > 0)
{
sb.AppendLine();
var gatesSummary = string.Join(", ", path.Gates.Select(FormatGateText));
sb.AppendLine($"Gates: {gatesSummary}");
var percentage = path.GateMultiplierBps / 100.0;
sb.AppendLine($"Final multiplier: {percentage:F0}%");
}
return sb.ToString();
}
private static string RenderManyText(IReadOnlyList<ExplainedPath> paths)
{
var sb = new StringBuilder();
sb.AppendLine($"Found {paths.Count} path(s):");
sb.AppendLine(new string('=', 60));
for (var i = 0; i < paths.Count; i++)
{
if (i > 0) sb.AppendLine(new string('-', 60));
sb.AppendLine($"Path {i + 1}:");
sb.Append(RenderText(paths[i]));
}
return sb.ToString();
}
private static string RenderResultText(PathExplanationResult result)
{
var sb = new StringBuilder();
sb.AppendLine($"Path Explanation Result");
sb.AppendLine($"Total paths: {result.TotalCount}");
sb.AppendLine($"Showing: {result.Paths.Count}");
if (result.GraphHash is not null)
sb.AppendLine($"Graph: {result.GraphHash}");
sb.AppendLine($"Generated: {result.GeneratedAt:u}");
sb.AppendLine();
sb.Append(RenderManyText(result.Paths.ToList()));
return sb.ToString();
}
private static string FormatGateText(DetectedGate gate)
{
var multiplier = gate.Type switch
{
GateType.AuthRequired => "30%",
GateType.FeatureFlag => "50%",
GateType.AdminOnly => "20%",
GateType.NonDefaultConfig => "70%",
_ => "100%"
};
return $"{gate.Detail} ({gate.Type.ToString().ToLowerInvariant()}, {multiplier})";
}
#endregion
#region Markdown Rendering
private static string RenderMarkdown(ExplainedPath path)
{
var sb = new StringBuilder();
// Header
sb.AppendLine($"### {path.EntrypointType}: `{path.EntrypointSymbol}`");
sb.AppendLine();
// Path as a code block
sb.AppendLine("```");
foreach (var hop in path.Hops)
{
var arrow = hop.IsEntrypoint ? "" : "→ ";
var location = hop.File is not null && hop.Line.HasValue
? $" ({hop.File}:{hop.Line})"
: "";
var sinkMarker = hop.IsSink ? $" [SINK: {path.SinkCategory}]" : "";
sb.AppendLine($"{arrow}{hop.Symbol}{location}{sinkMarker}");
}
sb.AppendLine("```");
sb.AppendLine();
// Gates table
if (path.Gates.Count > 0)
{
sb.AppendLine("**Gates:**");
sb.AppendLine();
sb.AppendLine("| Type | Detail | Multiplier |");
sb.AppendLine("|------|--------|------------|");
foreach (var gate in path.Gates)
{
var multiplier = gate.Type switch
{
GateType.AuthRequired => "30%",
GateType.FeatureFlag => "50%",
GateType.AdminOnly => "20%",
GateType.NonDefaultConfig => "70%",
_ => "100%"
};
sb.AppendLine($"| {gate.Type} | {gate.Detail} | {multiplier} |");
}
sb.AppendLine();
var percentage = path.GateMultiplierBps / 100.0;
sb.AppendLine($"**Final multiplier:** {percentage:F0}%");
}
return sb.ToString();
}
private static string RenderManyMarkdown(IReadOnlyList<ExplainedPath> paths)
{
var sb = new StringBuilder();
sb.AppendLine($"## Reachability Paths ({paths.Count} found)");
sb.AppendLine();
for (var i = 0; i < paths.Count; i++)
{
sb.AppendLine($"---");
sb.AppendLine($"#### Path {i + 1}");
sb.AppendLine();
sb.Append(RenderMarkdown(paths[i]));
sb.AppendLine();
}
return sb.ToString();
}
private static string RenderResultMarkdown(PathExplanationResult result)
{
var sb = new StringBuilder();
sb.AppendLine("# Path Explanation Result");
sb.AppendLine();
sb.AppendLine($"- **Total paths:** {result.TotalCount}");
sb.AppendLine($"- **Showing:** {result.Paths.Count}");
if (result.HasMore)
sb.AppendLine($"- **More available:** Yes");
if (result.GraphHash is not null)
sb.AppendLine($"- **Graph hash:** `{result.GraphHash}`");
sb.AppendLine($"- **Generated:** {result.GeneratedAt:u}");
sb.AppendLine();
sb.Append(RenderManyMarkdown(result.Paths.ToList()));
return sb.ToString();
}
#endregion
#region JSON Rendering
private static string RenderJson(ExplainedPath path)
{
return JsonSerializer.Serialize(path, JsonOptions);
}
private static string RenderManyJson(IReadOnlyList<ExplainedPath> paths)
{
return JsonSerializer.Serialize(new { paths }, JsonOptions);
}
#endregion
}

View File

@@ -7,6 +7,7 @@
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Cache\StellaOps.Scanner.Cache.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.Analyzers.Native\StellaOps.Scanner.Analyzers.Native.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Replay.Core\StellaOps.Replay.Core.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />

View File

@@ -0,0 +1,229 @@
// -----------------------------------------------------------------------------
// EpssProvider.cs
// Sprint: SPRINT_3410_0002_0001_epss_scanner_integration
// Task: EPSS-SCAN-004
// Description: PostgreSQL-backed EPSS provider implementation.
// -----------------------------------------------------------------------------
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Core.Epss;
using StellaOps.Scanner.Storage.Epss;
using StellaOps.Scanner.Storage.Repositories;
namespace StellaOps.Scanner.Storage.Epss;
/// <summary>
/// PostgreSQL-backed implementation of <see cref="IEpssProvider"/>.
/// Provides EPSS score lookups with optional caching.
/// </summary>
public sealed class EpssProvider : IEpssProvider
{
private readonly IEpssRepository _repository;
private readonly EpssProviderOptions _options;
private readonly ILogger<EpssProvider> _logger;
private readonly TimeProvider _timeProvider;
public EpssProvider(
IEpssRepository repository,
IOptions<EpssProviderOptions> options,
ILogger<EpssProvider> logger,
TimeProvider? timeProvider = null)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<EpssEvidence?> GetCurrentAsync(string cveId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
var results = await _repository.GetCurrentAsync(new[] { cveId }, cancellationToken).ConfigureAwait(false);
if (!results.TryGetValue(cveId, out var entry))
{
_logger.LogDebug("EPSS score not found for {CveId}", cveId);
return null;
}
return MapToEvidence(cveId, entry, fromCache: false);
}
public async Task<EpssBatchResult> GetCurrentBatchAsync(
IEnumerable<string> cveIds,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(cveIds);
var cveIdList = cveIds.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
if (cveIdList.Count == 0)
{
return new EpssBatchResult
{
Found = Array.Empty<EpssEvidence>(),
NotFound = Array.Empty<string>(),
ModelDate = DateOnly.FromDateTime(_timeProvider.GetUtcNow().Date),
LookupTimeMs = 0
};
}
// Enforce max batch size
if (cveIdList.Count > _options.MaxBatchSize)
{
_logger.LogWarning(
"Batch size {BatchSize} exceeds maximum {MaxBatchSize}, truncating",
cveIdList.Count,
_options.MaxBatchSize);
cveIdList = cveIdList.Take(_options.MaxBatchSize).ToList();
}
var sw = Stopwatch.StartNew();
var results = await _repository.GetCurrentAsync(cveIdList, cancellationToken).ConfigureAwait(false);
sw.Stop();
var found = new List<EpssEvidence>(results.Count);
var notFound = new List<string>();
DateOnly? modelDate = null;
foreach (var cveId in cveIdList)
{
if (results.TryGetValue(cveId, out var entry))
{
found.Add(MapToEvidence(cveId, entry, fromCache: false));
modelDate ??= entry.ModelDate;
}
else
{
notFound.Add(cveId);
}
}
_logger.LogDebug(
"EPSS batch lookup: {Found}/{Total} found in {ElapsedMs}ms",
found.Count,
cveIdList.Count,
sw.ElapsedMilliseconds);
return new EpssBatchResult
{
Found = found,
NotFound = notFound,
ModelDate = modelDate ?? DateOnly.FromDateTime(_timeProvider.GetUtcNow().Date),
LookupTimeMs = sw.ElapsedMilliseconds,
PartiallyFromCache = false
};
}
public async Task<EpssEvidence?> GetAsOfDateAsync(
string cveId,
DateOnly asOfDate,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
// Get history for just that date
var history = await _repository.GetHistoryAsync(cveId, 1, cancellationToken).ConfigureAwait(false);
// Find the entry closest to (but not after) the requested date
var entry = history
.Where(e => e.ModelDate <= asOfDate)
.OrderByDescending(e => e.ModelDate)
.FirstOrDefault();
if (entry is null)
{
_logger.LogDebug("EPSS score not found for {CveId} as of {AsOfDate}", cveId, asOfDate);
return null;
}
return new EpssEvidence
{
CveId = cveId,
Score = entry.Score,
Percentile = entry.Percentile,
ModelDate = entry.ModelDate,
CapturedAt = _timeProvider.GetUtcNow(),
Source = _options.SourceIdentifier,
FromCache = false
};
}
public async Task<IReadOnlyList<EpssEvidence>> GetHistoryAsync(
string cveId,
DateOnly startDate,
DateOnly endDate,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
var days = endDate.DayNumber - startDate.DayNumber + 1;
if (days <= 0)
{
return Array.Empty<EpssEvidence>();
}
var history = await _repository.GetHistoryAsync(cveId, days, cancellationToken).ConfigureAwait(false);
return history
.Where(e => e.ModelDate >= startDate && e.ModelDate <= endDate)
.OrderBy(e => e.ModelDate)
.Select(e => new EpssEvidence
{
CveId = cveId,
Score = e.Score,
Percentile = e.Percentile,
ModelDate = e.ModelDate,
CapturedAt = _timeProvider.GetUtcNow(),
Source = _options.SourceIdentifier,
FromCache = false
})
.ToList();
}
public async Task<DateOnly?> GetLatestModelDateAsync(CancellationToken cancellationToken = default)
{
// Get any CVE to determine the latest model date
// This is a heuristic - in production, we'd have a metadata table
var results = await _repository.GetCurrentAsync(
new[] { "CVE-2021-44228" }, // Log4Shell - almost certainly in any EPSS dataset
cancellationToken).ConfigureAwait(false);
if (results.Count > 0)
{
return results.Values.First().ModelDate;
}
return null;
}
public async Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default)
{
try
{
var modelDate = await GetLatestModelDateAsync(cancellationToken).ConfigureAwait(false);
return modelDate.HasValue;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "EPSS provider availability check failed");
return false;
}
}
private EpssEvidence MapToEvidence(string cveId, EpssCurrentEntry entry, bool fromCache)
{
return new EpssEvidence
{
CveId = cveId,
Score = entry.Score,
Percentile = entry.Percentile,
ModelDate = entry.ModelDate,
CapturedAt = _timeProvider.GetUtcNow(),
Source = _options.SourceIdentifier,
FromCache = fromCache
};
}
}

View File

@@ -88,7 +88,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<IEpssRepository, PostgresEpssRepository>();
services.AddSingleton<EpssOnlineSource>();
services.AddSingleton<EpssBundleSource>();
services.AddSingleton<EpssChangeDetector>();
// Note: EpssChangeDetector is a static class, no DI registration needed
// Witness storage (Sprint: SPRINT_3700_0001_0001)
services.AddScoped<IWitnessRepository, PostgresWitnessRepository>();

View File

@@ -18,6 +18,8 @@ namespace StellaOps.Scanner.Storage.Repositories;
/// </summary>
public sealed class PostgresWitnessRepository : IWitnessRepository
{
private const string TenantContext = "00000000-0000-0000-0000-000000000001";
private readonly ScannerDataSource _dataSource;
private readonly ILogger<PostgresWitnessRepository> _logger;
@@ -48,7 +50,7 @@ public sealed class PostgresWitnessRepository : IWitnessRepository
RETURNING witness_id
""";
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var conn = await _dataSource.OpenConnectionAsync(TenantContext, cancellationToken).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("witness_hash", witness.WitnessHash);
@@ -82,7 +84,7 @@ public sealed class PostgresWitnessRepository : IWitnessRepository
WHERE witness_id = @witness_id
""";
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var conn = await _dataSource.OpenConnectionAsync(TenantContext, cancellationToken).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("witness_id", witnessId);
@@ -107,7 +109,7 @@ public sealed class PostgresWitnessRepository : IWitnessRepository
WHERE witness_hash = @witness_hash
""";
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var conn = await _dataSource.OpenConnectionAsync(TenantContext, cancellationToken).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("witness_hash", witnessHash);
@@ -133,7 +135,7 @@ public sealed class PostgresWitnessRepository : IWitnessRepository
ORDER BY created_at DESC
""";
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var conn = await _dataSource.OpenConnectionAsync(TenantContext, cancellationToken).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("graph_hash", graphHash);
@@ -158,7 +160,7 @@ public sealed class PostgresWitnessRepository : IWitnessRepository
ORDER BY created_at DESC
""";
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var conn = await _dataSource.OpenConnectionAsync(TenantContext, cancellationToken).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("scan_id", scanId);
@@ -185,7 +187,7 @@ public sealed class PostgresWitnessRepository : IWitnessRepository
ORDER BY created_at DESC
""";
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var conn = await _dataSource.OpenConnectionAsync(TenantContext, cancellationToken).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("sink_cve", cveId);
@@ -211,7 +213,7 @@ public sealed class PostgresWitnessRepository : IWitnessRepository
WHERE witness_id = @witness_id
""";
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var conn = await _dataSource.OpenConnectionAsync(TenantContext, cancellationToken).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("witness_id", witnessId);
cmd.Parameters.AddWithValue("dsse_envelope", dsseEnvelopeJson);
@@ -239,7 +241,7 @@ public sealed class PostgresWitnessRepository : IWitnessRepository
)
""";
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var conn = await _dataSource.OpenConnectionAsync(TenantContext, cancellationToken).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("witness_id", verification.WitnessId);
cmd.Parameters.AddWithValue("verified_at", verification.VerifiedAt == default ? DateTimeOffset.UtcNow : verification.VerifiedAt);

View File

@@ -0,0 +1,133 @@
// -----------------------------------------------------------------------------
// InternalCallGraphTests.cs
// Sprint: SPRINT_3700_0003_0001_trigger_extraction
// Description: Unit tests for InternalCallGraph.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.VulnSurfaces.CallGraph;
using StellaOps.Scanner.VulnSurfaces.Models;
using Xunit;
namespace StellaOps.Scanner.VulnSurfaces.Tests;
public class InternalCallGraphTests
{
[Fact]
public void AddMethod_StoresMethod()
{
// Arrange
var graph = new InternalCallGraph
{
PackageId = "TestPackage",
Version = "1.0.0"
};
var method = new InternalMethodRef
{
MethodKey = "Namespace.Class::Method()",
Name = "Method",
DeclaringType = "Namespace.Class",
IsPublic = true
};
// Act
graph.AddMethod(method);
// Assert
Assert.True(graph.ContainsMethod("Namespace.Class::Method()"));
Assert.Equal(1, graph.MethodCount);
}
[Fact]
public void AddEdge_CreatesForwardAndReverseMapping()
{
// Arrange
var graph = new InternalCallGraph
{
PackageId = "TestPackage",
Version = "1.0.0"
};
var edge = new InternalCallEdge
{
Caller = "A::M1()",
Callee = "A::M2()"
};
// Act
graph.AddEdge(edge);
// Assert
Assert.Contains("A::M2()", graph.GetCallees("A::M1()"));
Assert.Contains("A::M1()", graph.GetCallers("A::M2()"));
Assert.Equal(1, graph.EdgeCount);
}
[Fact]
public void GetPublicMethods_ReturnsOnlyPublic()
{
// Arrange
var graph = new InternalCallGraph
{
PackageId = "TestPackage",
Version = "1.0.0"
};
graph.AddMethod(new InternalMethodRef
{
MethodKey = "A::Public()",
Name = "Public",
DeclaringType = "A",
IsPublic = true
});
graph.AddMethod(new InternalMethodRef
{
MethodKey = "A::Private()",
Name = "Private",
DeclaringType = "A",
IsPublic = false
});
// Act
var publicMethods = graph.GetPublicMethods().ToList();
// Assert
Assert.Single(publicMethods);
Assert.Equal("A::Public()", publicMethods[0].MethodKey);
}
[Fact]
public void GetCallees_EmptyForUnknownMethod()
{
// Arrange
var graph = new InternalCallGraph
{
PackageId = "TestPackage",
Version = "1.0.0"
};
// Act
var callees = graph.GetCallees("Unknown::Method()");
// Assert
Assert.Empty(callees);
}
[Fact]
public void GetMethod_ReturnsNullForUnknown()
{
// Arrange
var graph = new InternalCallGraph
{
PackageId = "TestPackage",
Version = "1.0.0"
};
// Act
var method = graph.GetMethod("Unknown::Method()");
// Assert
Assert.Null(method);
}
}

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>StellaOps.Scanner.VulnSurfaces.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.VulnSurfaces\StellaOps.Scanner.VulnSurfaces.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,292 @@
// -----------------------------------------------------------------------------
// TriggerMethodExtractorTests.cs
// Sprint: SPRINT_3700_0003_0001_trigger_extraction
// Description: Unit tests for TriggerMethodExtractor.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.VulnSurfaces.CallGraph;
using StellaOps.Scanner.VulnSurfaces.Models;
using StellaOps.Scanner.VulnSurfaces.Triggers;
using Xunit;
namespace StellaOps.Scanner.VulnSurfaces.Tests;
public class TriggerMethodExtractorTests
{
private readonly TriggerMethodExtractor _extractor;
public TriggerMethodExtractorTests()
{
_extractor = new TriggerMethodExtractor(NullLogger<TriggerMethodExtractor>.Instance);
}
[Fact]
public async Task ExtractAsync_DirectPath_FindsTrigger()
{
// Arrange
var graph = CreateTestGraph();
// Public -> Internal -> Sink
graph.AddMethod(new InternalMethodRef
{
MethodKey = "Namespace.Class::PublicMethod()",
Name = "PublicMethod",
DeclaringType = "Namespace.Class",
IsPublic = true
});
graph.AddMethod(new InternalMethodRef
{
MethodKey = "Namespace.Class::InternalHelper()",
Name = "InternalHelper",
DeclaringType = "Namespace.Class",
IsPublic = false
});
graph.AddMethod(new InternalMethodRef
{
MethodKey = "Namespace.Class::VulnerableSink(String)",
Name = "VulnerableSink",
DeclaringType = "Namespace.Class",
IsPublic = false
});
graph.AddEdge(new InternalCallEdge
{
Caller = "Namespace.Class::PublicMethod()",
Callee = "Namespace.Class::InternalHelper()"
});
graph.AddEdge(new InternalCallEdge
{
Caller = "Namespace.Class::InternalHelper()",
Callee = "Namespace.Class::VulnerableSink(String)"
});
var request = new TriggerExtractionRequest
{
SurfaceId = 1,
SinkMethodKeys = ["Namespace.Class::VulnerableSink(String)"],
Graph = graph
};
// Act
var result = await _extractor.ExtractAsync(request);
// Assert
Assert.True(result.Success);
Assert.Single(result.Triggers);
var trigger = result.Triggers[0];
Assert.Equal("Namespace.Class::PublicMethod()", trigger.TriggerMethodKey);
Assert.Equal("Namespace.Class::VulnerableSink(String)", trigger.SinkMethodKey);
Assert.Equal(2, trigger.Depth);
Assert.False(trigger.IsInterfaceExpansion);
}
[Fact]
public async Task ExtractAsync_NoPath_ReturnsEmpty()
{
// Arrange
var graph = CreateTestGraph();
graph.AddMethod(new InternalMethodRef
{
MethodKey = "Namespace.Class::PublicMethod()",
Name = "PublicMethod",
DeclaringType = "Namespace.Class",
IsPublic = true
});
graph.AddMethod(new InternalMethodRef
{
MethodKey = "Namespace.Class::UnreachableSink()",
Name = "UnreachableSink",
DeclaringType = "Namespace.Class",
IsPublic = false
});
// No edge between them
var request = new TriggerExtractionRequest
{
SurfaceId = 1,
SinkMethodKeys = ["Namespace.Class::UnreachableSink()"],
Graph = graph
};
// Act
var result = await _extractor.ExtractAsync(request);
// Assert
Assert.True(result.Success);
Assert.Empty(result.Triggers);
}
[Fact]
public async Task ExtractAsync_MultiplePublicMethods_FindsAllTriggers()
{
// Arrange
var graph = CreateTestGraph();
graph.AddMethod(new InternalMethodRef
{
MethodKey = "Class::Api1()",
Name = "Api1",
DeclaringType = "Class",
IsPublic = true
});
graph.AddMethod(new InternalMethodRef
{
MethodKey = "Class::Api2()",
Name = "Api2",
DeclaringType = "Class",
IsPublic = true
});
graph.AddMethod(new InternalMethodRef
{
MethodKey = "Class::Sink()",
Name = "Sink",
DeclaringType = "Class",
IsPublic = false
});
graph.AddEdge(new InternalCallEdge { Caller = "Class::Api1()", Callee = "Class::Sink()" });
graph.AddEdge(new InternalCallEdge { Caller = "Class::Api2()", Callee = "Class::Sink()" });
var request = new TriggerExtractionRequest
{
SurfaceId = 1,
SinkMethodKeys = ["Class::Sink()"],
Graph = graph
};
// Act
var result = await _extractor.ExtractAsync(request);
// Assert
Assert.True(result.Success);
Assert.Equal(2, result.Triggers.Count);
Assert.Contains(result.Triggers, t => t.TriggerMethodKey == "Class::Api1()");
Assert.Contains(result.Triggers, t => t.TriggerMethodKey == "Class::Api2()");
}
[Fact]
public async Task ExtractAsync_MaxDepthExceeded_DoesNotFindTrigger()
{
// Arrange
var graph = CreateTestGraph();
// Create a long chain: Public -> M1 -> M2 -> M3 -> M4 -> M5 -> Sink
graph.AddMethod(new InternalMethodRef
{
MethodKey = "C::Public()",
Name = "Public",
DeclaringType = "C",
IsPublic = true
});
for (int i = 1; i <= 5; i++)
{
graph.AddMethod(new InternalMethodRef
{
MethodKey = $"C::M{i}()",
Name = $"M{i}",
DeclaringType = "C",
IsPublic = false
});
}
graph.AddMethod(new InternalMethodRef
{
MethodKey = "C::Sink()",
Name = "Sink",
DeclaringType = "C",
IsPublic = false
});
graph.AddEdge(new InternalCallEdge { Caller = "C::Public()", Callee = "C::M1()" });
graph.AddEdge(new InternalCallEdge { Caller = "C::M1()", Callee = "C::M2()" });
graph.AddEdge(new InternalCallEdge { Caller = "C::M2()", Callee = "C::M3()" });
graph.AddEdge(new InternalCallEdge { Caller = "C::M3()", Callee = "C::M4()" });
graph.AddEdge(new InternalCallEdge { Caller = "C::M4()", Callee = "C::M5()" });
graph.AddEdge(new InternalCallEdge { Caller = "C::M5()", Callee = "C::Sink()" });
var request = new TriggerExtractionRequest
{
SurfaceId = 1,
SinkMethodKeys = ["C::Sink()"],
Graph = graph,
MaxDepth = 3 // Too shallow to reach sink
};
// Act
var result = await _extractor.ExtractAsync(request);
// Assert
Assert.True(result.Success);
Assert.Empty(result.Triggers);
}
[Fact]
public async Task ExtractAsync_VirtualMethod_ReducesConfidence()
{
// Arrange
var graph = CreateTestGraph();
graph.AddMethod(new InternalMethodRef
{
MethodKey = "C::Public()",
Name = "Public",
DeclaringType = "C",
IsPublic = true
});
graph.AddMethod(new InternalMethodRef
{
MethodKey = "C::Virtual()",
Name = "Virtual",
DeclaringType = "C",
IsPublic = false,
IsVirtual = true
});
graph.AddMethod(new InternalMethodRef
{
MethodKey = "C::Sink()",
Name = "Sink",
DeclaringType = "C",
IsPublic = false
});
graph.AddEdge(new InternalCallEdge { Caller = "C::Public()", Callee = "C::Virtual()" });
graph.AddEdge(new InternalCallEdge { Caller = "C::Virtual()", Callee = "C::Sink()" });
var request = new TriggerExtractionRequest
{
SurfaceId = 1,
SinkMethodKeys = ["C::Sink()"],
Graph = graph
};
// Act
var result = await _extractor.ExtractAsync(request);
// Assert
Assert.True(result.Success);
Assert.Single(result.Triggers);
Assert.True(result.Triggers[0].Confidence < 1.0);
}
private static InternalCallGraph CreateTestGraph()
{
return new InternalCallGraph
{
PackageId = "TestPackage",
Version = "1.0.0"
};
}
}

View File

@@ -0,0 +1,125 @@
// -----------------------------------------------------------------------------
// IVulnSurfaceBuilder.cs
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core
// Description: Interface for building vulnerability surfaces.
// -----------------------------------------------------------------------------
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Scanner.VulnSurfaces.Models;
namespace StellaOps.Scanner.VulnSurfaces.Builder;
/// <summary>
/// Orchestrates vulnerability surface computation:
/// 1. Downloads vulnerable and fixed package versions
/// 2. Fingerprints methods in both versions
/// 3. Computes diff to identify sink methods
/// 4. Optionally extracts trigger methods
/// </summary>
public interface IVulnSurfaceBuilder
{
/// <summary>
/// Builds a vulnerability surface for a CVE.
/// </summary>
/// <param name="request">Build request with CVE and package details.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Built vulnerability surface.</returns>
Task<VulnSurfaceBuildResult> BuildAsync(
VulnSurfaceBuildRequest request,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Request to build a vulnerability surface.
/// </summary>
public sealed record VulnSurfaceBuildRequest
{
/// <summary>
/// CVE ID.
/// </summary>
public required string CveId { get; init; }
/// <summary>
/// Package name.
/// </summary>
public required string PackageName { get; init; }
/// <summary>
/// Ecosystem (nuget, npm, maven, pypi).
/// </summary>
public required string Ecosystem { get; init; }
/// <summary>
/// Vulnerable version to analyze.
/// </summary>
public required string VulnVersion { get; init; }
/// <summary>
/// Fixed version for comparison.
/// </summary>
public required string FixedVersion { get; init; }
/// <summary>
/// Working directory for package downloads.
/// </summary>
public string? WorkingDirectory { get; init; }
/// <summary>
/// Whether to extract trigger methods.
/// </summary>
public bool ExtractTriggers { get; init; } = true;
/// <summary>
/// Custom registry URL (null for defaults).
/// </summary>
public string? RegistryUrl { get; init; }
}
/// <summary>
/// Result of building a vulnerability surface.
/// </summary>
public sealed record VulnSurfaceBuildResult
{
/// <summary>
/// Whether build succeeded.
/// </summary>
public bool Success { get; init; }
/// <summary>
/// Built vulnerability surface.
/// </summary>
public VulnSurface? Surface { get; init; }
/// <summary>
/// Error message if failed.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Total build duration.
/// </summary>
public System.TimeSpan Duration { get; init; }
/// <summary>
/// Creates a successful result.
/// </summary>
public static VulnSurfaceBuildResult Ok(VulnSurface surface, System.TimeSpan duration) =>
new()
{
Success = true,
Surface = surface,
Duration = duration
};
/// <summary>
/// Creates a failed result.
/// </summary>
public static VulnSurfaceBuildResult Fail(string error, System.TimeSpan duration) =>
new()
{
Success = false,
Error = error,
Duration = duration
};
}

View File

@@ -0,0 +1,269 @@
// -----------------------------------------------------------------------------
// VulnSurfaceBuilder.cs
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core
// Description: Orchestrates vulnerability surface computation.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.VulnSurfaces.CallGraph;
using StellaOps.Scanner.VulnSurfaces.Download;
using StellaOps.Scanner.VulnSurfaces.Fingerprint;
using StellaOps.Scanner.VulnSurfaces.Models;
using StellaOps.Scanner.VulnSurfaces.Triggers;
namespace StellaOps.Scanner.VulnSurfaces.Builder;
/// <summary>
/// Default implementation of vulnerability surface builder.
/// </summary>
public sealed class VulnSurfaceBuilder : IVulnSurfaceBuilder
{
private readonly IEnumerable<IPackageDownloader> _downloaders;
private readonly IEnumerable<IMethodFingerprinter> _fingerprinters;
private readonly IMethodDiffEngine _diffEngine;
private readonly ITriggerMethodExtractor _triggerExtractor;
private readonly IEnumerable<IInternalCallGraphBuilder> _graphBuilders;
private readonly ILogger<VulnSurfaceBuilder> _logger;
public VulnSurfaceBuilder(
IEnumerable<IPackageDownloader> downloaders,
IEnumerable<IMethodFingerprinter> fingerprinters,
IMethodDiffEngine diffEngine,
ITriggerMethodExtractor triggerExtractor,
IEnumerable<IInternalCallGraphBuilder> graphBuilders,
ILogger<VulnSurfaceBuilder> logger)
{
_downloaders = downloaders ?? throw new ArgumentNullException(nameof(downloaders));
_fingerprinters = fingerprinters ?? throw new ArgumentNullException(nameof(fingerprinters));
_diffEngine = diffEngine ?? throw new ArgumentNullException(nameof(diffEngine));
_triggerExtractor = triggerExtractor ?? throw new ArgumentNullException(nameof(triggerExtractor));
_graphBuilders = graphBuilders ?? throw new ArgumentNullException(nameof(graphBuilders));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<VulnSurfaceBuildResult> BuildAsync(
VulnSurfaceBuildRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var sw = Stopwatch.StartNew();
_logger.LogInformation(
"Building vulnerability surface for {CveId}: {Package} {VulnVersion} → {FixedVersion}",
request.CveId, request.PackageName, request.VulnVersion, request.FixedVersion);
try
{
// 1. Get ecosystem-specific downloader and fingerprinter
var downloader = _downloaders.FirstOrDefault(d =>
d.Ecosystem.Equals(request.Ecosystem, StringComparison.OrdinalIgnoreCase));
if (downloader == null)
{
sw.Stop();
return VulnSurfaceBuildResult.Fail($"No downloader for ecosystem: {request.Ecosystem}", sw.Elapsed);
}
var fingerprinter = _fingerprinters.FirstOrDefault(f =>
f.Ecosystem.Equals(request.Ecosystem, StringComparison.OrdinalIgnoreCase));
if (fingerprinter == null)
{
sw.Stop();
return VulnSurfaceBuildResult.Fail($"No fingerprinter for ecosystem: {request.Ecosystem}", sw.Elapsed);
}
// 2. Setup working directory
var workDir = request.WorkingDirectory ?? Path.Combine(Path.GetTempPath(), "vulnsurfaces", request.CveId);
Directory.CreateDirectory(workDir);
// 3. Download both versions
var vulnDownload = await downloader.DownloadAsync(new PackageDownloadRequest
{
PackageName = request.PackageName,
Version = request.VulnVersion,
OutputDirectory = Path.Combine(workDir, "vuln"),
RegistryUrl = request.RegistryUrl
}, cancellationToken);
if (!vulnDownload.Success)
{
sw.Stop();
return VulnSurfaceBuildResult.Fail($"Failed to download vulnerable version: {vulnDownload.Error}", sw.Elapsed);
}
var fixedDownload = await downloader.DownloadAsync(new PackageDownloadRequest
{
PackageName = request.PackageName,
Version = request.FixedVersion,
OutputDirectory = Path.Combine(workDir, "fixed"),
RegistryUrl = request.RegistryUrl
}, cancellationToken);
if (!fixedDownload.Success)
{
sw.Stop();
return VulnSurfaceBuildResult.Fail($"Failed to download fixed version: {fixedDownload.Error}", sw.Elapsed);
}
// 4. Fingerprint both versions
var vulnFingerprints = await fingerprinter.FingerprintAsync(new FingerprintRequest
{
PackagePath = vulnDownload.ExtractedPath!,
PackageName = request.PackageName,
Version = request.VulnVersion
}, cancellationToken);
if (!vulnFingerprints.Success)
{
sw.Stop();
return VulnSurfaceBuildResult.Fail($"Failed to fingerprint vulnerable version: {vulnFingerprints.Error}", sw.Elapsed);
}
var fixedFingerprints = await fingerprinter.FingerprintAsync(new FingerprintRequest
{
PackagePath = fixedDownload.ExtractedPath!,
PackageName = request.PackageName,
Version = request.FixedVersion
}, cancellationToken);
if (!fixedFingerprints.Success)
{
sw.Stop();
return VulnSurfaceBuildResult.Fail($"Failed to fingerprint fixed version: {fixedFingerprints.Error}", sw.Elapsed);
}
// 5. Compute diff
var diff = await _diffEngine.DiffAsync(new MethodDiffRequest
{
VulnFingerprints = vulnFingerprints,
FixedFingerprints = fixedFingerprints
}, cancellationToken);
if (!diff.Success)
{
sw.Stop();
return VulnSurfaceBuildResult.Fail($"Failed to compute diff: {diff.Error}", sw.Elapsed);
}
// 6. Build sinks from diff
var sinks = BuildSinks(diff);
// 7. Optionally extract triggers
var triggerCount = 0;
if (request.ExtractTriggers && sinks.Count > 0)
{
var graphBuilder = _graphBuilders.FirstOrDefault(b =>
b.Ecosystem.Equals(request.Ecosystem, StringComparison.OrdinalIgnoreCase));
if (graphBuilder != null)
{
var graphResult = await graphBuilder.BuildAsync(new InternalCallGraphBuildRequest
{
PackageId = request.PackageName,
Version = request.VulnVersion,
PackagePath = vulnDownload.ExtractedPath!
}, cancellationToken);
if (graphResult.Success && graphResult.Graph != null)
{
var triggerResult = await _triggerExtractor.ExtractAsync(new TriggerExtractionRequest
{
SurfaceId = 0, // Will be assigned when persisted
SinkMethodKeys = sinks.Select(s => s.MethodKey).ToList(),
Graph = graphResult.Graph
}, cancellationToken);
if (triggerResult.Success)
{
triggerCount = triggerResult.Triggers.Count;
}
}
}
}
// 8. Build surface
var surface = new VulnSurface
{
CveId = request.CveId,
PackageId = request.PackageName,
Ecosystem = request.Ecosystem,
VulnVersion = request.VulnVersion,
FixedVersion = request.FixedVersion,
Sinks = sinks,
TriggerCount = triggerCount,
Status = VulnSurfaceStatus.Computed,
Confidence = ComputeConfidence(diff, sinks.Count),
ComputedAt = DateTimeOffset.UtcNow
};
sw.Stop();
_logger.LogInformation(
"Built vulnerability surface for {CveId}: {SinkCount} sinks, {TriggerCount} triggers in {Duration}ms",
request.CveId, sinks.Count, triggerCount, sw.ElapsedMilliseconds);
return VulnSurfaceBuildResult.Ok(surface, sw.Elapsed);
}
catch (Exception ex)
{
sw.Stop();
_logger.LogError(ex, "Failed to build vulnerability surface for {CveId}", request.CveId);
return VulnSurfaceBuildResult.Fail(ex.Message, sw.Elapsed);
}
}
private static List<VulnSurfaceSink> BuildSinks(MethodDiffResult diff)
{
var sinks = new List<VulnSurfaceSink>();
foreach (var modified in diff.Modified)
{
sinks.Add(new VulnSurfaceSink
{
MethodKey = modified.MethodKey,
DeclaringType = modified.VulnVersion.DeclaringType,
MethodName = modified.VulnVersion.Name,
Signature = modified.VulnVersion.Signature,
ChangeType = modified.ChangeType,
VulnHash = modified.VulnVersion.BodyHash,
FixedHash = modified.FixedVersion.BodyHash
});
}
foreach (var removed in diff.Removed)
{
sinks.Add(new VulnSurfaceSink
{
MethodKey = removed.MethodKey,
DeclaringType = removed.DeclaringType,
MethodName = removed.Name,
Signature = removed.Signature,
ChangeType = MethodChangeType.Removed,
VulnHash = removed.BodyHash
});
}
return sinks;
}
private static double ComputeConfidence(MethodDiffResult diff, int sinkCount)
{
if (sinkCount == 0)
return 0.0;
// Higher confidence with more modified methods vs just removed
var modifiedRatio = (double)diff.Modified.Count / diff.TotalChanges;
return Math.Round(0.7 + (modifiedRatio * 0.3), 3);
}
}

View File

@@ -0,0 +1,216 @@
// -----------------------------------------------------------------------------
// CecilInternalGraphBuilder.cs
// Sprint: SPRINT_3700_0003_0001_trigger_extraction
// Description: .NET internal call graph builder using Mono.Cecil.
// -----------------------------------------------------------------------------
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Mono.Cecil;
using Mono.Cecil.Cil;
using StellaOps.Scanner.VulnSurfaces.Models;
namespace StellaOps.Scanner.VulnSurfaces.CallGraph;
/// <summary>
/// Internal call graph builder for .NET assemblies using Mono.Cecil.
/// </summary>
public sealed class CecilInternalGraphBuilder : IInternalCallGraphBuilder
{
private readonly ILogger<CecilInternalGraphBuilder> _logger;
public CecilInternalGraphBuilder(ILogger<CecilInternalGraphBuilder> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public string Ecosystem => "nuget";
/// <inheritdoc />
public bool CanHandle(string packagePath)
{
if (string.IsNullOrEmpty(packagePath))
return false;
// Check for .nupkg or directory with .dll files
if (packagePath.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase))
return true;
if (Directory.Exists(packagePath))
{
return Directory.EnumerateFiles(packagePath, "*.dll", SearchOption.AllDirectories).Any();
}
return packagePath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase);
}
/// <inheritdoc />
public async Task<InternalCallGraphBuildResult> BuildAsync(
InternalCallGraphBuildRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var sw = Stopwatch.StartNew();
var graph = new InternalCallGraph
{
PackageId = request.PackageId,
Version = request.Version
};
try
{
var dllFiles = GetAssemblyFiles(request.PackagePath);
var filesProcessed = 0;
foreach (var dllPath in dllFiles)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
await ProcessAssemblyAsync(dllPath, graph, request.IncludePrivateMethods, cancellationToken);
filesProcessed++;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to process assembly {Path}", dllPath);
// Continue with other assemblies
}
}
sw.Stop();
_logger.LogDebug(
"Built internal call graph for {PackageId} v{Version}: {Methods} methods, {Edges} edges in {Duration}ms",
request.PackageId, request.Version, graph.MethodCount, graph.EdgeCount, sw.ElapsedMilliseconds);
return InternalCallGraphBuildResult.Ok(graph, sw.Elapsed, filesProcessed);
}
catch (Exception ex)
{
sw.Stop();
_logger.LogWarning(ex, "Failed to build internal call graph for {PackageId}", request.PackageId);
return InternalCallGraphBuildResult.Fail(ex.Message, sw.Elapsed);
}
}
private static string[] GetAssemblyFiles(string packagePath)
{
if (File.Exists(packagePath) && packagePath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
{
return [packagePath];
}
if (Directory.Exists(packagePath))
{
return Directory.GetFiles(packagePath, "*.dll", SearchOption.AllDirectories);
}
// For .nupkg, would need to extract first
return [];
}
private Task ProcessAssemblyAsync(
string dllPath,
InternalCallGraph graph,
bool includePrivate,
CancellationToken cancellationToken)
{
return Task.Run(() =>
{
var readerParams = new ReaderParameters
{
ReadSymbols = false,
ReadingMode = ReadingMode.Deferred
};
using var assembly = AssemblyDefinition.ReadAssembly(dllPath, readerParams);
foreach (var module in assembly.Modules)
{
cancellationToken.ThrowIfCancellationRequested();
foreach (var type in module.Types)
{
ProcessType(type, graph, includePrivate);
}
}
}, cancellationToken);
}
private void ProcessType(TypeDefinition type, InternalCallGraph graph, bool includePrivate)
{
// Skip nested types at top level (they're processed from parent)
// But process nested types found within
foreach (var nestedType in type.NestedTypes)
{
ProcessType(nestedType, graph, includePrivate);
}
foreach (var method in type.Methods)
{
if (!includePrivate && !IsPublicOrProtected(method))
continue;
var methodRef = CreateMethodRef(method);
graph.AddMethod(methodRef);
// Extract call edges from method body
if (method.HasBody)
{
foreach (var instruction in method.Body.Instructions)
{
if (IsCallInstruction(instruction.OpCode) && instruction.Operand is MethodReference callee)
{
var calleeKey = GetMethodKey(callee);
var edge = new InternalCallEdge
{
Caller = methodRef.MethodKey,
Callee = calleeKey,
CallSiteOffset = instruction.Offset,
IsVirtualCall = instruction.OpCode == OpCodes.Callvirt
};
graph.AddEdge(edge);
}
}
}
}
}
private static bool IsCallInstruction(OpCode opCode) =>
opCode == OpCodes.Call ||
opCode == OpCodes.Callvirt ||
opCode == OpCodes.Newobj;
private static bool IsPublicOrProtected(MethodDefinition method) =>
method.IsPublic || method.IsFamily || method.IsFamilyOrAssembly;
private static InternalMethodRef CreateMethodRef(MethodDefinition method)
{
return new InternalMethodRef
{
MethodKey = GetMethodKey(method),
Name = method.Name,
DeclaringType = method.DeclaringType.FullName,
IsPublic = method.IsPublic,
IsInterface = method.DeclaringType.IsInterface,
IsVirtual = method.IsVirtual || method.IsAbstract,
Parameters = method.Parameters.Select(p => p.ParameterType.Name).ToList(),
ReturnType = method.ReturnType.Name
};
}
private static string GetMethodKey(MethodReference method)
{
var paramTypes = string.Join(",", method.Parameters.Select(p => p.ParameterType.Name));
return $"{method.DeclaringType.FullName}::{method.Name}({paramTypes})";
}
}

View File

@@ -0,0 +1,124 @@
// -----------------------------------------------------------------------------
// IInternalCallGraphBuilder.cs
// Sprint: SPRINT_3700_0003_0001_trigger_extraction
// Description: Interface for building internal call graphs from package sources.
// -----------------------------------------------------------------------------
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.VulnSurfaces.CallGraph;
/// <summary>
/// Builds internal call graphs from package/assembly sources.
/// Implementations exist for different ecosystems (.NET, Java, Node.js, Python).
/// </summary>
public interface IInternalCallGraphBuilder
{
/// <summary>
/// Ecosystem this builder supports (e.g., "nuget", "maven", "npm", "pypi").
/// </summary>
string Ecosystem { get; }
/// <summary>
/// Checks if this builder can handle the given package.
/// </summary>
/// <param name="packagePath">Path to package archive or extracted directory.</param>
bool CanHandle(string packagePath);
/// <summary>
/// Builds an internal call graph from a package.
/// </summary>
/// <param name="request">Build request with package details.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Internal call graph for the package.</returns>
Task<InternalCallGraphBuildResult> BuildAsync(
InternalCallGraphBuildRequest request,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Request to build an internal call graph.
/// </summary>
public sealed record InternalCallGraphBuildRequest
{
/// <summary>
/// Package identifier (PURL or package name).
/// </summary>
public required string PackageId { get; init; }
/// <summary>
/// Package version.
/// </summary>
public required string Version { get; init; }
/// <summary>
/// Path to the package archive or extracted directory.
/// </summary>
public required string PackagePath { get; init; }
/// <summary>
/// Whether to include private methods in the graph.
/// Default is false (only public API surface).
/// </summary>
public bool IncludePrivateMethods { get; init; }
/// <summary>
/// Maximum depth for call graph traversal.
/// </summary>
public int MaxDepth { get; init; } = 20;
}
/// <summary>
/// Result of building an internal call graph.
/// </summary>
public sealed record InternalCallGraphBuildResult
{
/// <summary>
/// Whether the build succeeded.
/// </summary>
public bool Success { get; init; }
/// <summary>
/// The built call graph (null if failed).
/// </summary>
public InternalCallGraph? Graph { get; init; }
/// <summary>
/// Error message if build failed.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Build duration.
/// </summary>
public TimeSpan Duration { get; init; }
/// <summary>
/// Number of assemblies/files processed.
/// </summary>
public int FilesProcessed { get; init; }
/// <summary>
/// Creates a successful result.
/// </summary>
public static InternalCallGraphBuildResult Ok(InternalCallGraph graph, TimeSpan duration, int filesProcessed) =>
new()
{
Success = true,
Graph = graph,
Duration = duration,
FilesProcessed = filesProcessed
};
/// <summary>
/// Creates a failed result.
/// </summary>
public static InternalCallGraphBuildResult Fail(string error, TimeSpan duration) =>
new()
{
Success = false,
Error = error,
Duration = duration
};
}

View File

@@ -0,0 +1,137 @@
// -----------------------------------------------------------------------------
// InternalCallGraph.cs
// Sprint: SPRINT_3700_0003_0001_trigger_extraction
// Description: Internal call graph model for within-package edges only.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using StellaOps.Scanner.VulnSurfaces.Models;
namespace StellaOps.Scanner.VulnSurfaces.CallGraph;
/// <summary>
/// Internal call graph for a single package/assembly.
/// Contains only within-package edges (no cross-package calls).
/// </summary>
public sealed class InternalCallGraph
{
private readonly Dictionary<string, InternalMethodRef> _methods = new(StringComparer.Ordinal);
private readonly Dictionary<string, HashSet<string>> _callersToCallees = new(StringComparer.Ordinal);
private readonly Dictionary<string, HashSet<string>> _calleesToCallers = new(StringComparer.Ordinal);
private readonly List<InternalCallEdge> _edges = [];
/// <summary>
/// Package/assembly identifier.
/// </summary>
public required string PackageId { get; init; }
/// <summary>
/// Package version.
/// </summary>
public string? Version { get; init; }
/// <summary>
/// All methods in the package.
/// </summary>
public IReadOnlyDictionary<string, InternalMethodRef> Methods => _methods;
/// <summary>
/// All edges in the call graph.
/// </summary>
public IReadOnlyList<InternalCallEdge> Edges => _edges;
/// <summary>
/// Number of methods.
/// </summary>
public int MethodCount => _methods.Count;
/// <summary>
/// Number of edges.
/// </summary>
public int EdgeCount => _edges.Count;
/// <summary>
/// Adds a method to the graph.
/// </summary>
public void AddMethod(InternalMethodRef method)
{
ArgumentNullException.ThrowIfNull(method);
_methods[method.MethodKey] = method;
}
/// <summary>
/// Adds an edge to the graph.
/// </summary>
public void AddEdge(InternalCallEdge edge)
{
ArgumentNullException.ThrowIfNull(edge);
_edges.Add(edge);
if (!_callersToCallees.TryGetValue(edge.Caller, out var callees))
{
callees = new HashSet<string>(StringComparer.Ordinal);
_callersToCallees[edge.Caller] = callees;
}
callees.Add(edge.Callee);
if (!_calleesToCallers.TryGetValue(edge.Callee, out var callers))
{
callers = new HashSet<string>(StringComparer.Ordinal);
_calleesToCallers[edge.Callee] = callers;
}
callers.Add(edge.Caller);
}
/// <summary>
/// Gets all callees of a method.
/// </summary>
public IReadOnlySet<string> GetCallees(string methodKey)
{
if (_callersToCallees.TryGetValue(methodKey, out var callees))
{
return callees;
}
return ImmutableHashSet<string>.Empty;
}
/// <summary>
/// Gets all callers of a method.
/// </summary>
public IReadOnlySet<string> GetCallers(string methodKey)
{
if (_calleesToCallers.TryGetValue(methodKey, out var callers))
{
return callers;
}
return ImmutableHashSet<string>.Empty;
}
/// <summary>
/// Gets all public methods in the graph.
/// </summary>
public IEnumerable<InternalMethodRef> GetPublicMethods()
{
foreach (var method in _methods.Values)
{
if (method.IsPublic)
{
yield return method;
}
}
}
/// <summary>
/// Checks if a method exists in the graph.
/// </summary>
public bool ContainsMethod(string methodKey) => _methods.ContainsKey(methodKey);
/// <summary>
/// Gets a method by key.
/// </summary>
public InternalMethodRef? GetMethod(string methodKey)
{
return _methods.GetValueOrDefault(methodKey);
}
}

View File

@@ -0,0 +1,67 @@
// -----------------------------------------------------------------------------
// VulnSurfacesServiceCollectionExtensions.cs
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core
// Description: DI registration for VulnSurfaces services.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Scanner.VulnSurfaces.Builder;
using StellaOps.Scanner.VulnSurfaces.CallGraph;
using StellaOps.Scanner.VulnSurfaces.Download;
using StellaOps.Scanner.VulnSurfaces.Fingerprint;
using StellaOps.Scanner.VulnSurfaces.Triggers;
namespace StellaOps.Scanner.VulnSurfaces.DependencyInjection;
/// <summary>
/// Extension methods for registering VulnSurfaces services.
/// </summary>
public static class VulnSurfacesServiceCollectionExtensions
{
/// <summary>
/// Adds VulnSurfaces services to the service collection.
/// </summary>
public static IServiceCollection AddVulnSurfaces(this IServiceCollection services)
{
// Package downloaders
services.AddHttpClient<NuGetPackageDownloader>();
services.TryAddEnumerable(ServiceDescriptor.Singleton<IPackageDownloader, NuGetPackageDownloader>());
// Method fingerprinters
services.TryAddEnumerable(ServiceDescriptor.Singleton<IMethodFingerprinter, CecilMethodFingerprinter>());
// Diff engine
services.TryAddSingleton<IMethodDiffEngine, MethodDiffEngine>();
// Call graph builders
services.TryAddEnumerable(ServiceDescriptor.Singleton<IInternalCallGraphBuilder, CecilInternalGraphBuilder>());
// Trigger extraction
services.TryAddSingleton<ITriggerMethodExtractor, TriggerMethodExtractor>();
// Surface builder orchestrator
services.TryAddSingleton<IVulnSurfaceBuilder, VulnSurfaceBuilder>();
return services;
}
/// <summary>
/// Adds the .NET (Cecil) call graph builder.
/// </summary>
public static IServiceCollection AddCecilCallGraphBuilder(this IServiceCollection services)
{
services.AddSingleton<IInternalCallGraphBuilder, CecilInternalGraphBuilder>();
return services;
}
/// <summary>
/// Adds the NuGet package downloader.
/// </summary>
public static IServiceCollection AddNuGetDownloader(this IServiceCollection services)
{
services.AddHttpClient<NuGetPackageDownloader>();
services.AddSingleton<IPackageDownloader, NuGetPackageDownloader>();
return services;
}
}

View File

@@ -0,0 +1,123 @@
// -----------------------------------------------------------------------------
// IPackageDownloader.cs
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core
// Description: Interface for downloading packages from various ecosystems.
// -----------------------------------------------------------------------------
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.VulnSurfaces.Download;
/// <summary>
/// Downloads packages from ecosystem-specific registries for analysis.
/// </summary>
public interface IPackageDownloader
{
/// <summary>
/// Ecosystem this downloader handles (nuget, npm, maven, pypi).
/// </summary>
string Ecosystem { get; }
/// <summary>
/// Downloads a package to a local directory.
/// </summary>
/// <param name="request">Download request with package details.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Download result with path to extracted package.</returns>
Task<PackageDownloadResult> DownloadAsync(
PackageDownloadRequest request,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Request to download a package.
/// </summary>
public sealed record PackageDownloadRequest
{
/// <summary>
/// Package name.
/// </summary>
public required string PackageName { get; init; }
/// <summary>
/// Package version.
/// </summary>
public required string Version { get; init; }
/// <summary>
/// Output directory for extracted package.
/// </summary>
public required string OutputDirectory { get; init; }
/// <summary>
/// Registry URL override (null for default).
/// </summary>
public string? RegistryUrl { get; init; }
/// <summary>
/// Whether to use cached version if available.
/// </summary>
public bool UseCache { get; init; } = true;
}
/// <summary>
/// Result of package download.
/// </summary>
public sealed record PackageDownloadResult
{
/// <summary>
/// Whether download succeeded.
/// </summary>
public bool Success { get; init; }
/// <summary>
/// Path to extracted package.
/// </summary>
public string? ExtractedPath { get; init; }
/// <summary>
/// Path to original archive.
/// </summary>
public string? ArchivePath { get; init; }
/// <summary>
/// Error message if failed.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Download duration.
/// </summary>
public TimeSpan Duration { get; init; }
/// <summary>
/// Whether result was from cache.
/// </summary>
public bool FromCache { get; init; }
/// <summary>
/// Creates a successful result.
/// </summary>
public static PackageDownloadResult Ok(string extractedPath, string archivePath, TimeSpan duration, bool fromCache = false) =>
new()
{
Success = true,
ExtractedPath = extractedPath,
ArchivePath = archivePath,
Duration = duration,
FromCache = fromCache
};
/// <summary>
/// Creates a failed result.
/// </summary>
public static PackageDownloadResult Fail(string error, TimeSpan duration) =>
new()
{
Success = false,
Error = error,
Duration = duration
};
}

View File

@@ -0,0 +1,136 @@
// -----------------------------------------------------------------------------
// NuGetPackageDownloader.cs
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core
// Description: Downloads NuGet packages for vulnerability surface analysis.
// -----------------------------------------------------------------------------
using System;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Scanner.VulnSurfaces.Download;
/// <summary>
/// Downloads NuGet packages from nuget.org or custom feeds.
/// </summary>
public sealed class NuGetPackageDownloader : IPackageDownloader
{
private const string DefaultRegistryUrl = "https://api.nuget.org/v3-flatcontainer";
private readonly HttpClient _httpClient;
private readonly ILogger<NuGetPackageDownloader> _logger;
private readonly NuGetDownloaderOptions _options;
public NuGetPackageDownloader(
HttpClient httpClient,
ILogger<NuGetPackageDownloader> logger,
IOptions<NuGetDownloaderOptions> options)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options?.Value ?? new NuGetDownloaderOptions();
}
/// <inheritdoc />
public string Ecosystem => "nuget";
/// <inheritdoc />
public async Task<PackageDownloadResult> DownloadAsync(
PackageDownloadRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var sw = Stopwatch.StartNew();
var packageLower = request.PackageName.ToLowerInvariant();
var versionLower = request.Version.ToLowerInvariant();
try
{
// Check cache first
var extractedDir = Path.Combine(request.OutputDirectory, $"{packageLower}.{versionLower}");
var archivePath = Path.Combine(request.OutputDirectory, $"{packageLower}.{versionLower}.nupkg");
if (request.UseCache && Directory.Exists(extractedDir))
{
sw.Stop();
_logger.LogDebug("Using cached package {Package} v{Version}", request.PackageName, request.Version);
return PackageDownloadResult.Ok(extractedDir, archivePath, sw.Elapsed, fromCache: true);
}
// Build download URL
var registryUrl = request.RegistryUrl ?? _options.RegistryUrl ?? DefaultRegistryUrl;
var downloadUrl = $"{registryUrl}/{packageLower}/{versionLower}/{packageLower}.{versionLower}.nupkg";
_logger.LogDebug("Downloading NuGet package from {Url}", downloadUrl);
// Download package
Directory.CreateDirectory(request.OutputDirectory);
using var response = await _httpClient.GetAsync(downloadUrl, cancellationToken);
if (!response.IsSuccessStatusCode)
{
sw.Stop();
var error = $"Failed to download: HTTP {(int)response.StatusCode} {response.ReasonPhrase}";
_logger.LogWarning("NuGet download failed for {Package} v{Version}: {Error}",
request.PackageName, request.Version, error);
return PackageDownloadResult.Fail(error, sw.Elapsed);
}
// Save archive
await using (var fs = File.Create(archivePath))
{
await response.Content.CopyToAsync(fs, cancellationToken);
}
// Extract
if (Directory.Exists(extractedDir))
{
Directory.Delete(extractedDir, recursive: true);
}
ZipFile.ExtractToDirectory(archivePath, extractedDir);
sw.Stop();
_logger.LogDebug("Downloaded and extracted {Package} v{Version} in {Duration}ms",
request.PackageName, request.Version, sw.ElapsedMilliseconds);
return PackageDownloadResult.Ok(extractedDir, archivePath, sw.Elapsed);
}
catch (Exception ex)
{
sw.Stop();
_logger.LogWarning(ex, "Failed to download NuGet package {Package} v{Version}",
request.PackageName, request.Version);
return PackageDownloadResult.Fail(ex.Message, sw.Elapsed);
}
}
}
/// <summary>
/// Options for NuGet package downloader.
/// </summary>
public sealed class NuGetDownloaderOptions
{
/// <summary>
/// Custom registry URL (null for nuget.org).
/// </summary>
public string? RegistryUrl { get; set; }
/// <summary>
/// Cache directory for downloaded packages.
/// </summary>
public string? CacheDirectory { get; set; }
/// <summary>
/// Maximum package size in bytes (0 for unlimited).
/// </summary>
public long MaxPackageSize { get; set; }
}

View File

@@ -0,0 +1,242 @@
// -----------------------------------------------------------------------------
// CecilMethodFingerprinter.cs
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core
// Description: .NET method fingerprinting using Mono.Cecil IL hashing.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Mono.Cecil;
using Mono.Cecil.Cil;
namespace StellaOps.Scanner.VulnSurfaces.Fingerprint;
/// <summary>
/// Computes method fingerprints for .NET assemblies using IL hashing.
/// </summary>
public sealed class CecilMethodFingerprinter : IMethodFingerprinter
{
private readonly ILogger<CecilMethodFingerprinter> _logger;
public CecilMethodFingerprinter(ILogger<CecilMethodFingerprinter> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public string Ecosystem => "nuget";
/// <inheritdoc />
public async Task<FingerprintResult> FingerprintAsync(
FingerprintRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var sw = Stopwatch.StartNew();
var methods = new Dictionary<string, MethodFingerprint>(StringComparer.Ordinal);
try
{
var dllFiles = GetAssemblyFiles(request.PackagePath);
var filesProcessed = 0;
foreach (var dllPath in dllFiles)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
await ProcessAssemblyAsync(dllPath, methods, request, cancellationToken);
filesProcessed++;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to process assembly {Path}", dllPath);
}
}
sw.Stop();
_logger.LogDebug(
"Fingerprinted {MethodCount} methods from {FileCount} files in {Duration}ms",
methods.Count, filesProcessed, sw.ElapsedMilliseconds);
return FingerprintResult.Ok(methods, sw.Elapsed, filesProcessed);
}
catch (Exception ex)
{
sw.Stop();
_logger.LogWarning(ex, "Failed to fingerprint package at {Path}", request.PackagePath);
return FingerprintResult.Fail(ex.Message, sw.Elapsed);
}
}
private static string[] GetAssemblyFiles(string packagePath)
{
if (!Directory.Exists(packagePath))
return [];
return Directory.GetFiles(packagePath, "*.dll", SearchOption.AllDirectories)
.Where(f => !f.Contains("ref" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
.ToArray();
}
private Task ProcessAssemblyAsync(
string dllPath,
Dictionary<string, MethodFingerprint> methods,
FingerprintRequest request,
CancellationToken cancellationToken)
{
return Task.Run(() =>
{
var readerParams = new ReaderParameters
{
ReadSymbols = false,
ReadingMode = ReadingMode.Deferred
};
using var assembly = AssemblyDefinition.ReadAssembly(dllPath, readerParams);
foreach (var module in assembly.Modules)
{
cancellationToken.ThrowIfCancellationRequested();
foreach (var type in module.Types)
{
ProcessType(type, methods, request);
}
}
}, cancellationToken);
}
private void ProcessType(
TypeDefinition type,
Dictionary<string, MethodFingerprint> methods,
FingerprintRequest request)
{
foreach (var nestedType in type.NestedTypes)
{
ProcessType(nestedType, methods, request);
}
foreach (var method in type.Methods)
{
if (!request.IncludePrivateMethods && !IsPublicOrProtected(method))
continue;
var fingerprint = CreateFingerprint(method, request.NormalizeMethodBodies);
methods[fingerprint.MethodKey] = fingerprint;
}
}
private static bool IsPublicOrProtected(MethodDefinition method) =>
method.IsPublic || method.IsFamily || method.IsFamilyOrAssembly;
private static MethodFingerprint CreateFingerprint(MethodDefinition method, bool normalize)
{
var methodKey = GetMethodKey(method);
var bodyHash = ComputeBodyHash(method, normalize);
var signatureHash = ComputeSignatureHash(method);
return new MethodFingerprint
{
MethodKey = methodKey,
DeclaringType = method.DeclaringType.FullName,
Name = method.Name,
Signature = GetSignature(method),
BodyHash = bodyHash,
SignatureHash = signatureHash,
IsPublic = method.IsPublic,
BodySize = method.HasBody ? method.Body.Instructions.Count : 0
};
}
private static string GetMethodKey(MethodDefinition method)
{
var paramTypes = string.Join(",", method.Parameters.Select(p => p.ParameterType.Name));
return $"{method.DeclaringType.FullName}::{method.Name}({paramTypes})";
}
private static string GetSignature(MethodDefinition method)
{
var sb = new StringBuilder();
sb.Append(method.ReturnType.Name);
sb.Append(' ');
sb.Append(method.Name);
sb.Append('(');
sb.Append(string.Join(", ", method.Parameters.Select(p => $"{p.ParameterType.Name} {p.Name}")));
sb.Append(')');
return sb.ToString();
}
private static string ComputeBodyHash(MethodDefinition method, bool normalize)
{
if (!method.HasBody)
return "empty";
using var sha256 = SHA256.Create();
var sb = new StringBuilder();
foreach (var instruction in method.Body.Instructions)
{
if (normalize)
{
// Normalize: skip debug instructions, use opcode names
if (IsDebugInstruction(instruction.OpCode))
continue;
sb.Append(instruction.OpCode.Name);
// Normalize operand references
if (instruction.Operand is MethodReference mr)
{
sb.Append(':');
sb.Append(mr.DeclaringType.Name);
sb.Append('.');
sb.Append(mr.Name);
}
else if (instruction.Operand is TypeReference tr)
{
sb.Append(':');
sb.Append(tr.Name);
}
else if (instruction.Operand is FieldReference fr)
{
sb.Append(':');
sb.Append(fr.Name);
}
}
else
{
sb.Append(instruction.ToString());
}
sb.Append(';');
}
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
var hash = sha256.ComputeHash(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static string ComputeSignatureHash(MethodDefinition method)
{
using var sha256 = SHA256.Create();
var sig = $"{method.ReturnType.FullName} {method.Name}({string.Join(",", method.Parameters.Select(p => p.ParameterType.FullName))})";
var bytes = Encoding.UTF8.GetBytes(sig);
var hash = sha256.ComputeHash(bytes);
return Convert.ToHexString(hash).ToLowerInvariant()[..16];
}
private static bool IsDebugInstruction(OpCode opCode) =>
opCode == OpCodes.Nop ||
opCode.Name.StartsWith("break", StringComparison.OrdinalIgnoreCase);
}

View File

@@ -0,0 +1,179 @@
// -----------------------------------------------------------------------------
// IMethodFingerprinter.cs
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core
// Description: Interface for computing method fingerprints for diff detection.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.VulnSurfaces.Fingerprint;
/// <summary>
/// Computes stable fingerprints for methods in a package.
/// Used to detect which methods changed between versions.
/// </summary>
public interface IMethodFingerprinter
{
/// <summary>
/// Ecosystem this fingerprinter handles.
/// </summary>
string Ecosystem { get; }
/// <summary>
/// Computes fingerprints for all methods in a package.
/// </summary>
/// <param name="request">Fingerprint request with package path.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Fingerprint result with method hashes.</returns>
Task<FingerprintResult> FingerprintAsync(
FingerprintRequest request,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Request to fingerprint methods in a package.
/// </summary>
public sealed record FingerprintRequest
{
/// <summary>
/// Path to extracted package directory.
/// </summary>
public required string PackagePath { get; init; }
/// <summary>
/// Package name for context.
/// </summary>
public string? PackageName { get; init; }
/// <summary>
/// Package version for context.
/// </summary>
public string? Version { get; init; }
/// <summary>
/// Whether to include private methods.
/// </summary>
public bool IncludePrivateMethods { get; init; }
/// <summary>
/// Whether to normalize method bodies before hashing.
/// </summary>
public bool NormalizeMethodBodies { get; init; } = true;
}
/// <summary>
/// Result of method fingerprinting.
/// </summary>
public sealed record FingerprintResult
{
/// <summary>
/// Whether fingerprinting succeeded.
/// </summary>
public bool Success { get; init; }
/// <summary>
/// Method fingerprints keyed by method key.
/// </summary>
public IReadOnlyDictionary<string, MethodFingerprint> Methods { get; init; } =
new Dictionary<string, MethodFingerprint>();
/// <summary>
/// Error message if failed.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Processing duration.
/// </summary>
public TimeSpan Duration { get; init; }
/// <summary>
/// Number of files processed.
/// </summary>
public int FilesProcessed { get; init; }
/// <summary>
/// Creates a successful result.
/// </summary>
public static FingerprintResult Ok(
IReadOnlyDictionary<string, MethodFingerprint> methods,
TimeSpan duration,
int filesProcessed) =>
new()
{
Success = true,
Methods = methods,
Duration = duration,
FilesProcessed = filesProcessed
};
/// <summary>
/// Creates a failed result.
/// </summary>
public static FingerprintResult Fail(string error, TimeSpan duration) =>
new()
{
Success = false,
Error = error,
Duration = duration
};
}
/// <summary>
/// Fingerprint for a single method.
/// </summary>
public sealed record MethodFingerprint
{
/// <summary>
/// Normalized method key.
/// </summary>
public required string MethodKey { get; init; }
/// <summary>
/// Declaring type/class.
/// </summary>
public required string DeclaringType { get; init; }
/// <summary>
/// Method name.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Method signature.
/// </summary>
public string? Signature { get; init; }
/// <summary>
/// Hash of method body (normalized).
/// </summary>
public required string BodyHash { get; init; }
/// <summary>
/// Hash of method signature only.
/// </summary>
public string? SignatureHash { get; init; }
/// <summary>
/// Whether method is public.
/// </summary>
public bool IsPublic { get; init; }
/// <summary>
/// Size of method body in bytes/instructions.
/// </summary>
public int BodySize { get; init; }
/// <summary>
/// Source file path (if available).
/// </summary>
public string? SourceFile { get; init; }
/// <summary>
/// Line number (if available).
/// </summary>
public int? LineNumber { get; init; }
}

View File

@@ -0,0 +1,225 @@
// -----------------------------------------------------------------------------
// MethodDiffEngine.cs
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core
// Description: Computes method-level diffs between package versions.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.VulnSurfaces.Models;
namespace StellaOps.Scanner.VulnSurfaces.Fingerprint;
/// <summary>
/// Computes diffs between method fingerprints from two package versions.
/// </summary>
public interface IMethodDiffEngine
{
/// <summary>
/// Computes the diff between vulnerable and fixed versions.
/// </summary>
Task<MethodDiffResult> DiffAsync(
MethodDiffRequest request,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Request to compute method diff.
/// </summary>
public sealed record MethodDiffRequest
{
/// <summary>
/// Fingerprints from vulnerable version.
/// </summary>
public required FingerprintResult VulnFingerprints { get; init; }
/// <summary>
/// Fingerprints from fixed version.
/// </summary>
public required FingerprintResult FixedFingerprints { get; init; }
/// <summary>
/// Whether to include methods that only changed signature.
/// </summary>
public bool IncludeSignatureChanges { get; init; } = true;
}
/// <summary>
/// Result of method diff.
/// </summary>
public sealed record MethodDiffResult
{
/// <summary>
/// Whether diff succeeded.
/// </summary>
public bool Success { get; init; }
/// <summary>
/// Methods that were modified (body changed).
/// </summary>
public IReadOnlyList<MethodDiff> Modified { get; init; } = [];
/// <summary>
/// Methods added in fixed version.
/// </summary>
public IReadOnlyList<MethodFingerprint> Added { get; init; } = [];
/// <summary>
/// Methods removed in fixed version.
/// </summary>
public IReadOnlyList<MethodFingerprint> Removed { get; init; } = [];
/// <summary>
/// Total number of changes.
/// </summary>
public int TotalChanges => Modified.Count + Added.Count + Removed.Count;
/// <summary>
/// Processing duration.
/// </summary>
public TimeSpan Duration { get; init; }
/// <summary>
/// Error message if failed.
/// </summary>
public string? Error { get; init; }
}
/// <summary>
/// A single method diff.
/// </summary>
public sealed record MethodDiff
{
/// <summary>
/// Method key.
/// </summary>
public required string MethodKey { get; init; }
/// <summary>
/// Fingerprint from vulnerable version.
/// </summary>
public required MethodFingerprint VulnVersion { get; init; }
/// <summary>
/// Fingerprint from fixed version.
/// </summary>
public required MethodFingerprint FixedVersion { get; init; }
/// <summary>
/// Type of change.
/// </summary>
public MethodChangeType ChangeType { get; init; }
}
/// <summary>
/// Default implementation of method diff engine.
/// </summary>
public sealed class MethodDiffEngine : IMethodDiffEngine
{
private readonly ILogger<MethodDiffEngine> _logger;
public MethodDiffEngine(ILogger<MethodDiffEngine> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public Task<MethodDiffResult> DiffAsync(
MethodDiffRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var sw = Stopwatch.StartNew();
try
{
var vulnMethods = request.VulnFingerprints.Methods;
var fixedMethods = request.FixedFingerprints.Methods;
var modified = new List<MethodDiff>();
var added = new List<MethodFingerprint>();
var removed = new List<MethodFingerprint>();
// Find modified and removed methods
foreach (var (key, vulnFp) in vulnMethods)
{
cancellationToken.ThrowIfCancellationRequested();
if (fixedMethods.TryGetValue(key, out var fixedFp))
{
// Method exists in both - check if changed
if (vulnFp.BodyHash != fixedFp.BodyHash)
{
modified.Add(new MethodDiff
{
MethodKey = key,
VulnVersion = vulnFp,
FixedVersion = fixedFp,
ChangeType = MethodChangeType.Modified
});
}
else if (request.IncludeSignatureChanges &&
vulnFp.SignatureHash != fixedFp.SignatureHash)
{
modified.Add(new MethodDiff
{
MethodKey = key,
VulnVersion = vulnFp,
FixedVersion = fixedFp,
ChangeType = MethodChangeType.SignatureChanged
});
}
}
else
{
// Method removed in fixed version
removed.Add(vulnFp);
}
}
// Find added methods
foreach (var (key, fixedFp) in fixedMethods)
{
cancellationToken.ThrowIfCancellationRequested();
if (!vulnMethods.ContainsKey(key))
{
added.Add(fixedFp);
}
}
sw.Stop();
_logger.LogDebug(
"Method diff: {Modified} modified, {Added} added, {Removed} removed in {Duration}ms",
modified.Count, added.Count, removed.Count, sw.ElapsedMilliseconds);
return Task.FromResult(new MethodDiffResult
{
Success = true,
Modified = modified,
Added = added,
Removed = removed,
Duration = sw.Elapsed
});
}
catch (Exception ex)
{
sw.Stop();
_logger.LogWarning(ex, "Method diff failed");
return Task.FromResult(new MethodDiffResult
{
Success = false,
Error = ex.Message,
Duration = sw.Elapsed
});
}
}
}

View File

@@ -0,0 +1,220 @@
// -----------------------------------------------------------------------------
// VulnSurface.cs
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core
// Description: Core models for vulnerability surface computation.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.VulnSurfaces.Models;
/// <summary>
/// A vulnerability surface represents the specific methods that changed
/// between a vulnerable and fixed version of a package.
/// </summary>
public sealed record VulnSurface
{
/// <summary>
/// Database ID.
/// </summary>
[JsonPropertyName("surface_id")]
public long SurfaceId { get; init; }
/// <summary>
/// CVE ID (e.g., "CVE-2024-12345").
/// </summary>
[JsonPropertyName("cve_id")]
public required string CveId { get; init; }
/// <summary>
/// Package identifier (PURL format preferred).
/// </summary>
[JsonPropertyName("package_id")]
public required string PackageId { get; init; }
/// <summary>
/// Ecosystem (nuget, npm, maven, pypi).
/// </summary>
[JsonPropertyName("ecosystem")]
public required string Ecosystem { get; init; }
/// <summary>
/// Vulnerable version analyzed.
/// </summary>
[JsonPropertyName("vuln_version")]
public required string VulnVersion { get; init; }
/// <summary>
/// Fixed version used for diff.
/// </summary>
[JsonPropertyName("fixed_version")]
public required string FixedVersion { get; init; }
/// <summary>
/// Sink methods (vulnerable code locations).
/// </summary>
[JsonPropertyName("sinks")]
public IReadOnlyList<VulnSurfaceSink> Sinks { get; init; } = [];
/// <summary>
/// Number of trigger methods that can reach sinks.
/// </summary>
[JsonPropertyName("trigger_count")]
public int TriggerCount { get; init; }
/// <summary>
/// Surface computation status.
/// </summary>
[JsonPropertyName("status")]
public VulnSurfaceStatus Status { get; init; }
/// <summary>
/// Confidence score (0.0-1.0).
/// </summary>
[JsonPropertyName("confidence")]
public double Confidence { get; init; } = 1.0;
/// <summary>
/// When the surface was computed.
/// </summary>
[JsonPropertyName("computed_at")]
public DateTimeOffset ComputedAt { get; init; }
/// <summary>
/// Error message if computation failed.
/// </summary>
[JsonPropertyName("error")]
public string? Error { get; init; }
}
/// <summary>
/// A sink method - a specific method that was modified in the security fix.
/// </summary>
public sealed record VulnSurfaceSink
{
/// <summary>
/// Database ID.
/// </summary>
[JsonPropertyName("sink_id")]
public long SinkId { get; init; }
/// <summary>
/// Parent surface ID.
/// </summary>
[JsonPropertyName("surface_id")]
public long SurfaceId { get; init; }
/// <summary>
/// Normalized method key.
/// </summary>
[JsonPropertyName("method_key")]
public required string MethodKey { get; init; }
/// <summary>
/// Declaring type/class name.
/// </summary>
[JsonPropertyName("declaring_type")]
public required string DeclaringType { get; init; }
/// <summary>
/// Method name.
/// </summary>
[JsonPropertyName("method_name")]
public required string MethodName { get; init; }
/// <summary>
/// Method signature.
/// </summary>
[JsonPropertyName("signature")]
public string? Signature { get; init; }
/// <summary>
/// Type of change detected.
/// </summary>
[JsonPropertyName("change_type")]
public MethodChangeType ChangeType { get; init; }
/// <summary>
/// Hash of the method in vulnerable version.
/// </summary>
[JsonPropertyName("vuln_hash")]
public string? VulnHash { get; init; }
/// <summary>
/// Hash of the method in fixed version.
/// </summary>
[JsonPropertyName("fixed_hash")]
public string? FixedHash { get; init; }
/// <summary>
/// Whether this sink is directly exploitable.
/// </summary>
[JsonPropertyName("is_direct_exploit")]
public bool IsDirectExploit { get; init; }
}
/// <summary>
/// Status of vulnerability surface computation.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum VulnSurfaceStatus
{
/// <summary>
/// Computation pending.
/// </summary>
Pending,
/// <summary>
/// Computation in progress.
/// </summary>
Computing,
/// <summary>
/// Successfully computed.
/// </summary>
Computed,
/// <summary>
/// Computation failed.
/// </summary>
Failed,
/// <summary>
/// No diff detected (versions identical).
/// </summary>
NoDiff,
/// <summary>
/// Package not found.
/// </summary>
PackageNotFound
}
/// <summary>
/// Type of method change detected.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum MethodChangeType
{
/// <summary>
/// Method body was modified.
/// </summary>
Modified,
/// <summary>
/// Method was added in fixed version.
/// </summary>
Added,
/// <summary>
/// Method was removed in fixed version.
/// </summary>
Removed,
/// <summary>
/// Method signature changed.
/// </summary>
SignatureChanged
}

View File

@@ -0,0 +1,168 @@
// -----------------------------------------------------------------------------
// VulnSurfaceTrigger.cs
// Sprint: SPRINT_3700_0003_0001_trigger_extraction
// Description: Model for trigger methods that can reach vulnerable sinks.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.VulnSurfaces.Models;
/// <summary>
/// Represents a trigger method - a public API that can reach a vulnerable sink method.
/// </summary>
public sealed record VulnSurfaceTrigger
{
/// <summary>
/// Surface ID this trigger belongs to.
/// </summary>
[JsonPropertyName("surface_id")]
public long SurfaceId { get; init; }
/// <summary>
/// Unique key for the trigger method (public API).
/// Format: namespace.class::methodName(signature)
/// </summary>
[JsonPropertyName("trigger_method_key")]
public required string TriggerMethodKey { get; init; }
/// <summary>
/// Unique key for the sink method (vulnerable code location).
/// </summary>
[JsonPropertyName("sink_method_key")]
public required string SinkMethodKey { get; init; }
/// <summary>
/// Internal call path from trigger to sink within the package.
/// </summary>
[JsonPropertyName("internal_path")]
public IReadOnlyList<string>? InternalPath { get; init; }
/// <summary>
/// Whether this trigger was found via interface/base method expansion.
/// </summary>
[JsonPropertyName("is_interface_expansion")]
public bool IsInterfaceExpansion { get; init; }
/// <summary>
/// Depth from trigger to sink.
/// </summary>
[JsonPropertyName("depth")]
public int Depth { get; init; }
/// <summary>
/// Confidence score for this trigger path (0.0-1.0).
/// </summary>
[JsonPropertyName("confidence")]
public double Confidence { get; init; } = 1.0;
}
/// <summary>
/// Internal method reference within a call graph.
/// </summary>
public sealed record InternalMethodRef
{
/// <summary>
/// Fully qualified method key.
/// </summary>
public required string MethodKey { get; init; }
/// <summary>
/// Method name without namespace.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Declaring type name.
/// </summary>
public required string DeclaringType { get; init; }
/// <summary>
/// Whether this method is public.
/// </summary>
public bool IsPublic { get; init; }
/// <summary>
/// Whether this method is from an interface.
/// </summary>
public bool IsInterface { get; init; }
/// <summary>
/// Whether this method is virtual/abstract (can be overridden).
/// </summary>
public bool IsVirtual { get; init; }
/// <summary>
/// Signature parameters.
/// </summary>
public IReadOnlyList<string>? Parameters { get; init; }
/// <summary>
/// Return type.
/// </summary>
public string? ReturnType { get; init; }
}
/// <summary>
/// Edge in the internal call graph.
/// </summary>
public sealed record InternalCallEdge
{
/// <summary>
/// Caller method key.
/// </summary>
public required string Caller { get; init; }
/// <summary>
/// Callee method key.
/// </summary>
public required string Callee { get; init; }
/// <summary>
/// Call site offset (IL offset for .NET, bytecode offset for Java).
/// </summary>
public int? CallSiteOffset { get; init; }
/// <summary>
/// Whether this is a virtual/dispatch call.
/// </summary>
public bool IsVirtualCall { get; init; }
}
/// <summary>
/// Result of trigger extraction for a vulnerability surface.
/// </summary>
public sealed record TriggerExtractionResult
{
/// <summary>
/// Whether extraction succeeded.
/// </summary>
public bool Success { get; init; }
/// <summary>
/// Extracted triggers.
/// </summary>
public IReadOnlyList<VulnSurfaceTrigger> Triggers { get; init; } = [];
/// <summary>
/// Error message if extraction failed.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Number of public methods analyzed.
/// </summary>
public int PublicMethodsAnalyzed { get; init; }
/// <summary>
/// Number of internal edges in the call graph.
/// </summary>
public int InternalEdgeCount { get; init; }
/// <summary>
/// Extraction duration.
/// </summary>
public TimeSpan Duration { get; init; }
}

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<RootNamespace>StellaOps.Scanner.VulnSurfaces</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="Mono.Cecil" Version="0.11.6" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Core\StellaOps.Scanner.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,65 @@
// -----------------------------------------------------------------------------
// ITriggerMethodExtractor.cs
// Sprint: SPRINT_3700_0003_0001_trigger_extraction
// Description: Interface for extracting trigger methods from internal call graphs.
// -----------------------------------------------------------------------------
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Scanner.VulnSurfaces.Models;
namespace StellaOps.Scanner.VulnSurfaces.Triggers;
/// <summary>
/// Extracts trigger methods (public API entry points) that can reach vulnerable sink methods.
/// Uses forward BFS from public methods to find paths to sinks.
/// </summary>
public interface ITriggerMethodExtractor
{
/// <summary>
/// Extracts trigger methods for a vulnerability surface.
/// </summary>
/// <param name="request">Extraction request with sink and graph info.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Extraction result with triggers.</returns>
Task<TriggerExtractionResult> ExtractAsync(
TriggerExtractionRequest request,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Request to extract trigger methods.
/// </summary>
public sealed record TriggerExtractionRequest
{
/// <summary>
/// Surface ID for the vulnerability.
/// </summary>
public long SurfaceId { get; init; }
/// <summary>
/// Sink method keys (vulnerable code locations).
/// </summary>
public required IReadOnlyList<string> SinkMethodKeys { get; init; }
/// <summary>
/// Internal call graph for the package.
/// </summary>
public required CallGraph.InternalCallGraph Graph { get; init; }
/// <summary>
/// Maximum BFS depth.
/// </summary>
public int MaxDepth { get; init; } = 20;
/// <summary>
/// Whether to expand interfaces and base classes.
/// </summary>
public bool ExpandInterfaces { get; init; } = true;
/// <summary>
/// Minimum confidence threshold for triggers.
/// </summary>
public double MinConfidence { get; init; } = 0.0;
}

View File

@@ -0,0 +1,270 @@
// -----------------------------------------------------------------------------
// TriggerMethodExtractor.cs
// Sprint: SPRINT_3700_0003_0001_trigger_extraction
// Description: Implementation of trigger method extraction using forward BFS.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.VulnSurfaces.CallGraph;
using StellaOps.Scanner.VulnSurfaces.Models;
namespace StellaOps.Scanner.VulnSurfaces.Triggers;
/// <summary>
/// Extracts trigger methods using forward BFS from public methods to sinks.
/// </summary>
public sealed class TriggerMethodExtractor : ITriggerMethodExtractor
{
private readonly ILogger<TriggerMethodExtractor> _logger;
public TriggerMethodExtractor(ILogger<TriggerMethodExtractor> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public Task<TriggerExtractionResult> ExtractAsync(
TriggerExtractionRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var sw = Stopwatch.StartNew();
try
{
var triggers = ExtractTriggersCore(request, cancellationToken);
sw.Stop();
_logger.LogDebug(
"Extracted {TriggerCount} triggers for surface {SurfaceId} in {Duration}ms",
triggers.Count, request.SurfaceId, sw.ElapsedMilliseconds);
return Task.FromResult(new TriggerExtractionResult
{
Success = true,
Triggers = triggers,
PublicMethodsAnalyzed = request.Graph.GetPublicMethods().Count(),
InternalEdgeCount = request.Graph.EdgeCount,
Duration = sw.Elapsed
});
}
catch (Exception ex)
{
sw.Stop();
_logger.LogWarning(ex, "Trigger extraction failed for surface {SurfaceId}", request.SurfaceId);
return Task.FromResult(new TriggerExtractionResult
{
Success = false,
Error = ex.Message,
Duration = sw.Elapsed
});
}
}
private List<VulnSurfaceTrigger> ExtractTriggersCore(
TriggerExtractionRequest request,
CancellationToken cancellationToken)
{
var triggers = new List<VulnSurfaceTrigger>();
var sinkSet = request.SinkMethodKeys.ToHashSet(StringComparer.Ordinal);
// For each public method, run forward BFS to find sinks
foreach (var publicMethod in request.Graph.GetPublicMethods())
{
cancellationToken.ThrowIfCancellationRequested();
var paths = FindPathsToSinks(
request.Graph,
publicMethod.MethodKey,
sinkSet,
request.MaxDepth,
cancellationToken);
foreach (var (sinkKey, path, isInterfaceExpansion) in paths)
{
var trigger = new VulnSurfaceTrigger
{
SurfaceId = request.SurfaceId,
TriggerMethodKey = publicMethod.MethodKey,
SinkMethodKey = sinkKey,
InternalPath = path,
Depth = path.Count - 1,
IsInterfaceExpansion = isInterfaceExpansion,
Confidence = ComputeConfidence(path, publicMethod, request.Graph)
};
if (trigger.Confidence >= request.MinConfidence)
{
triggers.Add(trigger);
}
}
}
// If interface expansion is enabled, also check interface implementations
if (request.ExpandInterfaces)
{
var interfaceTriggers = ExtractInterfaceExpansionTriggers(
request, sinkSet, triggers, cancellationToken);
triggers.AddRange(interfaceTriggers);
}
return triggers;
}
private static List<(string SinkKey, List<string> Path, bool IsInterfaceExpansion)> FindPathsToSinks(
InternalCallGraph graph,
string startMethod,
HashSet<string> sinks,
int maxDepth,
CancellationToken cancellationToken)
{
var results = new List<(string, List<string>, bool)>();
var visited = new HashSet<string>(StringComparer.Ordinal);
var queue = new Queue<(string Method, List<string> Path)>();
queue.Enqueue((startMethod, [startMethod]));
visited.Add(startMethod);
while (queue.Count > 0)
{
cancellationToken.ThrowIfCancellationRequested();
var (current, path) = queue.Dequeue();
if (path.Count > maxDepth)
continue;
// Check if current is a sink
if (sinks.Contains(current) && path.Count > 1)
{
results.Add((current, new List<string>(path), false));
}
// Explore callees
foreach (var callee in graph.GetCallees(current))
{
if (!visited.Contains(callee))
{
visited.Add(callee);
var newPath = new List<string>(path) { callee };
queue.Enqueue((callee, newPath));
}
}
}
return results;
}
private IEnumerable<VulnSurfaceTrigger> ExtractInterfaceExpansionTriggers(
TriggerExtractionRequest request,
HashSet<string> sinkSet,
List<VulnSurfaceTrigger> existingTriggers,
CancellationToken cancellationToken)
{
// Find interface methods and their implementations
var interfaceMethods = request.Graph.Methods.Values
.Where(m => m.IsInterface || m.IsVirtual)
.ToList();
var expansionTriggers = new List<VulnSurfaceTrigger>();
foreach (var interfaceMethod in interfaceMethods)
{
cancellationToken.ThrowIfCancellationRequested();
// Find implementations by name matching (simplified)
var implementations = FindPotentialImplementations(
request.Graph, interfaceMethod.MethodKey, interfaceMethod.Name);
foreach (var implKey in implementations)
{
// Check if implementation reaches any sink
var paths = FindPathsToSinks(
request.Graph, implKey, sinkSet, request.MaxDepth, cancellationToken);
foreach (var (sinkKey, path, _) in paths)
{
// Skip if we already have this trigger from direct analysis
if (existingTriggers.Any(t =>
t.TriggerMethodKey == interfaceMethod.MethodKey &&
t.SinkMethodKey == sinkKey))
{
continue;
}
// Add interface method -> implementation -> sink trigger
var fullPath = new List<string> { interfaceMethod.MethodKey };
fullPath.AddRange(path);
expansionTriggers.Add(new VulnSurfaceTrigger
{
SurfaceId = request.SurfaceId,
TriggerMethodKey = interfaceMethod.MethodKey,
SinkMethodKey = sinkKey,
InternalPath = fullPath,
Depth = fullPath.Count - 1,
IsInterfaceExpansion = true,
Confidence = 0.8 * ComputeConfidence(path, request.Graph.GetMethod(implKey), request.Graph)
});
}
}
}
return expansionTriggers;
}
private static IEnumerable<string> FindPotentialImplementations(
InternalCallGraph graph,
string interfaceMethodKey,
string methodName)
{
// Find methods with same name that aren't the interface method itself
return graph.Methods.Values
.Where(m => m.Name == methodName &&
m.MethodKey != interfaceMethodKey &&
!m.IsInterface)
.Select(m => m.MethodKey);
}
private static double ComputeConfidence(
List<string> path,
InternalMethodRef? startMethod,
InternalCallGraph graph)
{
// Base confidence starts at 1.0
var confidence = 1.0;
// Reduce confidence for longer paths
confidence *= Math.Max(0.5, 1.0 - (path.Count * 0.05));
// Reduce confidence if path goes through virtual calls
var virtualCallCount = 0;
for (var i = 0; i < path.Count - 1; i++)
{
var method = graph.GetMethod(path[i + 1]);
if (method?.IsVirtual == true)
{
virtualCallCount++;
}
}
confidence *= Math.Max(0.6, 1.0 - (virtualCallCount * 0.1));
// Boost confidence if start method is explicitly public
if (startMethod?.IsPublic == true)
{
confidence = Math.Min(1.0, confidence * 1.1);
}
return Math.Round(confidence, 3);
}
}

View File

@@ -0,0 +1,341 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Analyzers.Native.Index;
using StellaOps.Scanner.Emit.Native;
using Xunit;
namespace StellaOps.Scanner.Emit.Tests.Native;
/// <summary>
/// Unit tests for <see cref="NativePurlBuilder"/>.
/// Sprint: SPRINT_3500_0012_0001
/// Task: BSE-008
/// </summary>
public sealed class NativePurlBuilderTests
{
private readonly NativePurlBuilder _builder = new();
#region FromIndexResult Tests
[Fact]
public void FromIndexResult_ReturnsPurlFromResult()
{
var result = new BuildIdLookupResult(
BuildId: "gnu-build-id:abc123",
Purl: "pkg:deb/debian/libc6@2.31",
Version: "2.31",
SourceDistro: "debian",
Confidence: BuildIdConfidence.Exact,
IndexedAt: DateTimeOffset.UtcNow);
var purl = _builder.FromIndexResult(result);
Assert.Equal("pkg:deb/debian/libc6@2.31", purl);
}
[Fact]
public void FromIndexResult_ThrowsForNull()
{
Assert.Throws<ArgumentNullException>(() => _builder.FromIndexResult(null!));
}
#endregion
#region FromUnresolvedBinary Tests
[Fact]
public void FromUnresolvedBinary_GeneratesGenericPurl()
{
var metadata = new NativeBinaryMetadata
{
Format = "elf",
FilePath = "/usr/lib/libssl.so.3"
};
var purl = _builder.FromUnresolvedBinary(metadata);
Assert.StartsWith("pkg:generic/libssl.so.3@unknown", purl);
}
[Fact]
public void FromUnresolvedBinary_IncludesBuildId()
{
var metadata = new NativeBinaryMetadata
{
Format = "elf",
FilePath = "/usr/lib/libssl.so.3",
BuildId = "gnu-build-id:abc123def456"
};
var purl = _builder.FromUnresolvedBinary(metadata);
Assert.Contains("build-id=gnu-build-id%3Aabc123def456", purl);
}
[Fact]
public void FromUnresolvedBinary_IncludesArchitecture()
{
var metadata = new NativeBinaryMetadata
{
Format = "elf",
FilePath = "/usr/lib/libssl.so.3",
Architecture = "x86_64"
};
var purl = _builder.FromUnresolvedBinary(metadata);
Assert.Contains("arch=x86_64", purl);
}
[Fact]
public void FromUnresolvedBinary_IncludesPlatform()
{
var metadata = new NativeBinaryMetadata
{
Format = "elf",
FilePath = "/usr/lib/libssl.so.3",
Platform = "linux"
};
var purl = _builder.FromUnresolvedBinary(metadata);
Assert.Contains("os=linux", purl);
}
[Fact]
public void FromUnresolvedBinary_SortsQualifiersAlphabetically()
{
var metadata = new NativeBinaryMetadata
{
Format = "elf",
FilePath = "/usr/lib/libssl.so.3",
BuildId = "gnu-build-id:abc",
Architecture = "x86_64",
Platform = "linux"
};
var purl = _builder.FromUnresolvedBinary(metadata);
// arch < build-id < os (alphabetical)
var archIndex = purl.IndexOf("arch=", StringComparison.Ordinal);
var buildIdIndex = purl.IndexOf("build-id=", StringComparison.Ordinal);
var osIndex = purl.IndexOf("os=", StringComparison.Ordinal);
Assert.True(archIndex < buildIdIndex);
Assert.True(buildIdIndex < osIndex);
}
#endregion
#region FromDistroPackage Tests
[Theory]
[InlineData("deb", "debian", "pkg:deb/debian/libc6@2.31")]
[InlineData("debian", "debian", "pkg:deb/debian/libc6@2.31")]
[InlineData("ubuntu", "ubuntu", "pkg:deb/ubuntu/libc6@2.31")]
[InlineData("rpm", "fedora", "pkg:rpm/fedora/libc6@2.31")]
[InlineData("apk", "alpine", "pkg:apk/alpine/libc6@2.31")]
[InlineData("pacman", "arch", "pkg:pacman/arch/libc6@2.31")]
public void FromDistroPackage_MapsDistroToPurlType(string distro, string distroName, string expectedPrefix)
{
var purl = _builder.FromDistroPackage(distro, distroName, "libc6", "2.31");
Assert.StartsWith(expectedPrefix, purl);
}
[Fact]
public void FromDistroPackage_IncludesArchitecture()
{
var purl = _builder.FromDistroPackage("deb", "debian", "libc6", "2.31", "amd64");
Assert.Equal("pkg:deb/debian/libc6@2.31?arch=amd64", purl);
}
[Fact]
public void FromDistroPackage_ThrowsForNullDistro()
{
Assert.ThrowsAny<ArgumentException>(() =>
_builder.FromDistroPackage(null!, "debian", "libc6", "2.31"));
}
[Fact]
public void FromDistroPackage_ThrowsForNullPackageName()
{
Assert.ThrowsAny<ArgumentException>(() =>
_builder.FromDistroPackage("deb", "debian", null!, "2.31"));
}
#endregion
}
/// <summary>
/// Unit tests for <see cref="NativeComponentEmitter"/>.
/// Sprint: SPRINT_3500_0012_0001
/// Task: BSE-008
/// </summary>
public sealed class NativeComponentEmitterTests
{
#region EmitAsync Tests
[Fact]
public async Task EmitAsync_UsesIndexMatch_WhenFound()
{
var index = new FakeBuildIdIndex();
index.AddEntry("gnu-build-id:abc123", new BuildIdLookupResult(
BuildId: "gnu-build-id:abc123",
Purl: "pkg:deb/debian/libc6@2.31",
Version: "2.31",
SourceDistro: "debian",
Confidence: BuildIdConfidence.Exact,
IndexedAt: DateTimeOffset.UtcNow));
var emitter = new NativeComponentEmitter(index, NullLogger<NativeComponentEmitter>.Instance);
var metadata = new NativeBinaryMetadata
{
Format = "elf",
FilePath = "/usr/lib/libc.so.6",
BuildId = "gnu-build-id:abc123"
};
var result = await emitter.EmitAsync(metadata);
Assert.True(result.IndexMatch);
Assert.Equal("pkg:deb/debian/libc6@2.31", result.Purl);
Assert.Equal("2.31", result.Version);
Assert.NotNull(result.LookupResult);
}
[Fact]
public async Task EmitAsync_FallsBackToGenericPurl_WhenNotFound()
{
var index = new FakeBuildIdIndex();
var emitter = new NativeComponentEmitter(index, NullLogger<NativeComponentEmitter>.Instance);
var metadata = new NativeBinaryMetadata
{
Format = "elf",
FilePath = "/usr/lib/libcustom.so",
BuildId = "gnu-build-id:notfound"
};
var result = await emitter.EmitAsync(metadata);
Assert.False(result.IndexMatch);
Assert.StartsWith("pkg:generic/libcustom.so@unknown", result.Purl);
Assert.Null(result.LookupResult);
}
[Fact]
public async Task EmitAsync_ExtractsFilename()
{
var index = new FakeBuildIdIndex();
var emitter = new NativeComponentEmitter(index, NullLogger<NativeComponentEmitter>.Instance);
var metadata = new NativeBinaryMetadata
{
Format = "elf",
FilePath = "/very/deep/path/to/libfoo.so.1.2.3"
};
var result = await emitter.EmitAsync(metadata);
Assert.Equal("libfoo.so.1.2.3", result.Name);
}
[Fact]
public async Task EmitAsync_UsesProductVersion_WhenNotInIndex()
{
var index = new FakeBuildIdIndex();
var emitter = new NativeComponentEmitter(index, NullLogger<NativeComponentEmitter>.Instance);
var metadata = new NativeBinaryMetadata
{
Format = "pe",
FilePath = "C:\\Windows\\System32\\kernel32.dll",
ProductVersion = "10.0.19041.1"
};
var result = await emitter.EmitAsync(metadata);
Assert.Equal("10.0.19041.1", result.Version);
}
#endregion
#region EmitBatchAsync Tests
[Fact]
public async Task EmitBatchAsync_ProcessesMultipleBinaries()
{
var index = new FakeBuildIdIndex();
index.AddEntry("gnu-build-id:aaa", new BuildIdLookupResult(
"gnu-build-id:aaa", "pkg:deb/debian/liba@1.0", "1.0", "debian", BuildIdConfidence.Exact, DateTimeOffset.UtcNow));
index.AddEntry("gnu-build-id:bbb", new BuildIdLookupResult(
"gnu-build-id:bbb", "pkg:deb/debian/libb@2.0", "2.0", "debian", BuildIdConfidence.Exact, DateTimeOffset.UtcNow));
var emitter = new NativeComponentEmitter(index, NullLogger<NativeComponentEmitter>.Instance);
var metadataList = new[]
{
new NativeBinaryMetadata { Format = "elf", FilePath = "/lib/liba.so", BuildId = "gnu-build-id:aaa" },
new NativeBinaryMetadata { Format = "elf", FilePath = "/lib/libb.so", BuildId = "gnu-build-id:bbb" },
new NativeBinaryMetadata { Format = "elf", FilePath = "/lib/libc.so", BuildId = "gnu-build-id:ccc" }
};
var results = await emitter.EmitBatchAsync(metadataList);
Assert.Equal(3, results.Count);
Assert.Equal(2, results.Count(r => r.IndexMatch));
Assert.Equal(1, results.Count(r => !r.IndexMatch));
}
[Fact]
public async Task EmitBatchAsync_ReturnsEmptyForEmptyInput()
{
var index = new FakeBuildIdIndex();
var emitter = new NativeComponentEmitter(index, NullLogger<NativeComponentEmitter>.Instance);
var results = await emitter.EmitBatchAsync(Array.Empty<NativeBinaryMetadata>());
Assert.Empty(results);
}
#endregion
#region Test Helpers
private sealed class FakeBuildIdIndex : IBuildIdIndex
{
private readonly Dictionary<string, BuildIdLookupResult> _entries = new(StringComparer.OrdinalIgnoreCase);
public int Count => _entries.Count;
public bool IsLoaded => true;
public void AddEntry(string buildId, BuildIdLookupResult result)
{
_entries[buildId] = result;
}
public Task<BuildIdLookupResult?> LookupAsync(string buildId, CancellationToken cancellationToken = default)
{
_entries.TryGetValue(buildId, out var result);
return Task.FromResult(result);
}
public Task<IReadOnlyList<BuildIdLookupResult>> BatchLookupAsync(
IEnumerable<string> buildIds,
CancellationToken cancellationToken = default)
{
var results = buildIds
.Where(id => _entries.ContainsKey(id))
.Select(id => _entries[id])
.ToList();
return Task.FromResult<IReadOnlyList<BuildIdLookupResult>>(results);
}
public Task LoadAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
}
#endregion
}

View File

@@ -0,0 +1,445 @@
// -----------------------------------------------------------------------------
// PathExplanationServiceTests.cs
// Sprint: SPRINT_3620_0002_0001_path_explanation
// Description: Unit tests for PathExplanationService.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.Reachability.Explanation;
using StellaOps.Scanner.Reachability.Gates;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests;
public class PathExplanationServiceTests
{
private readonly PathExplanationService _service;
private readonly PathRenderer _renderer;
public PathExplanationServiceTests()
{
_service = new PathExplanationService(
NullLogger<PathExplanationService>.Instance);
_renderer = new PathRenderer();
}
[Fact]
public async Task ExplainAsync_WithSimplePath_ReturnsExplainedPath()
{
// Arrange
var graph = CreateSimpleGraph();
var query = new PathExplanationQuery();
// Act
var result = await _service.ExplainAsync(graph, query);
// Assert
Assert.NotNull(result);
Assert.True(result.TotalCount >= 0);
}
[Fact]
public async Task ExplainAsync_WithSinkFilter_FiltersResults()
{
// Arrange
var graph = CreateGraphWithMultipleSinks();
var query = new PathExplanationQuery { SinkId = "sink-1" };
// Act
var result = await _service.ExplainAsync(graph, query);
// Assert
Assert.NotNull(result);
foreach (var path in result.Paths)
{
Assert.Equal("sink-1", path.SinkId);
}
}
[Fact]
public async Task ExplainAsync_WithGatesFilter_FiltersPathsWithGates()
{
// Arrange
var graph = CreateGraphWithGates();
var query = new PathExplanationQuery { HasGates = true };
// Act
var result = await _service.ExplainAsync(graph, query);
// Assert
Assert.NotNull(result);
foreach (var path in result.Paths)
{
Assert.True(path.Gates.Count > 0);
}
}
[Fact]
public async Task ExplainAsync_WithMaxPathLength_LimitsPathLength()
{
// Arrange
var graph = CreateDeepGraph(10);
var query = new PathExplanationQuery { MaxPathLength = 5 };
// Act
var result = await _service.ExplainAsync(graph, query);
// Assert
Assert.NotNull(result);
foreach (var path in result.Paths)
{
Assert.True(path.PathLength <= 5);
}
}
[Fact]
public async Task ExplainAsync_WithMaxPaths_LimitsResults()
{
// Arrange
var graph = CreateGraphWithMultiplePaths(20);
var query = new PathExplanationQuery { MaxPaths = 5 };
// Act
var result = await _service.ExplainAsync(graph, query);
// Assert
Assert.NotNull(result);
Assert.True(result.Paths.Count <= 5);
if (result.TotalCount > 5)
{
Assert.True(result.HasMore);
}
}
[Fact]
public void Renderer_Text_ProducesExpectedFormat()
{
// Arrange
var path = CreateTestPath();
// Act
var text = _renderer.Render(path, PathOutputFormat.Text);
// Assert
Assert.Contains(path.EntrypointSymbol, text);
Assert.Contains("SINK:", text);
}
[Fact]
public void Renderer_Markdown_ProducesExpectedFormat()
{
// Arrange
var path = CreateTestPath();
// Act
var markdown = _renderer.Render(path, PathOutputFormat.Markdown);
// Assert
Assert.Contains("###", markdown);
Assert.Contains("```", markdown);
Assert.Contains(path.EntrypointSymbol, markdown);
}
[Fact]
public void Renderer_Json_ProducesValidJson()
{
// Arrange
var path = CreateTestPath();
// Act
var json = _renderer.Render(path, PathOutputFormat.Json);
// Assert
Assert.StartsWith("{", json.Trim());
Assert.EndsWith("}", json.Trim());
Assert.Contains("sink_id", json);
Assert.Contains("entrypoint_id", json);
}
[Fact]
public void Renderer_WithGates_IncludesGateInfo()
{
// Arrange
var path = CreateTestPathWithGates();
// Act
var text = _renderer.Render(path, PathOutputFormat.Text);
// Assert
Assert.Contains("Gates:", text);
Assert.Contains("multiplier", text.ToLowerInvariant());
}
[Fact]
public async Task ExplainPathAsync_WithValidId_ReturnsPath()
{
// Arrange
var graph = CreateSimpleGraph();
// This test verifies the API works, actual path lookup depends on graph structure
// Act
var result = await _service.ExplainPathAsync(graph, "entry-1:sink-1:0");
// The result may be null if path doesn't exist, that's OK
Assert.True(result is null || result.PathId is not null);
}
[Fact]
public void GateMultiplier_Calculation_IsCorrect()
{
// Arrange - path with auth gate
var pathWithAuth = CreateTestPathWithGates();
// Assert - auth gate should reduce multiplier
Assert.True(pathWithAuth.GateMultiplierBps < 10000);
}
[Fact]
public void PathWithoutGates_HasFullMultiplier()
{
// Arrange
var path = CreateTestPath();
// Assert - no gates = 100% multiplier
Assert.Equal(10000, path.GateMultiplierBps);
}
private static RichGraph CreateSimpleGraph()
{
return new RichGraph
{
Schema = "stellaops.richgraph.v1",
Meta = new RichGraphMeta { Hash = "test-hash" },
Roots = new[]
{
new RichGraphRoot("entry-1", "runtime", null)
},
Nodes = new[]
{
new RichGraphNode(
Id: "entry-1",
SymbolId: "Handler.handle",
CodeId: null,
Purl: null,
Lang: "java",
Kind: "http_handler",
Display: "GET /users",
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null),
new RichGraphNode(
Id: "sink-1",
SymbolId: "DB.query",
CodeId: null,
Purl: null,
Lang: "java",
Kind: "sql_sink",
Display: "executeQuery",
BuildId: null,
Evidence: null,
Attributes: new Dictionary<string, string> { ["is_sink"] = "true" },
SymbolDigest: null)
},
Edges = new[]
{
new RichGraphEdge("entry-1", "sink-1", "call", null)
}
};
}
private static RichGraph CreateGraphWithMultipleSinks()
{
return new RichGraph
{
Schema = "stellaops.richgraph.v1",
Meta = new RichGraphMeta { Hash = "test-hash" },
Roots = new[] { new RichGraphRoot("entry-1", "runtime", null) },
Nodes = new[]
{
new RichGraphNode("entry-1", "Handler", null, null, "java", "handler", null, null, null, null, null),
new RichGraphNode("sink-1", "Sink1", null, null, "java", "sink", null, null, null,
new Dictionary<string, string> { ["is_sink"] = "true" }, null),
new RichGraphNode("sink-2", "Sink2", null, null, "java", "sink", null, null, null,
new Dictionary<string, string> { ["is_sink"] = "true" }, null)
},
Edges = new[]
{
new RichGraphEdge("entry-1", "sink-1", "call", null),
new RichGraphEdge("entry-1", "sink-2", "call", null)
}
};
}
private static RichGraph CreateGraphWithGates()
{
var gates = new[]
{
new DetectedGate
{
Type = GateType.AuthRequired,
Detail = "@Authenticated",
GuardSymbol = "AuthFilter",
Confidence = 0.9,
DetectionMethod = "annotation"
}
};
return new RichGraph
{
Schema = "stellaops.richgraph.v1",
Meta = new RichGraphMeta { Hash = "test-hash" },
Roots = new[] { new RichGraphRoot("entry-1", "runtime", null) },
Nodes = new[]
{
new RichGraphNode("entry-1", "Handler", null, null, "java", "handler", null, null, null, null, null),
new RichGraphNode("sink-1", "Sink", null, null, "java", "sink", null, null, null,
new Dictionary<string, string> { ["is_sink"] = "true" }, null)
},
Edges = new[]
{
new RichGraphEdge("entry-1", "sink-1", "call", gates)
}
};
}
private static RichGraph CreateDeepGraph(int depth)
{
var nodes = new List<RichGraphNode>();
var edges = new List<RichGraphEdge>();
for (var i = 0; i < depth; i++)
{
var attrs = i == depth - 1
? new Dictionary<string, string> { ["is_sink"] = "true" }
: null;
nodes.Add(new RichGraphNode($"node-{i}", $"Method{i}", null, null, "java", i == depth - 1 ? "sink" : "method", null, null, null, attrs, null));
if (i > 0)
{
edges.Add(new RichGraphEdge($"node-{i - 1}", $"node-{i}", "call", null));
}
}
return new RichGraph
{
Schema = "stellaops.richgraph.v1",
Meta = new RichGraphMeta { Hash = "test-hash" },
Roots = new[] { new RichGraphRoot("node-0", "runtime", null) },
Nodes = nodes,
Edges = edges
};
}
private static RichGraph CreateGraphWithMultiplePaths(int pathCount)
{
var nodes = new List<RichGraphNode>
{
new("entry-1", "Handler", null, null, "java", "handler", null, null, null, null, null)
};
var edges = new List<RichGraphEdge>();
for (var i = 0; i < pathCount; i++)
{
nodes.Add(new RichGraphNode($"sink-{i}", $"Sink{i}", null, null, "java", "sink", null, null, null,
new Dictionary<string, string> { ["is_sink"] = "true" }, null));
edges.Add(new RichGraphEdge("entry-1", $"sink-{i}", "call", null));
}
return new RichGraph
{
Schema = "stellaops.richgraph.v1",
Meta = new RichGraphMeta { Hash = "test-hash" },
Roots = new[] { new RichGraphRoot("entry-1", "runtime", null) },
Nodes = nodes,
Edges = edges
};
}
private static ExplainedPath CreateTestPath()
{
return new ExplainedPath
{
PathId = "entry:sink:0",
SinkId = "sink-1",
SinkSymbol = "DB.query",
SinkCategory = SinkCategory.SqlRaw,
EntrypointId = "entry-1",
EntrypointSymbol = "Handler.handle",
EntrypointType = EntrypointType.HttpEndpoint,
PathLength = 2,
Hops = new[]
{
new ExplainedPathHop
{
NodeId = "entry-1",
Symbol = "Handler.handle",
Package = "app",
Depth = 0,
IsEntrypoint = true,
IsSink = false
},
new ExplainedPathHop
{
NodeId = "sink-1",
Symbol = "DB.query",
Package = "database",
Depth = 1,
IsEntrypoint = false,
IsSink = true
}
},
Gates = Array.Empty<DetectedGate>(),
GateMultiplierBps = 10000
};
}
private static ExplainedPath CreateTestPathWithGates()
{
return new ExplainedPath
{
PathId = "entry:sink:0",
SinkId = "sink-1",
SinkSymbol = "DB.query",
SinkCategory = SinkCategory.SqlRaw,
EntrypointId = "entry-1",
EntrypointSymbol = "Handler.handle",
EntrypointType = EntrypointType.HttpEndpoint,
PathLength = 2,
Hops = new[]
{
new ExplainedPathHop
{
NodeId = "entry-1",
Symbol = "Handler.handle",
Package = "app",
Depth = 0,
IsEntrypoint = true,
IsSink = false
},
new ExplainedPathHop
{
NodeId = "sink-1",
Symbol = "DB.query",
Package = "database",
Depth = 1,
IsEntrypoint = false,
IsSink = true
}
},
Gates = new[]
{
new DetectedGate
{
Type = GateType.AuthRequired,
Detail = "@Authenticated",
GuardSymbol = "AuthFilter",
Confidence = 0.9,
DetectionMethod = "annotation"
}
},
GateMultiplierBps = 3000
};
}
}

View File

@@ -0,0 +1,412 @@
// -----------------------------------------------------------------------------
// RichGraphBoundaryExtractorTests.cs
// Sprint: SPRINT_3800_0002_0001_boundary_richgraph
// Description: Unit tests for RichGraphBoundaryExtractor.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.Reachability.Boundary;
using StellaOps.Scanner.Reachability.Gates;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests;
public class RichGraphBoundaryExtractorTests
{
private readonly RichGraphBoundaryExtractor _extractor;
public RichGraphBoundaryExtractorTests()
{
_extractor = new RichGraphBoundaryExtractor(
NullLogger<RichGraphBoundaryExtractor>.Instance);
}
[Fact]
public void Extract_HttpRoot_ReturnsBoundaryWithApiSurface()
{
var root = new RichGraphRoot("root-http", "runtime", null);
var rootNode = new RichGraphNode(
Id: "node-1",
SymbolId: "com.example.Controller.handle",
CodeId: null,
Purl: null,
Lang: "java",
Kind: "http_handler",
Display: "POST /api/users",
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null);
var result = _extractor.Extract(root, rootNode, BoundaryExtractionContext.Empty);
Assert.NotNull(result);
Assert.Equal("network", result.Kind);
Assert.NotNull(result.Surface);
Assert.Equal("api", result.Surface.Type);
Assert.Equal("https", result.Surface.Protocol);
}
[Fact]
public void Extract_GrpcRoot_ReturnsBoundaryWithGrpcProtocol()
{
var root = new RichGraphRoot("root-grpc", "runtime", null);
var rootNode = new RichGraphNode(
Id: "node-1",
SymbolId: "com.example.UserService.getUser",
CodeId: null,
Purl: null,
Lang: "java",
Kind: "grpc_method",
Display: "UserService.GetUser",
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null);
var result = _extractor.Extract(root, rootNode, BoundaryExtractionContext.Empty);
Assert.NotNull(result);
Assert.NotNull(result.Surface);
Assert.Equal("grpc", result.Surface.Protocol);
}
[Fact]
public void Extract_CliRoot_ReturnsProcessBoundary()
{
var root = new RichGraphRoot("root-cli", "runtime", null);
var rootNode = new RichGraphNode(
Id: "node-1",
SymbolId: "Main",
CodeId: null,
Purl: null,
Lang: "csharp",
Kind: "cli_command",
Display: "stella scan",
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null);
var result = _extractor.Extract(root, rootNode, BoundaryExtractionContext.Empty);
Assert.NotNull(result);
Assert.Equal("process", result.Kind);
Assert.NotNull(result.Surface);
Assert.Equal("cli", result.Surface.Type);
}
[Fact]
public void Extract_LibraryPhase_ReturnsLibraryBoundary()
{
var root = new RichGraphRoot("root-lib", "library", null);
var rootNode = new RichGraphNode(
Id: "node-1",
SymbolId: "Utils.parseJson",
CodeId: null,
Purl: null,
Lang: "javascript",
Kind: "function",
Display: "parseJson",
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null);
var result = _extractor.Extract(root, rootNode, BoundaryExtractionContext.Empty);
Assert.NotNull(result);
Assert.Equal("library", result.Kind);
Assert.NotNull(result.Surface);
Assert.Equal("library", result.Surface.Type);
}
[Fact]
public void Extract_WithAuthGate_SetsAuthRequired()
{
var root = new RichGraphRoot("root-auth", "runtime", null);
var rootNode = new RichGraphNode(
Id: "node-1",
SymbolId: "Controller.handle",
CodeId: null,
Purl: null,
Lang: "java",
Kind: "http_handler",
Display: null,
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null);
var context = BoundaryExtractionContext.FromGates(new[]
{
new DetectedGate
{
Type = GateType.AuthRequired,
Detail = "JWT token required",
GuardSymbol = "AuthFilter.doFilter",
Confidence = 0.9,
DetectionMethod = "pattern_match"
}
});
var result = _extractor.Extract(root, rootNode, context);
Assert.NotNull(result);
Assert.NotNull(result.Auth);
Assert.True(result.Auth.Required);
Assert.Equal("jwt", result.Auth.Type);
}
[Fact]
public void Extract_WithAdminGate_SetsAdminRole()
{
var root = new RichGraphRoot("root-admin", "runtime", null);
var rootNode = new RichGraphNode(
Id: "node-1",
SymbolId: "AdminController.handle",
CodeId: null,
Purl: null,
Lang: "java",
Kind: "http_handler",
Display: null,
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null);
var context = BoundaryExtractionContext.FromGates(new[]
{
new DetectedGate
{
Type = GateType.AdminOnly,
Detail = "Requires admin role",
GuardSymbol = "RoleFilter.check",
Confidence = 0.85,
DetectionMethod = "annotation"
}
});
var result = _extractor.Extract(root, rootNode, context);
Assert.NotNull(result);
Assert.NotNull(result.Auth);
Assert.True(result.Auth.Required);
Assert.NotNull(result.Auth.Roles);
Assert.Contains("admin", result.Auth.Roles);
}
[Fact]
public void Extract_WithFeatureFlagGate_AddsControl()
{
var root = new RichGraphRoot("root-ff", "runtime", null);
var rootNode = new RichGraphNode(
Id: "node-1",
SymbolId: "BetaFeature.handle",
CodeId: null,
Purl: null,
Lang: "java",
Kind: "http_handler",
Display: null,
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null);
var context = BoundaryExtractionContext.FromGates(new[]
{
new DetectedGate
{
Type = GateType.FeatureFlag,
Detail = "beta_users_only",
GuardSymbol = "FeatureFlags.isEnabled",
Confidence = 0.95,
DetectionMethod = "call_analysis"
}
});
var result = _extractor.Extract(root, rootNode, context);
Assert.NotNull(result);
Assert.NotNull(result.Controls);
Assert.Single(result.Controls);
Assert.Equal("feature_flag", result.Controls[0].Type);
Assert.True(result.Controls[0].Active);
}
[Fact]
public void Extract_WithInternetFacingContext_SetsExposure()
{
var root = new RichGraphRoot("root-public", "runtime", null);
var rootNode = new RichGraphNode(
Id: "node-1",
SymbolId: "PublicApi.handle",
CodeId: null,
Purl: null,
Lang: "java",
Kind: "http_handler",
Display: null,
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null);
var context = BoundaryExtractionContext.ForEnvironment(
"production",
isInternetFacing: true,
networkZone: "dmz");
var result = _extractor.Extract(root, rootNode, context);
Assert.NotNull(result);
Assert.NotNull(result.Exposure);
Assert.True(result.Exposure.InternetFacing);
Assert.Equal("dmz", result.Exposure.Zone);
Assert.Equal("public", result.Exposure.Level);
}
[Fact]
public void Extract_InternalService_SetsInternalExposure()
{
var root = new RichGraphRoot("root-internal", "runtime", null);
var rootNode = new RichGraphNode(
Id: "node-1",
SymbolId: "InternalService.process",
CodeId: null,
Purl: null,
Lang: "java",
Kind: "internal_handler",
Display: null,
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null);
var result = _extractor.Extract(root, rootNode, BoundaryExtractionContext.Empty);
Assert.NotNull(result);
Assert.NotNull(result.Exposure);
Assert.False(result.Exposure.InternetFacing);
Assert.Equal("internal", result.Exposure.Level);
}
[Fact]
public void Extract_SetsConfidenceBasedOnContext()
{
var root = new RichGraphRoot("root-1", "runtime", null);
var rootNode = new RichGraphNode(
Id: "node-1",
SymbolId: "Api.handle",
CodeId: null,
Purl: null,
Lang: "java",
Kind: "http_handler",
Display: null,
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null);
// Empty context should have lower confidence
var emptyResult = _extractor.Extract(root, rootNode, BoundaryExtractionContext.Empty);
// Rich context should have higher confidence
var richContext = new BoundaryExtractionContext
{
IsInternetFacing = true,
NetworkZone = "dmz",
DetectedGates = new[]
{
new DetectedGate
{
Type = GateType.AuthRequired,
Detail = "auth",
GuardSymbol = "auth",
Confidence = 0.9,
DetectionMethod = "test"
}
}
};
var richResult = _extractor.Extract(root, rootNode, richContext);
Assert.NotNull(emptyResult);
Assert.NotNull(richResult);
Assert.True(richResult.Confidence > emptyResult.Confidence);
}
[Fact]
public void Extract_IsDeterministic()
{
var root = new RichGraphRoot("root-det", "runtime", null);
var rootNode = new RichGraphNode(
Id: "node-1",
SymbolId: "Api.handle",
CodeId: null,
Purl: null,
Lang: "java",
Kind: "http_handler",
Display: "GET /api/test",
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null);
var context = BoundaryExtractionContext.FromGates(new[]
{
new DetectedGate
{
Type = GateType.AuthRequired,
Detail = "JWT",
GuardSymbol = "Auth",
Confidence = 0.9,
DetectionMethod = "test"
}
});
var result1 = _extractor.Extract(root, rootNode, context);
var result2 = _extractor.Extract(root, rootNode, context);
Assert.NotNull(result1);
Assert.NotNull(result2);
Assert.Equal(result1.Kind, result2.Kind);
Assert.Equal(result1.Surface?.Type, result2.Surface?.Type);
Assert.Equal(result1.Auth?.Required, result2.Auth?.Required);
Assert.Equal(result1.Confidence, result2.Confidence);
}
[Fact]
public void CanHandle_AlwaysReturnsTrue()
{
Assert.True(_extractor.CanHandle(BoundaryExtractionContext.Empty));
Assert.True(_extractor.CanHandle(BoundaryExtractionContext.ForEnvironment("test")));
}
[Fact]
public void Priority_ReturnsBaseValue()
{
Assert.Equal(100, _extractor.Priority);
}
[Fact]
public async Task ExtractAsync_ReturnsResult()
{
var root = new RichGraphRoot("root-async", "runtime", null);
var rootNode = new RichGraphNode(
Id: "node-1",
SymbolId: "Api.handle",
CodeId: null,
Purl: null,
Lang: "java",
Kind: "http_handler",
Display: null,
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null);
var result = await _extractor.ExtractAsync(root, rootNode, BoundaryExtractionContext.Empty);
Assert.NotNull(result);
Assert.Equal("network", result.Kind);
}
}

View File

@@ -0,0 +1,289 @@
// -----------------------------------------------------------------------------
// EpssProviderTests.cs
// Sprint: SPRINT_3410_0002_0001_epss_scanner_integration
// Task: EPSS-SCAN-010
// Description: Unit tests for EpssProvider.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Scanner.Core.Epss;
using StellaOps.Scanner.Storage.Epss;
using StellaOps.Scanner.Storage.Repositories;
using Xunit;
namespace StellaOps.Scanner.Storage.Tests;
/// <summary>
/// Unit tests for <see cref="EpssProvider"/>.
/// </summary>
public sealed class EpssProviderTests
{
private readonly Mock<IEpssRepository> _mockRepository;
private readonly EpssProviderOptions _options;
private readonly FakeTimeProvider _timeProvider;
private readonly EpssProvider _provider;
public EpssProviderTests()
{
_mockRepository = new Mock<IEpssRepository>();
_options = new EpssProviderOptions
{
EnableCache = false,
MaxBatchSize = 100,
SourceIdentifier = "test"
};
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 12, 18, 12, 0, 0, TimeSpan.Zero));
_provider = new EpssProvider(
_mockRepository.Object,
Options.Create(_options),
NullLogger<EpssProvider>.Instance,
_timeProvider);
}
#region GetCurrentAsync Tests
[Fact]
public async Task GetCurrentAsync_ReturnsEvidence_WhenFound()
{
var cveId = "CVE-2021-44228";
var modelDate = new DateOnly(2025, 12, 17);
var entry = new EpssCurrentEntry(cveId, 0.97, 0.99, modelDate, Guid.NewGuid());
_mockRepository
.Setup(r => r.GetCurrentAsync(It.Is<IEnumerable<string>>(ids => ids.Contains(cveId)), It.IsAny<CancellationToken>()))
.ReturnsAsync(new Dictionary<string, EpssCurrentEntry> { [cveId] = entry });
var result = await _provider.GetCurrentAsync(cveId);
Assert.NotNull(result);
Assert.Equal(cveId, result.CveId);
Assert.Equal(0.97, result.Score);
Assert.Equal(0.99, result.Percentile);
Assert.Equal(modelDate, result.ModelDate);
Assert.Equal("test", result.Source);
}
[Fact]
public async Task GetCurrentAsync_ReturnsNull_WhenNotFound()
{
var cveId = "CVE-9999-99999";
_mockRepository
.Setup(r => r.GetCurrentAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new Dictionary<string, EpssCurrentEntry>());
var result = await _provider.GetCurrentAsync(cveId);
Assert.Null(result);
}
[Fact]
public async Task GetCurrentAsync_ThrowsForNullCveId()
{
await Assert.ThrowsAnyAsync<ArgumentException>(() => _provider.GetCurrentAsync(null!));
}
[Fact]
public async Task GetCurrentAsync_ThrowsForEmptyCveId()
{
await Assert.ThrowsAnyAsync<ArgumentException>(() => _provider.GetCurrentAsync(""));
}
#endregion
#region GetCurrentBatchAsync Tests
[Fact]
public async Task GetCurrentBatchAsync_ReturnsBatchResult()
{
var cveIds = new[] { "CVE-2021-44228", "CVE-2022-22965", "CVE-9999-99999" };
var modelDate = new DateOnly(2025, 12, 17);
var runId = Guid.NewGuid();
var results = new Dictionary<string, EpssCurrentEntry>
{
["CVE-2021-44228"] = new("CVE-2021-44228", 0.97, 0.99, modelDate, runId),
["CVE-2022-22965"] = new("CVE-2022-22965", 0.95, 0.98, modelDate, runId)
};
_mockRepository
.Setup(r => r.GetCurrentAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(results);
var batch = await _provider.GetCurrentBatchAsync(cveIds);
Assert.Equal(2, batch.Found.Count);
Assert.Single(batch.NotFound);
Assert.Contains("CVE-9999-99999", batch.NotFound);
Assert.Equal(modelDate, batch.ModelDate);
}
[Fact]
public async Task GetCurrentBatchAsync_ReturnsEmptyForEmptyInput()
{
var batch = await _provider.GetCurrentBatchAsync(Array.Empty<string>());
Assert.Empty(batch.Found);
Assert.Empty(batch.NotFound);
Assert.Equal(0, batch.LookupTimeMs);
}
[Fact]
public async Task GetCurrentBatchAsync_DeduplicatesCveIds()
{
var cveIds = new[] { "CVE-2021-44228", "cve-2021-44228", "CVE-2021-44228" };
var modelDate = new DateOnly(2025, 12, 17);
var runId = Guid.NewGuid();
_mockRepository
.Setup(r => r.GetCurrentAsync(
It.Is<IEnumerable<string>>(ids => ids.Count() == 1),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new Dictionary<string, EpssCurrentEntry>
{
["CVE-2021-44228"] = new("CVE-2021-44228", 0.97, 0.99, modelDate, runId)
});
var batch = await _provider.GetCurrentBatchAsync(cveIds);
Assert.Single(batch.Found);
_mockRepository.Verify(
r => r.GetCurrentAsync(It.Is<IEnumerable<string>>(ids => ids.Count() == 1), It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task GetCurrentBatchAsync_TruncatesOverMaxBatchSize()
{
// Create more CVEs than max batch size
var cveIds = Enumerable.Range(1, 150).Select(i => $"CVE-2021-{i:D5}").ToArray();
_mockRepository
.Setup(r => r.GetCurrentAsync(
It.Is<IEnumerable<string>>(ids => ids.Count() <= _options.MaxBatchSize),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new Dictionary<string, EpssCurrentEntry>());
var batch = await _provider.GetCurrentBatchAsync(cveIds);
_mockRepository.Verify(
r => r.GetCurrentAsync(
It.Is<IEnumerable<string>>(ids => ids.Count() == _options.MaxBatchSize),
It.IsAny<CancellationToken>()),
Times.Once);
}
#endregion
#region GetHistoryAsync Tests
[Fact]
public async Task GetHistoryAsync_ReturnsFilteredResults()
{
var cveId = "CVE-2021-44228";
var startDate = new DateOnly(2025, 12, 15);
var endDate = new DateOnly(2025, 12, 17);
var runId = Guid.NewGuid();
var history = new List<EpssHistoryEntry>
{
new(new DateOnly(2025, 12, 14), 0.95, 0.97, runId), // Before range
new(new DateOnly(2025, 12, 15), 0.96, 0.98, runId), // In range
new(new DateOnly(2025, 12, 16), 0.96, 0.98, runId), // In range
new(new DateOnly(2025, 12, 17), 0.97, 0.99, runId), // In range
new(new DateOnly(2025, 12, 18), 0.97, 0.99, runId), // After range
};
_mockRepository
.Setup(r => r.GetHistoryAsync(cveId, It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(history);
var result = await _provider.GetHistoryAsync(cveId, startDate, endDate);
Assert.Equal(3, result.Count);
Assert.All(result, e => Assert.True(e.ModelDate >= startDate && e.ModelDate <= endDate));
Assert.Equal(startDate, result.First().ModelDate);
Assert.Equal(endDate, result.Last().ModelDate);
}
[Fact]
public async Task GetHistoryAsync_ReturnsEmpty_WhenStartAfterEnd()
{
var cveId = "CVE-2021-44228";
var startDate = new DateOnly(2025, 12, 17);
var endDate = new DateOnly(2025, 12, 15);
var result = await _provider.GetHistoryAsync(cveId, startDate, endDate);
Assert.Empty(result);
_mockRepository.Verify(r => r.GetHistoryAsync(It.IsAny<string>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
}
#endregion
#region IsAvailableAsync Tests
[Fact]
public async Task IsAvailableAsync_ReturnsTrue_WhenDataExists()
{
var modelDate = new DateOnly(2025, 12, 17);
var runId = Guid.NewGuid();
_mockRepository
.Setup(r => r.GetCurrentAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new Dictionary<string, EpssCurrentEntry>
{
["CVE-2021-44228"] = new("CVE-2021-44228", 0.97, 0.99, modelDate, runId)
});
var result = await _provider.IsAvailableAsync();
Assert.True(result);
}
[Fact]
public async Task IsAvailableAsync_ReturnsFalse_WhenNoData()
{
_mockRepository
.Setup(r => r.GetCurrentAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new Dictionary<string, EpssCurrentEntry>());
var result = await _provider.IsAvailableAsync();
Assert.False(result);
}
[Fact]
public async Task IsAvailableAsync_ReturnsFalse_WhenExceptionThrown()
{
_mockRepository
.Setup(r => r.GetCurrentAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Database unavailable"));
var result = await _provider.IsAvailableAsync();
Assert.False(result);
}
#endregion
#region Test Helpers
private sealed class FakeTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public FakeTimeProvider(DateTimeOffset now)
{
_now = now;
}
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan duration) => _now = _now.Add(duration);
}
#endregion
}

View File

@@ -5,6 +5,12 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
<PackageReference Include="Moq" Version="4.*" />
<PackageReference Include="xunit" Version="2.*" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.*" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Storage/StellaOps.Scanner.Storage.csproj" />
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Infrastructure.Postgres.Testing\\StellaOps.Infrastructure.Postgres.Testing.csproj" />