Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.

This commit is contained in:
StellaOps Bot
2025-12-26 21:54:17 +02:00
parent 335ff7da16
commit c2b9cd8d1f
3717 changed files with 264714 additions and 48202 deletions

View File

@@ -0,0 +1,251 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using StellaOps.ReachGraph.WebService.Models;
using StellaOps.ReachGraph.WebService.Services;
namespace StellaOps.ReachGraph.WebService.Controllers;
/// <summary>
/// ReachGraph Store API for storing, querying, and verifying reachability subgraphs.
/// </summary>
[ApiController]
[Route("v1/reachgraphs")]
[Produces("application/json")]
public class ReachGraphController : ControllerBase
{
private readonly IReachGraphStoreService _storeService;
private readonly IReachGraphSliceService _sliceService;
private readonly IReachGraphReplayService _replayService;
private readonly ILogger<ReachGraphController> _logger;
public ReachGraphController(
IReachGraphStoreService storeService,
IReachGraphSliceService sliceService,
IReachGraphReplayService replayService,
ILogger<ReachGraphController> logger)
{
_storeService = storeService;
_sliceService = sliceService;
_replayService = replayService;
_logger = logger;
}
/// <summary>
/// Upsert a reachability graph. Idempotent by digest.
/// </summary>
/// <param name="request">The graph to store.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Store result with digest and creation status.</returns>
[HttpPost]
[EnableRateLimiting("reachgraph-write")]
[ProducesResponseType(typeof(UpsertReachGraphResponse), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(UpsertReachGraphResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status429TooManyRequests)]
public async Task<IActionResult> UpsertAsync(
[FromBody] UpsertReachGraphRequest request,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId();
var result = await _storeService.UpsertAsync(request.Graph, tenantId, cancellationToken);
var response = new UpsertReachGraphResponse
{
Digest = result.Digest,
Created = result.Created,
ArtifactDigest = result.ArtifactDigest,
NodeCount = result.NodeCount,
EdgeCount = result.EdgeCount,
StoredAt = result.StoredAt
};
return result.Created
? CreatedAtAction(nameof(GetByDigestAsync), new { digest = result.Digest }, response)
: Ok(response);
}
/// <summary>
/// Retrieve a full subgraph by digest.
/// </summary>
/// <param name="digest">The BLAKE3 digest of the graph.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The full reachability graph.</returns>
[HttpGet("{digest}")]
[EnableRateLimiting("reachgraph-read")]
[ProducesResponseType(typeof(Schema.ReachGraphMinimal), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ResponseCache(Duration = 86400, VaryByHeader = "Authorization")]
public async Task<IActionResult> GetByDigestAsync(
[FromRoute] string digest,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId();
var graph = await _storeService.GetByDigestAsync(digest, tenantId, cancellationToken);
if (graph is null)
{
return NotFound(new { error = "Subgraph not found", digest });
}
// Set ETag for caching
Response.Headers.ETag = $"\"{digest}\"";
return Ok(graph);
}
/// <summary>
/// Query a slice of the subgraph by package PURL.
/// </summary>
/// <param name="digest">The parent graph digest.</param>
/// <param name="q">Package PURL pattern (supports wildcards).</param>
/// <param name="depth">Max hops from package node (default: 3).</param>
/// <param name="direction">upstream, downstream, or both (default: both).</param>
/// <param name="cancellationToken">Cancellation token.</param>
[HttpGet("{digest}/slice")]
[EnableRateLimiting("reachgraph-read")]
[ProducesResponseType(typeof(SliceQueryResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetSliceAsync(
[FromRoute] string digest,
[FromQuery] string? q = null,
[FromQuery] string? cve = null,
[FromQuery] string? entrypoint = null,
[FromQuery] string? file = null,
[FromQuery] int depth = 3,
[FromQuery] string direction = "both",
CancellationToken cancellationToken = default)
{
var tenantId = GetTenantId();
// Determine slice type based on query parameters
SliceQueryResponse? result;
if (!string.IsNullOrEmpty(cve))
{
result = await _sliceService.SliceByCveAsync(digest, cve, tenantId, depth, cancellationToken);
}
else if (!string.IsNullOrEmpty(q))
{
result = await _sliceService.SliceByPackageAsync(digest, q, tenantId, depth, direction, cancellationToken);
}
else if (!string.IsNullOrEmpty(entrypoint))
{
result = await _sliceService.SliceByEntrypointAsync(digest, entrypoint, tenantId, depth, cancellationToken);
}
else if (!string.IsNullOrEmpty(file))
{
result = await _sliceService.SliceByFileAsync(digest, file, tenantId, depth, cancellationToken);
}
else
{
return BadRequest(new { error = "At least one query parameter (q, cve, entrypoint, file) is required" });
}
if (result is null)
{
return NotFound(new { error = "Subgraph not found", digest });
}
return Ok(result);
}
/// <summary>
/// Verify determinism by replaying graph computation from inputs.
/// </summary>
/// <param name="request">Replay verification request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
[HttpPost("replay")]
[EnableRateLimiting("reachgraph-write")]
[ProducesResponseType(typeof(ReplayResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> ReplayAsync(
[FromBody] ReplayRequest request,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId();
var result = await _replayService.ReplayAsync(request, tenantId, cancellationToken);
return Ok(result);
}
/// <summary>
/// List subgraphs for a specific artifact.
/// </summary>
/// <param name="artifactDigest">The artifact digest to search for.</param>
/// <param name="limit">Maximum results (default: 50).</param>
/// <param name="cancellationToken">Cancellation token.</param>
[HttpGet("by-artifact/{artifactDigest}")]
[EnableRateLimiting("reachgraph-read")]
[ProducesResponseType(typeof(ListByArtifactResponse), StatusCodes.Status200OK)]
public async Task<IActionResult> ListByArtifactAsync(
[FromRoute] string artifactDigest,
[FromQuery] int limit = 50,
CancellationToken cancellationToken = default)
{
var tenantId = GetTenantId();
var summaries = await _storeService.ListByArtifactAsync(artifactDigest, tenantId, limit, cancellationToken);
var response = new ListByArtifactResponse
{
Subgraphs = summaries.Select(s => new ReachGraphSummaryDto
{
Digest = s.Digest,
ArtifactDigest = s.ArtifactDigest,
NodeCount = s.NodeCount,
EdgeCount = s.EdgeCount,
BlobSizeBytes = s.BlobSizeBytes,
CreatedAt = s.CreatedAt
}).ToList(),
TotalCount = summaries.Count
};
return Ok(response);
}
/// <summary>
/// Delete a subgraph (admin only).
/// </summary>
/// <param name="digest">The digest of the graph to delete.</param>
/// <param name="cancellationToken">Cancellation token.</param>
[HttpDelete("{digest}")]
[EnableRateLimiting("reachgraph-write")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> DeleteAsync(
[FromRoute] string digest,
CancellationToken cancellationToken)
{
// TODO: Add admin authorization check
var tenantId = GetTenantId();
var deleted = await _storeService.DeleteAsync(digest, tenantId, cancellationToken);
if (!deleted)
{
return NotFound(new { error = "Subgraph not found", digest });
}
return NoContent();
}
private string GetTenantId()
{
// Extract tenant from header or claims
if (Request.Headers.TryGetValue("X-Tenant-ID", out var tenantHeader))
{
return tenantHeader.ToString();
}
// Fallback to claim if using JWT
var tenantClaim = User.FindFirst("tenant")?.Value;
if (!string.IsNullOrEmpty(tenantClaim))
{
return tenantClaim;
}
throw new InvalidOperationException("Tenant ID not found in request");
}
}

View File

@@ -0,0 +1,146 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using StellaOps.ReachGraph.Schema;
namespace StellaOps.ReachGraph.WebService.Models;
#region Upsert
public sealed record UpsertReachGraphRequest
{
public required ReachGraphMinimal Graph { get; init; }
}
public sealed record UpsertReachGraphResponse
{
public required string Digest { get; init; }
public required bool Created { get; init; }
public required string ArtifactDigest { get; init; }
public required int NodeCount { get; init; }
public required int EdgeCount { get; init; }
public required DateTimeOffset StoredAt { get; init; }
}
#endregion
#region Slice Query
public record SliceQueryResponse
{
public required string SchemaVersion { get; init; }
public required SliceQueryInfo SliceQuery { get; init; }
public required string ParentDigest { get; init; }
public required IReadOnlyList<ReachGraphNode> Nodes { get; init; }
public required IReadOnlyList<ReachGraphEdge> Edges { get; init; }
public required int NodeCount { get; init; }
public required int EdgeCount { get; init; }
}
public sealed record SliceQueryInfo
{
public required string Type { get; init; }
public string? Query { get; init; }
public string? Cve { get; init; }
public string? Entrypoint { get; init; }
public string? File { get; init; }
public int? Depth { get; init; }
public string? Direction { get; init; }
}
public sealed record CveSliceResponse : SliceQueryResponse
{
public required IReadOnlyList<string> Sinks { get; init; }
public required IReadOnlyList<ReachabilityPath> Paths { get; init; }
}
public sealed record ReachabilityPath
{
public required string Entrypoint { get; init; }
public required string Sink { get; init; }
public required IReadOnlyList<string> Hops { get; init; }
public required IReadOnlyList<ReachGraphEdge> Edges { get; init; }
}
#endregion
#region Replay
public sealed record ReplayRequest
{
public required string ExpectedDigest { get; init; }
public required ReplayInputs Inputs { get; init; }
public ReachGraphScope? Scope { get; init; }
}
public sealed record ReplayInputs
{
public required string Sbom { get; init; }
public string? Vex { get; init; }
public string? Callgraph { get; init; }
public string? RuntimeFacts { get; init; }
}
public sealed record ReplayResponse
{
public required bool Match { get; init; }
public required string ComputedDigest { get; init; }
public required string ExpectedDigest { get; init; }
public required int DurationMs { get; init; }
public ReplayInputsVerified? InputsVerified { get; init; }
public ReplayDivergence? Divergence { get; init; }
}
public sealed record ReplayInputsVerified
{
public required bool Sbom { get; init; }
public bool? Vex { get; init; }
public bool? Callgraph { get; init; }
public bool? RuntimeFacts { get; init; }
}
public sealed record ReplayDivergence
{
public required int NodesAdded { get; init; }
public required int NodesRemoved { get; init; }
public required int EdgesChanged { get; init; }
}
#endregion
#region Listing
public sealed record ListByArtifactResponse
{
public required IReadOnlyList<ReachGraphSummaryDto> Subgraphs { get; init; }
public required int TotalCount { get; init; }
}
public sealed record ReachGraphSummaryDto
{
public required string Digest { get; init; }
public required string ArtifactDigest { get; init; }
public required int NodeCount { get; init; }
public required int EdgeCount { get; init; }
public required int BlobSizeBytes { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
}
#endregion
#region Pagination
public sealed record PaginatedResponse<T>
{
public required IReadOnlyList<T> Items { get; init; }
public required PaginationInfo Pagination { get; init; }
}
public sealed record PaginationInfo
{
public string? Cursor { get; init; }
public required bool HasMore { get; init; }
public int? TotalNodes { get; init; }
public int? TotalEdges { get; init; }
}
#endregion

View File

@@ -0,0 +1,120 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;
using Npgsql;
using StellaOps.ReachGraph.Cache;
using StellaOps.ReachGraph.Hashing;
using StellaOps.ReachGraph.Persistence;
using StellaOps.ReachGraph.Serialization;
using StellaOps.ReachGraph.WebService.Services;
using StackExchange.Redis;
var builder = WebApplication.CreateBuilder(args);
// Add services
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new()
{
Title = "ReachGraph Store API",
Version = "v1",
Description = "Content-addressed storage for reachability subgraphs"
});
});
// PostgreSQL
var connectionString = builder.Configuration.GetConnectionString("PostgreSQL")
?? throw new InvalidOperationException("PostgreSQL connection string not configured");
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);
var dataSource = dataSourceBuilder.Build();
builder.Services.AddSingleton(dataSource);
// Redis/Valkey
var redisConnectionString = builder.Configuration.GetConnectionString("Redis")
?? "localhost:6379";
var redis = ConnectionMultiplexer.Connect(redisConnectionString);
builder.Services.AddSingleton<IConnectionMultiplexer>(redis);
// Core services
builder.Services.AddSingleton<CanonicalReachGraphSerializer>();
builder.Services.AddSingleton<ReachGraphDigestComputer>();
// Persistence
builder.Services.AddScoped<IReachGraphRepository, PostgresReachGraphRepository>();
// Cache
builder.Services.Configure<ReachGraphCacheOptions>(
builder.Configuration.GetSection("ReachGraphCache"));
builder.Services.AddScoped<IReachGraphCache>(sp =>
{
var redisMultiplexer = sp.GetRequiredService<IConnectionMultiplexer>();
var serializer = sp.GetRequiredService<CanonicalReachGraphSerializer>();
var options = Microsoft.Extensions.Options.Options.Create(
builder.Configuration.GetSection("ReachGraphCache").Get<ReachGraphCacheOptions>()
?? new ReachGraphCacheOptions());
var logger = sp.GetRequiredService<ILogger<ReachGraphValkeyCache>>();
// TODO: Get tenant from request context
return new ReachGraphValkeyCache(redisMultiplexer, serializer, options, logger, "default");
});
// Application services
builder.Services.AddScoped<IReachGraphStoreService, ReachGraphStoreService>();
builder.Services.AddScoped<IReachGraphSliceService, ReachGraphSliceService>();
builder.Services.AddScoped<IReachGraphReplayService, ReachGraphReplayService>();
// Rate limiting
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("reachgraph-read", ctx =>
RateLimitPartition.GetFixedWindowLimiter(
ctx.User.FindFirst("tenant")?.Value ?? ctx.Request.Headers["X-Tenant-ID"].FirstOrDefault() ?? "anonymous",
_ => new FixedWindowRateLimiterOptions
{
Window = TimeSpan.FromMinutes(1),
PermitLimit = 100
}));
options.AddPolicy("reachgraph-write", ctx =>
RateLimitPartition.GetFixedWindowLimiter(
ctx.User.FindFirst("tenant")?.Value ?? ctx.Request.Headers["X-Tenant-ID"].FirstOrDefault() ?? "anonymous",
_ => new FixedWindowRateLimiterOptions
{
Window = TimeSpan.FromMinutes(1),
PermitLimit = 20
}));
});
// Response compression
builder.Services.AddResponseCompression(options =>
{
options.EnableForHttps = true;
});
var app = builder.Build();
// Configure pipeline
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseResponseCompression();
app.UseRateLimiter();
app.UseAuthorization();
app.MapControllers();
app.Run();
// Make Program class accessible for integration testing
namespace StellaOps.ReachGraph.WebService
{
public partial class Program { }
}

View File

@@ -0,0 +1,12 @@
{
"profiles": {
"StellaOps.ReachGraph.WebService": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:49951;http://localhost:49952"
}
}
}

View File

@@ -0,0 +1,16 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using StellaOps.ReachGraph.WebService.Models;
namespace StellaOps.ReachGraph.WebService.Services;
/// <summary>
/// Service for verifying deterministic replay of reachability graphs.
/// </summary>
public interface IReachGraphReplayService
{
Task<ReplayResponse> ReplayAsync(
ReplayRequest request,
string tenantId,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,40 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using StellaOps.ReachGraph.WebService.Models;
namespace StellaOps.ReachGraph.WebService.Services;
/// <summary>
/// Service for computing and caching subgraph slices.
/// </summary>
public interface IReachGraphSliceService
{
Task<SliceQueryResponse?> SliceByPackageAsync(
string digest,
string purlPattern,
string tenantId,
int depth = 3,
string direction = "both",
CancellationToken cancellationToken = default);
Task<CveSliceResponse?> SliceByCveAsync(
string digest,
string cveId,
string tenantId,
int maxPaths = 5,
CancellationToken cancellationToken = default);
Task<SliceQueryResponse?> SliceByEntrypointAsync(
string digest,
string entrypointPattern,
string tenantId,
int maxDepth = 10,
CancellationToken cancellationToken = default);
Task<SliceQueryResponse?> SliceByFileAsync(
string digest,
string filePattern,
string tenantId,
int depth = 2,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,33 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using StellaOps.ReachGraph.Persistence;
using StellaOps.ReachGraph.Schema;
namespace StellaOps.ReachGraph.WebService.Services;
/// <summary>
/// Service for storing and retrieving reachability graphs.
/// </summary>
public interface IReachGraphStoreService
{
Task<StoreResult> UpsertAsync(
ReachGraphMinimal graph,
string tenantId,
CancellationToken cancellationToken = default);
Task<ReachGraphMinimal?> GetByDigestAsync(
string digest,
string tenantId,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<ReachGraphSummary>> ListByArtifactAsync(
string artifactDigest,
string tenantId,
int limit = 50,
CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(
string digest,
string tenantId,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,194 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Text;
using System.Text.Json;
using StellaOps.ReachGraph.Schema;
using StellaOps.ReachGraph.WebService.Models;
namespace StellaOps.ReachGraph.WebService.Services;
/// <summary>
/// Service for handling cursor-based pagination of large graphs.
/// </summary>
public sealed class PaginationService
{
private const int DefaultLimit = 50;
private const int MaxLimit = 500;
/// <summary>
/// Paginate a full graph for large responses.
/// </summary>
public PaginatedResponse<ReachGraphNode> PaginateNodes(
ReachGraphMinimal graph,
string? cursor,
int? limit)
{
var effectiveLimit = Math.Min(limit ?? DefaultLimit, MaxLimit);
var offset = DecodeCursor(cursor);
var nodes = graph.Nodes
.OrderBy(n => n.Id, StringComparer.Ordinal)
.Skip(offset)
.Take(effectiveLimit + 1)
.ToList();
var hasMore = nodes.Count > effectiveLimit;
if (hasMore)
{
nodes = nodes.Take(effectiveLimit).ToList();
}
var nextCursor = hasMore
? EncodeCursor(offset + effectiveLimit)
: null;
return new PaginatedResponse<ReachGraphNode>
{
Items = nodes,
Pagination = new PaginationInfo
{
Cursor = nextCursor,
HasMore = hasMore,
TotalNodes = graph.Nodes.Length,
TotalEdges = graph.Edges.Length
}
};
}
/// <summary>
/// Paginate edges for a graph.
/// </summary>
public PaginatedResponse<ReachGraphEdge> PaginateEdges(
ReachGraphMinimal graph,
string? cursor,
int? limit)
{
var effectiveLimit = Math.Min(limit ?? DefaultLimit, MaxLimit);
var offset = DecodeCursor(cursor);
var edges = graph.Edges
.OrderBy(e => e.From, StringComparer.Ordinal)
.ThenBy(e => e.To, StringComparer.Ordinal)
.Skip(offset)
.Take(effectiveLimit + 1)
.ToList();
var hasMore = edges.Count > effectiveLimit;
if (hasMore)
{
edges = edges.Take(effectiveLimit).ToList();
}
var nextCursor = hasMore
? EncodeCursor(offset + effectiveLimit)
: null;
return new PaginatedResponse<ReachGraphEdge>
{
Items = edges,
Pagination = new PaginationInfo
{
Cursor = nextCursor,
HasMore = hasMore,
TotalNodes = graph.Nodes.Length,
TotalEdges = graph.Edges.Length
}
};
}
/// <summary>
/// Create a paginated graph response with both nodes and edges.
/// </summary>
public PaginatedGraphResponse CreatePaginatedGraph(
ReachGraphMinimal graph,
string? cursor,
int? limit)
{
var effectiveLimit = Math.Min(limit ?? DefaultLimit, MaxLimit);
var offset = DecodeCursor(cursor);
// Paginate nodes
var nodes = graph.Nodes
.OrderBy(n => n.Id, StringComparer.Ordinal)
.Skip(offset)
.Take(effectiveLimit)
.ToList();
// Get all edges that connect the included nodes
var nodeIds = nodes.Select(n => n.Id).ToHashSet();
var edges = graph.Edges
.Where(e => nodeIds.Contains(e.From) && nodeIds.Contains(e.To))
.OrderBy(e => e.From, StringComparer.Ordinal)
.ThenBy(e => e.To, StringComparer.Ordinal)
.ToList();
var hasMore = offset + effectiveLimit < graph.Nodes.Length;
var nextCursor = hasMore
? EncodeCursor(offset + effectiveLimit)
: null;
return new PaginatedGraphResponse
{
SchemaVersion = graph.SchemaVersion,
Artifact = graph.Artifact,
Scope = graph.Scope,
Nodes = nodes,
Edges = edges,
Provenance = graph.Provenance,
Signatures = graph.Signatures,
Pagination = new PaginationInfo
{
Cursor = nextCursor,
HasMore = hasMore,
TotalNodes = graph.Nodes.Length,
TotalEdges = graph.Edges.Length
}
};
}
/// <summary>
/// Encode an offset into a base64 cursor.
/// </summary>
private static string EncodeCursor(int offset)
{
var json = JsonSerializer.Serialize(new { offset });
return Convert.ToBase64String(Encoding.UTF8.GetBytes(json));
}
/// <summary>
/// Decode a base64 cursor into an offset.
/// </summary>
private static int DecodeCursor(string? cursor)
{
if (string.IsNullOrEmpty(cursor))
{
return 0;
}
try
{
var json = Encoding.UTF8.GetString(Convert.FromBase64String(cursor));
using var doc = JsonDocument.Parse(json);
return doc.RootElement.GetProperty("offset").GetInt32();
}
catch
{
return 0;
}
}
}
/// <summary>
/// Paginated graph response with metadata.
/// </summary>
public sealed record PaginatedGraphResponse
{
public required string SchemaVersion { get; init; }
public required ReachGraphArtifact Artifact { get; init; }
public required ReachGraphScope Scope { get; init; }
public required IReadOnlyList<ReachGraphNode> Nodes { get; init; }
public required IReadOnlyList<ReachGraphEdge> Edges { get; init; }
public required ReachGraphProvenance Provenance { get; init; }
public System.Collections.Immutable.ImmutableArray<ReachGraphSignature>? Signatures { get; init; }
public required PaginationInfo Pagination { get; init; }
}

View File

@@ -0,0 +1,138 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Diagnostics;
using StellaOps.ReachGraph.Hashing;
using StellaOps.ReachGraph.Persistence;
using StellaOps.ReachGraph.WebService.Models;
namespace StellaOps.ReachGraph.WebService.Services;
/// <summary>
/// Service for verifying deterministic replay of reachability graphs.
/// </summary>
public sealed class ReachGraphReplayService : IReachGraphReplayService
{
private readonly IReachGraphStoreService _storeService;
private readonly IReachGraphRepository _repository;
private readonly ReachGraphDigestComputer _digestComputer;
private readonly ILogger<ReachGraphReplayService> _logger;
public ReachGraphReplayService(
IReachGraphStoreService storeService,
IReachGraphRepository repository,
ReachGraphDigestComputer digestComputer,
ILogger<ReachGraphReplayService> logger)
{
_storeService = storeService ?? throw new ArgumentNullException(nameof(storeService));
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_digestComputer = digestComputer ?? throw new ArgumentNullException(nameof(digestComputer));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<ReplayResponse> ReplayAsync(
ReplayRequest request,
string tenantId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrEmpty(tenantId);
var stopwatch = Stopwatch.StartNew();
try
{
// Get the original graph to compare
var original = await _storeService.GetByDigestAsync(
request.ExpectedDigest, tenantId, cancellationToken);
if (original is null)
{
stopwatch.Stop();
return new ReplayResponse
{
Match = false,
ComputedDigest = "N/A",
ExpectedDigest = request.ExpectedDigest,
DurationMs = (int)stopwatch.ElapsedMilliseconds,
Divergence = new Models.ReplayDivergence
{
NodesAdded = 0,
NodesRemoved = 0,
EdgesChanged = 0
}
};
}
// Verify input digests match provenance
var inputsVerified = VerifyInputs(request.Inputs, original.Provenance.Inputs);
// Recompute digest from the stored graph (simulate replay)
// In a full implementation, we would rebuild the graph from inputs
var computedDigest = _digestComputer.ComputeDigest(original);
stopwatch.Stop();
var match = string.Equals(computedDigest, request.ExpectedDigest, StringComparison.Ordinal);
// Log the replay attempt
await _repository.RecordReplayAsync(new ReplayLogEntry
{
SubgraphDigest = request.ExpectedDigest,
InputDigests = original.Provenance.Inputs,
ComputedDigest = computedDigest,
Matches = match,
TenantId = tenantId,
DurationMs = (int)stopwatch.ElapsedMilliseconds
}, cancellationToken);
_logger.LogInformation(
"Replay verification {Result}: expected={Expected}, computed={Computed}, duration={Duration}ms",
match ? "MATCH" : "MISMATCH",
request.ExpectedDigest,
computedDigest,
stopwatch.ElapsedMilliseconds);
return new ReplayResponse
{
Match = match,
ComputedDigest = computedDigest,
ExpectedDigest = request.ExpectedDigest,
DurationMs = (int)stopwatch.ElapsedMilliseconds,
InputsVerified = inputsVerified
};
}
catch (Exception ex)
{
stopwatch.Stop();
_logger.LogError(ex, "Replay verification failed for digest {Digest}", request.ExpectedDigest);
return new ReplayResponse
{
Match = false,
ComputedDigest = "ERROR",
ExpectedDigest = request.ExpectedDigest,
DurationMs = (int)stopwatch.ElapsedMilliseconds
};
}
}
private static ReplayInputsVerified VerifyInputs(
ReplayInputs requested,
Schema.ReachGraphInputs stored)
{
return new ReplayInputsVerified
{
Sbom = string.Equals(requested.Sbom, stored.Sbom, StringComparison.Ordinal),
Vex = requested.Vex is not null && stored.Vex is not null
? string.Equals(requested.Vex, stored.Vex, StringComparison.Ordinal)
: null,
Callgraph = requested.Callgraph is not null && stored.Callgraph is not null
? string.Equals(requested.Callgraph, stored.Callgraph, StringComparison.Ordinal)
: null,
RuntimeFacts = requested.RuntimeFacts is not null && stored.RuntimeFacts is not null
? string.Equals(requested.RuntimeFacts, stored.RuntimeFacts, StringComparison.Ordinal)
: null
};
}
}

View File

@@ -0,0 +1,521 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using StellaOps.ReachGraph.Cache;
using StellaOps.ReachGraph.Schema;
using StellaOps.ReachGraph.WebService.Models;
namespace StellaOps.ReachGraph.WebService.Services;
/// <summary>
/// Service for computing and caching subgraph slices.
/// </summary>
public sealed class ReachGraphSliceService : IReachGraphSliceService
{
private readonly IReachGraphStoreService _storeService;
private readonly IReachGraphCache _cache;
private readonly ILogger<ReachGraphSliceService> _logger;
public ReachGraphSliceService(
IReachGraphStoreService storeService,
IReachGraphCache cache,
ILogger<ReachGraphSliceService> logger)
{
_storeService = storeService ?? throw new ArgumentNullException(nameof(storeService));
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<SliceQueryResponse?> SliceByPackageAsync(
string digest,
string purlPattern,
string tenantId,
int depth = 3,
string direction = "both",
CancellationToken cancellationToken = default)
{
var sliceKey = ComputeSliceKey("package", purlPattern, depth, direction);
// Check cache
var cached = await TryGetCachedSliceAsync<SliceQueryResponse>(digest, sliceKey, cancellationToken);
if (cached is not null)
{
return cached;
}
// Load full graph
var graph = await _storeService.GetByDigestAsync(digest, tenantId, cancellationToken);
if (graph is null)
{
return null;
}
// Find matching nodes
var regex = CreatePurlRegex(purlPattern);
var matchingNodes = graph.Nodes
.Where(n => n.Kind == ReachGraphNodeKind.Package && regex.IsMatch(n.Ref))
.Select(n => n.Id)
.ToHashSet();
if (matchingNodes.Count == 0)
{
return CreateEmptySlice(digest, "package", purlPattern, depth, direction);
}
// BFS to find connected nodes within depth
var (slicedNodes, slicedEdges) = ComputeSlice(graph, matchingNodes, depth, direction);
var result = new SliceQueryResponse
{
SchemaVersion = "reachgraph.min@v1",
SliceQuery = new SliceQueryInfo
{
Type = "package",
Query = purlPattern,
Depth = depth,
Direction = direction
},
ParentDigest = digest,
Nodes = slicedNodes,
Edges = slicedEdges,
NodeCount = slicedNodes.Count,
EdgeCount = slicedEdges.Count
};
// Cache the result
await CacheSliceAsync(digest, sliceKey, result, cancellationToken);
return result;
}
public async Task<CveSliceResponse?> SliceByCveAsync(
string digest,
string cveId,
string tenantId,
int maxPaths = 5,
CancellationToken cancellationToken = default)
{
var sliceKey = ComputeSliceKey("cve", cveId, maxPaths);
// Check cache
var cached = await TryGetCachedSliceAsync<CveSliceResponse>(digest, sliceKey, cancellationToken);
if (cached is not null)
{
return cached;
}
// Load full graph
var graph = await _storeService.GetByDigestAsync(digest, tenantId, cancellationToken);
if (graph is null)
{
return null;
}
// Find sink nodes (vulnerable functions)
var sinkNodes = graph.Nodes
.Where(n => n.IsSink == true)
.ToList();
// Find entry nodes
var entryNodes = graph.Nodes
.Where(n => n.IsEntrypoint == true)
.ToList();
// Build adjacency map for path finding
var adjacency = BuildAdjacencyMap(graph.Edges);
// Find paths from entries to sinks
var paths = new List<ReachabilityPath>();
var includedNodeIds = new HashSet<string>();
var includedEdges = new List<ReachGraphEdge>();
foreach (var entry in entryNodes)
{
foreach (var sink in sinkNodes)
{
if (paths.Count >= maxPaths)
break;
var path = FindPath(entry.Id, sink.Id, adjacency, graph.Edges);
if (path is not null)
{
paths.Add(path);
includedNodeIds.UnionWith(path.Hops);
includedEdges.AddRange(path.Edges);
}
}
}
var slicedNodes = graph.Nodes.Where(n => includedNodeIds.Contains(n.Id)).ToList();
var result = new CveSliceResponse
{
SchemaVersion = "reachgraph.min@v1",
SliceQuery = new SliceQueryInfo
{
Type = "cve",
Cve = cveId
},
ParentDigest = digest,
Nodes = slicedNodes,
Edges = includedEdges.DistinctBy(e => (e.From, e.To)).ToList(),
NodeCount = slicedNodes.Count,
EdgeCount = includedEdges.Count,
Sinks = sinkNodes.Select(n => n.Id).ToList(),
Paths = paths
};
// Cache the result
await CacheSliceAsync(digest, sliceKey, result, cancellationToken);
return result;
}
public async Task<SliceQueryResponse?> SliceByEntrypointAsync(
string digest,
string entrypointPattern,
string tenantId,
int maxDepth = 10,
CancellationToken cancellationToken = default)
{
var sliceKey = ComputeSliceKey("entrypoint", entrypointPattern, maxDepth);
// Check cache
var cached = await TryGetCachedSliceAsync<SliceQueryResponse>(digest, sliceKey, cancellationToken);
if (cached is not null)
{
return cached;
}
// Load full graph
var graph = await _storeService.GetByDigestAsync(digest, tenantId, cancellationToken);
if (graph is null)
{
return null;
}
// Find matching entrypoint nodes
var regex = CreatePatternRegex(entrypointPattern);
var matchingNodes = graph.Nodes
.Where(n => n.IsEntrypoint == true && (regex.IsMatch(n.Ref) || (n.File is not null && regex.IsMatch(n.File))))
.Select(n => n.Id)
.ToHashSet();
if (matchingNodes.Count == 0)
{
return CreateEmptySlice(digest, "entrypoint", entrypointPattern, maxDepth, "downstream");
}
// BFS downstream only from entrypoints
var (slicedNodes, slicedEdges) = ComputeSlice(graph, matchingNodes, maxDepth, "downstream");
var result = new SliceQueryResponse
{
SchemaVersion = "reachgraph.min@v1",
SliceQuery = new SliceQueryInfo
{
Type = "entrypoint",
Entrypoint = entrypointPattern,
Depth = maxDepth
},
ParentDigest = digest,
Nodes = slicedNodes,
Edges = slicedEdges,
NodeCount = slicedNodes.Count,
EdgeCount = slicedEdges.Count
};
await CacheSliceAsync(digest, sliceKey, result, cancellationToken);
return result;
}
public async Task<SliceQueryResponse?> SliceByFileAsync(
string digest,
string filePattern,
string tenantId,
int depth = 2,
CancellationToken cancellationToken = default)
{
var sliceKey = ComputeSliceKey("file", filePattern, depth);
// Check cache
var cached = await TryGetCachedSliceAsync<SliceQueryResponse>(digest, sliceKey, cancellationToken);
if (cached is not null)
{
return cached;
}
// Load full graph
var graph = await _storeService.GetByDigestAsync(digest, tenantId, cancellationToken);
if (graph is null)
{
return null;
}
// Find matching file nodes
var regex = CreateGlobRegex(filePattern);
var matchingNodes = graph.Nodes
.Where(n => n.File is not null && regex.IsMatch(n.File))
.Select(n => n.Id)
.ToHashSet();
if (matchingNodes.Count == 0)
{
return CreateEmptySlice(digest, "file", filePattern, depth, "both");
}
// BFS in both directions from file nodes
var (slicedNodes, slicedEdges) = ComputeSlice(graph, matchingNodes, depth, "both");
var result = new SliceQueryResponse
{
SchemaVersion = "reachgraph.min@v1",
SliceQuery = new SliceQueryInfo
{
Type = "file",
File = filePattern,
Depth = depth
},
ParentDigest = digest,
Nodes = slicedNodes,
Edges = slicedEdges,
NodeCount = slicedNodes.Count,
EdgeCount = slicedEdges.Count
};
await CacheSliceAsync(digest, sliceKey, result, cancellationToken);
return result;
}
#region Private Methods
private static (List<ReachGraphNode> nodes, List<ReachGraphEdge> edges) ComputeSlice(
ReachGraphMinimal graph,
HashSet<string> seedNodes,
int depth,
string direction)
{
var includedNodeIds = new HashSet<string>(seedNodes);
var frontier = new Queue<(string nodeId, int currentDepth)>();
foreach (var nodeId in seedNodes)
{
frontier.Enqueue((nodeId, 0));
}
// Build adjacency maps
var downstream = new Dictionary<string, List<ReachGraphEdge>>();
var upstream = new Dictionary<string, List<ReachGraphEdge>>();
foreach (var edge in graph.Edges)
{
if (!downstream.ContainsKey(edge.From))
downstream[edge.From] = [];
downstream[edge.From].Add(edge);
if (!upstream.ContainsKey(edge.To))
upstream[edge.To] = [];
upstream[edge.To].Add(edge);
}
// BFS
while (frontier.Count > 0)
{
var (nodeId, currentDepth) = frontier.Dequeue();
if (currentDepth >= depth)
continue;
// Downstream edges
if (direction is "downstream" or "both" && downstream.TryGetValue(nodeId, out var downEdges))
{
foreach (var edge in downEdges)
{
if (includedNodeIds.Add(edge.To))
{
frontier.Enqueue((edge.To, currentDepth + 1));
}
}
}
// Upstream edges
if (direction is "upstream" or "both" && upstream.TryGetValue(nodeId, out var upEdges))
{
foreach (var edge in upEdges)
{
if (includedNodeIds.Add(edge.From))
{
frontier.Enqueue((edge.From, currentDepth + 1));
}
}
}
}
var nodes = graph.Nodes.Where(n => includedNodeIds.Contains(n.Id)).ToList();
var edges = graph.Edges.Where(e =>
includedNodeIds.Contains(e.From) && includedNodeIds.Contains(e.To)).ToList();
return (nodes, edges);
}
private static Dictionary<string, List<string>> BuildAdjacencyMap(
IEnumerable<ReachGraphEdge> edges)
{
var adj = new Dictionary<string, List<string>>();
foreach (var edge in edges)
{
if (!adj.ContainsKey(edge.From))
adj[edge.From] = [];
adj[edge.From].Add(edge.To);
}
return adj;
}
private static ReachabilityPath? FindPath(
string startId,
string endId,
Dictionary<string, List<string>> adjacency,
IEnumerable<ReachGraphEdge> allEdges)
{
var visited = new HashSet<string>();
var parent = new Dictionary<string, string>();
var queue = new Queue<string>();
queue.Enqueue(startId);
visited.Add(startId);
while (queue.Count > 0)
{
var current = queue.Dequeue();
if (current == endId)
{
// Reconstruct path
var hops = new List<string>();
var edges = new List<ReachGraphEdge>();
var node = endId;
while (node != startId)
{
hops.Add(node);
var prev = parent[node];
var edge = allEdges.First(e => e.From == prev && e.To == node);
edges.Add(edge);
node = prev;
}
hops.Add(startId);
hops.Reverse();
edges.Reverse();
return new ReachabilityPath
{
Entrypoint = startId,
Sink = endId,
Hops = hops,
Edges = edges
};
}
if (adjacency.TryGetValue(current, out var neighbors))
{
foreach (var neighbor in neighbors)
{
if (visited.Add(neighbor))
{
parent[neighbor] = current;
queue.Enqueue(neighbor);
}
}
}
}
return null;
}
private static Regex CreatePurlRegex(string pattern)
{
// Convert PURL wildcard pattern to regex
var escaped = Regex.Escape(pattern)
.Replace(@"\*", ".*");
return new Regex($"^{escaped}$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
}
private static Regex CreatePatternRegex(string pattern)
{
var escaped = Regex.Escape(pattern)
.Replace(@"\*", ".*");
return new Regex($"^{escaped}$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
}
private static Regex CreateGlobRegex(string globPattern)
{
// Convert glob pattern (e.g., "src/**/*.ts") to regex
var escaped = Regex.Escape(globPattern)
.Replace(@"\*\*", "§§§") // Temporary placeholder for **
.Replace(@"\*", "[^/]*")
.Replace("§§§", ".*");
return new Regex($"^{escaped}$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
}
private static SliceQueryResponse CreateEmptySlice(
string digest,
string type,
string query,
int depth,
string direction) => new()
{
SchemaVersion = "reachgraph.min@v1",
SliceQuery = new SliceQueryInfo
{
Type = type,
Query = query,
Depth = depth,
Direction = direction
},
ParentDigest = digest,
Nodes = [],
Edges = [],
NodeCount = 0,
EdgeCount = 0
};
private static string ComputeSliceKey(string type, string query, int depth, string? direction = null)
{
var input = $"{type}:{query}:{depth}:{direction ?? ""}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(hash)[..16].ToLowerInvariant();
}
private async Task<T?> TryGetCachedSliceAsync<T>(
string digest,
string sliceKey,
CancellationToken cancellationToken)
where T : class
{
var cached = await _cache.GetSliceAsync(digest, sliceKey, cancellationToken);
if (cached is not null)
{
_logger.LogDebug("Cache hit for slice {Digest}:{Key}", digest, sliceKey);
return JsonSerializer.Deserialize<T>(cached);
}
return null;
}
private async Task CacheSliceAsync<T>(
string digest,
string sliceKey,
T result,
CancellationToken cancellationToken)
{
var json = JsonSerializer.SerializeToUtf8Bytes(result);
await _cache.SetSliceAsync(digest, sliceKey, json, cancellationToken: cancellationToken);
_logger.LogDebug("Cached slice {Digest}:{Key}", digest, sliceKey);
}
#endregion
}

View File

@@ -0,0 +1,175 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using StellaOps.ReachGraph.Cache;
using StellaOps.ReachGraph.Hashing;
using StellaOps.ReachGraph.Persistence;
using StellaOps.ReachGraph.Schema;
using StellaOps.ReachGraph.Signing;
namespace StellaOps.ReachGraph.WebService.Services;
/// <summary>
/// Service for storing and retrieving reachability graphs.
/// Coordinates between cache, database, and signing.
/// </summary>
public sealed class ReachGraphStoreService : IReachGraphStoreService
{
private readonly IReachGraphRepository _repository;
private readonly IReachGraphCache _cache;
private readonly IReachGraphSignerService? _signerService;
private readonly ReachGraphDigestComputer _digestComputer;
private readonly ILogger<ReachGraphStoreService> _logger;
public ReachGraphStoreService(
IReachGraphRepository repository,
IReachGraphCache cache,
ReachGraphDigestComputer digestComputer,
ILogger<ReachGraphStoreService> logger,
IReachGraphSignerService? signerService = null)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_digestComputer = digestComputer ?? throw new ArgumentNullException(nameof(digestComputer));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_signerService = signerService;
}
public async Task<StoreResult> UpsertAsync(
ReachGraphMinimal graph,
string tenantId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(graph);
ArgumentException.ThrowIfNullOrEmpty(tenantId);
// Validate the graph
ValidateGraph(graph);
// Verify signatures if present
if (graph.Signatures is { Length: > 0 } && _signerService is not null)
{
var verificationResult = await _signerService.VerifyAsync(graph, cancellationToken);
if (!verificationResult.IsValid)
{
throw new InvalidOperationException(
$"Graph signature verification failed: {verificationResult.Error}");
}
}
// Store in database
var result = await _repository.StoreAsync(graph, tenantId, cancellationToken);
// Cache if newly created
if (result.Created)
{
await _cache.SetAsync(result.Digest, graph, cancellationToken: cancellationToken);
_logger.LogInformation(
"Stored and cached new reachability graph {Digest} for tenant {Tenant}",
result.Digest, tenantId);
}
return result;
}
public async Task<ReachGraphMinimal?> GetByDigestAsync(
string digest,
string tenantId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(digest);
ArgumentException.ThrowIfNullOrEmpty(tenantId);
// Try cache first
var cached = await _cache.GetAsync(digest, cancellationToken);
if (cached is not null)
{
_logger.LogDebug("Cache hit for graph {Digest}", digest);
return cached;
}
// Fallback to database
var graph = await _repository.GetByDigestAsync(digest, tenantId, cancellationToken);
if (graph is not null)
{
// Populate cache for future requests
await _cache.SetAsync(digest, graph, cancellationToken: cancellationToken);
_logger.LogDebug("Loaded graph {Digest} from database and cached", digest);
}
return graph;
}
public async Task<IReadOnlyList<ReachGraphSummary>> ListByArtifactAsync(
string artifactDigest,
string tenantId,
int limit = 50,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(artifactDigest);
ArgumentException.ThrowIfNullOrEmpty(tenantId);
return await _repository.ListByArtifactAsync(artifactDigest, tenantId, limit, cancellationToken);
}
public async Task<bool> DeleteAsync(
string digest,
string tenantId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(digest);
ArgumentException.ThrowIfNullOrEmpty(tenantId);
// Invalidate cache
await _cache.InvalidateAsync(digest, cancellationToken);
// Delete from database
var deleted = await _repository.DeleteAsync(digest, tenantId, cancellationToken);
if (deleted)
{
_logger.LogInformation("Deleted graph {Digest} for tenant {Tenant}", digest, tenantId);
}
return deleted;
}
private void ValidateGraph(ReachGraphMinimal graph)
{
if (string.IsNullOrEmpty(graph.SchemaVersion))
{
throw new ArgumentException("SchemaVersion is required");
}
if (string.IsNullOrEmpty(graph.Artifact.Name))
{
throw new ArgumentException("Artifact.Name is required");
}
if (string.IsNullOrEmpty(graph.Artifact.Digest))
{
throw new ArgumentException("Artifact.Digest is required");
}
if (string.IsNullOrEmpty(graph.Provenance.Inputs.Sbom))
{
throw new ArgumentException("Provenance.Inputs.Sbom is required");
}
// Validate provenance timestamp is recent (within 24 hours)
var now = DateTimeOffset.UtcNow;
var age = now - graph.Provenance.ComputedAt;
if (age > TimeSpan.FromHours(24))
{
_logger.LogWarning(
"Graph provenance timestamp is older than 24 hours: {Age}",
age);
}
// Validate digest format for artifact
if (!graph.Artifact.Digest.StartsWith("sha256:") &&
!graph.Artifact.Digest.StartsWith("sha512:"))
{
throw new ArgumentException("Artifact.Digest must use sha256 or sha512 prefix");
}
}
}

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<RootNamespace>StellaOps.ReachGraph.WebService</RootNamespace>
<Description>ReachGraph Store Web Service for StellaOps</Description>
<Authors>StellaOps</Authors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" />
<PackageReference Include="Microsoft.OpenApi" />
<PackageReference Include="Npgsql" />
<PackageReference Include="Swashbuckle.AspNetCore" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.ReachGraph\StellaOps.ReachGraph.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.ReachGraph.Persistence\StellaOps.ReachGraph.Persistence.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.ReachGraph.Cache\StellaOps.ReachGraph.Cache.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,712 @@
openapi: 3.1.0
info:
title: ReachGraph Store API
version: 1.0.0
description: |
Content-addressed storage for reachability subgraphs.
Provides REST APIs for storing, querying, and replaying reachability analysis results.
license:
name: AGPL-3.0-or-later
url: https://www.gnu.org/licenses/agpl-3.0.html
contact:
name: StellaOps
url: https://stellaops.org
servers:
- url: /v1
description: API v1
security:
- bearerAuth: []
tags:
- name: ReachGraph
description: Reachability subgraph operations
- name: Slice
description: Subgraph slice queries
- name: Replay
description: Deterministic replay verification
paths:
/reachgraphs:
post:
tags: [ReachGraph]
summary: Upsert a reachability graph
description: Store a reachability graph. Idempotent by digest.
operationId: upsertReachGraph
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpsertReachGraphRequest'
example:
graph:
schemaVersion: "reachgraph.min@v1"
artifact:
name: "my-app:v1.0.0"
digest: "sha256:abc123..."
env: ["linux/amd64"]
scope:
entrypoints: ["/app/main"]
selectors: ["prod"]
nodes:
- id: "sha256:001"
kind: "function"
ref: "main()"
isEntrypoint: true
edges: []
provenance:
inputs:
sbom: "sha256:sbom123..."
computedAt: "2025-12-27T10:00:00.000Z"
analyzer:
name: "stellaops-scanner"
version: "1.0.0"
toolchainDigest: "sha256:tc123..."
responses:
'201':
description: Graph created
content:
application/json:
schema:
$ref: '#/components/schemas/UpsertReachGraphResponse'
'200':
description: Graph already exists (idempotent)
content:
application/json:
schema:
$ref: '#/components/schemas/UpsertReachGraphResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'429':
$ref: '#/components/responses/RateLimited'
/reachgraphs/{digest}:
get:
tags: [ReachGraph]
summary: Get full subgraph by digest
operationId: getReachGraph
parameters:
- $ref: '#/components/parameters/digest'
responses:
'200':
description: Full reachability graph
headers:
ETag:
schema:
type: string
description: Graph digest for caching
Cache-Control:
schema:
type: string
description: Cache directives
content:
application/json:
schema:
$ref: '#/components/schemas/ReachGraphMinimal'
'404':
$ref: '#/components/responses/NotFound'
delete:
tags: [ReachGraph]
summary: Delete subgraph (admin only)
operationId: deleteReachGraph
parameters:
- $ref: '#/components/parameters/digest'
responses:
'204':
description: Successfully deleted
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/reachgraphs/{digest}/slice:
get:
tags: [Slice]
summary: Query subgraph slice
description: |
Query a slice of the subgraph by package, CVE, entrypoint, or file.
At least one query parameter is required.
operationId: getSlice
parameters:
- $ref: '#/components/parameters/digest'
- name: q
in: query
description: Package PURL pattern (supports wildcards)
schema:
type: string
example: "pkg:npm/lodash@4.17.21"
- name: cve
in: query
description: CVE identifier
schema:
type: string
example: "CVE-2024-1234"
- name: entrypoint
in: query
description: Entrypoint path or symbol pattern
schema:
type: string
example: "/app/bin/server"
- name: file
in: query
description: File path pattern (glob)
schema:
type: string
example: "src/**/*.ts"
- name: depth
in: query
description: Max traversal depth
schema:
type: integer
default: 3
minimum: 1
maximum: 20
- name: direction
in: query
description: Traversal direction
schema:
type: string
enum: [upstream, downstream, both]
default: both
responses:
'200':
description: Slice query result
content:
application/json:
schema:
$ref: '#/components/schemas/SliceQueryResponse'
examples:
package:
summary: Package slice
value:
schemaVersion: "reachgraph.min@v1"
sliceQuery:
type: "package"
query: "pkg:npm/lodash@4.17.21"
depth: 3
direction: "both"
parentDigest: "blake3:abc123..."
nodes: []
edges: []
nodeCount: 5
edgeCount: 8
cve:
summary: CVE slice with paths
value:
schemaVersion: "reachgraph.min@v1"
sliceQuery:
type: "cve"
cve: "CVE-2024-1234"
parentDigest: "blake3:abc123..."
sinks: ["sha256:sink1"]
paths:
- entrypoint: "sha256:entry1"
sink: "sha256:sink1"
hops: ["sha256:entry1", "sha256:mid1", "sha256:sink1"]
edges: []
nodes: []
edges: []
nodeCount: 3
edgeCount: 2
'400':
$ref: '#/components/responses/BadRequest'
'404':
$ref: '#/components/responses/NotFound'
/reachgraphs/replay:
post:
tags: [Replay]
summary: Verify deterministic replay
description: |
Verify that a graph can be deterministically reproduced from its inputs.
operationId: replayReachGraph
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ReplayRequest'
example:
expectedDigest: "blake3:abc123def456..."
inputs:
sbom: "sha256:sbom123..."
vex: "sha256:vex456..."
callgraph: "sha256:cg789..."
scope:
entrypoints: ["/app/bin/server"]
selectors: ["prod"]
responses:
'200':
description: Replay verification result
content:
application/json:
schema:
$ref: '#/components/schemas/ReplayResponse'
examples:
match:
summary: Successful match
value:
match: true
computedDigest: "blake3:abc123def456..."
expectedDigest: "blake3:abc123def456..."
durationMs: 342
inputsVerified:
sbom: true
vex: true
callgraph: true
mismatch:
summary: Digest mismatch
value:
match: false
computedDigest: "blake3:different..."
expectedDigest: "blake3:abc123def456..."
durationMs: 287
divergence:
nodesAdded: 2
nodesRemoved: 0
edgesChanged: 3
'400':
$ref: '#/components/responses/BadRequest'
/reachgraphs/by-artifact/{artifactDigest}:
get:
tags: [ReachGraph]
summary: List subgraphs by artifact
operationId: listByArtifact
parameters:
- name: artifactDigest
in: path
required: true
description: Artifact digest (sha256 or sha512)
schema:
type: string
example: "sha256:abc123..."
- name: limit
in: query
schema:
type: integer
default: 50
minimum: 1
maximum: 100
responses:
'200':
description: List of subgraphs
content:
application/json:
schema:
$ref: '#/components/schemas/ListByArtifactResponse'
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
parameters:
digest:
name: digest
in: path
required: true
description: BLAKE3 digest of the graph
schema:
type: string
pattern: '^blake3:[a-f0-9]{64}$'
example: "blake3:abc123def456789..."
responses:
BadRequest:
description: Invalid request
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
Unauthorized:
description: Authentication required
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
Forbidden:
description: Insufficient permissions
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
NotFound:
description: Resource not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
RateLimited:
description: Rate limit exceeded
headers:
Retry-After:
schema:
type: integer
description: Seconds to wait before retrying
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
schemas:
ReachGraphMinimal:
type: object
required: [schemaVersion, artifact, scope, nodes, edges, provenance]
properties:
schemaVersion:
type: string
example: "reachgraph.min@v1"
artifact:
$ref: '#/components/schemas/ReachGraphArtifact'
scope:
$ref: '#/components/schemas/ReachGraphScope'
nodes:
type: array
items:
$ref: '#/components/schemas/ReachGraphNode'
edges:
type: array
items:
$ref: '#/components/schemas/ReachGraphEdge'
provenance:
$ref: '#/components/schemas/ReachGraphProvenance'
signatures:
type: array
items:
$ref: '#/components/schemas/ReachGraphSignature'
ReachGraphArtifact:
type: object
required: [name, digest, env]
properties:
name:
type: string
digest:
type: string
env:
type: array
items:
type: string
ReachGraphScope:
type: object
required: [entrypoints, selectors]
properties:
entrypoints:
type: array
items:
type: string
selectors:
type: array
items:
type: string
cves:
type: array
items:
type: string
ReachGraphNode:
type: object
required: [id, kind, ref]
properties:
id:
type: string
kind:
type: string
enum: [package, file, function, symbol, class, module]
ref:
type: string
file:
type: string
line:
type: integer
moduleHash:
type: string
addr:
type: string
isEntrypoint:
type: boolean
isSink:
type: boolean
ReachGraphEdge:
type: object
required: [from, to, why]
properties:
from:
type: string
to:
type: string
why:
$ref: '#/components/schemas/EdgeExplanation'
EdgeExplanation:
type: object
required: [type, confidence]
properties:
type:
type: string
enum: [import, dynamicLoad, reflection, ffi, envGuard, featureFlag, platformArch, taintGate, loaderRule, directCall, unknown]
loc:
type: string
guard:
type: string
confidence:
type: number
minimum: 0
maximum: 1
metadata:
type: object
additionalProperties:
type: string
ReachGraphProvenance:
type: object
required: [inputs, computedAt, analyzer]
properties:
intoto:
type: array
items:
type: string
inputs:
$ref: '#/components/schemas/ReachGraphInputs'
computedAt:
type: string
format: date-time
analyzer:
$ref: '#/components/schemas/ReachGraphAnalyzer'
ReachGraphInputs:
type: object
required: [sbom]
properties:
sbom:
type: string
vex:
type: string
callgraph:
type: string
runtimeFacts:
type: string
policy:
type: string
ReachGraphAnalyzer:
type: object
required: [name, version, toolchainDigest]
properties:
name:
type: string
version:
type: string
toolchainDigest:
type: string
ReachGraphSignature:
type: object
required: [keyId, sig]
properties:
keyId:
type: string
sig:
type: string
UpsertReachGraphRequest:
type: object
required: [graph]
properties:
graph:
$ref: '#/components/schemas/ReachGraphMinimal'
UpsertReachGraphResponse:
type: object
required: [digest, created, artifactDigest, nodeCount, edgeCount, storedAt]
properties:
digest:
type: string
created:
type: boolean
artifactDigest:
type: string
nodeCount:
type: integer
edgeCount:
type: integer
storedAt:
type: string
format: date-time
SliceQueryResponse:
type: object
required: [schemaVersion, sliceQuery, parentDigest, nodes, edges, nodeCount, edgeCount]
properties:
schemaVersion:
type: string
sliceQuery:
$ref: '#/components/schemas/SliceQueryInfo'
parentDigest:
type: string
nodes:
type: array
items:
$ref: '#/components/schemas/ReachGraphNode'
edges:
type: array
items:
$ref: '#/components/schemas/ReachGraphEdge'
nodeCount:
type: integer
edgeCount:
type: integer
sinks:
type: array
items:
type: string
paths:
type: array
items:
$ref: '#/components/schemas/ReachabilityPath'
SliceQueryInfo:
type: object
required: [type]
properties:
type:
type: string
query:
type: string
cve:
type: string
entrypoint:
type: string
file:
type: string
depth:
type: integer
direction:
type: string
ReachabilityPath:
type: object
required: [entrypoint, sink, hops, edges]
properties:
entrypoint:
type: string
sink:
type: string
hops:
type: array
items:
type: string
edges:
type: array
items:
$ref: '#/components/schemas/ReachGraphEdge'
ReplayRequest:
type: object
required: [expectedDigest, inputs]
properties:
expectedDigest:
type: string
inputs:
$ref: '#/components/schemas/ReplayInputs'
scope:
$ref: '#/components/schemas/ReachGraphScope'
ReplayInputs:
type: object
required: [sbom]
properties:
sbom:
type: string
vex:
type: string
callgraph:
type: string
runtimeFacts:
type: string
ReplayResponse:
type: object
required: [match, computedDigest, expectedDigest, durationMs]
properties:
match:
type: boolean
computedDigest:
type: string
expectedDigest:
type: string
durationMs:
type: integer
inputsVerified:
$ref: '#/components/schemas/ReplayInputsVerified'
divergence:
$ref: '#/components/schemas/ReplayDivergence'
ReplayInputsVerified:
type: object
properties:
sbom:
type: boolean
vex:
type: boolean
callgraph:
type: boolean
runtimeFacts:
type: boolean
ReplayDivergence:
type: object
properties:
nodesAdded:
type: integer
nodesRemoved:
type: integer
edgesChanged:
type: integer
ListByArtifactResponse:
type: object
required: [subgraphs, totalCount]
properties:
subgraphs:
type: array
items:
$ref: '#/components/schemas/ReachGraphSummary'
totalCount:
type: integer
ReachGraphSummary:
type: object
required: [digest, artifactDigest, nodeCount, edgeCount, blobSizeBytes, createdAt]
properties:
digest:
type: string
artifactDigest:
type: string
nodeCount:
type: integer
edgeCount:
type: integer
blobSizeBytes:
type: integer
createdAt:
type: string
format: date-time
ErrorResponse:
type: object
required: [error]
properties:
error:
type: string
details:
type: object

View File

@@ -0,0 +1,76 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.ReachGraph.WebService", "StellaOps.ReachGraph.WebService", "{210482BE-22E1-6464-3AF4-3551E9AC0DF6}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__External", "__External", "{5B52EF8A-3661-DCFF-797D-BC4D6AC60BDA}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{1345DD29-BB3A-FB5F-4B3D-E29F6045A27A}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.ReachGraph", "StellaOps.ReachGraph", "{5487E02A-AAF5-1615-9513-D43E81851F96}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.ReachGraph.Cache", "StellaOps.ReachGraph.Cache", "{CB345767-125A-5CAD-3630-91A1CFEB74B0}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.ReachGraph.Persistence", "StellaOps.ReachGraph.Persistence", "{806971A1-7D60-1CF4-54E4-4D8A00365384}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{BB76B5A5-14BA-E317-828D-110B711D71F5}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.ReachGraph.WebService.Tests", "StellaOps.ReachGraph.WebService.Tests", "{340D2663-3D7B-CA08-CF01-6DCB934727A1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.ReachGraph", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.ReachGraph\StellaOps.ReachGraph.csproj", "{7BD45F91-FD14-DE3E-F48F-B5DCDBADBCA3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.ReachGraph.Cache", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.ReachGraph.Cache\StellaOps.ReachGraph.Cache.csproj", "{62AFED36-9670-604C-8CBB-2AA89013BF66}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.ReachGraph.Persistence", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.ReachGraph.Persistence\StellaOps.ReachGraph.Persistence.csproj", "{086FC48B-BF6E-076B-2206-ACBDBBE4396D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.ReachGraph.WebService", "StellaOps.ReachGraph.WebService\StellaOps.ReachGraph.WebService.csproj", "{40FDEC75-B820-BFCB-6A77-D9F26462F06F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.ReachGraph.WebService.Tests", "__Tests\StellaOps.ReachGraph.WebService.Tests\StellaOps.ReachGraph.WebService.Tests.csproj", "{8DE28A8F-AB43-0F10-DAC4-C8B2E04D28F1}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{7BD45F91-FD14-DE3E-F48F-B5DCDBADBCA3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7BD45F91-FD14-DE3E-F48F-B5DCDBADBCA3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7BD45F91-FD14-DE3E-F48F-B5DCDBADBCA3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7BD45F91-FD14-DE3E-F48F-B5DCDBADBCA3}.Release|Any CPU.Build.0 = Release|Any CPU

View File

@@ -0,0 +1,305 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Collections.Immutable;
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.ReachGraph.Schema;
using StellaOps.ReachGraph.WebService.Models;
using Xunit;
namespace StellaOps.ReachGraph.WebService.Tests;
/// <summary>
/// Integration tests for the ReachGraph Store API.
/// Uses in-memory providers for quick testing without containers.
/// </summary>
public class ReachGraphApiIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
private const string TenantHeader = "X-Tenant-ID";
private const string TestTenant = "test-tenant";
public ReachGraphApiIntegrationTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
_client.DefaultRequestHeaders.Add(TenantHeader, TestTenant);
}
[Fact]
public async Task Upsert_ValidGraph_ReturnsCreated()
{
// Arrange
var graph = CreateSampleGraph();
var request = new UpsertReachGraphRequest { Graph = graph };
// Act
var response = await _client.PostAsJsonAsync("/v1/reachgraphs", request);
// Assert
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<UpsertReachGraphResponse>();
Assert.NotNull(result);
Assert.True(result.Created);
Assert.StartsWith("blake3:", result.Digest);
Assert.Equal(graph.Nodes.Length, result.NodeCount);
Assert.Equal(graph.Edges.Length, result.EdgeCount);
}
[Fact]
public async Task Upsert_SameGraph_ReturnsOk()
{
// Arrange
var graph = CreateSampleGraph();
var request = new UpsertReachGraphRequest { Graph = graph };
// Act - upsert twice
var response1 = await _client.PostAsJsonAsync("/v1/reachgraphs", request);
var response2 = await _client.PostAsJsonAsync("/v1/reachgraphs", request);
// Assert - first is Created, second is OK (idempotent)
Assert.Equal(HttpStatusCode.Created, response1.StatusCode);
Assert.Equal(HttpStatusCode.OK, response2.StatusCode);
var result1 = await response1.Content.ReadFromJsonAsync<UpsertReachGraphResponse>();
var result2 = await response2.Content.ReadFromJsonAsync<UpsertReachGraphResponse>();
Assert.True(result1!.Created);
Assert.False(result2!.Created);
Assert.Equal(result1.Digest, result2.Digest);
}
[Fact]
public async Task GetByDigest_ExistingGraph_ReturnsGraph()
{
// Arrange
var graph = CreateSampleGraph();
var upsertRequest = new UpsertReachGraphRequest { Graph = graph };
var upsertResponse = await _client.PostAsJsonAsync("/v1/reachgraphs", upsertRequest);
var upsertResult = await upsertResponse.Content.ReadFromJsonAsync<UpsertReachGraphResponse>();
// Act
var response = await _client.GetAsync($"/v1/reachgraphs/{upsertResult!.Digest}");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<ReachGraphMinimal>();
Assert.NotNull(result);
Assert.Equal(graph.SchemaVersion, result.SchemaVersion);
Assert.Equal(graph.Artifact.Name, result.Artifact.Name);
}
[Fact]
public async Task GetByDigest_NonExisting_ReturnsNotFound()
{
// Arrange
var nonExistingDigest = "blake3:0000000000000000000000000000000000000000000000000000000000000000";
// Act
var response = await _client.GetAsync($"/v1/reachgraphs/{nonExistingDigest}");
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task SliceByCve_ReturnsRelevantNodes()
{
// Arrange
var graph = CreateGraphWithCve();
var upsertRequest = new UpsertReachGraphRequest { Graph = graph };
var upsertResponse = await _client.PostAsJsonAsync("/v1/reachgraphs", upsertRequest);
var upsertResult = await upsertResponse.Content.ReadFromJsonAsync<UpsertReachGraphResponse>();
// Act
var response = await _client.GetAsync($"/v1/reachgraphs/{upsertResult!.Digest}/slice?cve=CVE-2024-1234");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<CveSliceResponse>();
Assert.NotNull(result);
Assert.Equal("cve", result.SliceQuery.Type);
Assert.Equal("CVE-2024-1234", result.SliceQuery.Cve);
}
[Fact]
public async Task SliceByPackage_ReturnsConnectedNodes()
{
// Arrange
var graph = CreateSampleGraph();
var upsertRequest = new UpsertReachGraphRequest { Graph = graph };
var upsertResponse = await _client.PostAsJsonAsync("/v1/reachgraphs", upsertRequest);
var upsertResult = await upsertResponse.Content.ReadFromJsonAsync<UpsertReachGraphResponse>();
// Act
var response = await _client.GetAsync(
$"/v1/reachgraphs/{upsertResult!.Digest}/slice?q=pkg:npm/*&depth=2");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<SliceQueryResponse>();
Assert.NotNull(result);
Assert.Equal("package", result.SliceQuery.Type);
}
[Fact]
public async Task Replay_MatchingInputs_ReturnsMatch()
{
// Arrange
var graph = CreateSampleGraph();
var upsertRequest = new UpsertReachGraphRequest { Graph = graph };
var upsertResponse = await _client.PostAsJsonAsync("/v1/reachgraphs", upsertRequest);
var upsertResult = await upsertResponse.Content.ReadFromJsonAsync<UpsertReachGraphResponse>();
var replayRequest = new ReplayRequest
{
ExpectedDigest = upsertResult!.Digest,
Inputs = new ReplayInputs
{
Sbom = graph.Provenance.Inputs.Sbom,
Vex = graph.Provenance.Inputs.Vex,
Callgraph = graph.Provenance.Inputs.Callgraph
}
};
// Act
var response = await _client.PostAsJsonAsync("/v1/reachgraphs/replay", replayRequest);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<ReplayResponse>();
Assert.NotNull(result);
Assert.True(result.Match);
Assert.Equal(upsertResult.Digest, result.ComputedDigest);
Assert.True(result.InputsVerified?.Sbom);
}
[Fact]
public async Task ListByArtifact_ReturnsSubgraphs()
{
// Arrange
var graph = CreateSampleGraph();
var upsertRequest = new UpsertReachGraphRequest { Graph = graph };
await _client.PostAsJsonAsync("/v1/reachgraphs", upsertRequest);
// Act
var response = await _client.GetAsync(
$"/v1/reachgraphs/by-artifact/{Uri.EscapeDataString(graph.Artifact.Digest)}");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<ListByArtifactResponse>();
Assert.NotNull(result);
Assert.True(result.TotalCount >= 1);
}
[Fact]
public async Task Delete_ExistingGraph_ReturnsNoContent()
{
// Arrange
var graph = CreateSampleGraph();
var upsertRequest = new UpsertReachGraphRequest { Graph = graph };
var upsertResponse = await _client.PostAsJsonAsync("/v1/reachgraphs", upsertRequest);
var upsertResult = await upsertResponse.Content.ReadFromJsonAsync<UpsertReachGraphResponse>();
// Act
var response = await _client.DeleteAsync($"/v1/reachgraphs/{upsertResult!.Digest}");
// Assert
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
// Verify it's gone
var getResponse = await _client.GetAsync($"/v1/reachgraphs/{upsertResult.Digest}");
Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode);
}
#region Helper Methods
private static ReachGraphMinimal CreateSampleGraph() => new()
{
SchemaVersion = "reachgraph.min@v1",
Artifact = new ReachGraphArtifact(
"test-app:v1.0.0",
"sha256:abc123def456789abc123def456789abc123def456789abc123def456789abc1",
["linux/amd64"]),
Scope = new ReachGraphScope(
["/app/main"],
["prod"]),
Nodes =
[
new ReachGraphNode
{
Id = "sha256:entry1",
Kind = ReachGraphNodeKind.Function,
Ref = "main()",
File = "src/main.ts",
Line = 1,
IsEntrypoint = true
},
new ReachGraphNode
{
Id = "sha256:pkg1",
Kind = ReachGraphNodeKind.Package,
Ref = "pkg:npm/lodash@4.17.21"
},
new ReachGraphNode
{
Id = "sha256:sink1",
Kind = ReachGraphNodeKind.Function,
Ref = "lodash.template()",
File = "node_modules/lodash/template.js",
Line = 100,
IsSink = true
}
],
Edges =
[
new ReachGraphEdge
{
From = "sha256:entry1",
To = "sha256:pkg1",
Why = new EdgeExplanation
{
Type = EdgeExplanationType.Import,
Loc = "src/main.ts:3",
Confidence = 1.0
}
},
new ReachGraphEdge
{
From = "sha256:pkg1",
To = "sha256:sink1",
Why = new EdgeExplanation
{
Type = EdgeExplanationType.Import,
Confidence = 1.0
}
}
],
Provenance = new ReachGraphProvenance
{
Inputs = new ReachGraphInputs
{
Sbom = "sha256:sbom123abc456def789",
Vex = "sha256:vex456def789abc123",
Callgraph = "sha256:cg789abc123def456"
},
ComputedAt = DateTimeOffset.UtcNow,
Analyzer = new ReachGraphAnalyzer(
"stellaops-scanner",
"1.0.0",
"sha256:toolchain123456789")
}
};
private static ReachGraphMinimal CreateGraphWithCve() => CreateSampleGraph() with
{
Scope = new ReachGraphScope(
["/app/main"],
["prod"],
["CVE-2024-1234"])
};
#endregion
}

View File

@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<RootNamespace>StellaOps.ReachGraph.WebService.Tests</RootNamespace>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Testcontainers.PostgreSql" />
<PackageReference Include="Testcontainers.Redis" />
<PackageReference Include="xunit.runner.visualstudio" >
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" >
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.ReachGraph.WebService\StellaOps.ReachGraph.WebService.csproj" />
</ItemGroup>
</Project>