partly or unimplemented features - now implemented

This commit is contained in:
master
2026-02-09 08:53:51 +02:00
parent 1bf6bbf395
commit 4bdc298ec1
674 changed files with 90194 additions and 2271 deletions

View File

@@ -0,0 +1,320 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using StellaOps.Reachability.Core;
namespace StellaOps.ReachGraph.WebService.Controllers;
/// <summary>
/// Unified Reachability Query API - facade for static, runtime, and hybrid queries.
/// </summary>
[ApiController]
[Route("v1/reachability")]
[Produces("application/json")]
public class ReachabilityController : ControllerBase
{
private readonly IReachabilityIndex _reachabilityIndex;
private readonly ILogger<ReachabilityController> _logger;
public ReachabilityController(
IReachabilityIndex reachabilityIndex,
ILogger<ReachabilityController> logger)
{
_reachabilityIndex = reachabilityIndex;
_logger = logger;
}
/// <summary>
/// Query static reachability from call graph analysis.
/// </summary>
/// <param name="request">Static query request.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Static reachability result.</returns>
[HttpPost("static")]
[EnableRateLimiting("reachgraph-read")]
[ProducesResponseType(typeof(StaticReachabilityResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> QueryStaticAsync(
[FromBody] StaticQueryRequest request,
CancellationToken ct)
{
if (request.Symbol is null)
{
return BadRequest(new { error = "Symbol is required" });
}
if (string.IsNullOrWhiteSpace(request.ArtifactDigest))
{
return BadRequest(new { error = "ArtifactDigest is required" });
}
var tenantId = GetTenantId();
_logger.LogDebug(
"Static reachability query for {Symbol} in {Artifact}, tenant={Tenant}",
BuildSymbolKey(request.Symbol),
request.ArtifactDigest,
tenantId);
var result = await _reachabilityIndex.QueryStaticAsync(
request.Symbol,
request.ArtifactDigest,
ct);
return Ok(result);
}
/// <summary>
/// Query runtime reachability from observed execution facts.
/// </summary>
/// <param name="request">Runtime query request.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Runtime reachability result.</returns>
[HttpPost("runtime")]
[EnableRateLimiting("reachgraph-read")]
[ProducesResponseType(typeof(RuntimeReachabilityResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> QueryRuntimeAsync(
[FromBody] RuntimeQueryRequest request,
CancellationToken ct)
{
if (request.Symbol is null)
{
return BadRequest(new { error = "Symbol is required" });
}
if (string.IsNullOrWhiteSpace(request.ArtifactDigest))
{
return BadRequest(new { error = "ArtifactDigest is required" });
}
var observationWindow = request.ObservationWindow ?? TimeSpan.FromDays(7);
var tenantId = GetTenantId();
_logger.LogDebug(
"Runtime reachability query for {Symbol} in {Artifact}, window={Window}, tenant={Tenant}",
BuildSymbolKey(request.Symbol),
request.ArtifactDigest,
observationWindow,
tenantId);
var result = await _reachabilityIndex.QueryRuntimeAsync(
request.Symbol,
request.ArtifactDigest,
observationWindow,
ct);
return Ok(result);
}
/// <summary>
/// Query hybrid reachability combining static analysis and runtime evidence.
/// </summary>
/// <param name="request">Hybrid query request.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Hybrid reachability result with verdict recommendation.</returns>
[HttpPost("hybrid")]
[EnableRateLimiting("reachgraph-read")]
[ProducesResponseType(typeof(HybridReachabilityResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> QueryHybridAsync(
[FromBody] HybridQueryRequest request,
CancellationToken ct)
{
if (request.Symbol is null)
{
return BadRequest(new { error = "Symbol is required" });
}
if (string.IsNullOrWhiteSpace(request.ArtifactDigest))
{
return BadRequest(new { error = "ArtifactDigest is required" });
}
var options = new HybridQueryOptions
{
IncludeStatic = request.IncludeStatic ?? true,
IncludeRuntime = request.IncludeRuntime ?? true,
ObservationWindow = request.ObservationWindow ?? TimeSpan.FromDays(7),
ConfidenceThreshold = request.ConfidenceThreshold ?? 0.8
};
var tenantId = GetTenantId();
_logger.LogDebug(
"Hybrid reachability query for {Symbol} in {Artifact}, static={Static}, runtime={Runtime}, tenant={Tenant}",
BuildSymbolKey(request.Symbol),
request.ArtifactDigest,
options.IncludeStatic,
options.IncludeRuntime,
tenantId);
var result = await _reachabilityIndex.QueryHybridAsync(
request.Symbol,
request.ArtifactDigest,
options,
ct);
return Ok(result);
}
/// <summary>
/// Batch query for multiple symbols (CVE vulnerability analysis).
/// </summary>
/// <param name="request">Batch query request.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Results for all symbols.</returns>
[HttpPost("batch")]
[EnableRateLimiting("reachgraph-read")]
[ProducesResponseType(typeof(BatchQueryResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> QueryBatchAsync(
[FromBody] BatchQueryRequest request,
CancellationToken ct)
{
if (request.Symbols is null || request.Symbols.Count == 0)
{
return BadRequest(new { error = "At least one symbol is required" });
}
if (string.IsNullOrWhiteSpace(request.ArtifactDigest))
{
return BadRequest(new { error = "ArtifactDigest is required" });
}
var options = new HybridQueryOptions
{
IncludeStatic = request.IncludeStatic ?? true,
IncludeRuntime = request.IncludeRuntime ?? true,
ObservationWindow = request.ObservationWindow ?? TimeSpan.FromDays(7),
ConfidenceThreshold = request.ConfidenceThreshold ?? 0.8
};
var tenantId = GetTenantId();
_logger.LogInformation(
"Batch reachability query for {Count} symbols in {Artifact}, tenant={Tenant}",
request.Symbols.Count,
request.ArtifactDigest,
tenantId);
var results = await _reachabilityIndex.QueryBatchAsync(
request.Symbols,
request.ArtifactDigest,
options,
ct);
var response = new BatchQueryResponse
{
ArtifactDigest = request.ArtifactDigest,
TotalSymbols = request.Symbols.Count,
Results = results.ToList()
};
return Ok(response);
}
private string? GetTenantId()
{
return User.FindFirst("tenant")?.Value
?? Request.Headers["X-Tenant-ID"].FirstOrDefault();
}
private static string BuildSymbolKey(SymbolRef symbol)
{
var parts = new List<string>();
if (!string.IsNullOrEmpty(symbol.Namespace)) parts.Add(symbol.Namespace);
if (!string.IsNullOrEmpty(symbol.TypeName)) parts.Add(symbol.TypeName);
if (!string.IsNullOrEmpty(symbol.MemberName)) parts.Add(symbol.MemberName);
return string.Join(".", parts);
}
}
/// <summary>
/// Request for static reachability query.
/// </summary>
public record StaticQueryRequest
{
/// <summary>Symbol to query.</summary>
public SymbolRef? Symbol { get; init; }
/// <summary>Target artifact digest (sha256:...).</summary>
public string? ArtifactDigest { get; init; }
}
/// <summary>
/// Request for runtime reachability query.
/// </summary>
public record RuntimeQueryRequest
{
/// <summary>Symbol to query.</summary>
public SymbolRef? Symbol { get; init; }
/// <summary>Target artifact digest.</summary>
public string? ArtifactDigest { get; init; }
/// <summary>Observation window to consider. Default: 7 days.</summary>
public TimeSpan? ObservationWindow { get; init; }
}
/// <summary>
/// Request for hybrid reachability query.
/// </summary>
public record HybridQueryRequest
{
/// <summary>Symbol to query.</summary>
public SymbolRef? Symbol { get; init; }
/// <summary>Target artifact digest.</summary>
public string? ArtifactDigest { get; init; }
/// <summary>Include static analysis. Default: true.</summary>
public bool? IncludeStatic { get; init; }
/// <summary>Include runtime evidence. Default: true.</summary>
public bool? IncludeRuntime { get; init; }
/// <summary>Observation window for runtime. Default: 7 days.</summary>
public TimeSpan? ObservationWindow { get; init; }
/// <summary>Confidence threshold for verdict. Default: 0.8.</summary>
public double? ConfidenceThreshold { get; init; }
}
/// <summary>
/// Request for batch reachability query.
/// </summary>
public record BatchQueryRequest
{
/// <summary>Symbols to query.</summary>
public IReadOnlyList<SymbolRef>? Symbols { get; init; }
/// <summary>Target artifact digest.</summary>
public string? ArtifactDigest { get; init; }
/// <summary>Include static analysis. Default: true.</summary>
public bool? IncludeStatic { get; init; }
/// <summary>Include runtime evidence. Default: true.</summary>
public bool? IncludeRuntime { get; init; }
/// <summary>Observation window for runtime. Default: 7 days.</summary>
public TimeSpan? ObservationWindow { get; init; }
/// <summary>Confidence threshold for verdict. Default: 0.8.</summary>
public double? ConfidenceThreshold { get; init; }
}
/// <summary>
/// Response for batch reachability query.
/// </summary>
public record BatchQueryResponse
{
/// <summary>Artifact digest queried.</summary>
public required string ArtifactDigest { get; init; }
/// <summary>Total symbols queried.</summary>
public int TotalSymbols { get; init; }
/// <summary>Results for each symbol.</summary>
public required IReadOnlyList<HybridReachabilityResult> Results { get; init; }
}

View File

@@ -71,6 +71,12 @@ builder.Services.AddScoped<IReachGraphStoreService, ReachGraphStoreService>();
builder.Services.AddScoped<IReachGraphSliceService, ReachGraphSliceService>();
builder.Services.AddScoped<IReachGraphReplayService, ReachGraphReplayService>();
// Reachability Core adapters and unified query interface
builder.Services.AddSingleton<TimeProvider>(TimeProvider.System);
builder.Services.AddScoped<StellaOps.Reachability.Core.IReachGraphAdapter, ReachGraphStoreAdapter>();
builder.Services.AddSingleton<StellaOps.Reachability.Core.ISignalsAdapter, InMemorySignalsAdapter>();
builder.Services.AddScoped<StellaOps.Reachability.Core.IReachabilityIndex, StellaOps.Reachability.Core.ReachabilityIndex>();
// Rate limiting
builder.Services.AddRateLimiter(options =>
{

View File

@@ -0,0 +1,228 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using System.Collections.Concurrent;
using System.Collections.Immutable;
using StellaOps.Reachability.Core;
namespace StellaOps.ReachGraph.WebService.Services;
/// <summary>
/// In-memory implementation of <see cref="ISignalsAdapter"/> for runtime observation facts.
/// Production deployments should integrate with the actual Signals runtime service.
/// </summary>
public sealed class InMemorySignalsAdapter : ISignalsAdapter
{
private readonly ConcurrentDictionary<string, List<ObservedSymbol>> _observations = new();
private readonly TimeProvider _timeProvider;
/// <summary>
/// Initializes a new instance of <see cref="InMemorySignalsAdapter"/>.
/// </summary>
public InMemorySignalsAdapter(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
/// <inheritdoc/>
public Task<RuntimeReachabilityResult> QueryAsync(
SymbolRef symbol,
string artifactDigest,
TimeSpan observationWindow,
string? tenantId,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(symbol);
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
var now = _timeProvider.GetUtcNow();
var windowStart = now - observationWindow;
var key = GetKey(artifactDigest, tenantId);
var symbolFqn = BuildSymbolFqn(symbol);
if (!_observations.TryGetValue(key, out var observations))
{
return Task.FromResult(CreateNotFoundResult(symbol, artifactDigest, observationWindow, windowStart, now));
}
var matches = observations
.Where(o => MatchesSymbol(o, symbol) && o.ObservedAt >= windowStart && o.ObservedAt <= now)
.ToList();
if (matches.Count == 0)
{
return Task.FromResult(CreateNotFoundResult(symbol, artifactDigest, observationWindow, windowStart, now));
}
var firstSeen = matches.Min(o => o.ObservedAt);
var lastSeen = matches.Max(o => o.ObservedAt);
var hitCount = matches.Sum(o => o.HitCount);
var contexts = matches
.Select(o => new ExecutionContext
{
Environment = o.Environment ?? "production",
Service = o.ServiceName,
TraceId = o.TraceId,
ObservedAt = o.ObservedAt
})
.Distinct()
.Take(10)
.ToImmutableArray();
var evidenceUris = matches
.Where(o => !string.IsNullOrEmpty(o.EvidenceUri))
.Select(o => o.EvidenceUri!)
.Distinct()
.ToImmutableArray();
var result = new RuntimeReachabilityResult
{
Symbol = symbol,
ArtifactDigest = artifactDigest,
WasObserved = true,
ObservationWindow = observationWindow,
WindowStart = windowStart,
WindowEnd = now,
HitCount = hitCount,
FirstSeen = firstSeen,
LastSeen = lastSeen,
Contexts = contexts,
EvidenceUris = evidenceUris
};
return Task.FromResult(result);
}
/// <inheritdoc/>
public Task<bool> HasFactsAsync(string artifactDigest, string? tenantId, CancellationToken ct)
{
var key = GetKey(artifactDigest, tenantId);
return Task.FromResult(_observations.ContainsKey(key) && _observations[key].Count > 0);
}
/// <inheritdoc/>
public Task<SignalsMetadata?> GetMetadataAsync(string artifactDigest, string? tenantId, CancellationToken ct)
{
var key = GetKey(artifactDigest, tenantId);
if (!_observations.TryGetValue(key, out var observations) || observations.Count == 0)
{
return Task.FromResult<SignalsMetadata?>(null);
}
var environments = observations
.Where(o => !string.IsNullOrEmpty(o.Environment))
.Select(o => o.Environment!)
.Distinct()
.ToList();
var metadata = new SignalsMetadata
{
ArtifactDigest = artifactDigest,
TenantId = tenantId,
EarliestObservation = observations.Min(o => o.ObservedAt),
LatestObservation = observations.Max(o => o.ObservedAt),
SymbolCount = observations.Select(o => o.SymbolRef).Distinct().Count(),
TotalObservations = observations.Sum(o => o.HitCount),
Environments = environments,
AgentVersion = "signals-inmemory@v1"
};
return Task.FromResult<SignalsMetadata?>(metadata);
}
/// <summary>
/// Records an observation for testing purposes.
/// </summary>
public void RecordObservation(
string artifactDigest,
string? tenantId,
SymbolRef symbol,
DateTimeOffset observedAt,
long hitCount = 1,
string? environment = null,
string? serviceName = null,
string? traceId = null)
{
var key = GetKey(artifactDigest, tenantId);
var symbolFqn = BuildSymbolFqn(symbol);
var observation = new ObservedSymbol
{
SymbolRef = symbolFqn,
Symbol = symbol,
ObservedAt = observedAt,
HitCount = hitCount,
Environment = environment ?? "production",
ServiceName = serviceName,
TraceId = traceId,
EvidenceUri = EvidenceUriBuilder.Build("signals", artifactDigest, $"symbol:{symbolFqn}")
};
var list = _observations.GetOrAdd(key, _ => new List<ObservedSymbol>());
lock (list)
{
list.Add(observation);
}
}
private static RuntimeReachabilityResult CreateNotFoundResult(
SymbolRef symbol,
string artifactDigest,
TimeSpan observationWindow,
DateTimeOffset windowStart,
DateTimeOffset windowEnd)
{
return new RuntimeReachabilityResult
{
Symbol = symbol,
ArtifactDigest = artifactDigest,
WasObserved = false,
ObservationWindow = observationWindow,
WindowStart = windowStart,
WindowEnd = windowEnd,
HitCount = 0,
FirstSeen = null,
LastSeen = null,
Contexts = ImmutableArray<ExecutionContext>.Empty,
EvidenceUris = ImmutableArray<string>.Empty
};
}
private static string GetKey(string artifactDigest, string? tenantId) =>
$"{tenantId ?? "default"}:{artifactDigest}".ToLowerInvariant();
private static string BuildSymbolFqn(SymbolRef symbol)
{
var parts = new List<string>();
if (!string.IsNullOrEmpty(symbol.Namespace)) parts.Add(symbol.Namespace);
if (!string.IsNullOrEmpty(symbol.TypeName)) parts.Add(symbol.TypeName);
if (!string.IsNullOrEmpty(symbol.MemberName)) parts.Add(symbol.MemberName);
return string.Join(".", parts);
}
private static bool MatchesSymbol(ObservedSymbol observation, SymbolRef symbol)
{
if (observation.Symbol is not null)
{
return observation.Symbol.Equals(symbol);
}
var targetFqn = BuildSymbolFqn(symbol);
return observation.SymbolRef.Equals(targetFqn, StringComparison.OrdinalIgnoreCase) ||
observation.SymbolRef.Contains(targetFqn, StringComparison.OrdinalIgnoreCase);
}
private sealed class ObservedSymbol
{
public required string SymbolRef { get; init; }
public SymbolRef? Symbol { get; init; }
public required DateTimeOffset ObservedAt { get; init; }
public long HitCount { get; init; }
public string? Environment { get; init; }
public string? ServiceName { get; init; }
public string? TraceId { get; init; }
public string? EvidenceUri { get; init; }
}
}

View File

@@ -0,0 +1,281 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using System.Collections.Immutable;
using StellaOps.Reachability.Core;
using StellaOps.ReachGraph.Schema;
namespace StellaOps.ReachGraph.WebService.Services;
/// <summary>
/// Adapter implementation that wires <see cref="IReachGraphAdapter"/> to <see cref="IReachGraphStoreService"/>.
/// </summary>
public sealed class ReachGraphStoreAdapter : IReachGraphAdapter
{
private readonly IReachGraphStoreService _storeService;
private readonly TimeProvider _timeProvider;
private readonly string _tenantId;
/// <summary>
/// Initializes a new instance of <see cref="ReachGraphStoreAdapter"/>.
/// </summary>
public ReachGraphStoreAdapter(
IReachGraphStoreService storeService,
TimeProvider timeProvider,
string tenantId = "default")
{
_storeService = storeService ?? throw new ArgumentNullException(nameof(storeService));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_tenantId = tenantId;
}
/// <inheritdoc/>
public async Task<StaticReachabilityResult> QueryAsync(
SymbolRef symbol,
string artifactDigest,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(symbol);
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
var graphs = await _storeService.ListByArtifactAsync(artifactDigest, _tenantId, 1, ct);
if (graphs.Count == 0)
{
return CreateNotFoundResult(symbol, artifactDigest);
}
var summary = graphs[0];
var graph = await _storeService.GetByDigestAsync(summary.Digest, _tenantId, ct);
if (graph is null)
{
return CreateNotFoundResult(symbol, artifactDigest);
}
// Search for the symbol in the graph
var (isReachable, pathCount, shortestPath, entrypoints) = SearchSymbolInGraph(graph, symbol);
return new StaticReachabilityResult
{
Symbol = symbol,
ArtifactDigest = artifactDigest,
IsReachable = isReachable,
PathCount = pathCount,
ShortestPathLength = shortestPath,
Entrypoints = entrypoints,
Guards = ImmutableArray<GuardCondition>.Empty,
EvidenceUris = CreateEvidenceUris(graph, symbol),
AnalyzedAt = _timeProvider.GetUtcNow(),
AnalyzerVersion = graph.SchemaVersion
};
}
/// <inheritdoc/>
public async Task<bool> HasGraphAsync(string artifactDigest, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
var graphs = await _storeService.ListByArtifactAsync(artifactDigest, _tenantId, 1, ct);
return graphs.Count > 0;
}
/// <inheritdoc/>
public async Task<ReachGraphMetadata?> GetMetadataAsync(string artifactDigest, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
var graphs = await _storeService.ListByArtifactAsync(artifactDigest, _tenantId, 10, ct);
if (graphs.Count == 0)
{
return null;
}
var summary = graphs[0];
var graph = await _storeService.GetByDigestAsync(summary.Digest, _tenantId, ct);
if (graph is null)
{
return null;
}
// Count entrypoints from scope
var entrypointCount = graph.Scope.Entrypoints?.Length ?? 0;
return new ReachGraphMetadata
{
ArtifactDigest = artifactDigest,
GraphDigest = summary.Digest,
CreatedAt = summary.CreatedAt,
NodeCount = graph.Nodes.Length,
EdgeCount = graph.Edges.Length,
EntrypointCount = entrypointCount,
Version = graph.SchemaVersion
};
}
private StaticReachabilityResult CreateNotFoundResult(SymbolRef symbol, string artifactDigest)
{
return new StaticReachabilityResult
{
Symbol = symbol,
ArtifactDigest = artifactDigest,
IsReachable = false,
PathCount = 0,
ShortestPathLength = null,
Entrypoints = ImmutableArray<string>.Empty,
Guards = ImmutableArray<GuardCondition>.Empty,
EvidenceUris = ImmutableArray<string>.Empty,
AnalyzedAt = _timeProvider.GetUtcNow(),
AnalyzerVersion = null
};
}
private static (bool isReachable, int pathCount, int? shortestPath, ImmutableArray<string> entrypoints) SearchSymbolInGraph(
ReachGraphMinimal graph,
SymbolRef symbol)
{
if (graph.Nodes.Length == 0)
{
return (false, 0, null, ImmutableArray<string>.Empty);
}
// Find the node matching the symbol
var targetNode = graph.Nodes.FirstOrDefault(n => MatchesSymbol(n, symbol));
if (targetNode is null)
{
return (false, 0, null, ImmutableArray<string>.Empty);
}
// Build adjacency list for BFS
var adjacency = new Dictionary<string, List<string>>();
foreach (var node in graph.Nodes)
{
adjacency[node.Id] = new List<string>();
}
foreach (var edge in graph.Edges)
{
if (adjacency.ContainsKey(edge.Source))
{
adjacency[edge.Source].Add(edge.Target);
}
}
// Get entrypoints from scope
var entrypoints = graph.Scope.Entrypoints ?? ImmutableArray<string>.Empty;
if (entrypoints.Length == 0)
{
// If no entrypoints defined, try to find nodes with no incoming edges
var hasIncoming = new HashSet<string>(graph.Edges.Select(e => e.Target));
entrypoints = graph.Nodes
.Where(n => !hasIncoming.Contains(n.Id))
.Select(n => n.Id)
.ToImmutableArray();
}
// BFS from each entrypoint to find paths to target
var reachableFrom = new List<string>();
var shortestPath = int.MaxValue;
var pathCount = 0;
foreach (var entrypoint in entrypoints)
{
var (canReach, distance) = BfsToTarget(adjacency, entrypoint, targetNode.Id);
if (canReach)
{
reachableFrom.Add(entrypoint);
pathCount++;
if (distance < shortestPath)
{
shortestPath = distance;
}
}
}
if (reachableFrom.Count == 0)
{
return (false, 0, null, ImmutableArray<string>.Empty);
}
return (true, pathCount, shortestPath, reachableFrom.ToImmutableArray());
}
private static bool MatchesSymbol(ReachGraphNode node, SymbolRef symbol)
{
// Match by reference or node ID
var symbolFqn = BuildSymbolFqn(symbol);
// Check against node's Ref (PURL for package, path for file, symbol for function)
if (node.Ref.Contains(symbolFqn, StringComparison.OrdinalIgnoreCase))
{
return true;
}
// Also check against node ID
if (node.Id.Contains(symbolFqn, StringComparison.OrdinalIgnoreCase))
{
return true;
}
// Check individual parts
if (!string.IsNullOrEmpty(symbol.MemberName) &&
node.Ref.Contains(symbol.MemberName, StringComparison.OrdinalIgnoreCase))
{
return true;
}
return false;
}
private static string BuildSymbolFqn(SymbolRef symbol)
{
var parts = new List<string>();
if (!string.IsNullOrEmpty(symbol.Namespace)) parts.Add(symbol.Namespace);
if (!string.IsNullOrEmpty(symbol.TypeName)) parts.Add(symbol.TypeName);
if (!string.IsNullOrEmpty(symbol.MemberName)) parts.Add(symbol.MemberName);
return string.Join(".", parts);
}
private static (bool canReach, int distance) BfsToTarget(
Dictionary<string, List<string>> adjacency,
string start,
string target)
{
if (start == target) return (true, 0);
var visited = new HashSet<string> { start };
var queue = new Queue<(string node, int depth)>();
queue.Enqueue((start, 0));
while (queue.Count > 0)
{
var (current, depth) = queue.Dequeue();
if (!adjacency.TryGetValue(current, out var neighbors))
{
continue;
}
foreach (var neighbor in neighbors)
{
if (neighbor == target)
{
return (true, depth + 1);
}
if (visited.Add(neighbor))
{
queue.Enqueue((neighbor, depth + 1));
}
}
}
return (false, -1);
}
private static ImmutableArray<string> CreateEvidenceUris(ReachGraphMinimal graph, SymbolRef symbol)
{
var artifactDigest = graph.Artifact.Digest ?? "unknown";
var symbolFqn = BuildSymbolFqn(symbol);
var evidenceUri = EvidenceUriBuilder.Build("reachgraph", artifactDigest, $"symbol:{symbolFqn}");
return ImmutableArray.Create(evidenceUri);
}
}

View File

@@ -0,0 +1,310 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Reachability.Core;
using StellaOps.ReachGraph.WebService.Services;
using Xunit;
namespace StellaOps.ReachGraph.WebService.Tests;
public class InMemorySignalsAdapterTests
{
private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero));
[Fact]
public async Task QueryAsync_ReturnsNotObserved_WhenNoFacts()
{
// Arrange
var adapter = new InMemorySignalsAdapter(_timeProvider);
var symbol = new SymbolRef
{
Namespace = "System",
TypeName = "String",
MemberName = "Trim"
};
// Act
var result = await adapter.QueryAsync(
symbol,
"sha256:test",
TimeSpan.FromDays(7),
"tenant1",
CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.WasObserved.Should().BeFalse();
result.HitCount.Should().Be(0);
}
[Fact]
public async Task QueryAsync_ReturnsObserved_WhenFactsExist()
{
// Arrange
var adapter = new InMemorySignalsAdapter(_timeProvider);
var symbol = new SymbolRef
{
Namespace = "MyApp",
TypeName = "Service",
MemberName = "Process"
};
adapter.RecordObservation(
"sha256:test",
"tenant1",
symbol,
_timeProvider.GetUtcNow().AddHours(-1),
hitCount: 100,
environment: "production",
serviceName: "api-gateway");
// Act
var result = await adapter.QueryAsync(
symbol,
"sha256:test",
TimeSpan.FromDays(7),
"tenant1",
CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.WasObserved.Should().BeTrue();
result.HitCount.Should().Be(100);
result.FirstSeen.Should().NotBeNull();
result.LastSeen.Should().NotBeNull();
}
[Fact]
public async Task QueryAsync_ReturnsNotObserved_WhenOutsideWindow()
{
// Arrange
var adapter = new InMemorySignalsAdapter(_timeProvider);
var symbol = new SymbolRef
{
Namespace = "MyApp",
TypeName = "Service",
MemberName = "Process"
};
// Record observation 10 days ago
adapter.RecordObservation(
"sha256:test",
"tenant1",
symbol,
_timeProvider.GetUtcNow().AddDays(-10),
hitCount: 50);
// Act - query with 7-day window
var result = await adapter.QueryAsync(
symbol,
"sha256:test",
TimeSpan.FromDays(7),
"tenant1",
CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.WasObserved.Should().BeFalse();
}
[Fact]
public async Task QueryAsync_AggregatesMultipleObservations()
{
// Arrange
var adapter = new InMemorySignalsAdapter(_timeProvider);
var symbol = new SymbolRef
{
Namespace = "MyApp",
TypeName = "Service",
MemberName = "Process"
};
adapter.RecordObservation(
"sha256:test",
"tenant1",
symbol,
_timeProvider.GetUtcNow().AddHours(-2),
hitCount: 50);
adapter.RecordObservation(
"sha256:test",
"tenant1",
symbol,
_timeProvider.GetUtcNow().AddHours(-1),
hitCount: 75);
// Act
var result = await adapter.QueryAsync(
symbol,
"sha256:test",
TimeSpan.FromDays(7),
"tenant1",
CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.WasObserved.Should().BeTrue();
result.HitCount.Should().Be(125); // 50 + 75
}
[Fact]
public async Task QueryAsync_IncludesContexts()
{
// Arrange
var adapter = new InMemorySignalsAdapter(_timeProvider);
var symbol = new SymbolRef
{
Namespace = "MyApp",
TypeName = "Service",
MemberName = "Process"
};
adapter.RecordObservation(
"sha256:test",
"tenant1",
symbol,
_timeProvider.GetUtcNow().AddMinutes(-30),
hitCount: 10,
environment: "production",
serviceName: "api-gateway",
traceId: "trace-001");
// Act
var result = await adapter.QueryAsync(
symbol,
"sha256:test",
TimeSpan.FromDays(7),
"tenant1",
CancellationToken.None);
// Assert
result.Contexts.Should().NotBeEmpty();
result.Contexts[0].Environment.Should().Be("production");
result.Contexts[0].Service.Should().Be("api-gateway");
result.Contexts[0].TraceId.Should().Be("trace-001");
}
[Fact]
public async Task QueryAsync_IsolatesByTenant()
{
// Arrange
var adapter = new InMemorySignalsAdapter(_timeProvider);
var symbol = new SymbolRef
{
Namespace = "MyApp",
TypeName = "Service",
MemberName = "Process"
};
adapter.RecordObservation(
"sha256:test",
"tenant1",
symbol,
_timeProvider.GetUtcNow().AddMinutes(-30),
hitCount: 100);
// Act - query different tenant
var result = await adapter.QueryAsync(
symbol,
"sha256:test",
TimeSpan.FromDays(7),
"tenant2",
CancellationToken.None);
// Assert
result.WasObserved.Should().BeFalse();
}
[Fact]
public async Task HasFactsAsync_ReturnsTrue_WhenFactsExist()
{
// Arrange
var adapter = new InMemorySignalsAdapter(_timeProvider);
var symbol = new SymbolRef
{
Namespace = "MyApp",
TypeName = "Service",
MemberName = "Process"
};
adapter.RecordObservation(
"sha256:test",
"tenant1",
symbol,
_timeProvider.GetUtcNow(),
hitCount: 1);
// Act
var result = await adapter.HasFactsAsync("sha256:test", "tenant1", CancellationToken.None);
// Assert
result.Should().BeTrue();
}
[Fact]
public async Task HasFactsAsync_ReturnsFalse_WhenNoFacts()
{
// Arrange
var adapter = new InMemorySignalsAdapter(_timeProvider);
// Act
var result = await adapter.HasFactsAsync("sha256:test", "tenant1", CancellationToken.None);
// Assert
result.Should().BeFalse();
}
[Fact]
public async Task GetMetadataAsync_ReturnsMetadata_WhenFactsExist()
{
// Arrange
var adapter = new InMemorySignalsAdapter(_timeProvider);
var symbol = new SymbolRef
{
Namespace = "MyApp",
TypeName = "Service",
MemberName = "Process"
};
adapter.RecordObservation(
"sha256:test",
"tenant1",
symbol,
_timeProvider.GetUtcNow().AddDays(-3),
hitCount: 50,
environment: "production");
adapter.RecordObservation(
"sha256:test",
"tenant1",
symbol,
_timeProvider.GetUtcNow().AddDays(-1),
hitCount: 100,
environment: "staging");
// Act
var metadata = await adapter.GetMetadataAsync("sha256:test", "tenant1", CancellationToken.None);
// Assert
metadata.Should().NotBeNull();
metadata!.ArtifactDigest.Should().Be("sha256:test");
metadata.TotalObservations.Should().Be(150);
metadata.Environments.Should().Contain("production");
metadata.Environments.Should().Contain("staging");
}
[Fact]
public async Task GetMetadataAsync_ReturnsNull_WhenNoFacts()
{
// Arrange
var adapter = new InMemorySignalsAdapter(_timeProvider);
// Act
var metadata = await adapter.GetMetadataAsync("sha256:test", "tenant1", CancellationToken.None);
// Assert
metadata.Should().BeNull();
}
}

View File

@@ -0,0 +1,270 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Reachability.Core;
using StellaOps.ReachGraph.Schema;
using StellaOps.ReachGraph.WebService.Services;
using Xunit;
namespace StellaOps.ReachGraph.WebService.Tests;
public class ReachGraphStoreAdapterTests
{
private readonly FakeTimeProvider _timeProvider = new(DateTimeOffset.UtcNow);
private readonly InMemoryReachGraphStoreService _storeService = new();
[Fact]
public async Task QueryAsync_ReturnsNotReachable_WhenGraphNotFound()
{
// Arrange
var adapter = CreateAdapter();
var symbol = new SymbolRef
{
Namespace = "System",
TypeName = "String",
MemberName = "Trim"
};
// Act
var result = await adapter.QueryAsync(symbol, "sha256:notfound", CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.IsReachable.Should().BeFalse();
result.Symbol.Should().Be(symbol);
result.ArtifactDigest.Should().Be("sha256:notfound");
}
[Fact]
public async Task QueryAsync_ReturnsReachable_WhenSymbolFoundInGraph()
{
// Arrange
var graph = CreateTestGraph("sha256:test123");
await _storeService.UpsertAsync(graph, "tenant1", CancellationToken.None);
var adapter = CreateAdapter();
var symbol = new SymbolRef
{
Namespace = "MyApp",
TypeName = "VulnerableClass",
MemberName = "Execute"
};
// Act
var result = await adapter.QueryAsync(symbol, "sha256:test123", CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.IsReachable.Should().BeTrue();
result.DistanceFromEntrypoint.Should().BeGreaterThanOrEqualTo(0);
}
[Fact]
public async Task QueryAsync_ReturnsNotReachable_WhenSymbolNotInGraph()
{
// Arrange
var graph = CreateTestGraph("sha256:test123");
await _storeService.UpsertAsync(graph, "tenant1", CancellationToken.None);
var adapter = CreateAdapter();
var symbol = new SymbolRef
{
Namespace = "NonExistent",
TypeName = "Class",
MemberName = "Method"
};
// Act
var result = await adapter.QueryAsync(symbol, "sha256:test123", CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.IsReachable.Should().BeFalse();
}
[Fact]
public async Task HasGraphAsync_ReturnsTrue_WhenGraphExists()
{
// Arrange
var graph = CreateTestGraph("sha256:exists123");
await _storeService.UpsertAsync(graph, "tenant1", CancellationToken.None);
var adapter = CreateAdapter();
// Act
var result = await adapter.HasGraphAsync("sha256:exists123", CancellationToken.None);
// Assert
result.Should().BeTrue();
}
[Fact]
public async Task HasGraphAsync_ReturnsFalse_WhenGraphNotExists()
{
// Arrange
var adapter = CreateAdapter();
// Act
var result = await adapter.HasGraphAsync("sha256:doesnotexist", CancellationToken.None);
// Assert
result.Should().BeFalse();
}
[Fact]
public async Task GetMetadataAsync_ReturnsMetadata_WhenGraphExists()
{
// Arrange
var graph = CreateTestGraph("sha256:metadata123");
await _storeService.UpsertAsync(graph, "tenant1", CancellationToken.None);
var adapter = CreateAdapter();
// Act
var metadata = await adapter.GetMetadataAsync("sha256:metadata123", CancellationToken.None);
// Assert
metadata.Should().NotBeNull();
metadata!.ArtifactDigest.Should().Be("sha256:metadata123");
metadata.NodeCount.Should().BeGreaterThan(0);
metadata.EdgeCount.Should().BeGreaterThan(0);
}
[Fact]
public async Task GetMetadataAsync_ReturnsNull_WhenGraphNotExists()
{
// Arrange
var adapter = CreateAdapter();
// Act
var metadata = await adapter.GetMetadataAsync("sha256:notfound", CancellationToken.None);
// Assert
metadata.Should().BeNull();
}
private ReachGraphStoreAdapter CreateAdapter()
{
return new ReachGraphStoreAdapter(
_storeService,
_timeProvider,
NullLogger<ReachGraphStoreAdapter>.Instance);
}
private static ReachGraphMinimal CreateTestGraph(string artifactDigest)
{
var entrypoint = new ReachGraphNode
{
Id = "entry-main",
Ref = "MyApp.Program.Main",
Kind = "method",
Depth = 0
};
var vulnerableClass = new ReachGraphNode
{
Id = "vulnerable-class",
Ref = "MyApp.VulnerableClass.Execute",
Kind = "method",
Depth = 1
};
var otherNode = new ReachGraphNode
{
Id = "other-node",
Ref = "MyApp.OtherClass.DoWork",
Kind = "method",
Depth = 2
};
var edges = ImmutableArray.Create(
new ReachGraphEdge
{
Source = "entry-main",
Target = "vulnerable-class"
},
new ReachGraphEdge
{
Source = "entry-main",
Target = "other-node"
});
return new ReachGraphMinimal
{
Artifact = new ReachGraphArtifact
{
Name = "test-artifact",
Digest = artifactDigest,
Env = "test"
},
Scope = new ReachGraphScope
{
Entrypoints = ImmutableArray.Create("entry-main"),
Selectors = ImmutableArray<string>.Empty,
Cves = null
},
Signature = null,
Nodes = ImmutableArray.Create(entrypoint, vulnerableClass, otherNode),
Edges = edges
};
}
}
/// <summary>
/// In-memory implementation of IReachGraphStoreService for testing.
/// </summary>
internal sealed class InMemoryReachGraphStoreService : IReachGraphStoreService
{
private readonly Dictionary<string, ReachGraphMinimal> _graphs = new();
public Task<ReachGraphStoreResult> UpsertAsync(
ReachGraphMinimal graph,
string? tenantId,
CancellationToken ct)
{
var digest = graph.Artifact.Digest;
var created = !_graphs.ContainsKey(digest);
_graphs[digest] = graph;
return Task.FromResult(new ReachGraphStoreResult
{
Digest = digest,
ArtifactDigest = digest,
Created = created,
NodeCount = graph.Nodes.Length,
EdgeCount = graph.Edges.Length,
StoredAt = DateTimeOffset.UtcNow
});
}
public Task<ReachGraphMinimal?> GetByDigestAsync(
string digest,
string? tenantId,
CancellationToken ct)
{
_graphs.TryGetValue(digest, out var graph);
return Task.FromResult(graph);
}
public Task<ReachGraphMinimal?> GetByArtifactAsync(
string artifactDigest,
string? tenantId,
CancellationToken ct)
{
var graph = _graphs.Values.FirstOrDefault(g => g.Artifact.Digest == artifactDigest);
return Task.FromResult(graph);
}
public Task<bool> ExistsAsync(string digest, string? tenantId, CancellationToken ct)
{
return Task.FromResult(_graphs.ContainsKey(digest));
}
public Task<bool> DeleteAsync(string digest, string? tenantId, CancellationToken ct)
{
return Task.FromResult(_graphs.Remove(digest));
}
}