feat(telemetry): add telemetry client and services for tracking events
- Implemented TelemetryClient to handle event queuing and flushing to the telemetry endpoint. - Created TtfsTelemetryService for emitting specific telemetry events related to TTFS. - Added tests for TelemetryClient to ensure event queuing and flushing functionality. - Introduced models for reachability drift detection, including DriftResult and DriftedSink. - Developed DriftApiService for interacting with the drift detection API. - Updated FirstSignalCardComponent to emit telemetry events on signal appearance. - Enhanced localization support for first signal component with i18n strings.
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IVulnSurfaceBuilder.cs
|
||||
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core
|
||||
// Description: Interface for building vulnerability surfaces.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scanner.VulnSurfaces.Models;
|
||||
|
||||
namespace StellaOps.Scanner.VulnSurfaces.Builder;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates vulnerability surface computation:
|
||||
/// 1. Downloads vulnerable and fixed package versions
|
||||
/// 2. Fingerprints methods in both versions
|
||||
/// 3. Computes diff to identify sink methods
|
||||
/// 4. Optionally extracts trigger methods
|
||||
/// </summary>
|
||||
public interface IVulnSurfaceBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds a vulnerability surface for a CVE.
|
||||
/// </summary>
|
||||
/// <param name="request">Build request with CVE and package details.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Built vulnerability surface.</returns>
|
||||
Task<VulnSurfaceBuildResult> BuildAsync(
|
||||
VulnSurfaceBuildRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to build a vulnerability surface.
|
||||
/// </summary>
|
||||
public sealed record VulnSurfaceBuildRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// CVE ID.
|
||||
/// </summary>
|
||||
public required string CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package name.
|
||||
/// </summary>
|
||||
public required string PackageName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ecosystem (nuget, npm, maven, pypi).
|
||||
/// </summary>
|
||||
public required string Ecosystem { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerable version to analyze.
|
||||
/// </summary>
|
||||
public required string VulnVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Fixed version for comparison.
|
||||
/// </summary>
|
||||
public required string FixedVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Working directory for package downloads.
|
||||
/// </summary>
|
||||
public string? WorkingDirectory { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to extract trigger methods.
|
||||
/// </summary>
|
||||
public bool ExtractTriggers { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Custom registry URL (null for defaults).
|
||||
/// </summary>
|
||||
public string? RegistryUrl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of building a vulnerability surface.
|
||||
/// </summary>
|
||||
public sealed record VulnSurfaceBuildResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether build succeeded.
|
||||
/// </summary>
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Built vulnerability surface.
|
||||
/// </summary>
|
||||
public VulnSurface? Surface { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total build duration.
|
||||
/// </summary>
|
||||
public System.TimeSpan Duration { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful result.
|
||||
/// </summary>
|
||||
public static VulnSurfaceBuildResult Ok(VulnSurface surface, System.TimeSpan duration) =>
|
||||
new()
|
||||
{
|
||||
Success = true,
|
||||
Surface = surface,
|
||||
Duration = duration
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed result.
|
||||
/// </summary>
|
||||
public static VulnSurfaceBuildResult Fail(string error, System.TimeSpan duration) =>
|
||||
new()
|
||||
{
|
||||
Success = false,
|
||||
Error = error,
|
||||
Duration = duration
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VulnSurfaceBuilder.cs
|
||||
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core
|
||||
// Description: Orchestrates vulnerability surface computation.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.VulnSurfaces.CallGraph;
|
||||
using StellaOps.Scanner.VulnSurfaces.Download;
|
||||
using StellaOps.Scanner.VulnSurfaces.Fingerprint;
|
||||
using StellaOps.Scanner.VulnSurfaces.Models;
|
||||
using StellaOps.Scanner.VulnSurfaces.Triggers;
|
||||
|
||||
namespace StellaOps.Scanner.VulnSurfaces.Builder;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of vulnerability surface builder.
|
||||
/// </summary>
|
||||
public sealed class VulnSurfaceBuilder : IVulnSurfaceBuilder
|
||||
{
|
||||
private readonly IEnumerable<IPackageDownloader> _downloaders;
|
||||
private readonly IEnumerable<IMethodFingerprinter> _fingerprinters;
|
||||
private readonly IMethodDiffEngine _diffEngine;
|
||||
private readonly ITriggerMethodExtractor _triggerExtractor;
|
||||
private readonly IEnumerable<IInternalCallGraphBuilder> _graphBuilders;
|
||||
private readonly ILogger<VulnSurfaceBuilder> _logger;
|
||||
|
||||
public VulnSurfaceBuilder(
|
||||
IEnumerable<IPackageDownloader> downloaders,
|
||||
IEnumerable<IMethodFingerprinter> fingerprinters,
|
||||
IMethodDiffEngine diffEngine,
|
||||
ITriggerMethodExtractor triggerExtractor,
|
||||
IEnumerable<IInternalCallGraphBuilder> graphBuilders,
|
||||
ILogger<VulnSurfaceBuilder> logger)
|
||||
{
|
||||
_downloaders = downloaders ?? throw new ArgumentNullException(nameof(downloaders));
|
||||
_fingerprinters = fingerprinters ?? throw new ArgumentNullException(nameof(fingerprinters));
|
||||
_diffEngine = diffEngine ?? throw new ArgumentNullException(nameof(diffEngine));
|
||||
_triggerExtractor = triggerExtractor ?? throw new ArgumentNullException(nameof(triggerExtractor));
|
||||
_graphBuilders = graphBuilders ?? throw new ArgumentNullException(nameof(graphBuilders));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<VulnSurfaceBuildResult> BuildAsync(
|
||||
VulnSurfaceBuildRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Building vulnerability surface for {CveId}: {Package} {VulnVersion} → {FixedVersion}",
|
||||
request.CveId, request.PackageName, request.VulnVersion, request.FixedVersion);
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Get ecosystem-specific downloader and fingerprinter
|
||||
var downloader = _downloaders.FirstOrDefault(d =>
|
||||
d.Ecosystem.Equals(request.Ecosystem, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (downloader == null)
|
||||
{
|
||||
sw.Stop();
|
||||
return VulnSurfaceBuildResult.Fail($"No downloader for ecosystem: {request.Ecosystem}", sw.Elapsed);
|
||||
}
|
||||
|
||||
var fingerprinter = _fingerprinters.FirstOrDefault(f =>
|
||||
f.Ecosystem.Equals(request.Ecosystem, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (fingerprinter == null)
|
||||
{
|
||||
sw.Stop();
|
||||
return VulnSurfaceBuildResult.Fail($"No fingerprinter for ecosystem: {request.Ecosystem}", sw.Elapsed);
|
||||
}
|
||||
|
||||
// 2. Setup working directory
|
||||
var workDir = request.WorkingDirectory ?? Path.Combine(Path.GetTempPath(), "vulnsurfaces", request.CveId);
|
||||
Directory.CreateDirectory(workDir);
|
||||
|
||||
// 3. Download both versions
|
||||
var vulnDownload = await downloader.DownloadAsync(new PackageDownloadRequest
|
||||
{
|
||||
PackageName = request.PackageName,
|
||||
Version = request.VulnVersion,
|
||||
OutputDirectory = Path.Combine(workDir, "vuln"),
|
||||
RegistryUrl = request.RegistryUrl
|
||||
}, cancellationToken);
|
||||
|
||||
if (!vulnDownload.Success)
|
||||
{
|
||||
sw.Stop();
|
||||
return VulnSurfaceBuildResult.Fail($"Failed to download vulnerable version: {vulnDownload.Error}", sw.Elapsed);
|
||||
}
|
||||
|
||||
var fixedDownload = await downloader.DownloadAsync(new PackageDownloadRequest
|
||||
{
|
||||
PackageName = request.PackageName,
|
||||
Version = request.FixedVersion,
|
||||
OutputDirectory = Path.Combine(workDir, "fixed"),
|
||||
RegistryUrl = request.RegistryUrl
|
||||
}, cancellationToken);
|
||||
|
||||
if (!fixedDownload.Success)
|
||||
{
|
||||
sw.Stop();
|
||||
return VulnSurfaceBuildResult.Fail($"Failed to download fixed version: {fixedDownload.Error}", sw.Elapsed);
|
||||
}
|
||||
|
||||
// 4. Fingerprint both versions
|
||||
var vulnFingerprints = await fingerprinter.FingerprintAsync(new FingerprintRequest
|
||||
{
|
||||
PackagePath = vulnDownload.ExtractedPath!,
|
||||
PackageName = request.PackageName,
|
||||
Version = request.VulnVersion
|
||||
}, cancellationToken);
|
||||
|
||||
if (!vulnFingerprints.Success)
|
||||
{
|
||||
sw.Stop();
|
||||
return VulnSurfaceBuildResult.Fail($"Failed to fingerprint vulnerable version: {vulnFingerprints.Error}", sw.Elapsed);
|
||||
}
|
||||
|
||||
var fixedFingerprints = await fingerprinter.FingerprintAsync(new FingerprintRequest
|
||||
{
|
||||
PackagePath = fixedDownload.ExtractedPath!,
|
||||
PackageName = request.PackageName,
|
||||
Version = request.FixedVersion
|
||||
}, cancellationToken);
|
||||
|
||||
if (!fixedFingerprints.Success)
|
||||
{
|
||||
sw.Stop();
|
||||
return VulnSurfaceBuildResult.Fail($"Failed to fingerprint fixed version: {fixedFingerprints.Error}", sw.Elapsed);
|
||||
}
|
||||
|
||||
// 5. Compute diff
|
||||
var diff = await _diffEngine.DiffAsync(new MethodDiffRequest
|
||||
{
|
||||
VulnFingerprints = vulnFingerprints,
|
||||
FixedFingerprints = fixedFingerprints
|
||||
}, cancellationToken);
|
||||
|
||||
if (!diff.Success)
|
||||
{
|
||||
sw.Stop();
|
||||
return VulnSurfaceBuildResult.Fail($"Failed to compute diff: {diff.Error}", sw.Elapsed);
|
||||
}
|
||||
|
||||
// 6. Build sinks from diff
|
||||
var sinks = BuildSinks(diff);
|
||||
|
||||
// 7. Optionally extract triggers
|
||||
var triggerCount = 0;
|
||||
|
||||
if (request.ExtractTriggers && sinks.Count > 0)
|
||||
{
|
||||
var graphBuilder = _graphBuilders.FirstOrDefault(b =>
|
||||
b.Ecosystem.Equals(request.Ecosystem, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (graphBuilder != null)
|
||||
{
|
||||
var graphResult = await graphBuilder.BuildAsync(new InternalCallGraphBuildRequest
|
||||
{
|
||||
PackageId = request.PackageName,
|
||||
Version = request.VulnVersion,
|
||||
PackagePath = vulnDownload.ExtractedPath!
|
||||
}, cancellationToken);
|
||||
|
||||
if (graphResult.Success && graphResult.Graph != null)
|
||||
{
|
||||
var triggerResult = await _triggerExtractor.ExtractAsync(new TriggerExtractionRequest
|
||||
{
|
||||
SurfaceId = 0, // Will be assigned when persisted
|
||||
SinkMethodKeys = sinks.Select(s => s.MethodKey).ToList(),
|
||||
Graph = graphResult.Graph
|
||||
}, cancellationToken);
|
||||
|
||||
if (triggerResult.Success)
|
||||
{
|
||||
triggerCount = triggerResult.Triggers.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Build surface
|
||||
var surface = new VulnSurface
|
||||
{
|
||||
CveId = request.CveId,
|
||||
PackageId = request.PackageName,
|
||||
Ecosystem = request.Ecosystem,
|
||||
VulnVersion = request.VulnVersion,
|
||||
FixedVersion = request.FixedVersion,
|
||||
Sinks = sinks,
|
||||
TriggerCount = triggerCount,
|
||||
Status = VulnSurfaceStatus.Computed,
|
||||
Confidence = ComputeConfidence(diff, sinks.Count),
|
||||
ComputedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
sw.Stop();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Built vulnerability surface for {CveId}: {SinkCount} sinks, {TriggerCount} triggers in {Duration}ms",
|
||||
request.CveId, sinks.Count, triggerCount, sw.ElapsedMilliseconds);
|
||||
|
||||
return VulnSurfaceBuildResult.Ok(surface, sw.Elapsed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
_logger.LogError(ex, "Failed to build vulnerability surface for {CveId}", request.CveId);
|
||||
return VulnSurfaceBuildResult.Fail(ex.Message, sw.Elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<VulnSurfaceSink> BuildSinks(MethodDiffResult diff)
|
||||
{
|
||||
var sinks = new List<VulnSurfaceSink>();
|
||||
|
||||
foreach (var modified in diff.Modified)
|
||||
{
|
||||
sinks.Add(new VulnSurfaceSink
|
||||
{
|
||||
MethodKey = modified.MethodKey,
|
||||
DeclaringType = modified.VulnVersion.DeclaringType,
|
||||
MethodName = modified.VulnVersion.Name,
|
||||
Signature = modified.VulnVersion.Signature,
|
||||
ChangeType = modified.ChangeType,
|
||||
VulnHash = modified.VulnVersion.BodyHash,
|
||||
FixedHash = modified.FixedVersion.BodyHash
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var removed in diff.Removed)
|
||||
{
|
||||
sinks.Add(new VulnSurfaceSink
|
||||
{
|
||||
MethodKey = removed.MethodKey,
|
||||
DeclaringType = removed.DeclaringType,
|
||||
MethodName = removed.Name,
|
||||
Signature = removed.Signature,
|
||||
ChangeType = MethodChangeType.Removed,
|
||||
VulnHash = removed.BodyHash
|
||||
});
|
||||
}
|
||||
|
||||
return sinks;
|
||||
}
|
||||
|
||||
private static double ComputeConfidence(MethodDiffResult diff, int sinkCount)
|
||||
{
|
||||
if (sinkCount == 0)
|
||||
return 0.0;
|
||||
|
||||
// Higher confidence with more modified methods vs just removed
|
||||
var modifiedRatio = (double)diff.Modified.Count / diff.TotalChanges;
|
||||
return Math.Round(0.7 + (modifiedRatio * 0.3), 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CecilInternalGraphBuilder.cs
|
||||
// Sprint: SPRINT_3700_0003_0001_trigger_extraction
|
||||
// Description: .NET internal call graph builder using Mono.Cecil.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Mono.Cecil;
|
||||
using Mono.Cecil.Cil;
|
||||
using StellaOps.Scanner.VulnSurfaces.Models;
|
||||
|
||||
namespace StellaOps.Scanner.VulnSurfaces.CallGraph;
|
||||
|
||||
/// <summary>
|
||||
/// Internal call graph builder for .NET assemblies using Mono.Cecil.
|
||||
/// </summary>
|
||||
public sealed class CecilInternalGraphBuilder : IInternalCallGraphBuilder
|
||||
{
|
||||
private readonly ILogger<CecilInternalGraphBuilder> _logger;
|
||||
|
||||
public CecilInternalGraphBuilder(ILogger<CecilInternalGraphBuilder> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Ecosystem => "nuget";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanHandle(string packagePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(packagePath))
|
||||
return false;
|
||||
|
||||
// Check for .nupkg or directory with .dll files
|
||||
if (packagePath.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
if (Directory.Exists(packagePath))
|
||||
{
|
||||
return Directory.EnumerateFiles(packagePath, "*.dll", SearchOption.AllDirectories).Any();
|
||||
}
|
||||
|
||||
return packagePath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<InternalCallGraphBuildResult> BuildAsync(
|
||||
InternalCallGraphBuildRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var graph = new InternalCallGraph
|
||||
{
|
||||
PackageId = request.PackageId,
|
||||
Version = request.Version
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var dllFiles = GetAssemblyFiles(request.PackagePath);
|
||||
var filesProcessed = 0;
|
||||
|
||||
foreach (var dllPath in dllFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
await ProcessAssemblyAsync(dllPath, graph, request.IncludePrivateMethods, cancellationToken);
|
||||
filesProcessed++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to process assembly {Path}", dllPath);
|
||||
// Continue with other assemblies
|
||||
}
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
_logger.LogDebug(
|
||||
"Built internal call graph for {PackageId} v{Version}: {Methods} methods, {Edges} edges in {Duration}ms",
|
||||
request.PackageId, request.Version, graph.MethodCount, graph.EdgeCount, sw.ElapsedMilliseconds);
|
||||
|
||||
return InternalCallGraphBuildResult.Ok(graph, sw.Elapsed, filesProcessed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
_logger.LogWarning(ex, "Failed to build internal call graph for {PackageId}", request.PackageId);
|
||||
return InternalCallGraphBuildResult.Fail(ex.Message, sw.Elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
private static string[] GetAssemblyFiles(string packagePath)
|
||||
{
|
||||
if (File.Exists(packagePath) && packagePath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return [packagePath];
|
||||
}
|
||||
|
||||
if (Directory.Exists(packagePath))
|
||||
{
|
||||
return Directory.GetFiles(packagePath, "*.dll", SearchOption.AllDirectories);
|
||||
}
|
||||
|
||||
// For .nupkg, would need to extract first
|
||||
return [];
|
||||
}
|
||||
|
||||
private Task ProcessAssemblyAsync(
|
||||
string dllPath,
|
||||
InternalCallGraph graph,
|
||||
bool includePrivate,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.Run(() =>
|
||||
{
|
||||
var readerParams = new ReaderParameters
|
||||
{
|
||||
ReadSymbols = false,
|
||||
ReadingMode = ReadingMode.Deferred
|
||||
};
|
||||
|
||||
using var assembly = AssemblyDefinition.ReadAssembly(dllPath, readerParams);
|
||||
|
||||
foreach (var module in assembly.Modules)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
foreach (var type in module.Types)
|
||||
{
|
||||
ProcessType(type, graph, includePrivate);
|
||||
}
|
||||
}
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
private void ProcessType(TypeDefinition type, InternalCallGraph graph, bool includePrivate)
|
||||
{
|
||||
// Skip nested types at top level (they're processed from parent)
|
||||
// But process nested types found within
|
||||
foreach (var nestedType in type.NestedTypes)
|
||||
{
|
||||
ProcessType(nestedType, graph, includePrivate);
|
||||
}
|
||||
|
||||
foreach (var method in type.Methods)
|
||||
{
|
||||
if (!includePrivate && !IsPublicOrProtected(method))
|
||||
continue;
|
||||
|
||||
var methodRef = CreateMethodRef(method);
|
||||
graph.AddMethod(methodRef);
|
||||
|
||||
// Extract call edges from method body
|
||||
if (method.HasBody)
|
||||
{
|
||||
foreach (var instruction in method.Body.Instructions)
|
||||
{
|
||||
if (IsCallInstruction(instruction.OpCode) && instruction.Operand is MethodReference callee)
|
||||
{
|
||||
var calleeKey = GetMethodKey(callee);
|
||||
|
||||
var edge = new InternalCallEdge
|
||||
{
|
||||
Caller = methodRef.MethodKey,
|
||||
Callee = calleeKey,
|
||||
CallSiteOffset = instruction.Offset,
|
||||
IsVirtualCall = instruction.OpCode == OpCodes.Callvirt
|
||||
};
|
||||
|
||||
graph.AddEdge(edge);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsCallInstruction(OpCode opCode) =>
|
||||
opCode == OpCodes.Call ||
|
||||
opCode == OpCodes.Callvirt ||
|
||||
opCode == OpCodes.Newobj;
|
||||
|
||||
private static bool IsPublicOrProtected(MethodDefinition method) =>
|
||||
method.IsPublic || method.IsFamily || method.IsFamilyOrAssembly;
|
||||
|
||||
private static InternalMethodRef CreateMethodRef(MethodDefinition method)
|
||||
{
|
||||
return new InternalMethodRef
|
||||
{
|
||||
MethodKey = GetMethodKey(method),
|
||||
Name = method.Name,
|
||||
DeclaringType = method.DeclaringType.FullName,
|
||||
IsPublic = method.IsPublic,
|
||||
IsInterface = method.DeclaringType.IsInterface,
|
||||
IsVirtual = method.IsVirtual || method.IsAbstract,
|
||||
Parameters = method.Parameters.Select(p => p.ParameterType.Name).ToList(),
|
||||
ReturnType = method.ReturnType.Name
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetMethodKey(MethodReference method)
|
||||
{
|
||||
var paramTypes = string.Join(",", method.Parameters.Select(p => p.ParameterType.Name));
|
||||
return $"{method.DeclaringType.FullName}::{method.Name}({paramTypes})";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IInternalCallGraphBuilder.cs
|
||||
// Sprint: SPRINT_3700_0003_0001_trigger_extraction
|
||||
// Description: Interface for building internal call graphs from package sources.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.VulnSurfaces.CallGraph;
|
||||
|
||||
/// <summary>
|
||||
/// Builds internal call graphs from package/assembly sources.
|
||||
/// Implementations exist for different ecosystems (.NET, Java, Node.js, Python).
|
||||
/// </summary>
|
||||
public interface IInternalCallGraphBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Ecosystem this builder supports (e.g., "nuget", "maven", "npm", "pypi").
|
||||
/// </summary>
|
||||
string Ecosystem { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Checks if this builder can handle the given package.
|
||||
/// </summary>
|
||||
/// <param name="packagePath">Path to package archive or extracted directory.</param>
|
||||
bool CanHandle(string packagePath);
|
||||
|
||||
/// <summary>
|
||||
/// Builds an internal call graph from a package.
|
||||
/// </summary>
|
||||
/// <param name="request">Build request with package details.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Internal call graph for the package.</returns>
|
||||
Task<InternalCallGraphBuildResult> BuildAsync(
|
||||
InternalCallGraphBuildRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to build an internal call graph.
|
||||
/// </summary>
|
||||
public sealed record InternalCallGraphBuildRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Package identifier (PURL or package name).
|
||||
/// </summary>
|
||||
public required string PackageId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package version.
|
||||
/// </summary>
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the package archive or extracted directory.
|
||||
/// </summary>
|
||||
public required string PackagePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include private methods in the graph.
|
||||
/// Default is false (only public API surface).
|
||||
/// </summary>
|
||||
public bool IncludePrivateMethods { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum depth for call graph traversal.
|
||||
/// </summary>
|
||||
public int MaxDepth { get; init; } = 20;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of building an internal call graph.
|
||||
/// </summary>
|
||||
public sealed record InternalCallGraphBuildResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the build succeeded.
|
||||
/// </summary>
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The built call graph (null if failed).
|
||||
/// </summary>
|
||||
public InternalCallGraph? Graph { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if build failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Build duration.
|
||||
/// </summary>
|
||||
public TimeSpan Duration { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of assemblies/files processed.
|
||||
/// </summary>
|
||||
public int FilesProcessed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful result.
|
||||
/// </summary>
|
||||
public static InternalCallGraphBuildResult Ok(InternalCallGraph graph, TimeSpan duration, int filesProcessed) =>
|
||||
new()
|
||||
{
|
||||
Success = true,
|
||||
Graph = graph,
|
||||
Duration = duration,
|
||||
FilesProcessed = filesProcessed
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed result.
|
||||
/// </summary>
|
||||
public static InternalCallGraphBuildResult Fail(string error, TimeSpan duration) =>
|
||||
new()
|
||||
{
|
||||
Success = false,
|
||||
Error = error,
|
||||
Duration = duration
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// InternalCallGraph.cs
|
||||
// Sprint: SPRINT_3700_0003_0001_trigger_extraction
|
||||
// Description: Internal call graph model for within-package edges only.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.VulnSurfaces.Models;
|
||||
|
||||
namespace StellaOps.Scanner.VulnSurfaces.CallGraph;
|
||||
|
||||
/// <summary>
|
||||
/// Internal call graph for a single package/assembly.
|
||||
/// Contains only within-package edges (no cross-package calls).
|
||||
/// </summary>
|
||||
public sealed class InternalCallGraph
|
||||
{
|
||||
private readonly Dictionary<string, InternalMethodRef> _methods = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, HashSet<string>> _callersToCallees = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, HashSet<string>> _calleesToCallers = new(StringComparer.Ordinal);
|
||||
private readonly List<InternalCallEdge> _edges = [];
|
||||
|
||||
/// <summary>
|
||||
/// Package/assembly identifier.
|
||||
/// </summary>
|
||||
public required string PackageId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package version.
|
||||
/// </summary>
|
||||
public string? Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// All methods in the package.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, InternalMethodRef> Methods => _methods;
|
||||
|
||||
/// <summary>
|
||||
/// All edges in the call graph.
|
||||
/// </summary>
|
||||
public IReadOnlyList<InternalCallEdge> Edges => _edges;
|
||||
|
||||
/// <summary>
|
||||
/// Number of methods.
|
||||
/// </summary>
|
||||
public int MethodCount => _methods.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Number of edges.
|
||||
/// </summary>
|
||||
public int EdgeCount => _edges.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Adds a method to the graph.
|
||||
/// </summary>
|
||||
public void AddMethod(InternalMethodRef method)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(method);
|
||||
_methods[method.MethodKey] = method;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an edge to the graph.
|
||||
/// </summary>
|
||||
public void AddEdge(InternalCallEdge edge)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(edge);
|
||||
_edges.Add(edge);
|
||||
|
||||
if (!_callersToCallees.TryGetValue(edge.Caller, out var callees))
|
||||
{
|
||||
callees = new HashSet<string>(StringComparer.Ordinal);
|
||||
_callersToCallees[edge.Caller] = callees;
|
||||
}
|
||||
callees.Add(edge.Callee);
|
||||
|
||||
if (!_calleesToCallers.TryGetValue(edge.Callee, out var callers))
|
||||
{
|
||||
callers = new HashSet<string>(StringComparer.Ordinal);
|
||||
_calleesToCallers[edge.Callee] = callers;
|
||||
}
|
||||
callers.Add(edge.Caller);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all callees of a method.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> GetCallees(string methodKey)
|
||||
{
|
||||
if (_callersToCallees.TryGetValue(methodKey, out var callees))
|
||||
{
|
||||
return callees;
|
||||
}
|
||||
return ImmutableHashSet<string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all callers of a method.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> GetCallers(string methodKey)
|
||||
{
|
||||
if (_calleesToCallers.TryGetValue(methodKey, out var callers))
|
||||
{
|
||||
return callers;
|
||||
}
|
||||
return ImmutableHashSet<string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all public methods in the graph.
|
||||
/// </summary>
|
||||
public IEnumerable<InternalMethodRef> GetPublicMethods()
|
||||
{
|
||||
foreach (var method in _methods.Values)
|
||||
{
|
||||
if (method.IsPublic)
|
||||
{
|
||||
yield return method;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a method exists in the graph.
|
||||
/// </summary>
|
||||
public bool ContainsMethod(string methodKey) => _methods.ContainsKey(methodKey);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a method by key.
|
||||
/// </summary>
|
||||
public InternalMethodRef? GetMethod(string methodKey)
|
||||
{
|
||||
return _methods.GetValueOrDefault(methodKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VulnSurfacesServiceCollectionExtensions.cs
|
||||
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core
|
||||
// Description: DI registration for VulnSurfaces services.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Scanner.VulnSurfaces.Builder;
|
||||
using StellaOps.Scanner.VulnSurfaces.CallGraph;
|
||||
using StellaOps.Scanner.VulnSurfaces.Download;
|
||||
using StellaOps.Scanner.VulnSurfaces.Fingerprint;
|
||||
using StellaOps.Scanner.VulnSurfaces.Triggers;
|
||||
|
||||
namespace StellaOps.Scanner.VulnSurfaces.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering VulnSurfaces services.
|
||||
/// </summary>
|
||||
public static class VulnSurfacesServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds VulnSurfaces services to the service collection.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddVulnSurfaces(this IServiceCollection services)
|
||||
{
|
||||
// Package downloaders
|
||||
services.AddHttpClient<NuGetPackageDownloader>();
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IPackageDownloader, NuGetPackageDownloader>());
|
||||
|
||||
// Method fingerprinters
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IMethodFingerprinter, CecilMethodFingerprinter>());
|
||||
|
||||
// Diff engine
|
||||
services.TryAddSingleton<IMethodDiffEngine, MethodDiffEngine>();
|
||||
|
||||
// Call graph builders
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IInternalCallGraphBuilder, CecilInternalGraphBuilder>());
|
||||
|
||||
// Trigger extraction
|
||||
services.TryAddSingleton<ITriggerMethodExtractor, TriggerMethodExtractor>();
|
||||
|
||||
// Surface builder orchestrator
|
||||
services.TryAddSingleton<IVulnSurfaceBuilder, VulnSurfaceBuilder>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the .NET (Cecil) call graph builder.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddCecilCallGraphBuilder(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IInternalCallGraphBuilder, CecilInternalGraphBuilder>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the NuGet package downloader.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddNuGetDownloader(this IServiceCollection services)
|
||||
{
|
||||
services.AddHttpClient<NuGetPackageDownloader>();
|
||||
services.AddSingleton<IPackageDownloader, NuGetPackageDownloader>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IPackageDownloader.cs
|
||||
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core
|
||||
// Description: Interface for downloading packages from various ecosystems.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.VulnSurfaces.Download;
|
||||
|
||||
/// <summary>
|
||||
/// Downloads packages from ecosystem-specific registries for analysis.
|
||||
/// </summary>
|
||||
public interface IPackageDownloader
|
||||
{
|
||||
/// <summary>
|
||||
/// Ecosystem this downloader handles (nuget, npm, maven, pypi).
|
||||
/// </summary>
|
||||
string Ecosystem { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Downloads a package to a local directory.
|
||||
/// </summary>
|
||||
/// <param name="request">Download request with package details.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Download result with path to extracted package.</returns>
|
||||
Task<PackageDownloadResult> DownloadAsync(
|
||||
PackageDownloadRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to download a package.
|
||||
/// </summary>
|
||||
public sealed record PackageDownloadRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Package name.
|
||||
/// </summary>
|
||||
public required string PackageName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package version.
|
||||
/// </summary>
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Output directory for extracted package.
|
||||
/// </summary>
|
||||
public required string OutputDirectory { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Registry URL override (null for default).
|
||||
/// </summary>
|
||||
public string? RegistryUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to use cached version if available.
|
||||
/// </summary>
|
||||
public bool UseCache { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of package download.
|
||||
/// </summary>
|
||||
public sealed record PackageDownloadResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether download succeeded.
|
||||
/// </summary>
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to extracted package.
|
||||
/// </summary>
|
||||
public string? ExtractedPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to original archive.
|
||||
/// </summary>
|
||||
public string? ArchivePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Download duration.
|
||||
/// </summary>
|
||||
public TimeSpan Duration { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether result was from cache.
|
||||
/// </summary>
|
||||
public bool FromCache { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful result.
|
||||
/// </summary>
|
||||
public static PackageDownloadResult Ok(string extractedPath, string archivePath, TimeSpan duration, bool fromCache = false) =>
|
||||
new()
|
||||
{
|
||||
Success = true,
|
||||
ExtractedPath = extractedPath,
|
||||
ArchivePath = archivePath,
|
||||
Duration = duration,
|
||||
FromCache = fromCache
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed result.
|
||||
/// </summary>
|
||||
public static PackageDownloadResult Fail(string error, TimeSpan duration) =>
|
||||
new()
|
||||
{
|
||||
Success = false,
|
||||
Error = error,
|
||||
Duration = duration
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// NuGetPackageDownloader.cs
|
||||
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core
|
||||
// Description: Downloads NuGet packages for vulnerability surface analysis.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Scanner.VulnSurfaces.Download;
|
||||
|
||||
/// <summary>
|
||||
/// Downloads NuGet packages from nuget.org or custom feeds.
|
||||
/// </summary>
|
||||
public sealed class NuGetPackageDownloader : IPackageDownloader
|
||||
{
|
||||
private const string DefaultRegistryUrl = "https://api.nuget.org/v3-flatcontainer";
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<NuGetPackageDownloader> _logger;
|
||||
private readonly NuGetDownloaderOptions _options;
|
||||
|
||||
public NuGetPackageDownloader(
|
||||
HttpClient httpClient,
|
||||
ILogger<NuGetPackageDownloader> logger,
|
||||
IOptions<NuGetDownloaderOptions> options)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value ?? new NuGetDownloaderOptions();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Ecosystem => "nuget";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PackageDownloadResult> DownloadAsync(
|
||||
PackageDownloadRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var packageLower = request.PackageName.ToLowerInvariant();
|
||||
var versionLower = request.Version.ToLowerInvariant();
|
||||
|
||||
try
|
||||
{
|
||||
// Check cache first
|
||||
var extractedDir = Path.Combine(request.OutputDirectory, $"{packageLower}.{versionLower}");
|
||||
var archivePath = Path.Combine(request.OutputDirectory, $"{packageLower}.{versionLower}.nupkg");
|
||||
|
||||
if (request.UseCache && Directory.Exists(extractedDir))
|
||||
{
|
||||
sw.Stop();
|
||||
_logger.LogDebug("Using cached package {Package} v{Version}", request.PackageName, request.Version);
|
||||
return PackageDownloadResult.Ok(extractedDir, archivePath, sw.Elapsed, fromCache: true);
|
||||
}
|
||||
|
||||
// Build download URL
|
||||
var registryUrl = request.RegistryUrl ?? _options.RegistryUrl ?? DefaultRegistryUrl;
|
||||
var downloadUrl = $"{registryUrl}/{packageLower}/{versionLower}/{packageLower}.{versionLower}.nupkg";
|
||||
|
||||
_logger.LogDebug("Downloading NuGet package from {Url}", downloadUrl);
|
||||
|
||||
// Download package
|
||||
Directory.CreateDirectory(request.OutputDirectory);
|
||||
|
||||
using var response = await _httpClient.GetAsync(downloadUrl, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
sw.Stop();
|
||||
var error = $"Failed to download: HTTP {(int)response.StatusCode} {response.ReasonPhrase}";
|
||||
_logger.LogWarning("NuGet download failed for {Package} v{Version}: {Error}",
|
||||
request.PackageName, request.Version, error);
|
||||
return PackageDownloadResult.Fail(error, sw.Elapsed);
|
||||
}
|
||||
|
||||
// Save archive
|
||||
await using (var fs = File.Create(archivePath))
|
||||
{
|
||||
await response.Content.CopyToAsync(fs, cancellationToken);
|
||||
}
|
||||
|
||||
// Extract
|
||||
if (Directory.Exists(extractedDir))
|
||||
{
|
||||
Directory.Delete(extractedDir, recursive: true);
|
||||
}
|
||||
|
||||
ZipFile.ExtractToDirectory(archivePath, extractedDir);
|
||||
|
||||
sw.Stop();
|
||||
_logger.LogDebug("Downloaded and extracted {Package} v{Version} in {Duration}ms",
|
||||
request.PackageName, request.Version, sw.ElapsedMilliseconds);
|
||||
|
||||
return PackageDownloadResult.Ok(extractedDir, archivePath, sw.Elapsed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
_logger.LogWarning(ex, "Failed to download NuGet package {Package} v{Version}",
|
||||
request.PackageName, request.Version);
|
||||
return PackageDownloadResult.Fail(ex.Message, sw.Elapsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for NuGet package downloader.
|
||||
/// </summary>
|
||||
public sealed class NuGetDownloaderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Custom registry URL (null for nuget.org).
|
||||
/// </summary>
|
||||
public string? RegistryUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cache directory for downloaded packages.
|
||||
/// </summary>
|
||||
public string? CacheDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum package size in bytes (0 for unlimited).
|
||||
/// </summary>
|
||||
public long MaxPackageSize { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CecilMethodFingerprinter.cs
|
||||
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core
|
||||
// Description: .NET method fingerprinting using Mono.Cecil IL hashing.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Mono.Cecil;
|
||||
using Mono.Cecil.Cil;
|
||||
|
||||
namespace StellaOps.Scanner.VulnSurfaces.Fingerprint;
|
||||
|
||||
/// <summary>
|
||||
/// Computes method fingerprints for .NET assemblies using IL hashing.
|
||||
/// </summary>
|
||||
public sealed class CecilMethodFingerprinter : IMethodFingerprinter
|
||||
{
|
||||
private readonly ILogger<CecilMethodFingerprinter> _logger;
|
||||
|
||||
public CecilMethodFingerprinter(ILogger<CecilMethodFingerprinter> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Ecosystem => "nuget";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<FingerprintResult> FingerprintAsync(
|
||||
FingerprintRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var methods = new Dictionary<string, MethodFingerprint>(StringComparer.Ordinal);
|
||||
|
||||
try
|
||||
{
|
||||
var dllFiles = GetAssemblyFiles(request.PackagePath);
|
||||
var filesProcessed = 0;
|
||||
|
||||
foreach (var dllPath in dllFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
await ProcessAssemblyAsync(dllPath, methods, request, cancellationToken);
|
||||
filesProcessed++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to process assembly {Path}", dllPath);
|
||||
}
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
_logger.LogDebug(
|
||||
"Fingerprinted {MethodCount} methods from {FileCount} files in {Duration}ms",
|
||||
methods.Count, filesProcessed, sw.ElapsedMilliseconds);
|
||||
|
||||
return FingerprintResult.Ok(methods, sw.Elapsed, filesProcessed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
_logger.LogWarning(ex, "Failed to fingerprint package at {Path}", request.PackagePath);
|
||||
return FingerprintResult.Fail(ex.Message, sw.Elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
private static string[] GetAssemblyFiles(string packagePath)
|
||||
{
|
||||
if (!Directory.Exists(packagePath))
|
||||
return [];
|
||||
|
||||
return Directory.GetFiles(packagePath, "*.dll", SearchOption.AllDirectories)
|
||||
.Where(f => !f.Contains("ref" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private Task ProcessAssemblyAsync(
|
||||
string dllPath,
|
||||
Dictionary<string, MethodFingerprint> methods,
|
||||
FingerprintRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.Run(() =>
|
||||
{
|
||||
var readerParams = new ReaderParameters
|
||||
{
|
||||
ReadSymbols = false,
|
||||
ReadingMode = ReadingMode.Deferred
|
||||
};
|
||||
|
||||
using var assembly = AssemblyDefinition.ReadAssembly(dllPath, readerParams);
|
||||
|
||||
foreach (var module in assembly.Modules)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
foreach (var type in module.Types)
|
||||
{
|
||||
ProcessType(type, methods, request);
|
||||
}
|
||||
}
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
private void ProcessType(
|
||||
TypeDefinition type,
|
||||
Dictionary<string, MethodFingerprint> methods,
|
||||
FingerprintRequest request)
|
||||
{
|
||||
foreach (var nestedType in type.NestedTypes)
|
||||
{
|
||||
ProcessType(nestedType, methods, request);
|
||||
}
|
||||
|
||||
foreach (var method in type.Methods)
|
||||
{
|
||||
if (!request.IncludePrivateMethods && !IsPublicOrProtected(method))
|
||||
continue;
|
||||
|
||||
var fingerprint = CreateFingerprint(method, request.NormalizeMethodBodies);
|
||||
methods[fingerprint.MethodKey] = fingerprint;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsPublicOrProtected(MethodDefinition method) =>
|
||||
method.IsPublic || method.IsFamily || method.IsFamilyOrAssembly;
|
||||
|
||||
private static MethodFingerprint CreateFingerprint(MethodDefinition method, bool normalize)
|
||||
{
|
||||
var methodKey = GetMethodKey(method);
|
||||
var bodyHash = ComputeBodyHash(method, normalize);
|
||||
var signatureHash = ComputeSignatureHash(method);
|
||||
|
||||
return new MethodFingerprint
|
||||
{
|
||||
MethodKey = methodKey,
|
||||
DeclaringType = method.DeclaringType.FullName,
|
||||
Name = method.Name,
|
||||
Signature = GetSignature(method),
|
||||
BodyHash = bodyHash,
|
||||
SignatureHash = signatureHash,
|
||||
IsPublic = method.IsPublic,
|
||||
BodySize = method.HasBody ? method.Body.Instructions.Count : 0
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetMethodKey(MethodDefinition method)
|
||||
{
|
||||
var paramTypes = string.Join(",", method.Parameters.Select(p => p.ParameterType.Name));
|
||||
return $"{method.DeclaringType.FullName}::{method.Name}({paramTypes})";
|
||||
}
|
||||
|
||||
private static string GetSignature(MethodDefinition method)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(method.ReturnType.Name);
|
||||
sb.Append(' ');
|
||||
sb.Append(method.Name);
|
||||
sb.Append('(');
|
||||
sb.Append(string.Join(", ", method.Parameters.Select(p => $"{p.ParameterType.Name} {p.Name}")));
|
||||
sb.Append(')');
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string ComputeBodyHash(MethodDefinition method, bool normalize)
|
||||
{
|
||||
if (!method.HasBody)
|
||||
return "empty";
|
||||
|
||||
using var sha256 = SHA256.Create();
|
||||
var sb = new StringBuilder();
|
||||
|
||||
foreach (var instruction in method.Body.Instructions)
|
||||
{
|
||||
if (normalize)
|
||||
{
|
||||
// Normalize: skip debug instructions, use opcode names
|
||||
if (IsDebugInstruction(instruction.OpCode))
|
||||
continue;
|
||||
|
||||
sb.Append(instruction.OpCode.Name);
|
||||
|
||||
// Normalize operand references
|
||||
if (instruction.Operand is MethodReference mr)
|
||||
{
|
||||
sb.Append(':');
|
||||
sb.Append(mr.DeclaringType.Name);
|
||||
sb.Append('.');
|
||||
sb.Append(mr.Name);
|
||||
}
|
||||
else if (instruction.Operand is TypeReference tr)
|
||||
{
|
||||
sb.Append(':');
|
||||
sb.Append(tr.Name);
|
||||
}
|
||||
else if (instruction.Operand is FieldReference fr)
|
||||
{
|
||||
sb.Append(':');
|
||||
sb.Append(fr.Name);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(instruction.ToString());
|
||||
}
|
||||
|
||||
sb.Append(';');
|
||||
}
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
|
||||
var hash = sha256.ComputeHash(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string ComputeSignatureHash(MethodDefinition method)
|
||||
{
|
||||
using var sha256 = SHA256.Create();
|
||||
var sig = $"{method.ReturnType.FullName} {method.Name}({string.Join(",", method.Parameters.Select(p => p.ParameterType.FullName))})";
|
||||
var bytes = Encoding.UTF8.GetBytes(sig);
|
||||
var hash = sha256.ComputeHash(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant()[..16];
|
||||
}
|
||||
|
||||
private static bool IsDebugInstruction(OpCode opCode) =>
|
||||
opCode == OpCodes.Nop ||
|
||||
opCode.Name.StartsWith("break", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IMethodFingerprinter.cs
|
||||
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core
|
||||
// Description: Interface for computing method fingerprints for diff detection.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.VulnSurfaces.Fingerprint;
|
||||
|
||||
/// <summary>
|
||||
/// Computes stable fingerprints for methods in a package.
|
||||
/// Used to detect which methods changed between versions.
|
||||
/// </summary>
|
||||
public interface IMethodFingerprinter
|
||||
{
|
||||
/// <summary>
|
||||
/// Ecosystem this fingerprinter handles.
|
||||
/// </summary>
|
||||
string Ecosystem { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Computes fingerprints for all methods in a package.
|
||||
/// </summary>
|
||||
/// <param name="request">Fingerprint request with package path.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Fingerprint result with method hashes.</returns>
|
||||
Task<FingerprintResult> FingerprintAsync(
|
||||
FingerprintRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to fingerprint methods in a package.
|
||||
/// </summary>
|
||||
public sealed record FingerprintRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Path to extracted package directory.
|
||||
/// </summary>
|
||||
public required string PackagePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package name for context.
|
||||
/// </summary>
|
||||
public string? PackageName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package version for context.
|
||||
/// </summary>
|
||||
public string? Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include private methods.
|
||||
/// </summary>
|
||||
public bool IncludePrivateMethods { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to normalize method bodies before hashing.
|
||||
/// </summary>
|
||||
public bool NormalizeMethodBodies { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of method fingerprinting.
|
||||
/// </summary>
|
||||
public sealed record FingerprintResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether fingerprinting succeeded.
|
||||
/// </summary>
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Method fingerprints keyed by method key.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, MethodFingerprint> Methods { get; init; } =
|
||||
new Dictionary<string, MethodFingerprint>();
|
||||
|
||||
/// <summary>
|
||||
/// Error message if failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Processing duration.
|
||||
/// </summary>
|
||||
public TimeSpan Duration { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of files processed.
|
||||
/// </summary>
|
||||
public int FilesProcessed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful result.
|
||||
/// </summary>
|
||||
public static FingerprintResult Ok(
|
||||
IReadOnlyDictionary<string, MethodFingerprint> methods,
|
||||
TimeSpan duration,
|
||||
int filesProcessed) =>
|
||||
new()
|
||||
{
|
||||
Success = true,
|
||||
Methods = methods,
|
||||
Duration = duration,
|
||||
FilesProcessed = filesProcessed
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed result.
|
||||
/// </summary>
|
||||
public static FingerprintResult Fail(string error, TimeSpan duration) =>
|
||||
new()
|
||||
{
|
||||
Success = false,
|
||||
Error = error,
|
||||
Duration = duration
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fingerprint for a single method.
|
||||
/// </summary>
|
||||
public sealed record MethodFingerprint
|
||||
{
|
||||
/// <summary>
|
||||
/// Normalized method key.
|
||||
/// </summary>
|
||||
public required string MethodKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Declaring type/class.
|
||||
/// </summary>
|
||||
public required string DeclaringType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Method name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Method signature.
|
||||
/// </summary>
|
||||
public string? Signature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash of method body (normalized).
|
||||
/// </summary>
|
||||
public required string BodyHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash of method signature only.
|
||||
/// </summary>
|
||||
public string? SignatureHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether method is public.
|
||||
/// </summary>
|
||||
public bool IsPublic { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size of method body in bytes/instructions.
|
||||
/// </summary>
|
||||
public int BodySize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source file path (if available).
|
||||
/// </summary>
|
||||
public string? SourceFile { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Line number (if available).
|
||||
/// </summary>
|
||||
public int? LineNumber { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// MethodDiffEngine.cs
|
||||
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core
|
||||
// Description: Computes method-level diffs between package versions.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.VulnSurfaces.Models;
|
||||
|
||||
namespace StellaOps.Scanner.VulnSurfaces.Fingerprint;
|
||||
|
||||
/// <summary>
|
||||
/// Computes diffs between method fingerprints from two package versions.
|
||||
/// </summary>
|
||||
public interface IMethodDiffEngine
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the diff between vulnerable and fixed versions.
|
||||
/// </summary>
|
||||
Task<MethodDiffResult> DiffAsync(
|
||||
MethodDiffRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to compute method diff.
|
||||
/// </summary>
|
||||
public sealed record MethodDiffRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Fingerprints from vulnerable version.
|
||||
/// </summary>
|
||||
public required FingerprintResult VulnFingerprints { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Fingerprints from fixed version.
|
||||
/// </summary>
|
||||
public required FingerprintResult FixedFingerprints { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include methods that only changed signature.
|
||||
/// </summary>
|
||||
public bool IncludeSignatureChanges { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of method diff.
|
||||
/// </summary>
|
||||
public sealed record MethodDiffResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether diff succeeded.
|
||||
/// </summary>
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Methods that were modified (body changed).
|
||||
/// </summary>
|
||||
public IReadOnlyList<MethodDiff> Modified { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Methods added in fixed version.
|
||||
/// </summary>
|
||||
public IReadOnlyList<MethodFingerprint> Added { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Methods removed in fixed version.
|
||||
/// </summary>
|
||||
public IReadOnlyList<MethodFingerprint> Removed { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Total number of changes.
|
||||
/// </summary>
|
||||
public int TotalChanges => Modified.Count + Added.Count + Removed.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Processing duration.
|
||||
/// </summary>
|
||||
public TimeSpan Duration { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single method diff.
|
||||
/// </summary>
|
||||
public sealed record MethodDiff
|
||||
{
|
||||
/// <summary>
|
||||
/// Method key.
|
||||
/// </summary>
|
||||
public required string MethodKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Fingerprint from vulnerable version.
|
||||
/// </summary>
|
||||
public required MethodFingerprint VulnVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Fingerprint from fixed version.
|
||||
/// </summary>
|
||||
public required MethodFingerprint FixedVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of change.
|
||||
/// </summary>
|
||||
public MethodChangeType ChangeType { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of method diff engine.
|
||||
/// </summary>
|
||||
public sealed class MethodDiffEngine : IMethodDiffEngine
|
||||
{
|
||||
private readonly ILogger<MethodDiffEngine> _logger;
|
||||
|
||||
public MethodDiffEngine(ILogger<MethodDiffEngine> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<MethodDiffResult> DiffAsync(
|
||||
MethodDiffRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
var vulnMethods = request.VulnFingerprints.Methods;
|
||||
var fixedMethods = request.FixedFingerprints.Methods;
|
||||
|
||||
var modified = new List<MethodDiff>();
|
||||
var added = new List<MethodFingerprint>();
|
||||
var removed = new List<MethodFingerprint>();
|
||||
|
||||
// Find modified and removed methods
|
||||
foreach (var (key, vulnFp) in vulnMethods)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (fixedMethods.TryGetValue(key, out var fixedFp))
|
||||
{
|
||||
// Method exists in both - check if changed
|
||||
if (vulnFp.BodyHash != fixedFp.BodyHash)
|
||||
{
|
||||
modified.Add(new MethodDiff
|
||||
{
|
||||
MethodKey = key,
|
||||
VulnVersion = vulnFp,
|
||||
FixedVersion = fixedFp,
|
||||
ChangeType = MethodChangeType.Modified
|
||||
});
|
||||
}
|
||||
else if (request.IncludeSignatureChanges &&
|
||||
vulnFp.SignatureHash != fixedFp.SignatureHash)
|
||||
{
|
||||
modified.Add(new MethodDiff
|
||||
{
|
||||
MethodKey = key,
|
||||
VulnVersion = vulnFp,
|
||||
FixedVersion = fixedFp,
|
||||
ChangeType = MethodChangeType.SignatureChanged
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Method removed in fixed version
|
||||
removed.Add(vulnFp);
|
||||
}
|
||||
}
|
||||
|
||||
// Find added methods
|
||||
foreach (var (key, fixedFp) in fixedMethods)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!vulnMethods.ContainsKey(key))
|
||||
{
|
||||
added.Add(fixedFp);
|
||||
}
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
|
||||
_logger.LogDebug(
|
||||
"Method diff: {Modified} modified, {Added} added, {Removed} removed in {Duration}ms",
|
||||
modified.Count, added.Count, removed.Count, sw.ElapsedMilliseconds);
|
||||
|
||||
return Task.FromResult(new MethodDiffResult
|
||||
{
|
||||
Success = true,
|
||||
Modified = modified,
|
||||
Added = added,
|
||||
Removed = removed,
|
||||
Duration = sw.Elapsed
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
_logger.LogWarning(ex, "Method diff failed");
|
||||
|
||||
return Task.FromResult(new MethodDiffResult
|
||||
{
|
||||
Success = false,
|
||||
Error = ex.Message,
|
||||
Duration = sw.Elapsed
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VulnSurface.cs
|
||||
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core
|
||||
// Description: Core models for vulnerability surface computation.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.VulnSurfaces.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A vulnerability surface represents the specific methods that changed
|
||||
/// between a vulnerable and fixed version of a package.
|
||||
/// </summary>
|
||||
public sealed record VulnSurface
|
||||
{
|
||||
/// <summary>
|
||||
/// Database ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("surface_id")]
|
||||
public long SurfaceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVE ID (e.g., "CVE-2024-12345").
|
||||
/// </summary>
|
||||
[JsonPropertyName("cve_id")]
|
||||
public required string CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package identifier (PURL format preferred).
|
||||
/// </summary>
|
||||
[JsonPropertyName("package_id")]
|
||||
public required string PackageId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ecosystem (nuget, npm, maven, pypi).
|
||||
/// </summary>
|
||||
[JsonPropertyName("ecosystem")]
|
||||
public required string Ecosystem { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerable version analyzed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vuln_version")]
|
||||
public required string VulnVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Fixed version used for diff.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fixed_version")]
|
||||
public required string FixedVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sink methods (vulnerable code locations).
|
||||
/// </summary>
|
||||
[JsonPropertyName("sinks")]
|
||||
public IReadOnlyList<VulnSurfaceSink> Sinks { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Number of trigger methods that can reach sinks.
|
||||
/// </summary>
|
||||
[JsonPropertyName("trigger_count")]
|
||||
public int TriggerCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Surface computation status.
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public VulnSurfaceStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence score (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidence")]
|
||||
public double Confidence { get; init; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// When the surface was computed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("computed_at")]
|
||||
public DateTimeOffset ComputedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if computation failed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A sink method - a specific method that was modified in the security fix.
|
||||
/// </summary>
|
||||
public sealed record VulnSurfaceSink
|
||||
{
|
||||
/// <summary>
|
||||
/// Database ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sink_id")]
|
||||
public long SinkId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Parent surface ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("surface_id")]
|
||||
public long SurfaceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Normalized method key.
|
||||
/// </summary>
|
||||
[JsonPropertyName("method_key")]
|
||||
public required string MethodKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Declaring type/class name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("declaring_type")]
|
||||
public required string DeclaringType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Method name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("method_name")]
|
||||
public required string MethodName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Method signature.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signature")]
|
||||
public string? Signature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of change detected.
|
||||
/// </summary>
|
||||
[JsonPropertyName("change_type")]
|
||||
public MethodChangeType ChangeType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash of the method in vulnerable version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vuln_hash")]
|
||||
public string? VulnHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash of the method in fixed version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fixed_hash")]
|
||||
public string? FixedHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this sink is directly exploitable.
|
||||
/// </summary>
|
||||
[JsonPropertyName("is_direct_exploit")]
|
||||
public bool IsDirectExploit { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status of vulnerability surface computation.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum VulnSurfaceStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Computation pending.
|
||||
/// </summary>
|
||||
Pending,
|
||||
|
||||
/// <summary>
|
||||
/// Computation in progress.
|
||||
/// </summary>
|
||||
Computing,
|
||||
|
||||
/// <summary>
|
||||
/// Successfully computed.
|
||||
/// </summary>
|
||||
Computed,
|
||||
|
||||
/// <summary>
|
||||
/// Computation failed.
|
||||
/// </summary>
|
||||
Failed,
|
||||
|
||||
/// <summary>
|
||||
/// No diff detected (versions identical).
|
||||
/// </summary>
|
||||
NoDiff,
|
||||
|
||||
/// <summary>
|
||||
/// Package not found.
|
||||
/// </summary>
|
||||
PackageNotFound
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of method change detected.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum MethodChangeType
|
||||
{
|
||||
/// <summary>
|
||||
/// Method body was modified.
|
||||
/// </summary>
|
||||
Modified,
|
||||
|
||||
/// <summary>
|
||||
/// Method was added in fixed version.
|
||||
/// </summary>
|
||||
Added,
|
||||
|
||||
/// <summary>
|
||||
/// Method was removed in fixed version.
|
||||
/// </summary>
|
||||
Removed,
|
||||
|
||||
/// <summary>
|
||||
/// Method signature changed.
|
||||
/// </summary>
|
||||
SignatureChanged
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VulnSurfaceTrigger.cs
|
||||
// Sprint: SPRINT_3700_0003_0001_trigger_extraction
|
||||
// Description: Model for trigger methods that can reach vulnerable sinks.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.VulnSurfaces.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a trigger method - a public API that can reach a vulnerable sink method.
|
||||
/// </summary>
|
||||
public sealed record VulnSurfaceTrigger
|
||||
{
|
||||
/// <summary>
|
||||
/// Surface ID this trigger belongs to.
|
||||
/// </summary>
|
||||
[JsonPropertyName("surface_id")]
|
||||
public long SurfaceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unique key for the trigger method (public API).
|
||||
/// Format: namespace.class::methodName(signature)
|
||||
/// </summary>
|
||||
[JsonPropertyName("trigger_method_key")]
|
||||
public required string TriggerMethodKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unique key for the sink method (vulnerable code location).
|
||||
/// </summary>
|
||||
[JsonPropertyName("sink_method_key")]
|
||||
public required string SinkMethodKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Internal call path from trigger to sink within the package.
|
||||
/// </summary>
|
||||
[JsonPropertyName("internal_path")]
|
||||
public IReadOnlyList<string>? InternalPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this trigger was found via interface/base method expansion.
|
||||
/// </summary>
|
||||
[JsonPropertyName("is_interface_expansion")]
|
||||
public bool IsInterfaceExpansion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Depth from trigger to sink.
|
||||
/// </summary>
|
||||
[JsonPropertyName("depth")]
|
||||
public int Depth { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence score for this trigger path (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidence")]
|
||||
public double Confidence { get; init; } = 1.0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal method reference within a call graph.
|
||||
/// </summary>
|
||||
public sealed record InternalMethodRef
|
||||
{
|
||||
/// <summary>
|
||||
/// Fully qualified method key.
|
||||
/// </summary>
|
||||
public required string MethodKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Method name without namespace.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Declaring type name.
|
||||
/// </summary>
|
||||
public required string DeclaringType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this method is public.
|
||||
/// </summary>
|
||||
public bool IsPublic { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this method is from an interface.
|
||||
/// </summary>
|
||||
public bool IsInterface { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this method is virtual/abstract (can be overridden).
|
||||
/// </summary>
|
||||
public bool IsVirtual { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signature parameters.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Parameters { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Return type.
|
||||
/// </summary>
|
||||
public string? ReturnType { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Edge in the internal call graph.
|
||||
/// </summary>
|
||||
public sealed record InternalCallEdge
|
||||
{
|
||||
/// <summary>
|
||||
/// Caller method key.
|
||||
/// </summary>
|
||||
public required string Caller { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Callee method key.
|
||||
/// </summary>
|
||||
public required string Callee { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Call site offset (IL offset for .NET, bytecode offset for Java).
|
||||
/// </summary>
|
||||
public int? CallSiteOffset { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is a virtual/dispatch call.
|
||||
/// </summary>
|
||||
public bool IsVirtualCall { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of trigger extraction for a vulnerability surface.
|
||||
/// </summary>
|
||||
public sealed record TriggerExtractionResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether extraction succeeded.
|
||||
/// </summary>
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Extracted triggers.
|
||||
/// </summary>
|
||||
public IReadOnlyList<VulnSurfaceTrigger> Triggers { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Error message if extraction failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of public methods analyzed.
|
||||
/// </summary>
|
||||
public int PublicMethodsAnalyzed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of internal edges in the call graph.
|
||||
/// </summary>
|
||||
public int InternalEdgeCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Extraction duration.
|
||||
/// </summary>
|
||||
public TimeSpan Duration { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
||||
<RootNamespace>StellaOps.Scanner.VulnSurfaces</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="Mono.Cecil" Version="0.11.6" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Scanner.Core\StellaOps.Scanner.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,65 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ITriggerMethodExtractor.cs
|
||||
// Sprint: SPRINT_3700_0003_0001_trigger_extraction
|
||||
// Description: Interface for extracting trigger methods from internal call graphs.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scanner.VulnSurfaces.Models;
|
||||
|
||||
namespace StellaOps.Scanner.VulnSurfaces.Triggers;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts trigger methods (public API entry points) that can reach vulnerable sink methods.
|
||||
/// Uses forward BFS from public methods to find paths to sinks.
|
||||
/// </summary>
|
||||
public interface ITriggerMethodExtractor
|
||||
{
|
||||
/// <summary>
|
||||
/// Extracts trigger methods for a vulnerability surface.
|
||||
/// </summary>
|
||||
/// <param name="request">Extraction request with sink and graph info.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Extraction result with triggers.</returns>
|
||||
Task<TriggerExtractionResult> ExtractAsync(
|
||||
TriggerExtractionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to extract trigger methods.
|
||||
/// </summary>
|
||||
public sealed record TriggerExtractionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Surface ID for the vulnerability.
|
||||
/// </summary>
|
||||
public long SurfaceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sink method keys (vulnerable code locations).
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> SinkMethodKeys { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Internal call graph for the package.
|
||||
/// </summary>
|
||||
public required CallGraph.InternalCallGraph Graph { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum BFS depth.
|
||||
/// </summary>
|
||||
public int MaxDepth { get; init; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to expand interfaces and base classes.
|
||||
/// </summary>
|
||||
public bool ExpandInterfaces { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum confidence threshold for triggers.
|
||||
/// </summary>
|
||||
public double MinConfidence { get; init; } = 0.0;
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TriggerMethodExtractor.cs
|
||||
// Sprint: SPRINT_3700_0003_0001_trigger_extraction
|
||||
// Description: Implementation of trigger method extraction using forward BFS.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.VulnSurfaces.CallGraph;
|
||||
using StellaOps.Scanner.VulnSurfaces.Models;
|
||||
|
||||
namespace StellaOps.Scanner.VulnSurfaces.Triggers;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts trigger methods using forward BFS from public methods to sinks.
|
||||
/// </summary>
|
||||
public sealed class TriggerMethodExtractor : ITriggerMethodExtractor
|
||||
{
|
||||
private readonly ILogger<TriggerMethodExtractor> _logger;
|
||||
|
||||
public TriggerMethodExtractor(ILogger<TriggerMethodExtractor> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TriggerExtractionResult> ExtractAsync(
|
||||
TriggerExtractionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
var triggers = ExtractTriggersCore(request, cancellationToken);
|
||||
|
||||
sw.Stop();
|
||||
|
||||
_logger.LogDebug(
|
||||
"Extracted {TriggerCount} triggers for surface {SurfaceId} in {Duration}ms",
|
||||
triggers.Count, request.SurfaceId, sw.ElapsedMilliseconds);
|
||||
|
||||
return Task.FromResult(new TriggerExtractionResult
|
||||
{
|
||||
Success = true,
|
||||
Triggers = triggers,
|
||||
PublicMethodsAnalyzed = request.Graph.GetPublicMethods().Count(),
|
||||
InternalEdgeCount = request.Graph.EdgeCount,
|
||||
Duration = sw.Elapsed
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
_logger.LogWarning(ex, "Trigger extraction failed for surface {SurfaceId}", request.SurfaceId);
|
||||
|
||||
return Task.FromResult(new TriggerExtractionResult
|
||||
{
|
||||
Success = false,
|
||||
Error = ex.Message,
|
||||
Duration = sw.Elapsed
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private List<VulnSurfaceTrigger> ExtractTriggersCore(
|
||||
TriggerExtractionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var triggers = new List<VulnSurfaceTrigger>();
|
||||
var sinkSet = request.SinkMethodKeys.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
// For each public method, run forward BFS to find sinks
|
||||
foreach (var publicMethod in request.Graph.GetPublicMethods())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var paths = FindPathsToSinks(
|
||||
request.Graph,
|
||||
publicMethod.MethodKey,
|
||||
sinkSet,
|
||||
request.MaxDepth,
|
||||
cancellationToken);
|
||||
|
||||
foreach (var (sinkKey, path, isInterfaceExpansion) in paths)
|
||||
{
|
||||
var trigger = new VulnSurfaceTrigger
|
||||
{
|
||||
SurfaceId = request.SurfaceId,
|
||||
TriggerMethodKey = publicMethod.MethodKey,
|
||||
SinkMethodKey = sinkKey,
|
||||
InternalPath = path,
|
||||
Depth = path.Count - 1,
|
||||
IsInterfaceExpansion = isInterfaceExpansion,
|
||||
Confidence = ComputeConfidence(path, publicMethod, request.Graph)
|
||||
};
|
||||
|
||||
if (trigger.Confidence >= request.MinConfidence)
|
||||
{
|
||||
triggers.Add(trigger);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If interface expansion is enabled, also check interface implementations
|
||||
if (request.ExpandInterfaces)
|
||||
{
|
||||
var interfaceTriggers = ExtractInterfaceExpansionTriggers(
|
||||
request, sinkSet, triggers, cancellationToken);
|
||||
triggers.AddRange(interfaceTriggers);
|
||||
}
|
||||
|
||||
return triggers;
|
||||
}
|
||||
|
||||
private static List<(string SinkKey, List<string> Path, bool IsInterfaceExpansion)> FindPathsToSinks(
|
||||
InternalCallGraph graph,
|
||||
string startMethod,
|
||||
HashSet<string> sinks,
|
||||
int maxDepth,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new List<(string, List<string>, bool)>();
|
||||
var visited = new HashSet<string>(StringComparer.Ordinal);
|
||||
var queue = new Queue<(string Method, List<string> Path)>();
|
||||
|
||||
queue.Enqueue((startMethod, [startMethod]));
|
||||
visited.Add(startMethod);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var (current, path) = queue.Dequeue();
|
||||
|
||||
if (path.Count > maxDepth)
|
||||
continue;
|
||||
|
||||
// Check if current is a sink
|
||||
if (sinks.Contains(current) && path.Count > 1)
|
||||
{
|
||||
results.Add((current, new List<string>(path), false));
|
||||
}
|
||||
|
||||
// Explore callees
|
||||
foreach (var callee in graph.GetCallees(current))
|
||||
{
|
||||
if (!visited.Contains(callee))
|
||||
{
|
||||
visited.Add(callee);
|
||||
var newPath = new List<string>(path) { callee };
|
||||
queue.Enqueue((callee, newPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private IEnumerable<VulnSurfaceTrigger> ExtractInterfaceExpansionTriggers(
|
||||
TriggerExtractionRequest request,
|
||||
HashSet<string> sinkSet,
|
||||
List<VulnSurfaceTrigger> existingTriggers,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Find interface methods and their implementations
|
||||
var interfaceMethods = request.Graph.Methods.Values
|
||||
.Where(m => m.IsInterface || m.IsVirtual)
|
||||
.ToList();
|
||||
|
||||
var expansionTriggers = new List<VulnSurfaceTrigger>();
|
||||
|
||||
foreach (var interfaceMethod in interfaceMethods)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Find implementations by name matching (simplified)
|
||||
var implementations = FindPotentialImplementations(
|
||||
request.Graph, interfaceMethod.MethodKey, interfaceMethod.Name);
|
||||
|
||||
foreach (var implKey in implementations)
|
||||
{
|
||||
// Check if implementation reaches any sink
|
||||
var paths = FindPathsToSinks(
|
||||
request.Graph, implKey, sinkSet, request.MaxDepth, cancellationToken);
|
||||
|
||||
foreach (var (sinkKey, path, _) in paths)
|
||||
{
|
||||
// Skip if we already have this trigger from direct analysis
|
||||
if (existingTriggers.Any(t =>
|
||||
t.TriggerMethodKey == interfaceMethod.MethodKey &&
|
||||
t.SinkMethodKey == sinkKey))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add interface method -> implementation -> sink trigger
|
||||
var fullPath = new List<string> { interfaceMethod.MethodKey };
|
||||
fullPath.AddRange(path);
|
||||
|
||||
expansionTriggers.Add(new VulnSurfaceTrigger
|
||||
{
|
||||
SurfaceId = request.SurfaceId,
|
||||
TriggerMethodKey = interfaceMethod.MethodKey,
|
||||
SinkMethodKey = sinkKey,
|
||||
InternalPath = fullPath,
|
||||
Depth = fullPath.Count - 1,
|
||||
IsInterfaceExpansion = true,
|
||||
Confidence = 0.8 * ComputeConfidence(path, request.Graph.GetMethod(implKey), request.Graph)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return expansionTriggers;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> FindPotentialImplementations(
|
||||
InternalCallGraph graph,
|
||||
string interfaceMethodKey,
|
||||
string methodName)
|
||||
{
|
||||
// Find methods with same name that aren't the interface method itself
|
||||
return graph.Methods.Values
|
||||
.Where(m => m.Name == methodName &&
|
||||
m.MethodKey != interfaceMethodKey &&
|
||||
!m.IsInterface)
|
||||
.Select(m => m.MethodKey);
|
||||
}
|
||||
|
||||
private static double ComputeConfidence(
|
||||
List<string> path,
|
||||
InternalMethodRef? startMethod,
|
||||
InternalCallGraph graph)
|
||||
{
|
||||
// Base confidence starts at 1.0
|
||||
var confidence = 1.0;
|
||||
|
||||
// Reduce confidence for longer paths
|
||||
confidence *= Math.Max(0.5, 1.0 - (path.Count * 0.05));
|
||||
|
||||
// Reduce confidence if path goes through virtual calls
|
||||
var virtualCallCount = 0;
|
||||
for (var i = 0; i < path.Count - 1; i++)
|
||||
{
|
||||
var method = graph.GetMethod(path[i + 1]);
|
||||
if (method?.IsVirtual == true)
|
||||
{
|
||||
virtualCallCount++;
|
||||
}
|
||||
}
|
||||
|
||||
confidence *= Math.Max(0.6, 1.0 - (virtualCallCount * 0.1));
|
||||
|
||||
// Boost confidence if start method is explicitly public
|
||||
if (startMethod?.IsPublic == true)
|
||||
{
|
||||
confidence = Math.Min(1.0, confidence * 1.1);
|
||||
}
|
||||
|
||||
return Math.Round(confidence, 3);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user