Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
120
src/ReachGraph/StellaOps.ReachGraph.WebService/Program.cs
Normal file
120
src/ReachGraph/StellaOps.ReachGraph.WebService/Program.cs
Normal 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 { }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"StellaOps.ReachGraph.WebService": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:49951;http://localhost:49952"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
712
src/ReachGraph/StellaOps.ReachGraph.WebService/openapi.yaml
Normal file
712
src/ReachGraph/StellaOps.ReachGraph.WebService/openapi.yaml
Normal 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
|
||||
76
src/ReachGraph/StellaOps.ReachGraph.sln
Normal file
76
src/ReachGraph/StellaOps.ReachGraph.sln
Normal 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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user