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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Cache\StellaOps.Scanner.Cache.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Surface.Env\StellaOps.Scanner.Surface.Env.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.SmartDiff\StellaOps.Scanner.SmartDiff.csproj" />
<ProjectReference Include="..\..\StellaOps.Scanner.Analyzers.Native\StellaOps.Scanner.Analyzers.Native.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Replay.Core\StellaOps.Replay.Core.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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