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