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,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);
}
}