386 lines
12 KiB
C#
386 lines
12 KiB
C#
// Licensed under BUSL-1.1. Copyright (C) 2026 StellaOps Contributors.
|
|
|
|
|
|
|
|
using Microsoft.Extensions.Logging;
|
|
using System.Collections.Immutable;
|
|
|
|
namespace StellaOps.BinaryIndex.Analysis;
|
|
|
|
/// <summary>
|
|
/// Adapter that implements <see cref="IBinaryReachabilityService"/> using ReachGraph module.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// This adapter bridges the BinaryIndex.Analysis module to the ReachGraph service layer.
|
|
/// It can be configured to use either:
|
|
/// </para>
|
|
/// <list type="bullet">
|
|
/// <item>Direct service injection (when running in same process as ReachGraph)</item>
|
|
/// <item>HTTP client (when ReachGraph runs as separate service)</item>
|
|
/// </list>
|
|
/// <para>
|
|
/// To use this adapter with direct injection, register it in DI after registering
|
|
/// the ReachGraph services:
|
|
/// <code>
|
|
/// services.AddReachGraphSliceService(); // From ReachGraph.WebService
|
|
/// services.AddBinaryReachabilityService<ReachGraphBinaryReachabilityService>();
|
|
/// </code>
|
|
/// </para>
|
|
/// <para>
|
|
/// To use this adapter with HTTP client, implement a custom adapter that uses
|
|
/// <c>IHttpClientFactory</c> to call the ReachGraph API endpoints.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class ReachGraphBinaryReachabilityService : IBinaryReachabilityService
|
|
{
|
|
private readonly IReachGraphSliceClient _sliceClient;
|
|
private readonly ILogger<ReachGraphBinaryReachabilityService> _logger;
|
|
|
|
/// <summary>
|
|
/// Creates a new ReachGraph-backed reachability service.
|
|
/// </summary>
|
|
/// <param name="sliceClient">ReachGraph slice client.</param>
|
|
/// <param name="logger">Logger.</param>
|
|
public ReachGraphBinaryReachabilityService(
|
|
IReachGraphSliceClient sliceClient,
|
|
ILogger<ReachGraphBinaryReachabilityService> logger)
|
|
{
|
|
_sliceClient = sliceClient;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<BinaryReachabilityResult> AnalyzeCveReachabilityAsync(
|
|
string artifactDigest,
|
|
string cveId,
|
|
string tenantId,
|
|
BinaryReachabilityOptions? options = null,
|
|
CancellationToken ct = default)
|
|
{
|
|
options ??= BinaryReachabilityOptions.Default;
|
|
|
|
_logger.LogDebug("Analyzing CVE {CveId} reachability in artifact {Digest}",
|
|
cveId, TruncateDigest(artifactDigest));
|
|
|
|
try
|
|
{
|
|
var response = await _sliceClient.SliceByCveAsync(
|
|
artifactDigest,
|
|
cveId,
|
|
tenantId,
|
|
options.MaxPaths,
|
|
ct);
|
|
|
|
if (response is null)
|
|
{
|
|
_logger.LogDebug("No reachability data found for CVE {CveId}", cveId);
|
|
return BinaryReachabilityResult.NotReachable();
|
|
}
|
|
|
|
// Map ReachGraph paths to our model
|
|
var paths = response.Paths
|
|
.Select(p => new ReachabilityPath
|
|
{
|
|
EntryPoint = p.Entrypoint,
|
|
Sink = p.Sink,
|
|
Nodes = p.Hops.ToImmutableArray()
|
|
})
|
|
.ToImmutableArray();
|
|
|
|
return new BinaryReachabilityResult
|
|
{
|
|
IsReachable = paths.Length > 0,
|
|
ReachableSinks = response.Sinks.ToImmutableArray(),
|
|
Paths = paths,
|
|
Confidence = 0.95m
|
|
};
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to analyze CVE {CveId} reachability", cveId);
|
|
return BinaryReachabilityResult.NotReachable();
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<ImmutableArray<ReachabilityPath>> FindPathsAsync(
|
|
string artifactDigest,
|
|
ImmutableArray<string> entryPoints,
|
|
ImmutableArray<string> sinks,
|
|
string tenantId,
|
|
int maxDepth = 20,
|
|
CancellationToken ct = default)
|
|
{
|
|
_logger.LogDebug("Finding paths in artifact {Digest} from {EntryCount} entries to {SinkCount} sinks",
|
|
TruncateDigest(artifactDigest), entryPoints.Length, sinks.Length);
|
|
|
|
var allPaths = new List<ReachabilityPath>();
|
|
|
|
try
|
|
{
|
|
// Query for each entry point pattern
|
|
foreach (var entryPoint in entryPoints)
|
|
{
|
|
var response = await _sliceClient.SliceByEntrypointAsync(
|
|
artifactDigest,
|
|
entryPoint,
|
|
tenantId,
|
|
maxDepth,
|
|
ct);
|
|
|
|
if (response is null)
|
|
continue;
|
|
|
|
// Check if any sink is reachable from this slice
|
|
// The slice contains all nodes reachable from the entry point
|
|
var reachableNodeIds = response.Nodes
|
|
.Select(n => n.Ref)
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var sink in sinks)
|
|
{
|
|
if (reachableNodeIds.Contains(sink))
|
|
{
|
|
// Sink is reachable - construct path
|
|
// Note: This is simplified; real implementation would trace actual path
|
|
allPaths.Add(new ReachabilityPath
|
|
{
|
|
EntryPoint = entryPoint,
|
|
Sink = sink,
|
|
Nodes = [entryPoint, sink] // Simplified
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return allPaths.ToImmutableArray();
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to find paths");
|
|
return ImmutableArray<ReachabilityPath>.Empty;
|
|
}
|
|
}
|
|
|
|
private static string TruncateDigest(string digest) =>
|
|
digest.Length > 20 ? digest[..20] + "..." : digest;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Client interface for ReachGraph slice operations.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// This interface abstracts the ReachGraph slice service to enable
|
|
/// different implementations (direct injection, HTTP client, gRPC).
|
|
/// </remarks>
|
|
public interface IReachGraphSliceClient
|
|
{
|
|
/// <summary>
|
|
/// Slices by CVE to get reachability paths.
|
|
/// </summary>
|
|
Task<CveSliceResult?> SliceByCveAsync(
|
|
string digest,
|
|
string cveId,
|
|
string tenantId,
|
|
int maxPaths = 5,
|
|
CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Slices by entry point pattern.
|
|
/// </summary>
|
|
Task<SliceResult?> SliceByEntrypointAsync(
|
|
string digest,
|
|
string entrypointPattern,
|
|
string tenantId,
|
|
int maxDepth = 10,
|
|
CancellationToken ct = default);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result of a CVE slice query.
|
|
/// </summary>
|
|
public sealed record CveSliceResult
|
|
{
|
|
/// <summary>Sinks that are reachable.</summary>
|
|
public required IReadOnlyList<string> Sinks { get; init; }
|
|
|
|
/// <summary>Paths from entries to sinks.</summary>
|
|
public required IReadOnlyList<CveSlicePath> Paths { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// A path in a CVE slice result.
|
|
/// </summary>
|
|
public sealed record CveSlicePath
|
|
{
|
|
/// <summary>Entry point function.</summary>
|
|
public required string Entrypoint { get; init; }
|
|
|
|
/// <summary>Sink function.</summary>
|
|
public required string Sink { get; init; }
|
|
|
|
/// <summary>Intermediate nodes.</summary>
|
|
public required IReadOnlyList<string> Hops { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result of a slice query.
|
|
/// </summary>
|
|
public sealed record SliceResult
|
|
{
|
|
/// <summary>Nodes in the slice.</summary>
|
|
public required IReadOnlyList<SliceNode> Nodes { get; init; }
|
|
|
|
/// <summary>Edges in the slice.</summary>
|
|
public required IReadOnlyList<SliceEdge> Edges { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// A node in a slice result.
|
|
/// </summary>
|
|
public sealed record SliceNode
|
|
{
|
|
/// <summary>Node ID.</summary>
|
|
public required string Id { get; init; }
|
|
|
|
/// <summary>Reference (function name, PURL, etc.).</summary>
|
|
public required string Ref { get; init; }
|
|
|
|
/// <summary>Node kind.</summary>
|
|
public string Kind { get; init; } = "Function";
|
|
}
|
|
|
|
/// <summary>
|
|
/// An edge in a slice result.
|
|
/// </summary>
|
|
public sealed record SliceEdge
|
|
{
|
|
/// <summary>Source node ID.</summary>
|
|
public required string From { get; init; }
|
|
|
|
/// <summary>Target node ID.</summary>
|
|
public required string To { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// HTTP implementation of IReachGraphSliceClient that calls the ReachGraph service API.
|
|
/// </summary>
|
|
public sealed class HttpReachGraphSliceClient : IReachGraphSliceClient
|
|
{
|
|
private readonly HttpClient _httpClient;
|
|
private readonly ILogger<HttpReachGraphSliceClient> _logger;
|
|
|
|
/// <summary>
|
|
/// Creates a new HTTP-backed ReachGraph slice client.
|
|
/// </summary>
|
|
/// <param name="httpClient">Pre-configured HttpClient targeting ReachGraph base URL.</param>
|
|
/// <param name="logger">Logger.</param>
|
|
public HttpReachGraphSliceClient(
|
|
HttpClient httpClient,
|
|
ILogger<HttpReachGraphSliceClient> logger)
|
|
{
|
|
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<CveSliceResult?> SliceByCveAsync(
|
|
string digest,
|
|
string cveId,
|
|
string tenantId,
|
|
int maxPaths = 5,
|
|
CancellationToken ct = default)
|
|
{
|
|
_logger.LogDebug("Querying ReachGraph slice-by-CVE: {CveId} for {Digest}", cveId, digest);
|
|
|
|
try
|
|
{
|
|
var url = $"api/v1/slice/cve?digest={Uri.EscapeDataString(digest)}&cveId={Uri.EscapeDataString(cveId)}&tenantId={Uri.EscapeDataString(tenantId)}&maxPaths={maxPaths}";
|
|
|
|
var response = await _httpClient.GetAsync(url, ct);
|
|
|
|
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
|
{
|
|
_logger.LogDebug("No slice data found for CVE {CveId}", cveId);
|
|
return null;
|
|
}
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
return await System.Text.Json.JsonSerializer.DeserializeAsync<CveSliceResult>(
|
|
await response.Content.ReadAsStreamAsync(ct),
|
|
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true },
|
|
ct);
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to query ReachGraph for CVE {CveId}", cveId);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<SliceResult?> SliceByEntrypointAsync(
|
|
string digest,
|
|
string entrypointPattern,
|
|
string tenantId,
|
|
int maxDepth = 10,
|
|
CancellationToken ct = default)
|
|
{
|
|
_logger.LogDebug("Querying ReachGraph slice-by-entrypoint: {Pattern} for {Digest}", entrypointPattern, digest);
|
|
|
|
try
|
|
{
|
|
var url = $"api/v1/slice/entrypoint?digest={Uri.EscapeDataString(digest)}&pattern={Uri.EscapeDataString(entrypointPattern)}&tenantId={Uri.EscapeDataString(tenantId)}&maxDepth={maxDepth}";
|
|
|
|
var response = await _httpClient.GetAsync(url, ct);
|
|
|
|
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
return await System.Text.Json.JsonSerializer.DeserializeAsync<SliceResult>(
|
|
await response.Content.ReadAsStreamAsync(ct),
|
|
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true },
|
|
ct);
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to query ReachGraph for entrypoint {Pattern}", entrypointPattern);
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Null implementation of IReachGraphSliceClient for testing.
|
|
/// </summary>
|
|
public sealed class NullReachGraphSliceClient : IReachGraphSliceClient
|
|
{
|
|
/// <inheritdoc />
|
|
public Task<CveSliceResult?> SliceByCveAsync(
|
|
string digest,
|
|
string cveId,
|
|
string tenantId,
|
|
int maxPaths = 5,
|
|
CancellationToken ct = default)
|
|
{
|
|
return Task.FromResult<CveSliceResult?>(null);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<SliceResult?> SliceByEntrypointAsync(
|
|
string digest,
|
|
string entrypointPattern,
|
|
string tenantId,
|
|
int maxDepth = 10,
|
|
CancellationToken ct = default)
|
|
{
|
|
return Task.FromResult<SliceResult?>(null);
|
|
}
|
|
}
|