- Implemented Attestation Chain API client with methods for verifying, fetching, and managing attestation chains. - Created models for Attestation Chain, including DSSE envelope structures and verification results. - Developed Triage Evidence API client for fetching finding evidence, including methods for evidence retrieval by CVE and component. - Added models for Triage Evidence, encapsulating evidence responses, entry points, boundary proofs, and VEX evidence. - Introduced mock implementations for both API clients to facilitate testing and development.
938 lines
34 KiB
C#
938 lines
34 KiB
C#
using System.IO;
|
|
using System.Net.Http;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Options;
|
|
using NetEscapades.Configuration.Yaml;
|
|
using StellaOps.Auth.Abstractions;
|
|
using StellaOps.Auth.ServerIntegration;
|
|
using StellaOps.Configuration;
|
|
using StellaOps.Signals.Authentication;
|
|
using StellaOps.Signals.Hosting;
|
|
using StellaOps.Signals.Models;
|
|
using StellaOps.Signals.Options;
|
|
using StellaOps.Signals.Parsing;
|
|
using StellaOps.Signals.Persistence;
|
|
using StellaOps.Signals.Routing;
|
|
using StellaOps.Signals.Services;
|
|
using StellaOps.Signals.Storage;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
builder.Configuration.AddStellaOpsDefaults(options =>
|
|
{
|
|
options.BasePath = builder.Environment.ContentRootPath;
|
|
options.EnvironmentPrefix = "SIGNALS_";
|
|
options.ConfigureBuilder = configurationBuilder =>
|
|
{
|
|
var contentRoot = builder.Environment.ContentRootPath;
|
|
foreach (var relative in new[]
|
|
{
|
|
"../etc/signals.yaml",
|
|
"../etc/signals.local.yaml",
|
|
"signals.yaml",
|
|
"signals.local.yaml"
|
|
})
|
|
{
|
|
var path = Path.Combine(contentRoot, relative);
|
|
configurationBuilder.AddYamlFile(path, optional: true);
|
|
}
|
|
};
|
|
});
|
|
|
|
var bootstrap = builder.Configuration.BindOptions<SignalsOptions>(
|
|
SignalsOptions.SectionName,
|
|
static (options, _) =>
|
|
{
|
|
SignalsAuthorityOptionsConfigurator.ApplyDefaults(options.Authority);
|
|
options.Validate();
|
|
});
|
|
|
|
builder.Services.AddOptions<SignalsOptions>()
|
|
.Bind(builder.Configuration.GetSection(SignalsOptions.SectionName))
|
|
.PostConfigure(static options =>
|
|
{
|
|
SignalsAuthorityOptionsConfigurator.ApplyDefaults(options.Authority);
|
|
options.Validate();
|
|
})
|
|
.Validate(static options =>
|
|
{
|
|
try
|
|
{
|
|
options.Validate();
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
throw new OptionsValidationException(
|
|
SignalsOptions.SectionName,
|
|
typeof(SignalsOptions),
|
|
new[] { ex.Message });
|
|
}
|
|
})
|
|
.ValidateOnStart();
|
|
|
|
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<SignalsOptions>>().Value);
|
|
builder.Services.AddSingleton<SignalsStartupState>();
|
|
builder.Services.AddSingleton(TimeProvider.System);
|
|
builder.Services.AddSingleton<SignalsSealedModeMonitor>();
|
|
builder.Services.AddProblemDetails();
|
|
builder.Services.AddHealthChecks();
|
|
builder.Services.AddRouting(options => options.LowercaseUrls = true);
|
|
|
|
builder.Services.AddSingleton<ICallgraphRepository, InMemoryCallgraphRepository>();
|
|
builder.Services.AddSingleton<ICallgraphNormalizationService, CallgraphNormalizationService>();
|
|
builder.Services.AddSingleton<ICallGraphProjectionRepository, InMemoryCallGraphProjectionRepository>();
|
|
|
|
// Configure callgraph artifact storage based on driver
|
|
if (bootstrap.Storage.IsRustFsDriver())
|
|
{
|
|
// Configure HttpClient for RustFS
|
|
builder.Services.AddHttpClient(RustFsCallgraphArtifactStore.HttpClientName, (sp, client) =>
|
|
{
|
|
var opts = sp.GetRequiredService<IOptions<SignalsOptions>>().Value;
|
|
client.Timeout = opts.Storage.RustFs.Timeout;
|
|
})
|
|
.ConfigurePrimaryHttpMessageHandler(sp =>
|
|
{
|
|
var opts = sp.GetRequiredService<IOptions<SignalsOptions>>().Value;
|
|
var handler = new HttpClientHandler();
|
|
if (opts.Storage.RustFs.AllowInsecureTls)
|
|
{
|
|
handler.ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
|
|
}
|
|
return handler;
|
|
});
|
|
|
|
builder.Services.AddSingleton<ICallgraphArtifactStore, RustFsCallgraphArtifactStore>();
|
|
}
|
|
else
|
|
{
|
|
builder.Services.AddSingleton<ICallgraphArtifactStore, FileSystemCallgraphArtifactStore>();
|
|
}
|
|
|
|
builder.Services.AddSingleton<ICallgraphParser>(new SimpleJsonCallgraphParser("java"));
|
|
builder.Services.AddSingleton<ICallgraphParser>(new SimpleJsonCallgraphParser("nodejs"));
|
|
builder.Services.AddSingleton<ICallgraphParser>(new SimpleJsonCallgraphParser("python"));
|
|
builder.Services.AddSingleton<ICallgraphParser>(new SimpleJsonCallgraphParser("go"));
|
|
builder.Services.AddSingleton<ICallgraphParserResolver, CallgraphParserResolver>();
|
|
builder.Services.AddSingleton<ICallgraphIngestionService, CallgraphIngestionService>();
|
|
builder.Services.AddSingleton<ICallGraphSyncService, CallGraphSyncService>();
|
|
builder.Services.AddSingleton<IReachabilityCache>(sp =>
|
|
{
|
|
var options = sp.GetRequiredService<IOptions<SignalsOptions>>().Value;
|
|
return new RedisReachabilityCache(options.Cache);
|
|
});
|
|
builder.Services.AddSingleton<IRedisConnectionFactory, RedisConnectionFactory>();
|
|
builder.Services.AddSingleton<ReachabilityFactEventBuilder>();
|
|
builder.Services.AddSingleton<InMemoryReachabilityFactRepository>();
|
|
builder.Services.AddSingleton<IReachabilityFactRepository>(sp =>
|
|
{
|
|
var inner = sp.GetRequiredService<InMemoryReachabilityFactRepository>();
|
|
var cache = sp.GetRequiredService<IReachabilityCache>();
|
|
return new ReachabilityFactCacheDecorator(inner, cache);
|
|
});
|
|
builder.Services.AddSingleton<IUnknownsRepository, InMemoryUnknownsRepository>();
|
|
builder.Services.AddOptions<UnknownsScoringOptions>()
|
|
.Bind(builder.Configuration.GetSection(UnknownsScoringOptions.SectionName));
|
|
builder.Services.AddOptions<UnknownsDecayOptions>()
|
|
.Bind(builder.Configuration.GetSection(UnknownsDecayOptions.SectionName));
|
|
builder.Services.AddSingleton<IDeploymentRefsRepository, InMemoryDeploymentRefsRepository>();
|
|
builder.Services.AddSingleton<IGraphMetricsRepository, InMemoryGraphMetricsRepository>();
|
|
builder.Services.AddSingleton<IUnknownsScoringService, UnknownsScoringService>();
|
|
builder.Services.AddSingleton<IUnknownsDecayService, UnknownsDecayService>();
|
|
builder.Services.AddSingleton<ISignalRefreshService, SignalRefreshService>();
|
|
builder.Services.AddHostedService<NightlyDecayWorker>();
|
|
builder.Services.AddSingleton<IReachabilityStoreRepository, InMemoryReachabilityStoreRepository>();
|
|
builder.Services.AddHttpClient<RouterEventsPublisher>((sp, client) =>
|
|
{
|
|
var opts = sp.GetRequiredService<SignalsOptions>().Events.Router;
|
|
if (Uri.TryCreate(opts.BaseUrl, UriKind.Absolute, out var baseUri))
|
|
{
|
|
client.BaseAddress = baseUri;
|
|
}
|
|
|
|
if (opts.TimeoutSeconds > 0)
|
|
{
|
|
client.Timeout = TimeSpan.FromSeconds(opts.TimeoutSeconds);
|
|
}
|
|
|
|
client.DefaultRequestHeaders.ConnectionClose = false;
|
|
}).ConfigurePrimaryHttpMessageHandler(sp =>
|
|
{
|
|
var opts = sp.GetRequiredService<SignalsOptions>().Events.Router;
|
|
var handler = new HttpClientHandler();
|
|
if (opts.AllowInsecureTls)
|
|
{
|
|
handler.ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
|
|
}
|
|
|
|
return handler;
|
|
});
|
|
builder.Services.AddSingleton<IEventsPublisher>(sp =>
|
|
{
|
|
var options = sp.GetRequiredService<SignalsOptions>();
|
|
var eventBuilder = sp.GetRequiredService<ReachabilityFactEventBuilder>();
|
|
|
|
if (!options.Events.Enabled)
|
|
{
|
|
return new NullEventsPublisher();
|
|
}
|
|
|
|
if (string.Equals(options.Events.Driver, "redis", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return new RedisEventsPublisher(
|
|
options,
|
|
sp.GetRequiredService<IRedisConnectionFactory>(),
|
|
eventBuilder,
|
|
sp.GetRequiredService<ILogger<RedisEventsPublisher>>());
|
|
}
|
|
|
|
if (string.Equals(options.Events.Driver, "router", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return sp.GetRequiredService<RouterEventsPublisher>();
|
|
}
|
|
|
|
return new InMemoryEventsPublisher(
|
|
sp.GetRequiredService<ILogger<InMemoryEventsPublisher>>(),
|
|
eventBuilder);
|
|
});
|
|
builder.Services.AddSingleton<IReachabilityScoringService, ReachabilityScoringService>();
|
|
builder.Services.AddSingleton<IScoreExplanationService, ScoreExplanationService>(); // Sprint: SPRINT_3800_0001_0002
|
|
builder.Services.AddSingleton<IRuntimeFactsProvenanceNormalizer, RuntimeFactsProvenanceNormalizer>();
|
|
builder.Services.AddSingleton<IRuntimeFactsIngestionService, RuntimeFactsIngestionService>();
|
|
builder.Services.AddSingleton<IReachabilityUnionIngestionService, ReachabilityUnionIngestionService>();
|
|
builder.Services.AddSingleton<IUnknownsIngestionService, UnknownsIngestionService>();
|
|
builder.Services.AddSingleton<SyntheticRuntimeProbeBuilder>();
|
|
|
|
if (bootstrap.Authority.Enabled)
|
|
{
|
|
builder.Services.AddHttpContextAccessor();
|
|
builder.Services.AddStellaOpsScopeHandler();
|
|
builder.Services.AddAuthorization(options =>
|
|
{
|
|
options.AddStellaOpsScopePolicy(SignalsPolicies.Read, SignalsPolicies.Read);
|
|
options.AddStellaOpsScopePolicy(SignalsPolicies.Write, SignalsPolicies.Write);
|
|
options.AddStellaOpsScopePolicy(SignalsPolicies.Admin, SignalsPolicies.Admin);
|
|
});
|
|
builder.Services.AddStellaOpsResourceServerAuthentication(
|
|
builder.Configuration,
|
|
configurationSection: $"{SignalsOptions.SectionName}:Authority",
|
|
configure: resourceOptions =>
|
|
{
|
|
resourceOptions.Authority = bootstrap.Authority.Issuer;
|
|
resourceOptions.RequireHttpsMetadata = bootstrap.Authority.RequireHttpsMetadata;
|
|
resourceOptions.MetadataAddress = bootstrap.Authority.MetadataAddress;
|
|
resourceOptions.BackchannelTimeout = TimeSpan.FromSeconds(bootstrap.Authority.BackchannelTimeoutSeconds);
|
|
resourceOptions.TokenClockSkew = TimeSpan.FromSeconds(bootstrap.Authority.TokenClockSkewSeconds);
|
|
|
|
resourceOptions.Audiences.Clear();
|
|
foreach (var audience in bootstrap.Authority.Audiences)
|
|
{
|
|
resourceOptions.Audiences.Add(audience);
|
|
}
|
|
|
|
resourceOptions.RequiredScopes.Clear();
|
|
foreach (var scope in bootstrap.Authority.RequiredScopes)
|
|
{
|
|
resourceOptions.RequiredScopes.Add(scope);
|
|
}
|
|
|
|
foreach (var tenant in bootstrap.Authority.RequiredTenants)
|
|
{
|
|
resourceOptions.RequiredTenants.Add(tenant);
|
|
}
|
|
|
|
foreach (var network in bootstrap.Authority.BypassNetworks)
|
|
{
|
|
resourceOptions.BypassNetworks.Add(network);
|
|
}
|
|
});
|
|
|
|
}
|
|
else
|
|
{
|
|
builder.Services.AddAuthorization();
|
|
builder.Services.AddAuthentication(options =>
|
|
{
|
|
options.DefaultAuthenticateScheme = "Anonymous";
|
|
options.DefaultChallengeScheme = "Anonymous";
|
|
}).AddScheme<AuthenticationSchemeOptions, AnonymousAuthenticationHandler>("Anonymous", static _ => { });
|
|
}
|
|
|
|
var app = builder.Build();
|
|
|
|
if (!bootstrap.Authority.Enabled)
|
|
{
|
|
app.Logger.LogWarning("Signals Authority authentication is disabled; relying on header-based development fallback.");
|
|
}
|
|
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
|
|
app.MapHealthChecks("/healthz").AllowAnonymous();
|
|
app.MapGet("/readyz", (SignalsStartupState state, SignalsSealedModeMonitor sealedModeMonitor) =>
|
|
{
|
|
if (!sealedModeMonitor.IsCompliant(out var reason))
|
|
{
|
|
return Results.Json(
|
|
new { status = "sealed-mode-blocked", reason },
|
|
statusCode: StatusCodes.Status503ServiceUnavailable);
|
|
}
|
|
|
|
return state.IsReady
|
|
? Results.Ok(new { status = "ready" })
|
|
: Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
|
}).AllowAnonymous();
|
|
|
|
var signalsGroup = app.MapGroup("/signals");
|
|
|
|
signalsGroup.MapGet("/ping", (HttpContext context, SignalsOptions options, SignalsSealedModeMonitor sealedModeMonitor) =>
|
|
{
|
|
if (!Program.TryAuthorize(context, requiredScope: SignalsPolicies.Read, fallbackAllowed: options.Authority.AllowAnonymousFallback, out var authFailure))
|
|
{
|
|
return authFailure ?? Results.Unauthorized();
|
|
}
|
|
|
|
if (!Program.TryEnsureSealedMode(sealedModeMonitor, out var sealedFailure))
|
|
{
|
|
return sealedFailure ?? Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
|
}
|
|
|
|
return Results.NoContent();
|
|
}).WithName("SignalsPing");
|
|
|
|
signalsGroup.MapGet("/status", (HttpContext context, SignalsOptions options, SignalsSealedModeMonitor sealedModeMonitor) =>
|
|
{
|
|
if (!Program.TryAuthorize(context, SignalsPolicies.Read, options.Authority.AllowAnonymousFallback, out var failure))
|
|
{
|
|
return failure ?? Results.Unauthorized();
|
|
}
|
|
|
|
var sealedCompliant = sealedModeMonitor.IsCompliant(out var sealedReason);
|
|
return Results.Ok(new
|
|
{
|
|
service = "signals",
|
|
version = typeof(Program).Assembly.GetName().Version?.ToString() ?? "unknown",
|
|
sealedMode = new
|
|
{
|
|
enforced = sealedModeMonitor.EnforcementEnabled,
|
|
compliant = sealedCompliant,
|
|
reason = sealedCompliant ? null : sealedReason
|
|
}
|
|
});
|
|
}).WithName("SignalsStatus");
|
|
|
|
signalsGroup.MapPost("/callgraphs", async Task<IResult> (
|
|
HttpContext context,
|
|
SignalsOptions options,
|
|
CallgraphIngestRequest request,
|
|
ICallgraphIngestionService ingestionService,
|
|
SignalsSealedModeMonitor sealedModeMonitor,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!Program.TryAuthorize(context, SignalsPolicies.Write, options.Authority.AllowAnonymousFallback, out var authFailure))
|
|
{
|
|
return authFailure ?? Results.Unauthorized();
|
|
}
|
|
|
|
if (!Program.TryEnsureSealedMode(sealedModeMonitor, out var sealedFailure))
|
|
{
|
|
return sealedFailure ?? Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
|
}
|
|
|
|
try
|
|
{
|
|
var result = await ingestionService.IngestAsync(request, cancellationToken).ConfigureAwait(false);
|
|
return Results.Accepted($"/signals/callgraphs/{result.CallgraphId}", result);
|
|
}
|
|
catch (CallgraphIngestionValidationException ex)
|
|
{
|
|
return Results.BadRequest(new { error = ex.Message });
|
|
}
|
|
catch (CallgraphParserNotFoundException ex)
|
|
{
|
|
return Results.BadRequest(new { error = ex.Message });
|
|
}
|
|
catch (CallgraphParserValidationException ex)
|
|
{
|
|
return Results.UnprocessableEntity(new { error = ex.Message });
|
|
}
|
|
catch (FormatException ex)
|
|
{
|
|
return Results.BadRequest(new { error = ex.Message });
|
|
}
|
|
}).WithName("SignalsCallgraphIngest");
|
|
|
|
signalsGroup.MapGet("/callgraphs/{callgraphId}", async Task<IResult> (
|
|
HttpContext context,
|
|
SignalsOptions options,
|
|
string callgraphId,
|
|
ICallgraphRepository callgraphRepository,
|
|
SignalsSealedModeMonitor sealedModeMonitor,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!Program.TryAuthorize(context, SignalsPolicies.Read, options.Authority.AllowAnonymousFallback, out var authFailure))
|
|
{
|
|
return authFailure ?? Results.Unauthorized();
|
|
}
|
|
|
|
if (!Program.TryEnsureSealedMode(sealedModeMonitor, out var sealedFailure))
|
|
{
|
|
return sealedFailure ?? Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(callgraphId))
|
|
{
|
|
return Results.BadRequest(new { error = "callgraphId is required." });
|
|
}
|
|
|
|
var document = await callgraphRepository.GetByIdAsync(callgraphId.Trim(), cancellationToken).ConfigureAwait(false);
|
|
return document is null ? Results.NotFound() : Results.Ok(document);
|
|
}).WithName("SignalsCallgraphGet");
|
|
|
|
signalsGroup.MapGet("/callgraphs/{callgraphId}/manifest", async Task<IResult> (
|
|
HttpContext context,
|
|
SignalsOptions options,
|
|
string callgraphId,
|
|
ICallgraphRepository callgraphRepository,
|
|
SignalsSealedModeMonitor sealedModeMonitor,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!Program.TryAuthorize(context, SignalsPolicies.Read, options.Authority.AllowAnonymousFallback, out var authFailure))
|
|
{
|
|
return authFailure ?? Results.Unauthorized();
|
|
}
|
|
|
|
if (!Program.TryEnsureSealedMode(sealedModeMonitor, out var sealedFailure))
|
|
{
|
|
return sealedFailure ?? Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(callgraphId))
|
|
{
|
|
return Results.BadRequest(new { error = "callgraphId is required." });
|
|
}
|
|
|
|
var document = await callgraphRepository.GetByIdAsync(callgraphId.Trim(), cancellationToken).ConfigureAwait(false);
|
|
if (document is null || string.IsNullOrWhiteSpace(document.Artifact.ManifestPath))
|
|
{
|
|
return Results.NotFound();
|
|
}
|
|
|
|
var manifestPath = Path.Combine(options.Storage.RootPath, document.Artifact.ManifestPath);
|
|
if (!File.Exists(manifestPath))
|
|
{
|
|
return Results.NotFound(new { error = "manifest not found" });
|
|
}
|
|
|
|
var bytes = await File.ReadAllBytesAsync(manifestPath, cancellationToken).ConfigureAwait(false);
|
|
return Results.File(bytes, "application/json");
|
|
}).WithName("SignalsCallgraphManifestGet");
|
|
|
|
signalsGroup.MapPost("/runtime-facts", async Task<IResult> (
|
|
HttpContext context,
|
|
SignalsOptions options,
|
|
RuntimeFactsIngestRequest request,
|
|
IRuntimeFactsIngestionService ingestionService,
|
|
SignalsSealedModeMonitor sealedModeMonitor,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!Program.TryAuthorize(context, SignalsPolicies.Write, options.Authority.AllowAnonymousFallback, out var authFailure))
|
|
{
|
|
return authFailure ?? Results.Unauthorized();
|
|
}
|
|
|
|
if (!Program.TryEnsureSealedMode(sealedModeMonitor, out var sealedFailure))
|
|
{
|
|
return sealedFailure ?? Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
|
}
|
|
|
|
try
|
|
{
|
|
var response = await ingestionService.IngestAsync(request, cancellationToken).ConfigureAwait(false);
|
|
return Results.Accepted($"/signals/runtime-facts/{response.SubjectKey}", response);
|
|
}
|
|
catch (RuntimeFactsValidationException ex)
|
|
{
|
|
return Results.BadRequest(new { error = ex.Message });
|
|
}
|
|
}).WithName("SignalsRuntimeIngest");
|
|
|
|
signalsGroup.MapPost("/runtime-facts/synthetic", async Task<IResult> (
|
|
HttpContext context,
|
|
SignalsOptions options,
|
|
SyntheticRuntimeProbeRequest request,
|
|
ICallgraphRepository callgraphRepository,
|
|
IRuntimeFactsIngestionService ingestionService,
|
|
SyntheticRuntimeProbeBuilder probeBuilder,
|
|
SignalsSealedModeMonitor sealedModeMonitor,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!Program.TryAuthorize(context, SignalsPolicies.Write, options.Authority.AllowAnonymousFallback, out var authFailure))
|
|
{
|
|
return authFailure ?? Results.Unauthorized();
|
|
}
|
|
|
|
if (!Program.TryEnsureSealedMode(sealedModeMonitor, out var sealedFailure))
|
|
{
|
|
return sealedFailure ?? Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(request.CallgraphId))
|
|
{
|
|
return Results.BadRequest(new { error = "callgraphId is required." });
|
|
}
|
|
|
|
var callgraph = await callgraphRepository.GetByIdAsync(request.CallgraphId.Trim(), cancellationToken).ConfigureAwait(false);
|
|
if (callgraph is null)
|
|
{
|
|
return Results.NotFound(new { error = "callgraph not found." });
|
|
}
|
|
|
|
var subject = request.Subject ?? new ReachabilitySubject { ScanId = $"synthetic-{callgraph.Id}" };
|
|
var events = probeBuilder.BuildEvents(callgraph, request.EventCount);
|
|
var metadata = request.Metadata is null
|
|
? new Dictionary<string, string?>(StringComparer.Ordinal)
|
|
: new Dictionary<string, string?>(request.Metadata, StringComparer.Ordinal);
|
|
metadata.TryAdd("source", "synthetic-probe");
|
|
|
|
var ingestRequest = new RuntimeFactsIngestRequest
|
|
{
|
|
CallgraphId = callgraph.Id,
|
|
Subject = subject,
|
|
Events = events,
|
|
Metadata = metadata
|
|
};
|
|
|
|
var response = await ingestionService.IngestAsync(ingestRequest, cancellationToken).ConfigureAwait(false);
|
|
return Results.Accepted($"/signals/runtime-facts/{response.SubjectKey}", response);
|
|
}).WithName("SignalsRuntimeIngestSynthetic");
|
|
|
|
signalsGroup.MapPost("/reachability/union", async Task<IResult> (
|
|
HttpContext context,
|
|
SignalsOptions options,
|
|
[FromHeader(Name = "X-Analysis-Id")] string? analysisId,
|
|
IReachabilityUnionIngestionService ingestionService,
|
|
SignalsSealedModeMonitor sealedModeMonitor,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!Program.TryAuthorize(context, SignalsPolicies.Write, options.Authority.AllowAnonymousFallback, out var authFailure))
|
|
{
|
|
return authFailure ?? Results.Unauthorized();
|
|
}
|
|
|
|
if (!Program.TryEnsureSealedMode(sealedModeMonitor, out var sealedFailure))
|
|
{
|
|
return sealedFailure ?? Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
|
}
|
|
|
|
var id = string.IsNullOrWhiteSpace(analysisId) ? Guid.NewGuid().ToString("N") : analysisId.Trim();
|
|
|
|
if (!string.Equals(context.Request.ContentType, "application/zip", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return Results.BadRequest(new { error = "Content-Type must be application/zip" });
|
|
}
|
|
|
|
try
|
|
{
|
|
var response = await ingestionService.IngestAsync(id, context.Request.Body, cancellationToken).ConfigureAwait(false);
|
|
return Results.Accepted($"/signals/reachability/union/{response.AnalysisId}/meta", response);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return Results.BadRequest(new { error = ex.Message });
|
|
}
|
|
}).WithName("SignalsReachabilityUnionIngest");
|
|
|
|
signalsGroup.MapGet("/reachability/union/{analysisId}/meta", async Task<IResult> (
|
|
HttpContext context,
|
|
SignalsOptions options,
|
|
string analysisId,
|
|
SignalsSealedModeMonitor sealedModeMonitor,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!Program.TryAuthorize(context, SignalsPolicies.Read, options.Authority.AllowAnonymousFallback, out var authFailure))
|
|
{
|
|
return authFailure ?? Results.Unauthorized();
|
|
}
|
|
|
|
if (!Program.TryEnsureSealedMode(sealedModeMonitor, out var sealedFailure))
|
|
{
|
|
return sealedFailure ?? Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(analysisId))
|
|
{
|
|
return Results.BadRequest(new { error = "analysisId is required." });
|
|
}
|
|
|
|
var path = Path.Combine(options.Storage.RootPath, "reachability_graphs", analysisId.Trim(), "meta.json");
|
|
if (!File.Exists(path))
|
|
{
|
|
return Results.NotFound();
|
|
}
|
|
|
|
var bytes = await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false);
|
|
return Results.File(bytes, "application/json");
|
|
}).WithName("SignalsReachabilityUnionMeta");
|
|
|
|
signalsGroup.MapGet("/reachability/union/{analysisId}/files/{fileName}", async Task<IResult> (
|
|
HttpContext context,
|
|
SignalsOptions options,
|
|
string analysisId,
|
|
string fileName,
|
|
SignalsSealedModeMonitor sealedModeMonitor,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!Program.TryAuthorize(context, SignalsPolicies.Read, options.Authority.AllowAnonymousFallback, out var authFailure))
|
|
{
|
|
return authFailure ?? Results.Unauthorized();
|
|
}
|
|
|
|
if (!Program.TryEnsureSealedMode(sealedModeMonitor, out var sealedFailure))
|
|
{
|
|
return sealedFailure ?? Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(analysisId) || string.IsNullOrWhiteSpace(fileName))
|
|
{
|
|
return Results.BadRequest(new { error = "analysisId and fileName are required." });
|
|
}
|
|
|
|
var root = Path.Combine(options.Storage.RootPath, "reachability_graphs", analysisId.Trim());
|
|
var path = Path.Combine(root, fileName.Replace('/', Path.DirectorySeparatorChar));
|
|
if (!File.Exists(path))
|
|
{
|
|
return Results.NotFound();
|
|
}
|
|
|
|
var contentType = fileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase) ? "application/json" : "application/x-ndjson";
|
|
var bytes = await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false);
|
|
return Results.File(bytes, contentType);
|
|
}).WithName("SignalsReachabilityUnionFile");
|
|
|
|
signalsGroup.MapPost("/runtime-facts/ndjson", async Task<IResult> (
|
|
HttpContext context,
|
|
SignalsOptions options,
|
|
[AsParameters] RuntimeFactsStreamMetadata metadata,
|
|
IRuntimeFactsIngestionService ingestionService,
|
|
SignalsSealedModeMonitor sealedModeMonitor,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!Program.TryAuthorize(context, SignalsPolicies.Write, options.Authority.AllowAnonymousFallback, out var authFailure))
|
|
{
|
|
return authFailure ?? Results.Unauthorized();
|
|
}
|
|
|
|
if (!Program.TryEnsureSealedMode(sealedModeMonitor, out var sealedFailure))
|
|
{
|
|
return sealedFailure ?? Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
|
}
|
|
|
|
if (metadata is null || string.IsNullOrWhiteSpace(metadata.CallgraphId))
|
|
{
|
|
return Results.BadRequest(new { error = "callgraphId is required." });
|
|
}
|
|
|
|
var subject = metadata.ToSubject();
|
|
|
|
var isGzip = string.Equals(context.Request.Headers.ContentEncoding, "gzip", StringComparison.OrdinalIgnoreCase);
|
|
var events = await RuntimeFactsNdjsonReader.ReadAsync(context.Request.Body, isGzip, cancellationToken).ConfigureAwait(false);
|
|
if (events.Count == 0)
|
|
{
|
|
return Results.BadRequest(new { error = "runtime fact stream was empty." });
|
|
}
|
|
|
|
var request = new RuntimeFactsIngestRequest
|
|
{
|
|
Subject = subject,
|
|
CallgraphId = metadata.CallgraphId,
|
|
Events = events
|
|
};
|
|
|
|
try
|
|
{
|
|
var response = await ingestionService.IngestAsync(request, cancellationToken).ConfigureAwait(false);
|
|
return Results.Accepted($"/signals/runtime-facts/{response.SubjectKey}", response);
|
|
}
|
|
catch (RuntimeFactsValidationException ex)
|
|
{
|
|
return Results.BadRequest(new { error = ex.Message });
|
|
}
|
|
}).WithName("SignalsRuntimeIngestNdjson");
|
|
|
|
signalsGroup.MapGet("/facts/{subjectKey}", async Task<IResult> (
|
|
HttpContext context,
|
|
SignalsOptions options,
|
|
string subjectKey,
|
|
IReachabilityFactRepository factRepository,
|
|
SignalsSealedModeMonitor sealedModeMonitor,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!Program.TryAuthorize(context, SignalsPolicies.Read, options.Authority.AllowAnonymousFallback, out var authFailure))
|
|
{
|
|
return authFailure ?? Results.Unauthorized();
|
|
}
|
|
|
|
if (!Program.TryEnsureSealedMode(sealedModeMonitor, out var sealedFailure))
|
|
{
|
|
return sealedFailure ?? Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(subjectKey))
|
|
{
|
|
return Results.BadRequest(new { error = "subjectKey is required." });
|
|
}
|
|
|
|
var fact = await factRepository.GetBySubjectAsync(subjectKey.Trim(), cancellationToken).ConfigureAwait(false);
|
|
return fact is null ? Results.NotFound() : Results.Ok(fact);
|
|
}).WithName("SignalsFactsGet");
|
|
|
|
signalsGroup.MapPost("/unknowns", async Task<IResult> (
|
|
HttpContext context,
|
|
SignalsOptions options,
|
|
UnknownsIngestRequest request,
|
|
IUnknownsIngestionService ingestionService,
|
|
SignalsSealedModeMonitor sealedModeMonitor,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!Program.TryAuthorize(context, SignalsPolicies.Write, options.Authority.AllowAnonymousFallback, out var authFailure))
|
|
{
|
|
return authFailure ?? Results.Unauthorized();
|
|
}
|
|
|
|
if (!Program.TryEnsureSealedMode(sealedModeMonitor, out var sealedFailure))
|
|
{
|
|
return sealedFailure ?? Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
|
}
|
|
|
|
try
|
|
{
|
|
var response = await ingestionService.IngestAsync(request, cancellationToken).ConfigureAwait(false);
|
|
return Results.Accepted($"/signals/unknowns/{response.SubjectKey}", response);
|
|
}
|
|
catch (UnknownsValidationException ex)
|
|
{
|
|
return Results.BadRequest(new { error = ex.Message });
|
|
}
|
|
}).WithName("SignalsUnknownsIngest");
|
|
|
|
signalsGroup.MapGet("/unknowns/{subjectKey}", async Task<IResult> (
|
|
HttpContext context,
|
|
SignalsOptions options,
|
|
string subjectKey,
|
|
IUnknownsRepository repository,
|
|
SignalsSealedModeMonitor sealedModeMonitor,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!Program.TryAuthorize(context, SignalsPolicies.Read, options.Authority.AllowAnonymousFallback, out var authFailure))
|
|
{
|
|
return authFailure ?? Results.Unauthorized();
|
|
}
|
|
|
|
if (!Program.TryEnsureSealedMode(sealedModeMonitor, out var sealedFailure))
|
|
{
|
|
return sealedFailure ?? Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(subjectKey))
|
|
{
|
|
return Results.BadRequest(new { error = "subjectKey is required." });
|
|
}
|
|
|
|
var items = await repository.GetBySubjectAsync(subjectKey.Trim(), cancellationToken).ConfigureAwait(false);
|
|
return items.Count == 0 ? Results.NotFound() : Results.Ok(items);
|
|
}).WithName("SignalsUnknownsGet");
|
|
|
|
signalsGroup.MapGet("/unknowns", async Task<IResult> (
|
|
HttpContext context,
|
|
SignalsOptions options,
|
|
IUnknownsRepository repository,
|
|
SignalsSealedModeMonitor sealedModeMonitor,
|
|
[FromQuery] string? band,
|
|
[FromQuery] int limit = 100,
|
|
[FromQuery] int offset = 0,
|
|
CancellationToken cancellationToken = default) =>
|
|
{
|
|
if (!Program.TryAuthorize(context, SignalsPolicies.Read, options.Authority.AllowAnonymousFallback, out var authFailure))
|
|
{
|
|
return authFailure ?? Results.Unauthorized();
|
|
}
|
|
|
|
if (!Program.TryEnsureSealedMode(sealedModeMonitor, out var sealedFailure))
|
|
{
|
|
return sealedFailure ?? Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
|
}
|
|
|
|
limit = Math.Clamp(limit, 1, 1000);
|
|
offset = Math.Max(0, offset);
|
|
|
|
UnknownsBand? bandFilter = null;
|
|
if (!string.IsNullOrWhiteSpace(band) && Enum.TryParse<UnknownsBand>(band, ignoreCase: true, out var parsedBand))
|
|
{
|
|
bandFilter = parsedBand;
|
|
}
|
|
|
|
var items = await repository.QueryAsync(bandFilter, limit, offset, cancellationToken).ConfigureAwait(false);
|
|
return Results.Ok(new
|
|
{
|
|
items,
|
|
count = items.Count,
|
|
limit,
|
|
offset,
|
|
band = bandFilter?.ToString().ToLowerInvariant()
|
|
});
|
|
}).WithName("SignalsUnknownsQuery");
|
|
|
|
signalsGroup.MapGet("/unknowns/{id}/explain", async Task<IResult> (
|
|
HttpContext context,
|
|
SignalsOptions options,
|
|
string id,
|
|
IUnknownsRepository repository,
|
|
IUnknownsScoringService scoringService,
|
|
SignalsSealedModeMonitor sealedModeMonitor,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!Program.TryAuthorize(context, SignalsPolicies.Read, options.Authority.AllowAnonymousFallback, out var authFailure))
|
|
{
|
|
return authFailure ?? Results.Unauthorized();
|
|
}
|
|
|
|
if (!Program.TryEnsureSealedMode(sealedModeMonitor, out var sealedFailure))
|
|
{
|
|
return sealedFailure ?? Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(id))
|
|
{
|
|
return Results.BadRequest(new { error = "id is required." });
|
|
}
|
|
|
|
var unknown = await repository.GetByIdAsync(id.Trim(), cancellationToken).ConfigureAwait(false);
|
|
if (unknown is null)
|
|
{
|
|
return Results.NotFound(new { error = $"Unknown with id '{id}' not found." });
|
|
}
|
|
|
|
return Results.Ok(new
|
|
{
|
|
id = unknown.Id,
|
|
subjectKey = unknown.SubjectKey,
|
|
band = unknown.Band.ToString().ToLowerInvariant(),
|
|
score = unknown.Score,
|
|
normalizationTrace = unknown.NormalizationTrace,
|
|
flags = unknown.Flags,
|
|
nextScheduledRescan = unknown.NextScheduledRescan,
|
|
rescanAttempts = unknown.RescanAttempts,
|
|
createdAt = unknown.CreatedAt,
|
|
updatedAt = unknown.UpdatedAt
|
|
});
|
|
}).WithName("SignalsUnknownsExplain");
|
|
|
|
signalsGroup.MapPost("/reachability/recompute", async Task<IResult> (
|
|
HttpContext context,
|
|
SignalsOptions options,
|
|
ReachabilityRecomputeRequest request,
|
|
IReachabilityScoringService scoringService,
|
|
SignalsSealedModeMonitor sealedModeMonitor,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!Program.TryAuthorize(context, SignalsPolicies.Admin, options.Authority.AllowAnonymousFallback, out var authFailure))
|
|
{
|
|
return authFailure ?? Results.Unauthorized();
|
|
}
|
|
|
|
if (!Program.TryEnsureSealedMode(sealedModeMonitor, out var sealedFailure))
|
|
{
|
|
return sealedFailure ?? Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
|
}
|
|
|
|
try
|
|
{
|
|
var fact = await scoringService.RecomputeAsync(request, cancellationToken).ConfigureAwait(false);
|
|
return Results.Ok(new
|
|
{
|
|
fact.Id,
|
|
fact.CallgraphId,
|
|
subject = fact.Subject,
|
|
fact.EntryPoints,
|
|
fact.States,
|
|
fact.ComputedAt
|
|
});
|
|
}
|
|
catch (ReachabilityScoringValidationException ex)
|
|
{
|
|
return Results.BadRequest(new { error = ex.Message });
|
|
}
|
|
catch (ReachabilityCallgraphNotFoundException ex)
|
|
{
|
|
return Results.NotFound(new { error = ex.Message });
|
|
}
|
|
}).WithName("SignalsReachabilityRecompute");
|
|
|
|
|
|
app.Run();
|
|
|
|
public partial class Program
|
|
{
|
|
internal static bool TryAuthorize(HttpContext httpContext, string requiredScope, bool fallbackAllowed, out IResult? failure)
|
|
{
|
|
if (httpContext.User?.Identity?.IsAuthenticated == true)
|
|
{
|
|
if (TokenScopeAuthorizer.HasScope(httpContext.User, requiredScope))
|
|
{
|
|
failure = null;
|
|
return true;
|
|
}
|
|
|
|
failure = Results.StatusCode(StatusCodes.Status403Forbidden);
|
|
return false;
|
|
}
|
|
|
|
if (!fallbackAllowed)
|
|
{
|
|
failure = Results.Unauthorized();
|
|
return false;
|
|
}
|
|
|
|
if (!httpContext.Request.Headers.TryGetValue("X-Scopes", out var scopesHeader) ||
|
|
string.IsNullOrWhiteSpace(scopesHeader.ToString()))
|
|
{
|
|
failure = Results.Unauthorized();
|
|
return false;
|
|
}
|
|
|
|
var principal = HeaderScopeAuthorizer.CreatePrincipal(scopesHeader.ToString());
|
|
if (HeaderScopeAuthorizer.HasScope(principal, requiredScope))
|
|
{
|
|
failure = null;
|
|
return true;
|
|
}
|
|
|
|
failure = Results.StatusCode(StatusCodes.Status403Forbidden);
|
|
return false;
|
|
}
|
|
|
|
internal static bool TryEnsureSealedMode(SignalsSealedModeMonitor monitor, out IResult? failure)
|
|
{
|
|
if (!monitor.EnforcementEnabled)
|
|
{
|
|
failure = null;
|
|
return true;
|
|
}
|
|
|
|
if (monitor.IsCompliant(out var reason))
|
|
{
|
|
failure = null;
|
|
return true;
|
|
}
|
|
|
|
failure = Results.Json(
|
|
new { error = "sealed-mode evidence invalid", reason },
|
|
statusCode: StatusCodes.Status503ServiceUnavailable);
|
|
return false;
|
|
}
|
|
}
|