feat: Add PathViewer and RiskDriftCard components with templates and styles

- Implemented PathViewerComponent for visualizing reachability call paths.
- Added RiskDriftCardComponent to display reachability drift results.
- Created corresponding HTML templates and SCSS styles for both components.
- Introduced test fixtures for reachability analysis in JSON format.
- Enhanced user interaction with collapsible and expandable features in PathViewer.
- Included risk trend visualization and summary metrics in RiskDriftCard.
This commit is contained in:
master
2025-12-18 18:35:30 +02:00
parent 811f35cba7
commit 0dc71e760a
70 changed files with 8904 additions and 163 deletions

View File

@@ -13,6 +13,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.VulnSurfaces.CallGraph;
using StellaOps.Scanner.VulnSurfaces.Diagnostics;
using StellaOps.Scanner.VulnSurfaces.Download;
using StellaOps.Scanner.VulnSurfaces.Fingerprint;
using StellaOps.Scanner.VulnSurfaces.Models;
@@ -56,6 +57,12 @@ public sealed class VulnSurfaceBuilder : IVulnSurfaceBuilder
ArgumentNullException.ThrowIfNull(request);
var sw = Stopwatch.StartNew();
var tags = new KeyValuePair<string, object?>[]
{
new("ecosystem", request.Ecosystem.ToLowerInvariant())
};
VulnSurfaceMetrics.BuildRequests.Add(1, tags);
_logger.LogInformation(
"Building vulnerability surface for {CveId}: {Package} {VulnVersion} → {FixedVersion}",
@@ -87,6 +94,8 @@ public sealed class VulnSurfaceBuilder : IVulnSurfaceBuilder
Directory.CreateDirectory(workDir);
// 3. Download both versions
VulnSurfaceMetrics.DownloadAttempts.Add(2, tags); // Two versions
var vulnDownload = await downloader.DownloadAsync(new PackageDownloadRequest
{
PackageName = request.PackageName,
@@ -98,9 +107,14 @@ public sealed class VulnSurfaceBuilder : IVulnSurfaceBuilder
if (!vulnDownload.Success)
{
sw.Stop();
VulnSurfaceMetrics.DownloadFailures.Add(1, tags);
VulnSurfaceMetrics.BuildFailures.Add(1, new KeyValuePair<string, object?>[] { new("ecosystem", request.Ecosystem.ToLowerInvariant()), new("reason", "download_vuln") });
return VulnSurfaceBuildResult.Fail($"Failed to download vulnerable version: {vulnDownload.Error}", sw.Elapsed);
}
VulnSurfaceMetrics.DownloadSuccesses.Add(1, tags);
VulnSurfaceMetrics.DownloadDurationSeconds.Record(vulnDownload.Duration.TotalSeconds, tags);
var fixedDownload = await downloader.DownloadAsync(new PackageDownloadRequest
{
PackageName = request.PackageName,
@@ -112,10 +126,16 @@ public sealed class VulnSurfaceBuilder : IVulnSurfaceBuilder
if (!fixedDownload.Success)
{
sw.Stop();
VulnSurfaceMetrics.DownloadFailures.Add(1, tags);
VulnSurfaceMetrics.BuildFailures.Add(1, new KeyValuePair<string, object?>[] { new("ecosystem", request.Ecosystem.ToLowerInvariant()), new("reason", "download_fixed") });
return VulnSurfaceBuildResult.Fail($"Failed to download fixed version: {fixedDownload.Error}", sw.Elapsed);
}
VulnSurfaceMetrics.DownloadSuccesses.Add(1, tags);
VulnSurfaceMetrics.DownloadDurationSeconds.Record(fixedDownload.Duration.TotalSeconds, tags);
// 4. Fingerprint both versions
var fpSw = Stopwatch.StartNew();
var vulnFingerprints = await fingerprinter.FingerprintAsync(new FingerprintRequest
{
PackagePath = vulnDownload.ExtractedPath!,
@@ -126,9 +146,15 @@ public sealed class VulnSurfaceBuilder : IVulnSurfaceBuilder
if (!vulnFingerprints.Success)
{
sw.Stop();
VulnSurfaceMetrics.BuildFailures.Add(1, new KeyValuePair<string, object?>[] { new("ecosystem", request.Ecosystem.ToLowerInvariant()), new("reason", "fingerprint_vuln") });
return VulnSurfaceBuildResult.Fail($"Failed to fingerprint vulnerable version: {vulnFingerprints.Error}", sw.Elapsed);
}
VulnSurfaceMetrics.FingerprintDurationSeconds.Record(fpSw.Elapsed.TotalSeconds, tags);
VulnSurfaceMetrics.MethodsFingerprinted.Add(vulnFingerprints.Methods.Count, tags);
VulnSurfaceMetrics.MethodsPerPackage.Record(vulnFingerprints.Methods.Count, tags);
fpSw.Restart();
var fixedFingerprints = await fingerprinter.FingerprintAsync(new FingerprintRequest
{
PackagePath = fixedDownload.ExtractedPath!,
@@ -139,10 +165,16 @@ public sealed class VulnSurfaceBuilder : IVulnSurfaceBuilder
if (!fixedFingerprints.Success)
{
sw.Stop();
VulnSurfaceMetrics.BuildFailures.Add(1, new KeyValuePair<string, object?>[] { new("ecosystem", request.Ecosystem.ToLowerInvariant()), new("reason", "fingerprint_fixed") });
return VulnSurfaceBuildResult.Fail($"Failed to fingerprint fixed version: {fixedFingerprints.Error}", sw.Elapsed);
}
VulnSurfaceMetrics.FingerprintDurationSeconds.Record(fpSw.Elapsed.TotalSeconds, tags);
VulnSurfaceMetrics.MethodsFingerprinted.Add(fixedFingerprints.Methods.Count, tags);
VulnSurfaceMetrics.MethodsPerPackage.Record(fixedFingerprints.Methods.Count, tags);
// 5. Compute diff
var diffSw = Stopwatch.StartNew();
var diff = await _diffEngine.DiffAsync(new MethodDiffRequest
{
VulnFingerprints = vulnFingerprints,
@@ -152,9 +184,12 @@ public sealed class VulnSurfaceBuilder : IVulnSurfaceBuilder
if (!diff.Success)
{
sw.Stop();
VulnSurfaceMetrics.BuildFailures.Add(1, new KeyValuePair<string, object?>[] { new("ecosystem", request.Ecosystem.ToLowerInvariant()), new("reason", "diff") });
return VulnSurfaceBuildResult.Fail($"Failed to compute diff: {diff.Error}", sw.Elapsed);
}
VulnSurfaceMetrics.DiffDurationSeconds.Record(diffSw.Elapsed.TotalSeconds, tags);
// 6. Build sinks from diff
var sinks = BuildSinks(diff);
@@ -209,6 +244,13 @@ public sealed class VulnSurfaceBuilder : IVulnSurfaceBuilder
sw.Stop();
// Record success metrics
VulnSurfaceMetrics.BuildSuccesses.Add(1, tags);
VulnSurfaceMetrics.BuildDurationSeconds.Record(sw.Elapsed.TotalSeconds, tags);
VulnSurfaceMetrics.SinksPerSurface.Record(sinks.Count, tags);
VulnSurfaceMetrics.SinksIdentified.Add(sinks.Count, tags);
VulnSurfaceMetrics.IncrementEcosystemCount(request.Ecosystem);
_logger.LogInformation(
"Built vulnerability surface for {CveId}: {SinkCount} sinks, {TriggerCount} triggers in {Duration}ms",
request.CveId, sinks.Count, triggerCount, sw.ElapsedMilliseconds);
@@ -218,6 +260,16 @@ public sealed class VulnSurfaceBuilder : IVulnSurfaceBuilder
catch (Exception ex)
{
sw.Stop();
// Record failure metrics
var failTags = new KeyValuePair<string, object?>[]
{
new("ecosystem", request.Ecosystem.ToLowerInvariant()),
new("reason", "exception")
};
VulnSurfaceMetrics.BuildFailures.Add(1, failTags);
VulnSurfaceMetrics.BuildDurationSeconds.Record(sw.Elapsed.TotalSeconds, tags);
_logger.LogError(ex, "Failed to build vulnerability surface for {CveId}", request.CveId);
return VulnSurfaceBuildResult.Fail(ex.Message, sw.Elapsed);
}

View File

@@ -0,0 +1,233 @@
// -----------------------------------------------------------------------------
// VulnSurfaceMetrics.cs
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core
// Task: SURF-019
// Description: Metrics for vulnerability surface computation.
// -----------------------------------------------------------------------------
using System.Diagnostics.Metrics;
namespace StellaOps.Scanner.VulnSurfaces.Diagnostics;
/// <summary>
/// Metrics for vulnerability surface computation and caching.
/// </summary>
public static class VulnSurfaceMetrics
{
private static readonly Meter Meter = new("StellaOps.Scanner.VulnSurfaces", "1.0.0");
// ===== BUILD COUNTERS =====
/// <summary>
/// Total surface build requests by ecosystem.
/// </summary>
public static readonly Counter<long> BuildRequests = Meter.CreateCounter<long>(
"stellaops_vulnsurface_build_requests_total",
description: "Total vulnerability surface build requests");
/// <summary>
/// Successful surface builds by ecosystem.
/// </summary>
public static readonly Counter<long> BuildSuccesses = Meter.CreateCounter<long>(
"stellaops_vulnsurface_build_successes_total",
description: "Total successful vulnerability surface builds");
/// <summary>
/// Failed surface builds by ecosystem and reason.
/// </summary>
public static readonly Counter<long> BuildFailures = Meter.CreateCounter<long>(
"stellaops_vulnsurface_build_failures_total",
description: "Total failed vulnerability surface builds");
/// <summary>
/// Cache hits when surface already computed.
/// </summary>
public static readonly Counter<long> CacheHits = Meter.CreateCounter<long>(
"stellaops_vulnsurface_cache_hits_total",
description: "Total cache hits for pre-computed surfaces");
// ===== DOWNLOAD COUNTERS =====
/// <summary>
/// Package downloads attempted by ecosystem.
/// </summary>
public static readonly Counter<long> DownloadAttempts = Meter.CreateCounter<long>(
"stellaops_vulnsurface_downloads_attempted_total",
description: "Total package download attempts");
/// <summary>
/// Successful package downloads.
/// </summary>
public static readonly Counter<long> DownloadSuccesses = Meter.CreateCounter<long>(
"stellaops_vulnsurface_downloads_succeeded_total",
description: "Total successful package downloads");
/// <summary>
/// Failed package downloads.
/// </summary>
public static readonly Counter<long> DownloadFailures = Meter.CreateCounter<long>(
"stellaops_vulnsurface_downloads_failed_total",
description: "Total failed package downloads");
// ===== FINGERPRINT COUNTERS =====
/// <summary>
/// Methods fingerprinted by ecosystem.
/// </summary>
public static readonly Counter<long> MethodsFingerprinted = Meter.CreateCounter<long>(
"stellaops_vulnsurface_methods_fingerprinted_total",
description: "Total methods fingerprinted");
/// <summary>
/// Methods changed (sinks) identified.
/// </summary>
public static readonly Counter<long> SinksIdentified = Meter.CreateCounter<long>(
"stellaops_vulnsurface_sinks_identified_total",
description: "Total sink methods (changed methods) identified");
// ===== TIMING HISTOGRAMS =====
/// <summary>
/// End-to-end surface build duration.
/// </summary>
public static readonly Histogram<double> BuildDurationSeconds = Meter.CreateHistogram<double>(
"stellaops_vulnsurface_build_duration_seconds",
unit: "s",
description: "Duration of surface build operations",
advice: new InstrumentAdvice<double>
{
HistogramBucketBoundaries = [0.1, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0, 120.0]
});
/// <summary>
/// Package download duration.
/// </summary>
public static readonly Histogram<double> DownloadDurationSeconds = Meter.CreateHistogram<double>(
"stellaops_vulnsurface_download_duration_seconds",
unit: "s",
description: "Duration of package download operations",
advice: new InstrumentAdvice<double>
{
HistogramBucketBoundaries = [0.1, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0]
});
/// <summary>
/// Fingerprinting duration per package.
/// </summary>
public static readonly Histogram<double> FingerprintDurationSeconds = Meter.CreateHistogram<double>(
"stellaops_vulnsurface_fingerprint_duration_seconds",
unit: "s",
description: "Duration of fingerprinting operations",
advice: new InstrumentAdvice<double>
{
HistogramBucketBoundaries = [0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0]
});
/// <summary>
/// Diff computation duration.
/// </summary>
public static readonly Histogram<double> DiffDurationSeconds = Meter.CreateHistogram<double>(
"stellaops_vulnsurface_diff_duration_seconds",
unit: "s",
description: "Duration of diff computation",
advice: new InstrumentAdvice<double>
{
HistogramBucketBoundaries = [0.001, 0.01, 0.05, 0.1, 0.25, 0.5, 1.0]
});
// ===== SIZE HISTOGRAMS =====
/// <summary>
/// Number of methods per package version.
/// </summary>
public static readonly Histogram<int> MethodsPerPackage = Meter.CreateHistogram<int>(
"stellaops_vulnsurface_methods_per_package",
description: "Number of methods per analyzed package version",
advice: new InstrumentAdvice<int>
{
HistogramBucketBoundaries = [10, 50, 100, 250, 500, 1000, 2500, 5000, 10000]
});
/// <summary>
/// Number of sinks per surface.
/// </summary>
public static readonly Histogram<int> SinksPerSurface = Meter.CreateHistogram<int>(
"stellaops_vulnsurface_sinks_per_surface",
description: "Number of sink methods per vulnerability surface",
advice: new InstrumentAdvice<int>
{
HistogramBucketBoundaries = [1, 2, 5, 10, 25, 50, 100, 250]
});
// ===== ECOSYSTEM DISTRIBUTION =====
private static int _nugetSurfaces;
private static int _npmSurfaces;
private static int _mavenSurfaces;
private static int _pypiSurfaces;
/// <summary>
/// Current count of NuGet surfaces.
/// </summary>
public static readonly ObservableGauge<int> NuGetSurfaceCount = Meter.CreateObservableGauge(
"stellaops_vulnsurface_nuget_count",
() => _nugetSurfaces,
description: "Current count of NuGet vulnerability surfaces");
/// <summary>
/// Current count of npm surfaces.
/// </summary>
public static readonly ObservableGauge<int> NpmSurfaceCount = Meter.CreateObservableGauge(
"stellaops_vulnsurface_npm_count",
() => _npmSurfaces,
description: "Current count of npm vulnerability surfaces");
/// <summary>
/// Current count of Maven surfaces.
/// </summary>
public static readonly ObservableGauge<int> MavenSurfaceCount = Meter.CreateObservableGauge(
"stellaops_vulnsurface_maven_count",
() => _mavenSurfaces,
description: "Current count of Maven vulnerability surfaces");
/// <summary>
/// Current count of PyPI surfaces.
/// </summary>
public static readonly ObservableGauge<int> PyPISurfaceCount = Meter.CreateObservableGauge(
"stellaops_vulnsurface_pypi_count",
() => _pypiSurfaces,
description: "Current count of PyPI vulnerability surfaces");
/// <summary>
/// Updates the ecosystem surface counts.
/// </summary>
public static void SetEcosystemCounts(int nuget, int npm, int maven, int pypi)
{
Interlocked.Exchange(ref _nugetSurfaces, nuget);
Interlocked.Exchange(ref _npmSurfaces, npm);
Interlocked.Exchange(ref _mavenSurfaces, maven);
Interlocked.Exchange(ref _pypiSurfaces, pypi);
}
/// <summary>
/// Increments the surface count for an ecosystem.
/// </summary>
public static void IncrementEcosystemCount(string ecosystem)
{
switch (ecosystem.ToLowerInvariant())
{
case "nuget":
Interlocked.Increment(ref _nugetSurfaces);
break;
case "npm":
Interlocked.Increment(ref _npmSurfaces);
break;
case "maven":
Interlocked.Increment(ref _mavenSurfaces);
break;
case "pypi":
Interlocked.Increment(ref _pypiSurfaces);
break;
}
}
}

View File

@@ -124,6 +124,12 @@ public sealed record VulnSurfaceSink
[JsonPropertyName("method_name")]
public required string MethodName { get; init; }
/// <summary>
/// Namespace/package.
/// </summary>
[JsonPropertyName("namespace")]
public string? Namespace { get; init; }
/// <summary>
/// Method signature.
/// </summary>
@@ -153,6 +159,42 @@ public sealed record VulnSurfaceSink
/// </summary>
[JsonPropertyName("is_direct_exploit")]
public bool IsDirectExploit { get; init; }
/// <summary>
/// Whether the method is public.
/// </summary>
[JsonPropertyName("is_public")]
public bool IsPublic { get; init; }
/// <summary>
/// Number of parameters.
/// </summary>
[JsonPropertyName("parameter_count")]
public int? ParameterCount { get; init; }
/// <summary>
/// Return type.
/// </summary>
[JsonPropertyName("return_type")]
public string? ReturnType { get; init; }
/// <summary>
/// Source file path (if available from debug symbols).
/// </summary>
[JsonPropertyName("source_file")]
public string? SourceFile { get; init; }
/// <summary>
/// Start line number.
/// </summary>
[JsonPropertyName("start_line")]
public int? StartLine { get; init; }
/// <summary>
/// End line number.
/// </summary>
[JsonPropertyName("end_line")]
public int? EndLine { get; init; }
}
/// <summary>

View File

@@ -14,6 +14,7 @@
<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" />
<PackageReference Include="Npgsql" Version="9.0.3" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,99 @@
// -----------------------------------------------------------------------------
// IVulnSurfaceRepository.cs
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core
// Task: SURF-016
// Description: Repository interface for vulnerability surfaces.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.VulnSurfaces.Models;
namespace StellaOps.Scanner.VulnSurfaces.Storage;
/// <summary>
/// Repository interface for vulnerability surface storage.
/// </summary>
public interface IVulnSurfaceRepository
{
/// <summary>
/// Creates a new vulnerability surface.
/// </summary>
Task<Guid> CreateSurfaceAsync(
Guid tenantId,
string cveId,
string ecosystem,
string packageName,
string vulnVersion,
string? fixedVersion,
string fingerprintMethod,
int totalMethodsVuln,
int totalMethodsFixed,
int changedMethodCount,
int? computationDurationMs,
string? attestationDigest,
CancellationToken cancellationToken = default);
/// <summary>
/// Adds a sink method to a vulnerability surface.
/// </summary>
Task<Guid> AddSinkAsync(
Guid surfaceId,
string methodKey,
string methodName,
string declaringType,
string changeType,
string? vulnHash,
string? fixedHash,
CancellationToken cancellationToken = default);
/// <summary>
/// Adds a trigger to a surface.
/// </summary>
Task<Guid> AddTriggerAsync(
Guid surfaceId,
string triggerMethodKey,
string sinkMethodKey,
int depth,
double confidence,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a vulnerability surface by CVE and package.
/// </summary>
Task<VulnSurface?> GetByCveAndPackageAsync(
Guid tenantId,
string cveId,
string ecosystem,
string packageName,
string vulnVersion,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets sinks for a vulnerability surface.
/// </summary>
Task<IReadOnlyList<VulnSurfaceSink>> GetSinksAsync(
Guid surfaceId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets triggers for a vulnerability surface.
/// </summary>
Task<IReadOnlyList<VulnSurfaceTrigger>> GetTriggersAsync(
Guid surfaceId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all surfaces for a CVE.
/// </summary>
Task<IReadOnlyList<VulnSurface>> GetSurfacesByCveAsync(
Guid tenantId,
string cveId,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a vulnerability surface and all related data.
/// </summary>
Task<bool> DeleteSurfaceAsync(
Guid surfaceId,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,100 @@
// -----------------------------------------------------------------------------
// IVulnSurfaceRepository.cs
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core
// Task: SURF-016
// Description: Repository interface for vulnerability surfaces.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.VulnSurfaces.Models;
namespace StellaOps.Scanner.VulnSurfaces.Storage;
/// <summary>
/// Repository interface for vulnerability surface storage.
/// </summary>
public interface IVulnSurfaceRepository
{
/// <summary>
/// Creates a new vulnerability surface.
/// </summary>
Task<Guid> CreateSurfaceAsync(
Guid tenantId,
string cveId,
string ecosystem,
string packageName,
string vulnVersion,
string? fixedVersion,
string fingerprintMethod,
int totalMethodsVuln,
int totalMethodsFixed,
int changedMethodCount,
int? computationDurationMs,
string? attestationDigest,
CancellationToken cancellationToken = default);
/// <summary>
/// Adds a sink method to a vulnerability surface.
/// </summary>
Task<Guid> AddSinkAsync(
Guid surfaceId,
string methodKey,
string methodName,
string declaringType,
string changeType,
string? vulnHash,
string? fixedHash,
CancellationToken cancellationToken = default);
/// <summary>
/// Adds a trigger to a surface.
/// </summary>
Task<Guid> AddTriggerAsync(
Guid surfaceId,
string triggerMethodKey,
string sinkMethodKey,
int depth,
double confidence,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a vulnerability surface by CVE and package.
/// </summary>
Task<VulnSurface?> GetByCveAndPackageAsync(
Guid tenantId,
string cveId,
string ecosystem,
string packageName,
string vulnVersion,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets sinks for a vulnerability surface.
/// </summary>
Task<IReadOnlyList<VulnSurfaceSink>> GetSinksAsync(
Guid surfaceId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets triggers for a vulnerability surface.
/// </summary>
Task<IReadOnlyList<VulnSurfaceTrigger>> GetTriggersAsync(
Guid surfaceId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all surfaces for a CVE.
/// </summary>
Task<IReadOnlyList<VulnSurface>> GetSurfacesByCveAsync(
Guid tenantId,
string cveId,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a vulnerability surface and all related data.
/// </summary>
Task<bool> DeleteSurfaceAsync(
Guid surfaceId,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,400 @@
// -----------------------------------------------------------------------------
// PostgresVulnSurfaceRepository.cs
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core
// Task: SURF-016
// Description: PostgreSQL implementation of vulnerability surface repository.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Scanner.VulnSurfaces.Models;
namespace StellaOps.Scanner.VulnSurfaces.Storage;
/// <summary>
/// PostgreSQL implementation of vulnerability surface repository.
/// </summary>
public sealed class PostgresVulnSurfaceRepository : IVulnSurfaceRepository
{
private readonly NpgsqlDataSource _dataSource;
private readonly ILogger<PostgresVulnSurfaceRepository> _logger;
private readonly int _commandTimeoutSeconds;
public PostgresVulnSurfaceRepository(
NpgsqlDataSource dataSource,
ILogger<PostgresVulnSurfaceRepository> logger,
int commandTimeoutSeconds = 30)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_commandTimeoutSeconds = commandTimeoutSeconds;
}
public async Task<Guid> CreateSurfaceAsync(
Guid tenantId,
string cveId,
string ecosystem,
string packageName,
string vulnVersion,
string? fixedVersion,
string fingerprintMethod,
int totalMethodsVuln,
int totalMethodsFixed,
int changedMethodCount,
int? computationDurationMs,
string? attestationDigest,
CancellationToken cancellationToken = default)
{
var id = Guid.NewGuid();
const string sql = """
INSERT INTO scanner.vuln_surfaces (
id, tenant_id, cve_id, package_ecosystem, package_name,
vuln_version, fixed_version, fingerprint_method,
total_methods_vuln, total_methods_fixed, changed_method_count,
computation_duration_ms, attestation_digest
) VALUES (
@id, @tenant_id, @cve_id, @ecosystem, @package_name,
@vuln_version, @fixed_version, @fingerprint_method,
@total_methods_vuln, @total_methods_fixed, @changed_method_count,
@computation_duration_ms, @attestation_digest
)
ON CONFLICT (tenant_id, cve_id, package_ecosystem, package_name, vuln_version)
DO UPDATE SET
fixed_version = EXCLUDED.fixed_version,
fingerprint_method = EXCLUDED.fingerprint_method,
total_methods_vuln = EXCLUDED.total_methods_vuln,
total_methods_fixed = EXCLUDED.total_methods_fixed,
changed_method_count = EXCLUDED.changed_method_count,
computation_duration_ms = EXCLUDED.computation_duration_ms,
attestation_digest = EXCLUDED.attestation_digest,
computed_at = now()
RETURNING id
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await SetTenantContextAsync(connection, tenantId, cancellationToken);
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _commandTimeoutSeconds;
command.Parameters.AddWithValue("id", id);
command.Parameters.AddWithValue("tenant_id", tenantId);
command.Parameters.AddWithValue("cve_id", cveId);
command.Parameters.AddWithValue("ecosystem", ecosystem);
command.Parameters.AddWithValue("package_name", packageName);
command.Parameters.AddWithValue("vuln_version", vulnVersion);
command.Parameters.AddWithValue("fixed_version", (object?)fixedVersion ?? DBNull.Value);
command.Parameters.AddWithValue("fingerprint_method", fingerprintMethod);
command.Parameters.AddWithValue("total_methods_vuln", totalMethodsVuln);
command.Parameters.AddWithValue("total_methods_fixed", totalMethodsFixed);
command.Parameters.AddWithValue("changed_method_count", changedMethodCount);
command.Parameters.AddWithValue("computation_duration_ms", (object?)computationDurationMs ?? DBNull.Value);
command.Parameters.AddWithValue("attestation_digest", (object?)attestationDigest ?? DBNull.Value);
var result = await command.ExecuteScalarAsync(cancellationToken);
return (Guid)result!;
}
public async Task<Guid> AddSinkAsync(
Guid surfaceId,
string methodKey,
string methodName,
string declaringType,
string changeType,
string? vulnHash,
string? fixedHash,
CancellationToken cancellationToken = default)
{
var id = Guid.NewGuid();
const string sql = """
INSERT INTO scanner.vuln_surface_sinks (
id, surface_id, method_key, method_name, declaring_type,
change_type, vuln_fingerprint, fixed_fingerprint
) VALUES (
@id, @surface_id, @method_key, @method_name, @declaring_type,
@change_type, @vuln_hash, @fixed_hash
)
ON CONFLICT (surface_id, method_key) DO UPDATE SET
change_type = EXCLUDED.change_type,
vuln_fingerprint = EXCLUDED.vuln_fingerprint,
fixed_fingerprint = EXCLUDED.fixed_fingerprint
RETURNING id
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _commandTimeoutSeconds;
command.Parameters.AddWithValue("id", id);
command.Parameters.AddWithValue("surface_id", surfaceId);
command.Parameters.AddWithValue("method_key", methodKey);
command.Parameters.AddWithValue("method_name", methodName);
command.Parameters.AddWithValue("declaring_type", declaringType);
command.Parameters.AddWithValue("change_type", changeType);
command.Parameters.AddWithValue("vuln_hash", (object?)vulnHash ?? DBNull.Value);
command.Parameters.AddWithValue("fixed_hash", (object?)fixedHash ?? DBNull.Value);
var result = await command.ExecuteScalarAsync(cancellationToken);
return (Guid)result!;
}
public async Task<Guid> AddTriggerAsync(
Guid surfaceId,
string triggerMethodKey,
string sinkMethodKey,
int depth,
double confidence,
CancellationToken cancellationToken = default)
{
var id = Guid.NewGuid();
const string sql = """
INSERT INTO scanner.vuln_surface_triggers (
id, sink_id, scan_id, caller_node_id, caller_method_key,
reachability_bucket, path_length, confidence, call_type, is_conditional
) VALUES (
@id,
(SELECT id FROM scanner.vuln_surface_sinks WHERE surface_id = @surface_id AND method_key = @sink_method_key LIMIT 1),
@surface_id::uuid,
@trigger_method_key,
@trigger_method_key,
'direct',
@depth,
@confidence,
'direct',
false
)
RETURNING id
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _commandTimeoutSeconds;
command.Parameters.AddWithValue("id", id);
command.Parameters.AddWithValue("surface_id", surfaceId);
command.Parameters.AddWithValue("trigger_method_key", triggerMethodKey);
command.Parameters.AddWithValue("sink_method_key", sinkMethodKey);
command.Parameters.AddWithValue("depth", depth);
command.Parameters.AddWithValue("confidence", (float)confidence);
var result = await command.ExecuteScalarAsync(cancellationToken);
return result is Guid g ? g : Guid.Empty;
}
public async Task<VulnSurface?> GetByCveAndPackageAsync(
Guid tenantId,
string cveId,
string ecosystem,
string packageName,
string vulnVersion,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, cve_id, package_ecosystem, package_name,
vuln_version, fixed_version, fingerprint_method,
total_methods_vuln, total_methods_fixed, changed_method_count,
computation_duration_ms, attestation_digest, computed_at
FROM scanner.vuln_surfaces
WHERE tenant_id = @tenant_id
AND cve_id = @cve_id
AND package_ecosystem = @ecosystem
AND package_name = @package_name
AND vuln_version = @vuln_version
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await SetTenantContextAsync(connection, tenantId, cancellationToken);
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _commandTimeoutSeconds;
command.Parameters.AddWithValue("tenant_id", tenantId);
command.Parameters.AddWithValue("cve_id", cveId);
command.Parameters.AddWithValue("ecosystem", ecosystem);
command.Parameters.AddWithValue("package_name", packageName);
command.Parameters.AddWithValue("vuln_version", vulnVersion);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
{
return null;
}
return MapToVulnSurface(reader);
}
public async Task<IReadOnlyList<VulnSurfaceSink>> GetSinksAsync(
Guid surfaceId,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, surface_id, method_key, method_name, declaring_type,
change_type, vuln_fingerprint, fixed_fingerprint
FROM scanner.vuln_surface_sinks
WHERE surface_id = @surface_id
ORDER BY declaring_type, method_name
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _commandTimeoutSeconds;
command.Parameters.AddWithValue("surface_id", surfaceId);
var sinks = new List<VulnSurfaceSink>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
sinks.Add(MapToSink(reader));
}
return sinks;
}
public async Task<IReadOnlyList<VulnSurfaceTrigger>> GetTriggersAsync(
Guid surfaceId,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT vst.id, vss.surface_id, vst.caller_method_key, vss.method_key,
vst.path_length, vst.confidence
FROM scanner.vuln_surface_triggers vst
JOIN scanner.vuln_surface_sinks vss ON vst.sink_id = vss.id
WHERE vss.surface_id = @surface_id
ORDER BY vst.path_length
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _commandTimeoutSeconds;
command.Parameters.AddWithValue("surface_id", surfaceId);
var triggers = new List<VulnSurfaceTrigger>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
triggers.Add(MapToTrigger(reader));
}
return triggers;
}
public async Task<IReadOnlyList<VulnSurface>> GetSurfacesByCveAsync(
Guid tenantId,
string cveId,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, cve_id, package_ecosystem, package_name,
vuln_version, fixed_version, fingerprint_method,
total_methods_vuln, total_methods_fixed, changed_method_count,
computation_duration_ms, attestation_digest, computed_at
FROM scanner.vuln_surfaces
WHERE tenant_id = @tenant_id AND cve_id = @cve_id
ORDER BY package_ecosystem, package_name, vuln_version
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await SetTenantContextAsync(connection, tenantId, cancellationToken);
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _commandTimeoutSeconds;
command.Parameters.AddWithValue("tenant_id", tenantId);
command.Parameters.AddWithValue("cve_id", cveId);
var surfaces = new List<VulnSurface>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
surfaces.Add(MapToVulnSurface(reader));
}
return surfaces;
}
public async Task<bool> DeleteSurfaceAsync(
Guid surfaceId,
CancellationToken cancellationToken = default)
{
const string sql = """
DELETE FROM scanner.vuln_surfaces WHERE id = @id
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _commandTimeoutSeconds;
command.Parameters.AddWithValue("id", surfaceId);
var rows = await command.ExecuteNonQueryAsync(cancellationToken);
return rows > 0;
}
private static async Task SetTenantContextAsync(
NpgsqlConnection connection,
Guid tenantId,
CancellationToken cancellationToken)
{
await using var command = new NpgsqlCommand(
$"SET LOCAL app.tenant_id = '{tenantId}'",
connection);
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static VulnSurface MapToVulnSurface(NpgsqlDataReader reader)
{
return new VulnSurface
{
SurfaceId = reader.GetGuid(0).GetHashCode(),
CveId = reader.GetString(2),
PackageId = $"pkg:{reader.GetString(3)}/{reader.GetString(4)}@{reader.GetString(5)}",
Ecosystem = reader.GetString(3),
VulnVersion = reader.GetString(5),
FixedVersion = reader.IsDBNull(6) ? string.Empty : reader.GetString(6),
Status = VulnSurfaceStatus.Computed,
Confidence = 1.0,
ComputedAt = reader.GetDateTime(13)
};
}
private static VulnSurfaceSink MapToSink(NpgsqlDataReader reader)
{
return new VulnSurfaceSink
{
SinkId = reader.GetGuid(0).GetHashCode(),
SurfaceId = reader.GetGuid(1).GetHashCode(),
MethodKey = reader.GetString(2),
MethodName = reader.GetString(3),
DeclaringType = reader.GetString(4),
ChangeType = ParseChangeType(reader.GetString(5)),
VulnHash = reader.IsDBNull(6) ? null : reader.GetString(6),
FixedHash = reader.IsDBNull(7) ? null : reader.GetString(7)
};
}
private static VulnSurfaceTrigger MapToTrigger(NpgsqlDataReader reader)
{
return new VulnSurfaceTrigger
{
SurfaceId = reader.GetGuid(1).GetHashCode(),
TriggerMethodKey = reader.GetString(2),
SinkMethodKey = reader.GetString(3),
Depth = reader.IsDBNull(4) ? 0 : reader.GetInt32(4),
Confidence = reader.IsDBNull(5) ? 1.0 : reader.GetFloat(5)
};
}
private static MethodChangeType ParseChangeType(string changeType) => changeType switch
{
"added" => MethodChangeType.Added,
"removed" => MethodChangeType.Removed,
"modified" => MethodChangeType.Modified,
"signaturechanged" => MethodChangeType.SignatureChanged,
_ => MethodChangeType.Modified
};
}