Stabilzie modules
This commit is contained in:
@@ -577,6 +577,11 @@ public static class StellaOpsScopes
|
||||
/// </summary>
|
||||
public const string GraphAdmin = "graph:admin";
|
||||
|
||||
/// <summary>
|
||||
/// Scope granting read-only access to analytics data.
|
||||
/// </summary>
|
||||
public const string AnalyticsRead = "analytics.read";
|
||||
|
||||
private static readonly IReadOnlyList<string> AllScopes = BuildAllScopes();
|
||||
private static readonly HashSet<string> KnownScopes = new(AllScopes, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
|
||||
@@ -86,6 +86,25 @@ public static class ServiceCollectionExtensions
|
||||
jwt.TokenValidationParameters.NameClaimType = ClaimTypes.Name;
|
||||
jwt.TokenValidationParameters.RoleClaimType = ClaimTypes.Role;
|
||||
jwt.ConfigurationManager = provider.GetRequiredService<StellaOpsAuthorityConfigurationManager>();
|
||||
|
||||
// Accept both "Bearer" and "DPoP" authorization schemes.
|
||||
// The StellaOps UI sends DPoP-bound access tokens with "Authorization: DPoP <token>".
|
||||
jwt.Events ??= new JwtBearerEvents();
|
||||
jwt.Events.OnMessageReceived = context =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(context.Token))
|
||||
{
|
||||
return System.Threading.Tasks.Task.CompletedTask;
|
||||
}
|
||||
|
||||
var authorization = context.Request.Headers.Authorization.ToString();
|
||||
if (authorization.StartsWith("DPoP ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
context.Token = authorization["DPoP ".Length..].Trim();
|
||||
}
|
||||
|
||||
return System.Threading.Tasks.Task.CompletedTask;
|
||||
};
|
||||
});
|
||||
|
||||
return services;
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
|
||||
using Microsoft.AspNetCore;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Server.AspNetCore;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Authority.Persistence.InMemory.Stores;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using System.Globalization;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
|
||||
namespace StellaOps.Authority;
|
||||
|
||||
/// <summary>
|
||||
/// Maps the /authorize endpoint for the OpenIddict authorization code flow.
|
||||
/// Renders a minimal login form on GET, validates credentials on POST,
|
||||
/// and issues an authorization code via OpenIddict SignIn.
|
||||
/// </summary>
|
||||
internal static class AuthorizeEndpointExtensions
|
||||
{
|
||||
public static void MapAuthorizeEndpoint(this WebApplication app)
|
||||
{
|
||||
app.MapGet("/authorize", HandleAuthorize);
|
||||
app.MapPost("/authorize", HandleAuthorize);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleAuthorize(
|
||||
HttpContext httpContext,
|
||||
IAuthorityIdentityProviderRegistry registry,
|
||||
IAuthorityClientStore clientStore,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var request = httpContext.GetOpenIddictServerRequest();
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "invalid_request", message = "Invalid authorization request." });
|
||||
}
|
||||
|
||||
// prompt=none: silent refresh — no interactive login allowed.
|
||||
if (string.Equals(request.Prompt, "none", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var redirectUri = request.RedirectUri;
|
||||
if (string.IsNullOrWhiteSpace(redirectUri))
|
||||
{
|
||||
return Results.BadRequest(new { error = "login_required", message = "User interaction is required." });
|
||||
}
|
||||
|
||||
return Results.Redirect(BuildErrorRedirect(redirectUri, "login_required", "User interaction is required.", request.State));
|
||||
}
|
||||
|
||||
// POST: extract and validate credentials from the form body.
|
||||
if (HttpMethods.IsPost(httpContext.Request.Method))
|
||||
{
|
||||
var form = await httpContext.Request.ReadFormAsync(httpContext.RequestAborted).ConfigureAwait(false);
|
||||
var username = form["username"].FirstOrDefault();
|
||||
var password = form["password"].FirstOrDefault();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(username) && !string.IsNullOrEmpty(password))
|
||||
{
|
||||
return await TryAuthenticateAndSignIn(
|
||||
httpContext, request, registry, clientStore, timeProvider,
|
||||
username!, password!).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return Results.Content(
|
||||
BuildLoginHtml(request, "Username and password are required."),
|
||||
"text/html", Encoding.UTF8);
|
||||
}
|
||||
|
||||
// GET: render the login form.
|
||||
return Results.Content(BuildLoginHtml(request), "text/html", Encoding.UTF8);
|
||||
}
|
||||
|
||||
private static async Task<IResult> TryAuthenticateAndSignIn(
|
||||
HttpContext httpContext,
|
||||
OpenIddictRequest request,
|
||||
IAuthorityIdentityProviderRegistry registry,
|
||||
IAuthorityClientStore clientStore,
|
||||
TimeProvider timeProvider,
|
||||
string username,
|
||||
string password)
|
||||
{
|
||||
// Find a password-capable provider.
|
||||
var providerMeta = registry.Providers.FirstOrDefault(
|
||||
static p => p.Capabilities.SupportsPassword);
|
||||
|
||||
if (providerMeta is null)
|
||||
{
|
||||
return Results.Content(
|
||||
BuildLoginHtml(request, "No identity provider is configured."),
|
||||
"text/html", Encoding.UTF8);
|
||||
}
|
||||
|
||||
await using var handle = await registry.AcquireAsync(
|
||||
providerMeta.Name, httpContext.RequestAborted).ConfigureAwait(false);
|
||||
var provider = handle.Provider;
|
||||
|
||||
var verification = await provider.Credentials.VerifyPasswordAsync(
|
||||
username, password, httpContext.RequestAborted).ConfigureAwait(false);
|
||||
|
||||
if (!verification.Succeeded || verification.User is null)
|
||||
{
|
||||
return Results.Content(
|
||||
BuildLoginHtml(request, verification.Message ?? "Invalid username or password.", username),
|
||||
"text/html", Encoding.UTF8);
|
||||
}
|
||||
|
||||
// Build ClaimsPrincipal (mirrors HandlePasswordGrantHandler pattern).
|
||||
var identity = new ClaimsIdentity(
|
||||
OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
|
||||
OpenIddictConstants.Claims.Name,
|
||||
OpenIddictConstants.Claims.Role);
|
||||
|
||||
identity.AddClaim(new Claim(OpenIddictConstants.Claims.Subject, verification.User.SubjectId));
|
||||
identity.AddClaim(new Claim(OpenIddictConstants.Claims.PreferredUsername, verification.User.Username));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(verification.User.DisplayName))
|
||||
{
|
||||
identity.AddClaim(new Claim(OpenIddictConstants.Claims.Name, verification.User.DisplayName!));
|
||||
}
|
||||
|
||||
foreach (var role in verification.User.Roles)
|
||||
{
|
||||
identity.AddClaim(new Claim(OpenIddictConstants.Claims.Role, role));
|
||||
}
|
||||
|
||||
// Resolve tenant from the client document.
|
||||
var clientId = request.ClientId;
|
||||
if (!string.IsNullOrWhiteSpace(clientId))
|
||||
{
|
||||
var client = await clientStore.FindByClientIdAsync(
|
||||
clientId!, httpContext.RequestAborted).ConfigureAwait(false);
|
||||
|
||||
if (client?.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenant) == true
|
||||
&& !string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
identity.SetClaim(StellaOpsClaimTypes.Tenant, tenant.Trim().ToLowerInvariant());
|
||||
}
|
||||
}
|
||||
|
||||
var issuedAt = timeProvider.GetUtcNow();
|
||||
identity.AddClaim(new Claim(
|
||||
OpenIddictConstants.Claims.AuthenticationTime,
|
||||
EpochTime.GetIntDate(issuedAt.UtcDateTime).ToString(CultureInfo.InvariantCulture),
|
||||
ClaimValueTypes.Integer64));
|
||||
|
||||
identity.SetDestinations(static claim => claim.Type switch
|
||||
{
|
||||
OpenIddictConstants.Claims.Subject => new[]
|
||||
{
|
||||
OpenIddictConstants.Destinations.AccessToken,
|
||||
OpenIddictConstants.Destinations.IdentityToken
|
||||
},
|
||||
OpenIddictConstants.Claims.Name => new[]
|
||||
{
|
||||
OpenIddictConstants.Destinations.AccessToken,
|
||||
OpenIddictConstants.Destinations.IdentityToken
|
||||
},
|
||||
OpenIddictConstants.Claims.PreferredUsername => new[]
|
||||
{
|
||||
OpenIddictConstants.Destinations.AccessToken
|
||||
},
|
||||
OpenIddictConstants.Claims.Role => new[]
|
||||
{
|
||||
OpenIddictConstants.Destinations.AccessToken
|
||||
},
|
||||
_ => new[] { OpenIddictConstants.Destinations.AccessToken }
|
||||
});
|
||||
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
principal.SetScopes(request.GetScopes());
|
||||
|
||||
// Enrich claims via the identity provider plugin.
|
||||
var enrichmentContext = new AuthorityClaimsEnrichmentContext(
|
||||
provider.Context, verification.User, null);
|
||||
await provider.ClaimsEnricher.EnrichAsync(
|
||||
identity, enrichmentContext, httpContext.RequestAborted).ConfigureAwait(false);
|
||||
|
||||
// SignIn via OpenIddict — generates the authorization code and
|
||||
// redirects the browser back to the client's redirect_uri.
|
||||
return Results.SignIn(
|
||||
principal,
|
||||
properties: null,
|
||||
OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||
}
|
||||
|
||||
private static string BuildErrorRedirect(
|
||||
string redirectUri, string error, string description, string? state)
|
||||
{
|
||||
var separator = redirectUri.Contains('?') ? '&' : '?';
|
||||
var sb = new StringBuilder(redirectUri);
|
||||
sb.Append(separator);
|
||||
sb.Append("error=").Append(Uri.EscapeDataString(error));
|
||||
sb.Append("&error_description=").Append(Uri.EscapeDataString(description));
|
||||
if (!string.IsNullOrWhiteSpace(state))
|
||||
{
|
||||
sb.Append("&state=").Append(Uri.EscapeDataString(state));
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string BuildLoginHtml(
|
||||
OpenIddictRequest request, string? error = null, string? username = null)
|
||||
{
|
||||
var enc = HtmlEncoder.Default;
|
||||
|
||||
var sb = new StringBuilder(8192);
|
||||
sb.AppendLine("<!DOCTYPE html>");
|
||||
sb.AppendLine("<html lang=\"en\">");
|
||||
sb.AppendLine("<head>");
|
||||
sb.AppendLine("<meta charset=\"utf-8\">");
|
||||
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">");
|
||||
sb.AppendLine("<title>Sign In — StellaOps</title>");
|
||||
sb.AppendLine("<style>");
|
||||
|
||||
// Reset
|
||||
sb.AppendLine("*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}");
|
||||
|
||||
// Body — warm amber light theme matching the Angular app
|
||||
sb.AppendLine("body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;");
|
||||
sb.AppendLine("background:linear-gradient(175deg,#FFFCF5 0%,#FFF9ED 40%,#FFFFFF 100%);");
|
||||
sb.AppendLine("color:#3D2E0A;display:flex;align-items:center;justify-content:center;min-height:100vh;");
|
||||
sb.AppendLine("-webkit-font-smoothing:antialiased;position:relative;overflow:hidden}");
|
||||
|
||||
// Animated background radials
|
||||
sb.AppendLine("body::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;");
|
||||
sb.AppendLine("background:radial-gradient(ellipse 70% 50% at 50% 0%,rgba(245,166,35,0.08) 0%,transparent 60%),");
|
||||
sb.AppendLine("radial-gradient(ellipse 60% 50% at 0% 100%,rgba(245,166,35,0.04) 0%,transparent 50%),");
|
||||
sb.AppendLine("radial-gradient(ellipse 50% 40% at 100% 80%,rgba(212,146,10,0.03) 0%,transparent 50%);");
|
||||
sb.AppendLine("pointer-events:none;z-index:0}");
|
||||
|
||||
// Card — frosted glass on warm light
|
||||
sb.AppendLine(".card{position:relative;z-index:1;background:rgba(255,255,255,0.8);");
|
||||
sb.AppendLine("backdrop-filter:blur(24px) saturate(1.4);-webkit-backdrop-filter:blur(24px) saturate(1.4);");
|
||||
sb.AppendLine("border-radius:24px;padding:2.5rem 2rem 2rem;width:100%;max-width:400px;");
|
||||
sb.AppendLine("border:1px solid rgba(212,201,168,0.25);");
|
||||
sb.AppendLine("box-shadow:0 0 60px rgba(245,166,35,0.06),0 20px 60px rgba(28,18,0,0.06),");
|
||||
sb.AppendLine("0 8px 24px rgba(28,18,0,0.04),inset 0 1px 0 rgba(255,255,255,0.8);");
|
||||
sb.AppendLine("animation:card-entrance 600ms cubic-bezier(0.18,0.89,0.32,1) both}");
|
||||
|
||||
// Logo container
|
||||
sb.AppendLine(".logo-wrap{text-align:center;margin-bottom:0.25rem}");
|
||||
sb.AppendLine(".logo-wrap img{width:56px;height:56px;border-radius:14px;");
|
||||
sb.AppendLine("filter:drop-shadow(0 4px 12px rgba(245,166,35,0.2));");
|
||||
sb.AppendLine("animation:logo-pop 650ms cubic-bezier(0.34,1.56,0.64,1) 100ms both}");
|
||||
|
||||
// Title
|
||||
sb.AppendLine("h1{font-size:1.5rem;text-align:center;margin-bottom:0.25rem;color:#1C1200;font-weight:700;");
|
||||
sb.AppendLine("letter-spacing:-0.03em;animation:slide-up 500ms ease 200ms both}");
|
||||
|
||||
// Subtitle
|
||||
sb.AppendLine(".subtitle{text-align:center;color:#6B5A2E;font-size:.8125rem;margin-bottom:1.5rem;");
|
||||
sb.AppendLine("font-weight:400;animation:fade-in 400ms ease 350ms both}");
|
||||
|
||||
// Error
|
||||
sb.AppendLine(".error{background:#fef2f2;border:1px solid rgba(239,68,68,0.2);color:#991b1b;");
|
||||
sb.AppendLine("padding:.75rem;border-radius:12px;margin-bottom:1rem;font-size:.8125rem;font-weight:500;");
|
||||
sb.AppendLine("display:flex;align-items:center;gap:.5rem}");
|
||||
sb.AppendLine(".error::before{content:'';width:6px;height:6px;border-radius:50%;background:#ef4444;flex-shrink:0}");
|
||||
|
||||
// Labels
|
||||
sb.AppendLine("label{display:block;font-size:.75rem;font-weight:600;color:#6B5A2E;margin-bottom:.375rem;");
|
||||
sb.AppendLine("letter-spacing:0.03em;text-transform:uppercase}");
|
||||
|
||||
// Inputs
|
||||
sb.AppendLine("input[type=text],input[type=password]{width:100%;padding:.75rem .875rem;");
|
||||
sb.AppendLine("background:#FFFCF5;border:1px solid rgba(212,201,168,0.4);border-radius:12px;");
|
||||
sb.AppendLine("color:#3D2E0A;font-size:.9375rem;margin-bottom:1rem;outline:none;font-family:inherit;");
|
||||
sb.AppendLine("transition:border-color .2s,box-shadow .2s}");
|
||||
sb.AppendLine("input[type=text]:focus,input[type=password]:focus{border-color:#F5A623;");
|
||||
sb.AppendLine("box-shadow:0 0 0 3px rgba(245,166,35,0.15)}");
|
||||
sb.AppendLine("input[type=text]::placeholder,input[type=password]::placeholder{color:#9A8F78}");
|
||||
|
||||
// Button — amber gradient CTA
|
||||
sb.AppendLine("button{width:100%;padding:.875rem;margin-top:0.25rem;");
|
||||
sb.AppendLine("background:linear-gradient(135deg,#F5A623 0%,#D4920A 100%);");
|
||||
sb.AppendLine("color:#fff;border:none;border-radius:14px;font-size:1rem;font-weight:600;");
|
||||
sb.AppendLine("cursor:pointer;font-family:inherit;letter-spacing:0.01em;position:relative;overflow:hidden;");
|
||||
sb.AppendLine("transition:transform .22s cubic-bezier(0.18,0.89,0.32,1),box-shadow .22s;");
|
||||
sb.AppendLine("box-shadow:0 2px 12px rgba(245,166,35,0.3),0 1px 3px rgba(28,18,0,0.08)}");
|
||||
sb.AppendLine("button:hover{transform:translateY(-2px);");
|
||||
sb.AppendLine("box-shadow:0 6px 24px rgba(245,166,35,0.4),0 2px 8px rgba(28,18,0,0.08)}");
|
||||
sb.AppendLine("button:active{transform:translateY(0);");
|
||||
sb.AppendLine("box-shadow:0 1px 6px rgba(245,166,35,0.2),0 1px 2px rgba(28,18,0,0.06)}");
|
||||
sb.AppendLine("button:focus-visible{outline:2px solid rgba(245,166,35,0.5);outline-offset:3px}");
|
||||
|
||||
// Shimmer effect on button
|
||||
sb.AppendLine("button::after{content:'';position:absolute;inset:0;");
|
||||
sb.AppendLine("background:linear-gradient(105deg,transparent 38%,rgba(255,255,255,0.3) 50%,transparent 62%);");
|
||||
sb.AppendLine("background-size:250% 100%;animation:shimmer 2.2s ease 1.2s}");
|
||||
|
||||
// Keyframes
|
||||
sb.AppendLine("@keyframes card-entrance{from{opacity:0;transform:translateY(24px) scale(0.97)}to{opacity:1;transform:translateY(0) scale(1)}}");
|
||||
sb.AppendLine("@keyframes logo-pop{from{opacity:0;transform:scale(0.6)}to{opacity:1;transform:scale(1)}}");
|
||||
sb.AppendLine("@keyframes slide-up{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}");
|
||||
sb.AppendLine("@keyframes fade-in{from{opacity:0}to{opacity:1}}");
|
||||
sb.AppendLine("@keyframes shimmer{0%{background-position:200% 0}100%{background-position:-100% 0}}");
|
||||
|
||||
// Reduced motion
|
||||
sb.AppendLine("@media(prefers-reduced-motion:reduce){.card,h1,.subtitle,.logo-wrap img,button::after{animation:none!important}");
|
||||
sb.AppendLine(".card,h1,.subtitle,.logo-wrap img{opacity:1}button{transition:none}}");
|
||||
|
||||
// Responsive
|
||||
sb.AppendLine("@media(max-width:480px){.card{margin:0 1rem;padding:2rem 1.5rem 1.75rem;border-radius:20px}}");
|
||||
|
||||
sb.AppendLine("</style>");
|
||||
sb.AppendLine("</head>");
|
||||
sb.AppendLine("<body>");
|
||||
sb.AppendLine("<form class=\"card\" method=\"post\" action=\"\">");
|
||||
|
||||
// Logo
|
||||
sb.AppendLine("<div class=\"logo-wrap\"><img src=\"/assets/img/site.png\" alt=\"\" width=\"56\" height=\"56\" /></div>");
|
||||
|
||||
sb.AppendLine("<h1>StellaOps</h1>");
|
||||
sb.AppendLine("<p class=\"subtitle\">Sign in to continue</p>");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(error))
|
||||
{
|
||||
sb.Append("<div class=\"error\">").Append(enc.Encode(error)).AppendLine("</div>");
|
||||
}
|
||||
|
||||
// Hidden fields for OIDC parameters
|
||||
AppendHidden(sb, "response_type", request.ResponseType);
|
||||
AppendHidden(sb, "client_id", request.ClientId);
|
||||
AppendHidden(sb, "redirect_uri", request.RedirectUri);
|
||||
AppendHidden(sb, "scope", request.Scope);
|
||||
AppendHidden(sb, "state", request.State);
|
||||
AppendHidden(sb, "nonce", request.Nonce);
|
||||
AppendHidden(sb, "code_challenge", request.CodeChallenge);
|
||||
AppendHidden(sb, "code_challenge_method", request.CodeChallengeMethod);
|
||||
if (!string.IsNullOrWhiteSpace(request.GetParameter("audience")?.ToString()))
|
||||
{
|
||||
AppendHidden(sb, "audience", request.GetParameter("audience")?.ToString());
|
||||
}
|
||||
|
||||
sb.AppendLine("<label for=\"username\">Username</label>");
|
||||
sb.Append("<input type=\"text\" id=\"username\" name=\"username\" autocomplete=\"username\" placeholder=\"Enter username\" required");
|
||||
if (!string.IsNullOrWhiteSpace(username))
|
||||
{
|
||||
sb.Append(" value=\"").Append(enc.Encode(username)).Append('"');
|
||||
}
|
||||
sb.AppendLine(" />");
|
||||
|
||||
sb.AppendLine("<label for=\"password\">Password</label>");
|
||||
sb.AppendLine("<input type=\"password\" id=\"password\" name=\"password\" autocomplete=\"current-password\" placeholder=\"Enter password\" required />");
|
||||
|
||||
sb.AppendLine("<button type=\"submit\">Sign In</button>");
|
||||
sb.AppendLine("</form>");
|
||||
sb.AppendLine("</body>");
|
||||
sb.AppendLine("</html>");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static void AppendHidden(StringBuilder sb, string name, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var enc = HtmlEncoder.Default;
|
||||
sb.Append("<input type=\"hidden\" name=\"")
|
||||
.Append(enc.Encode(name))
|
||||
.Append("\" value=\"")
|
||||
.Append(enc.Encode(value))
|
||||
.AppendLine("\" />");
|
||||
}
|
||||
}
|
||||
@@ -79,15 +79,24 @@ internal static class ConsoleBrandingEndpointExtensions
|
||||
// Placeholder: load from storage
|
||||
var branding = GetDefaultBranding(tenantId);
|
||||
|
||||
await WriteAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
timeProvider,
|
||||
"authority.console.branding.read",
|
||||
AuthEventOutcome.Success,
|
||||
null,
|
||||
BuildProperties(("tenant.id", tenantId)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await WriteAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
timeProvider,
|
||||
"authority.console.branding.read",
|
||||
AuthEventOutcome.Success,
|
||||
null,
|
||||
BuildProperties(("tenant.id", tenantId)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Best-effort audit for public branding endpoint.
|
||||
// Do not fail the request if the audit sink is unavailable
|
||||
// (e.g. DB schema not yet initialized).
|
||||
}
|
||||
|
||||
return Results.Ok(branding);
|
||||
}
|
||||
|
||||
@@ -24,30 +24,40 @@ internal sealed class TenantHeaderFilter : IEndpointFilter
|
||||
}
|
||||
|
||||
var tenantHeader = httpContext.Request.Headers[AuthorityHttpHeaders.Tenant];
|
||||
if (IsMissing(tenantHeader))
|
||||
{
|
||||
return ValueTask.FromResult<object?>(Results.BadRequest(new
|
||||
{
|
||||
error = "tenant_header_missing",
|
||||
message = $"Header '{AuthorityHttpHeaders.Tenant}' is required."
|
||||
}));
|
||||
}
|
||||
|
||||
var normalizedHeader = tenantHeader.ToString().Trim().ToLowerInvariant();
|
||||
var claimTenant = principal.FindFirstValue(StellaOpsClaimTypes.Tenant);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(claimTenant))
|
||||
// Determine effective tenant:
|
||||
// 1. If both header and claim present: they must match
|
||||
// 2. If header present but no claim: use header value (bootstrapped users have no tenant claim)
|
||||
// 3. If no header but claim present: use claim value
|
||||
// 4. If neither present: default to "default"
|
||||
string effectiveTenant;
|
||||
|
||||
if (!IsMissing(tenantHeader))
|
||||
{
|
||||
return ValueTask.FromResult<object?>(Results.Forbid());
|
||||
var normalizedHeader = tenantHeader.ToString().Trim().ToLowerInvariant();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(claimTenant))
|
||||
{
|
||||
var normalizedClaim = claimTenant.Trim().ToLowerInvariant();
|
||||
if (!string.Equals(normalizedClaim, normalizedHeader, StringComparison.Ordinal))
|
||||
{
|
||||
return ValueTask.FromResult<object?>(Results.Forbid());
|
||||
}
|
||||
}
|
||||
|
||||
effectiveTenant = normalizedHeader;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(claimTenant))
|
||||
{
|
||||
effectiveTenant = claimTenant.Trim().ToLowerInvariant();
|
||||
}
|
||||
else
|
||||
{
|
||||
effectiveTenant = "default";
|
||||
}
|
||||
|
||||
var normalizedClaim = claimTenant.Trim().ToLowerInvariant();
|
||||
if (!string.Equals(normalizedClaim, normalizedHeader, StringComparison.Ordinal))
|
||||
{
|
||||
return ValueTask.FromResult<object?>(Results.Forbid());
|
||||
}
|
||||
|
||||
httpContext.Items[TenantItemKey] = normalizedHeader;
|
||||
httpContext.Items[TenantItemKey] = effectiveTenant;
|
||||
return next(context);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Server;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Authority.OpenIddict.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// Handles the token request for the authorization_code grant type.
|
||||
/// OpenIddict (in degraded mode) validates the authorization code and
|
||||
/// populates context.Principal before this handler runs. We simply
|
||||
/// sign in with the already-validated principal to issue tokens.
|
||||
/// </summary>
|
||||
internal sealed class HandleAuthorizationCodeGrantHandler : IOpenIddictServerHandler<OpenIddictServerEvents.HandleTokenRequestContext>
|
||||
{
|
||||
private readonly ILogger<HandleAuthorizationCodeGrantHandler> logger;
|
||||
|
||||
public HandleAuthorizationCodeGrantHandler(ILogger<HandleAuthorizationCodeGrantHandler> logger)
|
||||
{
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public ValueTask HandleAsync(OpenIddictServerEvents.HandleTokenRequestContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (!context.Request.IsAuthorizationCodeGrantType())
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
// The principal was built by AuthorizeEndpoint and embedded in the
|
||||
// self-contained authorization code. OpenIddict already validated
|
||||
// the code (PKCE, redirect_uri, expiry) and deserialized the
|
||||
// principal into context.Principal.
|
||||
var principal = context.Principal;
|
||||
if (principal is null)
|
||||
{
|
||||
logger.LogError("Authorization code grant failed: no principal found in the validated authorization code.");
|
||||
context.Reject(
|
||||
OpenIddictConstants.Errors.InvalidGrant,
|
||||
"The authorization code is no longer valid.");
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Authorization code grant succeeded for subject {Subject}.",
|
||||
principal.FindFirst(OpenIddictConstants.Claims.Subject)?.Value ?? "<unknown>");
|
||||
|
||||
context.Principal = principal;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1830,7 +1830,6 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
|
||||
await PersistTokenAsync(context, document, tokenId, grantedScopes, session!, activity).ConfigureAwait(false);
|
||||
|
||||
context.Principal = principal;
|
||||
context.HandleRequest();
|
||||
logger.LogInformation("Issued client credentials access token for {ClientId} with scopes {Scopes}.", document.ClientId, grantedScopes);
|
||||
}
|
||||
finally
|
||||
|
||||
@@ -1307,7 +1307,10 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
|
||||
}
|
||||
|
||||
var issuedAt = timeProvider.GetUtcNow();
|
||||
identity.SetClaim(OpenIddictConstants.Claims.AuthenticationTime, issuedAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture));
|
||||
identity.AddClaim(new Claim(
|
||||
OpenIddictConstants.Claims.AuthenticationTime,
|
||||
issuedAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture),
|
||||
ClaimValueTypes.Integer64));
|
||||
|
||||
AuthoritySenderConstraintHelper.ApplySenderConstraintClaims(context.Transaction, identity);
|
||||
|
||||
@@ -1433,7 +1436,6 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
|
||||
}
|
||||
|
||||
context.Principal = principal;
|
||||
context.HandleRequest();
|
||||
activity?.SetTag("authority.subject_id", verification.User.SubjectId);
|
||||
logger.LogInformation("Password grant issued for {Username} with subject {SubjectId}.", verification.User.Username, verification.User.SubjectId);
|
||||
}
|
||||
|
||||
@@ -293,10 +293,16 @@ builder.Services.AddSingleton(pluginRegistrationSummary);
|
||||
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
|
||||
builder.Services.AddRouting(options => options.LowercaseUrls = true);
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddAuthentication();
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(builder.Configuration, configurationSection: null);
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
builder.Services.AddStellaOpsScopeHandler();
|
||||
// The Authority validates its own tokens for admin endpoints. Configure the JWKS
|
||||
// backchannel to accept the Authority's self-signed certificate (self-referential).
|
||||
builder.Services.AddHttpClient("StellaOps.Auth.ServerIntegration.Metadata")
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new System.Net.Http.HttpClientHandler
|
||||
{
|
||||
ServerCertificateCustomValidationCallback = System.Net.Http.HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
|
||||
});
|
||||
|
||||
builder.Services.TryAddSingleton<StellaOpsBypassEvaluator>();
|
||||
|
||||
@@ -321,6 +327,8 @@ builder.Services.AddOpenIddict()
|
||||
options.AllowPasswordFlow();
|
||||
options.AllowClientCredentialsFlow();
|
||||
options.AllowRefreshTokenFlow();
|
||||
options.AllowAuthorizationCodeFlow();
|
||||
options.RequireProofKeyForCodeExchange();
|
||||
|
||||
options.SetAccessTokenLifetime(authorityOptions.AccessTokenLifetime);
|
||||
options.SetRefreshTokenLifetime(authorityOptions.RefreshTokenLifetime);
|
||||
@@ -328,9 +336,8 @@ builder.Services.AddOpenIddict()
|
||||
options.SetAuthorizationCodeLifetime(authorityOptions.AuthorizationCodeLifetime);
|
||||
options.SetDeviceCodeLifetime(authorityOptions.DeviceCodeLifetime);
|
||||
|
||||
options.EnableDegradedMode();
|
||||
options.DisableAccessTokenEncryption();
|
||||
options.DisableTokenStorage();
|
||||
options.DisableAuthorizationStorage();
|
||||
|
||||
options.RegisterScopes(
|
||||
new[]
|
||||
@@ -348,8 +355,7 @@ builder.Services.AddOpenIddict()
|
||||
.AddEphemeralSigningKey();
|
||||
|
||||
var aspNetCoreBuilder = options.UseAspNetCore()
|
||||
.EnableAuthorizationEndpointPassthrough()
|
||||
.EnableTokenEndpointPassthrough();
|
||||
.EnableAuthorizationEndpointPassthrough();
|
||||
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
@@ -363,6 +369,11 @@ builder.Services.AddOpenIddict()
|
||||
});
|
||||
#endif
|
||||
|
||||
options.AddEventHandler<OpenIddictServerEvents.ValidateAuthorizationRequestContext>(descriptor =>
|
||||
{
|
||||
descriptor.UseScopedHandler<ValidateAuthorizationRequestHandler>();
|
||||
});
|
||||
|
||||
options.AddEventHandler<OpenIddictServerEvents.ValidateTokenRequestContext>(descriptor =>
|
||||
{
|
||||
descriptor.UseScopedHandler<ValidatePasswordGrantHandler>();
|
||||
@@ -388,6 +399,11 @@ builder.Services.AddOpenIddict()
|
||||
descriptor.UseScopedHandler<HandleClientCredentialsHandler>();
|
||||
});
|
||||
|
||||
options.AddEventHandler<OpenIddictServerEvents.HandleTokenRequestContext>(descriptor =>
|
||||
{
|
||||
descriptor.UseScopedHandler<HandleAuthorizationCodeGrantHandler>();
|
||||
});
|
||||
|
||||
options.AddEventHandler<OpenIddictServerEvents.ValidateTokenContext>(descriptor =>
|
||||
{
|
||||
descriptor.UseScopedHandler<ValidateAccessTokenHandler>();
|
||||
@@ -398,6 +414,16 @@ builder.Services.AddOpenIddict()
|
||||
descriptor.UseScopedHandler<PersistTokensHandler>();
|
||||
});
|
||||
|
||||
options.AddEventHandler<OpenIddictServerEvents.ValidateIntrospectionRequestContext>(descriptor =>
|
||||
{
|
||||
descriptor.UseScopedHandler<ValidateIntrospectionRequestHandler>();
|
||||
});
|
||||
|
||||
options.AddEventHandler<OpenIddictServerEvents.ValidateRevocationRequestContext>(descriptor =>
|
||||
{
|
||||
descriptor.UseScopedHandler<ValidateRevocationRequestHandler>();
|
||||
});
|
||||
|
||||
options.AddEventHandler<OpenIddictServerEvents.HandleRevocationRequestContext>(descriptor =>
|
||||
{
|
||||
descriptor.UseScopedHandler<HandleRevocationRequestHandler>();
|
||||
@@ -3117,6 +3143,7 @@ app.MapAuthorityOpenApiDiscovery();
|
||||
app.MapConsoleEndpoints();
|
||||
app.MapConsoleAdminEndpoints();
|
||||
app.MapConsoleBrandingEndpoints();
|
||||
app.MapAuthorizeEndpoint();
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -263,6 +263,99 @@ public sealed record SliceEdge
|
||||
public required string To { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HTTP implementation of IReachGraphSliceClient that calls the ReachGraph service API.
|
||||
/// </summary>
|
||||
public sealed class HttpReachGraphSliceClient : IReachGraphSliceClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<HttpReachGraphSliceClient> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new HTTP-backed ReachGraph slice client.
|
||||
/// </summary>
|
||||
/// <param name="httpClient">Pre-configured HttpClient targeting ReachGraph base URL.</param>
|
||||
/// <param name="logger">Logger.</param>
|
||||
public HttpReachGraphSliceClient(
|
||||
HttpClient httpClient,
|
||||
ILogger<HttpReachGraphSliceClient> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<CveSliceResult?> SliceByCveAsync(
|
||||
string digest,
|
||||
string cveId,
|
||||
string tenantId,
|
||||
int maxPaths = 5,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogDebug("Querying ReachGraph slice-by-CVE: {CveId} for {Digest}", cveId, digest);
|
||||
|
||||
try
|
||||
{
|
||||
var url = $"api/v1/slice/cve?digest={Uri.EscapeDataString(digest)}&cveId={Uri.EscapeDataString(cveId)}&tenantId={Uri.EscapeDataString(tenantId)}&maxPaths={maxPaths}";
|
||||
|
||||
var response = await _httpClient.GetAsync(url, ct);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.LogDebug("No slice data found for CVE {CveId}", cveId);
|
||||
return null;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await System.Text.Json.JsonSerializer.DeserializeAsync<CveSliceResult>(
|
||||
await response.Content.ReadAsStreamAsync(ct),
|
||||
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true },
|
||||
ct);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to query ReachGraph for CVE {CveId}", cveId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SliceResult?> SliceByEntrypointAsync(
|
||||
string digest,
|
||||
string entrypointPattern,
|
||||
string tenantId,
|
||||
int maxDepth = 10,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogDebug("Querying ReachGraph slice-by-entrypoint: {Pattern} for {Digest}", entrypointPattern, digest);
|
||||
|
||||
try
|
||||
{
|
||||
var url = $"api/v1/slice/entrypoint?digest={Uri.EscapeDataString(digest)}&pattern={Uri.EscapeDataString(entrypointPattern)}&tenantId={Uri.EscapeDataString(tenantId)}&maxDepth={maxDepth}";
|
||||
|
||||
var response = await _httpClient.GetAsync(url, ct);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await System.Text.Json.JsonSerializer.DeserializeAsync<SliceResult>(
|
||||
await response.Content.ReadAsStreamAsync(ct),
|
||||
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true },
|
||||
ct);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to query ReachGraph for entrypoint {Pattern}", entrypointPattern);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null implementation of IReachGraphSliceClient for testing.
|
||||
/// </summary>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Http;
|
||||
|
||||
namespace StellaOps.BinaryIndex.Analysis;
|
||||
|
||||
@@ -104,4 +105,26 @@ public static class ServiceCollectionExtensions
|
||||
services.AddSingleton(factory);
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the ReachGraph HTTP integration, providing a real
|
||||
/// <see cref="IReachGraphSliceClient"/> and <see cref="IBinaryReachabilityService"/>.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="reachGraphBaseUrl">Base URL of the ReachGraph service.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddReachGraphIntegration(
|
||||
this IServiceCollection services,
|
||||
string reachGraphBaseUrl)
|
||||
{
|
||||
services.AddHttpClient<IReachGraphSliceClient, HttpReachGraphSliceClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(reachGraphBaseUrl);
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
services.AddBinaryReachabilityService<ReachGraphBinaryReachabilityService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,22 +23,220 @@ public sealed partial class TaintGateExtractor : ITaintGateExtractor
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// Security-relevant API call patterns that indicate taint gates
|
||||
private static readonly HashSet<string> SecurityApis = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"memcpy", "memmove", "memset", "strcpy", "strncpy", "strcat", "strncat",
|
||||
"sprintf", "snprintf", "vsprintf", "vsnprintf",
|
||||
"malloc", "calloc", "realloc", "free",
|
||||
"read", "write", "recv", "send", "recvfrom", "sendto",
|
||||
"open", "fopen", "close", "fclose",
|
||||
"strlen", "strcmp", "strncmp", "memcmp",
|
||||
"atoi", "atol", "strtol", "strtoul",
|
||||
"getenv", "setenv", "system", "exec", "popen",
|
||||
"checksum", "verify", "validate", "authenticate", "authorize",
|
||||
"encrypt", "decrypt", "sign", "hash",
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> BoundsCheckApis = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"strlen", "sizeof", "strnlen", "wcslen", "memcmp", "strncmp",
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> AuthApis = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"authenticate", "authorize", "checkperm", "verify_token", "check_auth",
|
||||
"login", "check_credentials", "validate_session",
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ImmutableArray<TaintGate>> ExtractAsync(
|
||||
public async Task<ImmutableArray<TaintGate>> ExtractAsync(
|
||||
string binaryPath,
|
||||
ImmutableArray<string> path,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// In a full implementation, this would:
|
||||
// 1. Disassemble the binary
|
||||
// 2. Trace the path through the CFG
|
||||
// 3. Identify conditional branches
|
||||
// 4. Classify conditions as taint gates
|
||||
|
||||
_logger.LogDebug("Extracting taint gates from path with {Count} nodes", path.Length);
|
||||
|
||||
// For now, return empty - full implementation requires disassembly integration
|
||||
return Task.FromResult(ImmutableArray<TaintGate>.Empty);
|
||||
if (path.IsDefaultOrEmpty || string.IsNullOrWhiteSpace(binaryPath))
|
||||
{
|
||||
return ImmutableArray<TaintGate>.Empty;
|
||||
}
|
||||
|
||||
// Extract structural taint gates by analyzing path nodes for security-relevant patterns
|
||||
var gates = new List<TaintGate>();
|
||||
var conditions = await ExtractConditionsFromPathAsync(binaryPath, path, ct);
|
||||
|
||||
if (!conditions.IsDefaultOrEmpty)
|
||||
{
|
||||
gates.AddRange(ClassifyConditions(conditions));
|
||||
}
|
||||
|
||||
// Additionally scan path nodes for security-relevant function calls
|
||||
for (int i = 0; i < path.Length; i++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var node = path[i];
|
||||
|
||||
// Check if the node name matches security-relevant APIs
|
||||
var stripped = StripDecoration(node);
|
||||
if (BoundsCheckApis.Contains(stripped))
|
||||
{
|
||||
gates.Add(new TaintGate
|
||||
{
|
||||
BlockId = $"path_{i}",
|
||||
Address = DeriveAddressFromName(node),
|
||||
GateType = TaintGateType.BoundsCheck,
|
||||
Condition = $"call to {stripped}",
|
||||
BlocksWhenTrue = false,
|
||||
Confidence = 0.7m
|
||||
});
|
||||
}
|
||||
else if (AuthApis.Contains(stripped))
|
||||
{
|
||||
gates.Add(new TaintGate
|
||||
{
|
||||
BlockId = $"path_{i}",
|
||||
Address = DeriveAddressFromName(node),
|
||||
GateType = TaintGateType.AuthCheck,
|
||||
Condition = $"call to {stripped}",
|
||||
BlocksWhenTrue = true,
|
||||
Confidence = 0.75m
|
||||
});
|
||||
}
|
||||
else if (SecurityApis.Contains(stripped))
|
||||
{
|
||||
gates.Add(new TaintGate
|
||||
{
|
||||
BlockId = $"path_{i}",
|
||||
Address = DeriveAddressFromName(node),
|
||||
GateType = TaintGateType.InputValidation,
|
||||
Condition = $"security-relevant call to {stripped}",
|
||||
BlocksWhenTrue = false,
|
||||
Confidence = 0.6m
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("Extracted {Count} taint gates from path", gates.Count);
|
||||
return gates.Distinct().ToImmutableArray();
|
||||
}
|
||||
|
||||
private async Task<ImmutableArray<(string BlockId, ulong Address, string Condition)>> ExtractConditionsFromPathAsync(
|
||||
string binaryPath,
|
||||
ImmutableArray<string> path,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Attempt to read binary and extract conditional branch patterns
|
||||
if (!File.Exists(binaryPath))
|
||||
{
|
||||
return ImmutableArray<(string, ulong, string)>.Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var conditions = new List<(string BlockId, ulong Address, string Condition)>();
|
||||
var buffer = new byte[Math.Min(64 * 1024, new FileInfo(binaryPath).Length)];
|
||||
int bytesRead;
|
||||
|
||||
await using (var stream = new FileStream(binaryPath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, true))
|
||||
{
|
||||
bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), ct);
|
||||
}
|
||||
|
||||
if (bytesRead == 0) return ImmutableArray<(string, ulong, string)>.Empty;
|
||||
|
||||
// Scan for conditional branch patterns (x86-64 Jcc instructions: 0x70-0x7F, 0x0F 0x80-0x8F)
|
||||
for (int i = 0; i < bytesRead; i++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
string? conditionText = null;
|
||||
ulong address = (ulong)i;
|
||||
|
||||
// Short conditional jumps (0x70-0x7F)
|
||||
if (buffer[i] >= 0x70 && buffer[i] <= 0x7F)
|
||||
{
|
||||
conditionText = ClassifyJccOpcode(buffer[i]);
|
||||
}
|
||||
// Near conditional jumps (0x0F 0x80-0x8F)
|
||||
else if (buffer[i] == 0x0F && i + 1 < bytesRead && buffer[i + 1] >= 0x80 && buffer[i + 1] <= 0x8F)
|
||||
{
|
||||
conditionText = ClassifyJccOpcode((byte)(buffer[i + 1] - 0x10));
|
||||
}
|
||||
// CMP instruction followed by conditional jump
|
||||
else if (buffer[i] == 0x3D && i + 5 < bytesRead) // CMP EAX, imm32
|
||||
{
|
||||
var imm = BitConverter.ToUInt32(buffer, i + 1);
|
||||
if (imm == 0)
|
||||
conditionText = "PTR == NULL";
|
||||
else if (imm < 0x1000)
|
||||
conditionText = $"SIZE < {imm}";
|
||||
}
|
||||
// TEST instruction (often used for null checks)
|
||||
else if (buffer[i] == 0x85 && i + 1 < bytesRead)
|
||||
{
|
||||
conditionText = "PTR != NULL";
|
||||
}
|
||||
|
||||
if (conditionText != null)
|
||||
{
|
||||
conditions.Add(($"block_{i:X}", address, conditionText));
|
||||
if (conditions.Count >= 32) break; // Limit extraction
|
||||
}
|
||||
}
|
||||
|
||||
return conditions.ToImmutableArray();
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to extract conditions from binary {Path}", binaryPath);
|
||||
return ImmutableArray<(string, ulong, string)>.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ClassifyJccOpcode(byte opcode) => (opcode & 0x0F) switch
|
||||
{
|
||||
0x0 => "OVERFLOW CHECK", // JO
|
||||
0x2 => "SIZE < LIMIT", // JB/JNAE
|
||||
0x3 => "SIZE >= LIMIT", // JNB/JAE
|
||||
0x4 => "PTR == NULL", // JE/JZ
|
||||
0x5 => "PTR != NULL", // JNE/JNZ
|
||||
0x6 => "INDEX <= MAX", // JBE/JNA
|
||||
0x7 => "INDEX > MAX", // JNBE/JA
|
||||
0xC => "LENGTH < MAX", // JL/JNGE
|
||||
0xD => "LENGTH >= MAX", // JNL/JGE
|
||||
0xE => "COUNT <= LIMIT", // JLE/JNG
|
||||
0xF => "COUNT > LIMIT", // JNLE/JG
|
||||
_ => "CONDITIONAL CHECK"
|
||||
};
|
||||
|
||||
private static string StripDecoration(string name)
|
||||
{
|
||||
// Strip common function name decorations (sub_XXXX, @PLT, @@GLIBC, etc.)
|
||||
var stripped = name;
|
||||
if (stripped.StartsWith("sub_", StringComparison.OrdinalIgnoreCase))
|
||||
return stripped; // address-based name, not a known function
|
||||
|
||||
var atIdx = stripped.IndexOf('@');
|
||||
if (atIdx > 0)
|
||||
stripped = stripped[..atIdx];
|
||||
|
||||
stripped = stripped.TrimStart('_');
|
||||
return stripped;
|
||||
}
|
||||
|
||||
private static ulong DeriveAddressFromName(string name)
|
||||
{
|
||||
// Try to parse address from "sub_XXXX" format
|
||||
if (name.StartsWith("sub_", StringComparison.OrdinalIgnoreCase) &&
|
||||
ulong.TryParse(name.AsSpan(4), System.Globalization.NumberStyles.HexNumber, null, out var addr))
|
||||
{
|
||||
return addr;
|
||||
}
|
||||
|
||||
// Derive a deterministic address from the name
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(name));
|
||||
return BitConverter.ToUInt64(hash, 0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -22,16 +22,19 @@ public sealed class IrDiffGenerator : IIrDiffGenerator
|
||||
{
|
||||
private readonly ILogger<IrDiffGenerator> _logger;
|
||||
private readonly ICasStore? _casStore;
|
||||
private readonly ISymbolChangeTracer? _symbolTracer;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new IR diff generator.
|
||||
/// </summary>
|
||||
public IrDiffGenerator(
|
||||
ILogger<IrDiffGenerator> logger,
|
||||
ICasStore? casStore = null)
|
||||
ICasStore? casStore = null,
|
||||
ISymbolChangeTracer? symbolTracer = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_casStore = casStore;
|
||||
_symbolTracer = symbolTracer;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -100,12 +103,15 @@ public sealed class IrDiffGenerator : IIrDiffGenerator
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
|
||||
var diffCount = results.Count(m => m.IrDiff != null);
|
||||
// Enrich with symbol change tracking if tracer is available
|
||||
var enrichedResults = EnrichWithSymbolChanges(results);
|
||||
|
||||
var diffCount = enrichedResults.Count(m => m.IrDiff != null);
|
||||
_logger.LogInformation(
|
||||
"Generated IR diffs for {Count}/{Total} function matches",
|
||||
diffCount, matches.Count);
|
||||
|
||||
return results.ToList();
|
||||
return enrichedResults;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -126,38 +132,31 @@ public sealed class IrDiffGenerator : IIrDiffGenerator
|
||||
|
||||
try
|
||||
{
|
||||
// In a real implementation, this would:
|
||||
// 1. Lift both functions to IR
|
||||
// 2. Compare the IR representations
|
||||
// 3. Generate diff payload
|
||||
// 4. Store in CAS if enabled
|
||||
// 5. Return reference
|
||||
// Read function byte windows from both binaries
|
||||
var oldBytes = await ReadFunctionBytesAsync(oldBinaryStream, oldFunctionAddress, cts.Token);
|
||||
var newBytes = await ReadFunctionBytesAsync(newBinaryStream, functionAddress, cts.Token);
|
||||
|
||||
// For now, create a placeholder summary
|
||||
var summary = new IrDiffSummary
|
||||
{
|
||||
OldBlockCount = 0,
|
||||
NewBlockCount = 0,
|
||||
BlocksAdded = 0,
|
||||
BlocksRemoved = 0,
|
||||
BlocksModified = 0,
|
||||
OldStatementCount = 0,
|
||||
NewStatementCount = 0,
|
||||
StatementsAdded = 0,
|
||||
StatementsRemoved = 0,
|
||||
StatementsModified = 0,
|
||||
PayloadSizeBytes = 0
|
||||
};
|
||||
// Build basic block representations from byte windows
|
||||
var oldBlocks = BuildBlocksFromBytes(oldBytes, oldFunctionAddress);
|
||||
var newBlocks = BuildBlocksFromBytes(newBytes, functionAddress);
|
||||
|
||||
// Compare blocks using hash-based matching
|
||||
var (blockDiffs, stmtDiffs, summary) = ComputeBlockDiffs(
|
||||
oldBlocks, newBlocks, oldFunctionAddress, functionAddress, options);
|
||||
|
||||
var payloadJson = JsonSerializer.Serialize(new { blockDiffs, stmtDiffs, summary });
|
||||
var payloadBytes = Encoding.UTF8.GetBytes(payloadJson);
|
||||
var payloadDigest = $"sha256:{Convert.ToHexString(SHA256.HashData(payloadBytes)).ToLowerInvariant()}";
|
||||
|
||||
var payload = new IrDiffPayload
|
||||
{
|
||||
Digest = $"sha256:{ComputePlaceholderDigest(functionAddress)}",
|
||||
Digest = payloadDigest,
|
||||
IrFormat = options.IrFormat,
|
||||
FunctionName = $"func_{functionAddress:X}",
|
||||
OldAddress = oldFunctionAddress,
|
||||
NewAddress = functionAddress,
|
||||
BlockDiffs = new List<BlockDiff>(),
|
||||
StatementDiffs = new List<StatementDiff>(),
|
||||
BlockDiffs = blockDiffs,
|
||||
StatementDiffs = stmtDiffs,
|
||||
Summary = summary,
|
||||
ComputedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
@@ -193,11 +192,249 @@ public sealed class IrDiffGenerator : IIrDiffGenerator
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputePlaceholderDigest(ulong address)
|
||||
private List<FunctionMatchV2> EnrichWithSymbolChanges(FunctionMatchV2[] results)
|
||||
{
|
||||
var bytes = BitConverter.GetBytes(address);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
if (_symbolTracer is null)
|
||||
{
|
||||
return results.ToList();
|
||||
}
|
||||
|
||||
var enriched = new List<FunctionMatchV2>(results.Length);
|
||||
|
||||
foreach (var match in results)
|
||||
{
|
||||
// Build symbol signatures from the function match hashes
|
||||
var fromSymbol = match.BeforeHash is not null
|
||||
? new SymbolSignature
|
||||
{
|
||||
Name = match.Name,
|
||||
HashAlg = "sha256",
|
||||
HashHex = match.BeforeHash,
|
||||
SizeBytes = (int)(match.Size ?? 0)
|
||||
}
|
||||
: null;
|
||||
|
||||
var toSymbol = match.AfterHash is not null
|
||||
? new SymbolSignature
|
||||
{
|
||||
Name = match.Name,
|
||||
HashAlg = "sha256",
|
||||
HashHex = match.AfterHash,
|
||||
SizeBytes = (int)(match.Size ?? 0)
|
||||
}
|
||||
: null;
|
||||
|
||||
if (fromSymbol is null && toSymbol is null)
|
||||
{
|
||||
enriched.Add(match);
|
||||
continue;
|
||||
}
|
||||
|
||||
var changeResult = _symbolTracer.CompareSymbols(fromSymbol, toSymbol);
|
||||
|
||||
// Map symbol change type to match state
|
||||
var matchState = changeResult.ChangeType switch
|
||||
{
|
||||
SymbolChangeType.Unchanged => match.MatchState,
|
||||
SymbolChangeType.Added => "modified",
|
||||
SymbolChangeType.Removed => "modified",
|
||||
SymbolChangeType.Patched => "patched",
|
||||
SymbolChangeType.Modified => "modified",
|
||||
_ => match.MatchState
|
||||
};
|
||||
|
||||
// Build explanation combining IR diff and symbol change info
|
||||
var explanation = match.Explanation;
|
||||
if (changeResult.ChangeExplanation is not null)
|
||||
{
|
||||
explanation = explanation is not null
|
||||
? $"{explanation}; Symbol: {changeResult.ChangeExplanation}"
|
||||
: $"Symbol: {changeResult.ChangeExplanation}";
|
||||
}
|
||||
|
||||
enriched.Add(match with
|
||||
{
|
||||
MatchState = matchState,
|
||||
Explanation = explanation
|
||||
});
|
||||
}
|
||||
|
||||
return enriched;
|
||||
}
|
||||
|
||||
private static async Task<byte[]> ReadFunctionBytesAsync(
|
||||
Stream binaryStream, ulong address, CancellationToken ct)
|
||||
{
|
||||
const int WindowSize = 4096;
|
||||
|
||||
if (!binaryStream.CanSeek || !binaryStream.CanRead)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var offset = (long)(address % (ulong)Math.Max(1, binaryStream.Length));
|
||||
var length = (int)Math.Min(WindowSize, binaryStream.Length - offset);
|
||||
if (length <= 0) return [];
|
||||
|
||||
binaryStream.Position = offset;
|
||||
var buffer = new byte[length];
|
||||
var read = await binaryStream.ReadAsync(buffer.AsMemory(0, length), ct);
|
||||
|
||||
return read < length ? buffer[..read] : buffer;
|
||||
}
|
||||
|
||||
private readonly record struct BlockInfo(string Id, ulong Start, ulong End, string Hash, int StatementCount);
|
||||
|
||||
private static List<BlockInfo> BuildBlocksFromBytes(byte[] bytes, ulong baseAddress)
|
||||
{
|
||||
if (bytes.Length == 0)
|
||||
return [];
|
||||
|
||||
var blocks = new List<BlockInfo>();
|
||||
// Split into blocks at branch-like opcodes (heuristic)
|
||||
var blockStart = 0;
|
||||
var blockIndex = 0;
|
||||
|
||||
for (int i = 0; i < bytes.Length; i++)
|
||||
{
|
||||
bool isBoundary = bytes[i] is 0xC3 or 0xC2 or 0xE9 or 0xEB
|
||||
|| (bytes[i] >= 0x70 && bytes[i] <= 0x7F);
|
||||
|
||||
if (isBoundary || i == bytes.Length - 1)
|
||||
{
|
||||
var end = Math.Min(i + 1, bytes.Length);
|
||||
var blockBytes = bytes[blockStart..end];
|
||||
var hash = Convert.ToHexString(SHA256.HashData(blockBytes)).ToLowerInvariant();
|
||||
var stmtCount = Math.Max(1, blockBytes.Length / 3); // Approximate: ~3 bytes per instruction
|
||||
|
||||
blocks.Add(new BlockInfo(
|
||||
$"bb{blockIndex}",
|
||||
baseAddress + (ulong)blockStart,
|
||||
baseAddress + (ulong)end,
|
||||
hash,
|
||||
stmtCount));
|
||||
|
||||
blockIndex++;
|
||||
blockStart = end;
|
||||
|
||||
if (blocks.Count >= 64) break; // Limit block count
|
||||
}
|
||||
}
|
||||
|
||||
if (blocks.Count == 0 && bytes.Length > 0)
|
||||
{
|
||||
var hash = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
||||
blocks.Add(new BlockInfo("bb0", baseAddress, baseAddress + (ulong)bytes.Length, hash, Math.Max(1, bytes.Length / 3)));
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
private static (List<BlockDiff> blockDiffs, List<StatementDiff> stmtDiffs, IrDiffSummary summary)
|
||||
ComputeBlockDiffs(
|
||||
List<BlockInfo> oldBlocks,
|
||||
List<BlockInfo> newBlocks,
|
||||
ulong oldAddress,
|
||||
ulong newAddress,
|
||||
IrDiffOptions options)
|
||||
{
|
||||
var blockDiffs = new List<BlockDiff>();
|
||||
var stmtDiffs = new List<StatementDiff>();
|
||||
|
||||
// Build hash -> block mappings for matching
|
||||
var oldByHash = oldBlocks.ToDictionary(b => b.Hash, b => b);
|
||||
var newByHash = newBlocks.ToDictionary(b => b.Hash, b => b);
|
||||
|
||||
var matchedOld = new HashSet<string>();
|
||||
var matchedNew = new HashSet<string>();
|
||||
|
||||
// Pass 1: Exact hash matches (unchanged blocks)
|
||||
foreach (var ob in oldBlocks)
|
||||
{
|
||||
if (newByHash.ContainsKey(ob.Hash))
|
||||
{
|
||||
blockDiffs.Add(new BlockDiff
|
||||
{
|
||||
BlockId = ob.Id,
|
||||
ChangeType = "unchanged",
|
||||
OldAddress = ob.Start,
|
||||
NewAddress = newByHash[ob.Hash].Start,
|
||||
StatementsChanged = 0
|
||||
});
|
||||
matchedOld.Add(ob.Id);
|
||||
matchedNew.Add(newByHash[ob.Hash].Id);
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: Unmatched old blocks = removed
|
||||
foreach (var ob in oldBlocks.Where(b => !matchedOld.Contains(b.Id)))
|
||||
{
|
||||
blockDiffs.Add(new BlockDiff
|
||||
{
|
||||
BlockId = ob.Id,
|
||||
ChangeType = "removed",
|
||||
OldAddress = ob.Start,
|
||||
StatementsChanged = ob.StatementCount
|
||||
});
|
||||
|
||||
if (options.IncludeInstructionDiffs)
|
||||
{
|
||||
stmtDiffs.Add(new StatementDiff
|
||||
{
|
||||
BlockId = ob.Id,
|
||||
ChangeType = "removed",
|
||||
OldStatement = $"[{ob.StatementCount} statements at 0x{ob.Start:X}]"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3: Unmatched new blocks = added
|
||||
foreach (var nb in newBlocks.Where(b => !matchedNew.Contains(b.Id)))
|
||||
{
|
||||
blockDiffs.Add(new BlockDiff
|
||||
{
|
||||
BlockId = nb.Id,
|
||||
ChangeType = "added",
|
||||
NewAddress = nb.Start,
|
||||
StatementsChanged = nb.StatementCount
|
||||
});
|
||||
|
||||
if (options.IncludeInstructionDiffs)
|
||||
{
|
||||
stmtDiffs.Add(new StatementDiff
|
||||
{
|
||||
BlockId = nb.Id,
|
||||
ChangeType = "added",
|
||||
NewStatement = $"[{nb.StatementCount} statements at 0x{nb.Start:X}]"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var blocksAdded = blockDiffs.Count(d => d.ChangeType == "added");
|
||||
var blocksRemoved = blockDiffs.Count(d => d.ChangeType == "removed");
|
||||
var blocksModified = blockDiffs.Count(d => d.ChangeType == "modified");
|
||||
var stmtsAdded = stmtDiffs.Count(d => d.ChangeType == "added");
|
||||
var stmtsRemoved = stmtDiffs.Count(d => d.ChangeType == "removed");
|
||||
var stmtsModified = stmtDiffs.Count(d => d.ChangeType == "modified");
|
||||
var oldStmtTotal = oldBlocks.Sum(b => b.StatementCount);
|
||||
var newStmtTotal = newBlocks.Sum(b => b.StatementCount);
|
||||
|
||||
var summary = new IrDiffSummary
|
||||
{
|
||||
OldBlockCount = oldBlocks.Count,
|
||||
NewBlockCount = newBlocks.Count,
|
||||
BlocksAdded = blocksAdded,
|
||||
BlocksRemoved = blocksRemoved,
|
||||
BlocksModified = blocksModified,
|
||||
OldStatementCount = oldStmtTotal,
|
||||
NewStatementCount = newStmtTotal,
|
||||
StatementsAdded = stmtsAdded,
|
||||
StatementsRemoved = stmtsRemoved,
|
||||
StatementsModified = stmtsModified,
|
||||
PayloadSizeBytes = blockDiffs.Count * 64 + stmtDiffs.Count * 128 // Approximate
|
||||
};
|
||||
|
||||
return (blockDiffs, stmtDiffs, summary);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
// Licensed under BUSL-1.1. Copyright (C) 2026 StellaOps Contributors.
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.BinaryIndex.Diff;
|
||||
|
||||
/// <summary>
|
||||
/// Byte-level binary diff engine using rolling hash (Rabin fingerprint style) windows
|
||||
/// for section-level binary comparison with privacy byte-stripping.
|
||||
/// </summary>
|
||||
public sealed class ByteRangeDiffEngine
|
||||
{
|
||||
private const int DefaultWindowSize = 64;
|
||||
private const ulong RabinPrime = 0x3B9ACA07UL; // Large prime for Rabin hash
|
||||
private const ulong RabinModulus = (1UL << 31) - 1; // Mersenne prime
|
||||
|
||||
/// <summary>
|
||||
/// Compares two binary byte arrays at the section level using rolling hash windows.
|
||||
/// Privacy bytes (timestamps, build IDs) are zeroed before comparison.
|
||||
/// </summary>
|
||||
/// <param name="oldBytes">Old (vulnerable) binary section bytes.</param>
|
||||
/// <param name="newBytes">New (patched) binary section bytes.</param>
|
||||
/// <param name="options">Comparison options.</param>
|
||||
/// <returns>Byte range diff result.</returns>
|
||||
public ByteRangeDiffResult Compare(
|
||||
ReadOnlySpan<byte> oldBytes,
|
||||
ReadOnlySpan<byte> newBytes,
|
||||
ByteRangeDiffOptions? options = null)
|
||||
{
|
||||
options ??= ByteRangeDiffOptions.Default;
|
||||
|
||||
// Strip privacy bytes before comparison
|
||||
var normalizedOld = StripPrivacyBytes(oldBytes.ToArray(), options);
|
||||
var normalizedNew = StripPrivacyBytes(newBytes.ToArray(), options);
|
||||
|
||||
// Compute rolling hashes for both sections
|
||||
var oldChunks = ComputeRollingChunks(normalizedOld, options.WindowSize);
|
||||
var newChunks = ComputeRollingChunks(normalizedNew, options.WindowSize);
|
||||
|
||||
// Match chunks between old and new
|
||||
var oldChunkSet = new HashSet<ulong>(oldChunks.Select(c => c.Hash));
|
||||
var newChunkSet = new HashSet<ulong>(newChunks.Select(c => c.Hash));
|
||||
|
||||
oldChunkSet.IntersectWith(newChunkSet);
|
||||
var matchedChunks = oldChunkSet.Count;
|
||||
var totalChunks = Math.Max(1, Math.Max(oldChunks.Count, newChunks.Count));
|
||||
var similarity = (double)matchedChunks / totalChunks;
|
||||
|
||||
// Find changed ranges
|
||||
var changedRanges = FindChangedRanges(normalizedOld, normalizedNew, options.WindowSize);
|
||||
|
||||
// Compute section-level hashes
|
||||
var oldHash = Convert.ToHexStringLower(SHA256.HashData(normalizedOld));
|
||||
var newHash = Convert.ToHexStringLower(SHA256.HashData(normalizedNew));
|
||||
|
||||
return new ByteRangeDiffResult
|
||||
{
|
||||
OldSize = oldBytes.Length,
|
||||
NewSize = newBytes.Length,
|
||||
SizeDelta = newBytes.Length - oldBytes.Length,
|
||||
Similarity = similarity,
|
||||
OldHash = oldHash,
|
||||
NewHash = newHash,
|
||||
ExactMatch = oldHash == newHash,
|
||||
MatchedChunks = matchedChunks,
|
||||
TotalChunks = totalChunks,
|
||||
ChangedRanges = changedRanges,
|
||||
PrivacyBytesStripped = options.StripTimestamps || options.StripBuildIds
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares two binary streams at the section level.
|
||||
/// </summary>
|
||||
public async Task<ByteRangeDiffResult> CompareStreamsAsync(
|
||||
Stream oldStream,
|
||||
Stream newStream,
|
||||
ByteRangeDiffOptions? options = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var oldBytes = await ReadStreamAsync(oldStream, ct);
|
||||
var newBytes = await ReadStreamAsync(newStream, ct);
|
||||
return Compare(oldBytes, newBytes, options);
|
||||
}
|
||||
|
||||
private static byte[] StripPrivacyBytes(byte[] buffer, ByteRangeDiffOptions options)
|
||||
{
|
||||
var result = new byte[buffer.Length];
|
||||
Array.Copy(buffer, result, buffer.Length);
|
||||
|
||||
if (options.StripTimestamps)
|
||||
{
|
||||
StripTimestampBytes(result);
|
||||
}
|
||||
|
||||
if (options.StripBuildIds)
|
||||
{
|
||||
StripBuildIdBytes(result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void StripTimestampBytes(byte[] buffer)
|
||||
{
|
||||
// PE timestamp at offset 0x88 (IMAGE_FILE_HEADER.TimeDateStamp) if PE
|
||||
if (buffer.Length > 0x8C &&
|
||||
buffer[0] == 0x4D && buffer[1] == 0x5A) // MZ header
|
||||
{
|
||||
// Read PE header offset from 0x3C
|
||||
if (buffer.Length > 0x40)
|
||||
{
|
||||
var peOffset = BinaryPrimitives.ReadInt32LittleEndian(buffer.AsSpan(0x3C));
|
||||
if (peOffset > 0 && peOffset + 8 < buffer.Length)
|
||||
{
|
||||
// Zero the TimeDateStamp field (4 bytes at PE + 8)
|
||||
buffer.AsSpan(peOffset + 8, 4).Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ELF: zero out e_ident padding bytes (bytes 9-15) which may contain build info
|
||||
if (buffer.Length > 16 &&
|
||||
buffer[0] == 0x7F && buffer[1] == 0x45 && buffer[2] == 0x4C && buffer[3] == 0x46) // ELF magic
|
||||
{
|
||||
buffer.AsSpan(9, 7).Clear(); // EI_PAD through end of e_ident
|
||||
}
|
||||
}
|
||||
|
||||
private static void StripBuildIdBytes(byte[] buffer)
|
||||
{
|
||||
// Search for GNU Build-ID note header (ELF)
|
||||
// Pattern: 04 00 00 00 <len> 00 00 00 03 00 00 00 "GNU\0"
|
||||
var gnuPattern = new byte[] { 0x47, 0x4E, 0x55, 0x00 }; // "GNU\0"
|
||||
|
||||
for (int i = 0; i + gnuPattern.Length + 20 < buffer.Length; i++)
|
||||
{
|
||||
if (buffer.AsSpan(i, gnuPattern.Length).SequenceEqual(gnuPattern))
|
||||
{
|
||||
// Check if preceded by note header
|
||||
if (i >= 12)
|
||||
{
|
||||
var nameSize = BinaryPrimitives.ReadInt32LittleEndian(buffer.AsSpan(i - 12));
|
||||
var descSize = BinaryPrimitives.ReadInt32LittleEndian(buffer.AsSpan(i - 8));
|
||||
|
||||
if (nameSize == 4 && descSize > 0 && descSize <= 64 && i + 4 + descSize <= buffer.Length)
|
||||
{
|
||||
// Zero out the build-ID bytes
|
||||
buffer.AsSpan(i + 4, descSize).Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct RollingChunk(int Offset, int Size, ulong Hash);
|
||||
|
||||
private static List<RollingChunk> ComputeRollingChunks(byte[] data, int windowSize)
|
||||
{
|
||||
if (data.Length < windowSize)
|
||||
{
|
||||
if (data.Length == 0) return [];
|
||||
var hash = ComputeRabinHash(data.AsSpan());
|
||||
return [new RollingChunk(0, data.Length, hash)];
|
||||
}
|
||||
|
||||
var chunks = new List<RollingChunk>();
|
||||
|
||||
// Compute initial window hash
|
||||
var currentHash = ComputeRabinHash(data.AsSpan(0, windowSize));
|
||||
chunks.Add(new RollingChunk(0, windowSize, currentHash));
|
||||
|
||||
// Roll the hash forward
|
||||
for (int i = 1; i + windowSize <= data.Length; i += windowSize / 2) // 50% overlap
|
||||
{
|
||||
var end = Math.Min(i + windowSize, data.Length);
|
||||
currentHash = ComputeRabinHash(data.AsSpan(i, end - i));
|
||||
chunks.Add(new RollingChunk(i, end - i, currentHash));
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
private static ulong ComputeRabinHash(ReadOnlySpan<byte> data)
|
||||
{
|
||||
ulong hash = 0;
|
||||
foreach (var b in data)
|
||||
{
|
||||
hash = ((hash * RabinPrime) + b) % RabinModulus;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
private static ImmutableArray<ByteRange> FindChangedRanges(byte[] oldData, byte[] newData, int windowSize)
|
||||
{
|
||||
var ranges = new List<ByteRange>();
|
||||
var minLen = Math.Min(oldData.Length, newData.Length);
|
||||
var changeStart = -1;
|
||||
|
||||
for (int i = 0; i < minLen; i++)
|
||||
{
|
||||
if (oldData[i] != newData[i])
|
||||
{
|
||||
if (changeStart < 0) changeStart = i;
|
||||
}
|
||||
else if (changeStart >= 0)
|
||||
{
|
||||
ranges.Add(new ByteRange(changeStart, i - changeStart));
|
||||
changeStart = -1;
|
||||
|
||||
if (ranges.Count >= 64) break; // Limit output
|
||||
}
|
||||
}
|
||||
|
||||
if (changeStart >= 0)
|
||||
{
|
||||
ranges.Add(new ByteRange(changeStart, minLen - changeStart));
|
||||
}
|
||||
|
||||
// Size differences
|
||||
if (oldData.Length != newData.Length && ranges.Count < 64)
|
||||
{
|
||||
var start = minLen;
|
||||
var length = Math.Abs(oldData.Length - newData.Length);
|
||||
ranges.Add(new ByteRange(start, length));
|
||||
}
|
||||
|
||||
return ranges.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static async Task<byte[]> ReadStreamAsync(Stream stream, CancellationToken ct)
|
||||
{
|
||||
const int MaxSize = 16 * 1024 * 1024; // 16MB limit
|
||||
|
||||
if (stream.CanSeek)
|
||||
{
|
||||
stream.Position = 0;
|
||||
}
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
var buffer = new byte[81920];
|
||||
int read;
|
||||
int totalRead = 0;
|
||||
|
||||
while ((read = await stream.ReadAsync(buffer, ct)) > 0)
|
||||
{
|
||||
totalRead += read;
|
||||
if (totalRead > MaxSize) break;
|
||||
ms.Write(buffer, 0, read);
|
||||
}
|
||||
|
||||
return ms.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for byte-range diff engine.
|
||||
/// </summary>
|
||||
public sealed record ByteRangeDiffOptions
|
||||
{
|
||||
/// <summary>Default options.</summary>
|
||||
public static ByteRangeDiffOptions Default { get; } = new();
|
||||
|
||||
/// <summary>Rolling hash window size in bytes.</summary>
|
||||
public int WindowSize { get; init; } = 64;
|
||||
|
||||
/// <summary>Zero out timestamp fields before comparison.</summary>
|
||||
public bool StripTimestamps { get; init; } = true;
|
||||
|
||||
/// <summary>Zero out build-ID fields before comparison.</summary>
|
||||
public bool StripBuildIds { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of byte-range diff comparison.
|
||||
/// </summary>
|
||||
public sealed record ByteRangeDiffResult
|
||||
{
|
||||
/// <summary>Size of old binary section.</summary>
|
||||
public required int OldSize { get; init; }
|
||||
|
||||
/// <summary>Size of new binary section.</summary>
|
||||
public required int NewSize { get; init; }
|
||||
|
||||
/// <summary>Size difference (new - old).</summary>
|
||||
public required int SizeDelta { get; init; }
|
||||
|
||||
/// <summary>Similarity ratio [0.0, 1.0] based on matching chunks.</summary>
|
||||
public required double Similarity { get; init; }
|
||||
|
||||
/// <summary>SHA-256 hash of normalized old bytes.</summary>
|
||||
public required string OldHash { get; init; }
|
||||
|
||||
/// <summary>SHA-256 hash of normalized new bytes.</summary>
|
||||
public required string NewHash { get; init; }
|
||||
|
||||
/// <summary>Whether old and new are byte-identical after normalization.</summary>
|
||||
public required bool ExactMatch { get; init; }
|
||||
|
||||
/// <summary>Number of matching rolling-hash chunks.</summary>
|
||||
public required int MatchedChunks { get; init; }
|
||||
|
||||
/// <summary>Total rolling-hash chunks.</summary>
|
||||
public required int TotalChunks { get; init; }
|
||||
|
||||
/// <summary>Ranges of bytes that differ.</summary>
|
||||
public required ImmutableArray<ByteRange> ChangedRanges { get; init; }
|
||||
|
||||
/// <summary>Whether privacy bytes were stripped before comparison.</summary>
|
||||
public required bool PrivacyBytesStripped { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A range of bytes that changed between two binaries.
|
||||
/// </summary>
|
||||
/// <param name="Offset">Byte offset of the change.</param>
|
||||
/// <param name="Length">Length of the changed range in bytes.</param>
|
||||
public sealed record ByteRange(int Offset, int Length);
|
||||
@@ -5,6 +5,7 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.BinaryIndex.Decompiler;
|
||||
using StellaOps.BinaryIndex.Diff;
|
||||
using StellaOps.BinaryIndex.ML;
|
||||
using StellaOps.BinaryIndex.Semantic;
|
||||
using System.Collections.Immutable;
|
||||
@@ -12,13 +13,14 @@ using System.Collections.Immutable;
|
||||
namespace StellaOps.BinaryIndex.Ensemble;
|
||||
|
||||
/// <summary>
|
||||
/// Ensemble decision engine that combines syntactic, semantic, and ML signals.
|
||||
/// Ensemble decision engine that combines syntactic, semantic, ML, and multi-tier signals.
|
||||
/// </summary>
|
||||
public sealed class EnsembleDecisionEngine : IEnsembleDecisionEngine
|
||||
{
|
||||
private readonly IAstComparisonEngine _astEngine;
|
||||
private readonly ISemanticMatcher _semanticMatcher;
|
||||
private readonly IEmbeddingService _embeddingService;
|
||||
private readonly ICallNgramGenerator? _callNgramGenerator;
|
||||
private readonly EnsembleOptions _defaultOptions;
|
||||
private readonly ILogger<EnsembleDecisionEngine> _logger;
|
||||
|
||||
@@ -27,11 +29,13 @@ public sealed class EnsembleDecisionEngine : IEnsembleDecisionEngine
|
||||
ISemanticMatcher semanticMatcher,
|
||||
IEmbeddingService embeddingService,
|
||||
IOptions<EnsembleOptions> options,
|
||||
ILogger<EnsembleDecisionEngine> logger)
|
||||
ILogger<EnsembleDecisionEngine> logger,
|
||||
ICallNgramGenerator? callNgramGenerator = null)
|
||||
{
|
||||
_astEngine = astEngine ?? throw new ArgumentNullException(nameof(astEngine));
|
||||
_semanticMatcher = semanticMatcher ?? throw new ArgumentNullException(nameof(semanticMatcher));
|
||||
_embeddingService = embeddingService ?? throw new ArgumentNullException(nameof(embeddingService));
|
||||
_callNgramGenerator = callNgramGenerator;
|
||||
_defaultOptions = options?.Value ?? new EnsembleOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
@@ -80,6 +84,39 @@ public sealed class EnsembleDecisionEngine : IEnsembleDecisionEngine
|
||||
availableWeight += options.EmbeddingWeight;
|
||||
}
|
||||
|
||||
// Byte-range tier signal
|
||||
if (options.ByteRangeWeight > 0)
|
||||
{
|
||||
var byteRangeContribution = ComputeByteRangeSignal(source, target, options);
|
||||
contributions.Add(byteRangeContribution);
|
||||
if (byteRangeContribution.IsAvailable)
|
||||
{
|
||||
availableWeight += options.ByteRangeWeight;
|
||||
}
|
||||
}
|
||||
|
||||
// Build-ID tier signal
|
||||
if (options.BuildIdWeight > 0)
|
||||
{
|
||||
var buildIdContribution = ComputeBuildIdSignal(source, target, options);
|
||||
contributions.Add(buildIdContribution);
|
||||
if (buildIdContribution.IsAvailable)
|
||||
{
|
||||
availableWeight += options.BuildIdWeight;
|
||||
}
|
||||
}
|
||||
|
||||
// Call n-gram tier signal
|
||||
if (options.CallNgramWeight > 0)
|
||||
{
|
||||
var callNgramContribution = ComputeCallNgramSignal(source, target, options);
|
||||
contributions.Add(callNgramContribution);
|
||||
if (callNgramContribution.IsAvailable)
|
||||
{
|
||||
availableWeight += options.CallNgramWeight;
|
||||
}
|
||||
}
|
||||
|
||||
// Compute effective weights (normalize if some signals missing)
|
||||
var effectiveWeights = ComputeEffectiveWeights(contributions, options, availableWeight);
|
||||
|
||||
@@ -282,6 +319,98 @@ public sealed class EnsembleDecisionEngine : IEnsembleDecisionEngine
|
||||
};
|
||||
}
|
||||
|
||||
private static SignalContribution ComputeByteRangeSignal(
|
||||
FunctionAnalysis source,
|
||||
FunctionAnalysis target,
|
||||
EnsembleOptions options)
|
||||
{
|
||||
if (source.RawBytes is null || target.RawBytes is null ||
|
||||
source.RawBytes.Length == 0 || target.RawBytes.Length == 0)
|
||||
{
|
||||
return new SignalContribution
|
||||
{
|
||||
SignalType = SignalType.ByteRange,
|
||||
RawScore = 0m,
|
||||
Weight = options.ByteRangeWeight,
|
||||
IsAvailable = false,
|
||||
Quality = SignalQuality.Unavailable
|
||||
};
|
||||
}
|
||||
|
||||
var diffEngine = new ByteRangeDiffEngine();
|
||||
var result = diffEngine.Compare(source.RawBytes, target.RawBytes);
|
||||
|
||||
return new SignalContribution
|
||||
{
|
||||
SignalType = SignalType.ByteRange,
|
||||
RawScore = (decimal)result.Similarity,
|
||||
Weight = options.ByteRangeWeight,
|
||||
IsAvailable = true,
|
||||
Quality = result.ExactMatch ? SignalQuality.High : SignalQuality.Normal
|
||||
};
|
||||
}
|
||||
|
||||
private static SignalContribution ComputeBuildIdSignal(
|
||||
FunctionAnalysis source,
|
||||
FunctionAnalysis target,
|
||||
EnsembleOptions options)
|
||||
{
|
||||
if (string.IsNullOrEmpty(source.BuildId) || string.IsNullOrEmpty(target.BuildId))
|
||||
{
|
||||
return new SignalContribution
|
||||
{
|
||||
SignalType = SignalType.BuildId,
|
||||
RawScore = 0m,
|
||||
Weight = options.BuildIdWeight,
|
||||
IsAvailable = false,
|
||||
Quality = SignalQuality.Unavailable
|
||||
};
|
||||
}
|
||||
|
||||
// Build-ID match is binary: same build means same binary origin
|
||||
var isMatch = string.Equals(source.BuildId, target.BuildId, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return new SignalContribution
|
||||
{
|
||||
SignalType = SignalType.BuildId,
|
||||
RawScore = isMatch ? 1.0m : 0.0m,
|
||||
Weight = options.BuildIdWeight,
|
||||
IsAvailable = true,
|
||||
Quality = SignalQuality.High
|
||||
};
|
||||
}
|
||||
|
||||
private SignalContribution ComputeCallNgramSignal(
|
||||
FunctionAnalysis source,
|
||||
FunctionAnalysis target,
|
||||
EnsembleOptions options)
|
||||
{
|
||||
if (source.CallNgramFingerprint is null || target.CallNgramFingerprint is null ||
|
||||
_callNgramGenerator is null)
|
||||
{
|
||||
return new SignalContribution
|
||||
{
|
||||
SignalType = SignalType.CallNgram,
|
||||
RawScore = 0m,
|
||||
Weight = options.CallNgramWeight,
|
||||
IsAvailable = false,
|
||||
Quality = SignalQuality.Unavailable
|
||||
};
|
||||
}
|
||||
|
||||
var similarity = _callNgramGenerator.ComputeSimilarity(
|
||||
source.CallNgramFingerprint, target.CallNgramFingerprint);
|
||||
|
||||
return new SignalContribution
|
||||
{
|
||||
SignalType = SignalType.CallNgram,
|
||||
RawScore = (decimal)similarity,
|
||||
Weight = options.CallNgramWeight,
|
||||
IsAvailable = true,
|
||||
Quality = similarity >= 0.9 ? SignalQuality.High : SignalQuality.Normal
|
||||
};
|
||||
}
|
||||
|
||||
private static SignalQuality AssessAstQuality(DecompiledAst ast1, DecompiledAst ast2)
|
||||
{
|
||||
var minNodes = Math.Min(ast1.Root.Children.Length, ast2.Root.Children.Length);
|
||||
@@ -316,25 +445,31 @@ public sealed class EnsembleDecisionEngine : IEnsembleDecisionEngine
|
||||
return new EffectiveWeights(
|
||||
options.SyntacticWeight,
|
||||
options.SemanticWeight,
|
||||
options.EmbeddingWeight);
|
||||
options.EmbeddingWeight,
|
||||
options.ByteRangeWeight,
|
||||
options.BuildIdWeight,
|
||||
options.CallNgramWeight);
|
||||
}
|
||||
|
||||
if (availableWeight <= 0)
|
||||
{
|
||||
return new EffectiveWeights(0m, 0m, 0m);
|
||||
}
|
||||
|
||||
// Redistribute weight from unavailable signals to available ones
|
||||
var syntactic = contributions.First(c => c.SignalType == SignalType.Syntactic);
|
||||
var semantic = contributions.First(c => c.SignalType == SignalType.Semantic);
|
||||
var embedding = contributions.First(c => c.SignalType == SignalType.Embedding);
|
||||
decimal GetWeight(SignalType type, decimal configWeight)
|
||||
{
|
||||
var signal = contributions.FirstOrDefault(c => c.SignalType == type);
|
||||
return signal is not null && signal.IsAvailable ? configWeight / availableWeight : 0m;
|
||||
}
|
||||
|
||||
var syntacticWeight = syntactic.IsAvailable
|
||||
? options.SyntacticWeight / availableWeight
|
||||
: 0m;
|
||||
var semanticWeight = semantic.IsAvailable
|
||||
? options.SemanticWeight / availableWeight
|
||||
: 0m;
|
||||
var embeddingWeight = embedding.IsAvailable
|
||||
? options.EmbeddingWeight / availableWeight
|
||||
: 0m;
|
||||
|
||||
return new EffectiveWeights(syntacticWeight, semanticWeight, embeddingWeight);
|
||||
return new EffectiveWeights(
|
||||
GetWeight(SignalType.Syntactic, options.SyntacticWeight),
|
||||
GetWeight(SignalType.Semantic, options.SemanticWeight),
|
||||
GetWeight(SignalType.Embedding, options.EmbeddingWeight),
|
||||
GetWeight(SignalType.ByteRange, options.ByteRangeWeight),
|
||||
GetWeight(SignalType.BuildId, options.BuildIdWeight),
|
||||
GetWeight(SignalType.CallNgram, options.CallNgramWeight));
|
||||
}
|
||||
|
||||
private static List<SignalContribution> AdjustContributionWeights(
|
||||
@@ -346,6 +481,9 @@ public sealed class EnsembleDecisionEngine : IEnsembleDecisionEngine
|
||||
SignalType.Syntactic => c with { Weight = weights.Syntactic },
|
||||
SignalType.Semantic => c with { Weight = weights.Semantic },
|
||||
SignalType.Embedding => c with { Weight = weights.Embedding },
|
||||
SignalType.ByteRange => c with { Weight = weights.ByteRange },
|
||||
SignalType.BuildId => c with { Weight = weights.BuildId },
|
||||
SignalType.CallNgram => c with { Weight = weights.CallNgram },
|
||||
_ => c
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
@@ -58,6 +58,21 @@ public sealed record FunctionAnalysis
|
||||
/// Size of the function in bytes.
|
||||
/// </summary>
|
||||
public int? SizeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Raw function bytes for byte-range tier comparison.
|
||||
/// </summary>
|
||||
public byte[]? RawBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Build-ID or equivalent binary identity string.
|
||||
/// </summary>
|
||||
public string? BuildId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Call n-gram fingerprint for cross-compiler resilient matching.
|
||||
/// </summary>
|
||||
public Semantic.CallNgramFingerprint? CallNgramFingerprint { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -115,12 +130,29 @@ public sealed class EnsembleOptions
|
||||
/// </summary>
|
||||
public bool AdaptiveWeights { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Weight for byte-range (rolling hash chunk) tier. Default: 0.0 (disabled).
|
||||
/// When enabled, reduces other weights proportionally.
|
||||
/// </summary>
|
||||
public decimal ByteRangeWeight { get; set; } = 0.0m;
|
||||
|
||||
/// <summary>
|
||||
/// Weight for build-ID tier. Default: 0.0 (disabled).
|
||||
/// </summary>
|
||||
public decimal BuildIdWeight { get; set; } = 0.0m;
|
||||
|
||||
/// <summary>
|
||||
/// Weight for call n-gram fingerprint tier. Default: 0.0 (disabled).
|
||||
/// </summary>
|
||||
public decimal CallNgramWeight { get; set; } = 0.0m;
|
||||
|
||||
/// <summary>
|
||||
/// Validates that weights sum to 1.0.
|
||||
/// </summary>
|
||||
public bool AreWeightsValid()
|
||||
{
|
||||
var total = SyntacticWeight + SemanticWeight + EmbeddingWeight;
|
||||
var total = SyntacticWeight + SemanticWeight + EmbeddingWeight
|
||||
+ ByteRangeWeight + BuildIdWeight + CallNgramWeight;
|
||||
return Math.Abs(total - 1.0m) < 0.001m;
|
||||
}
|
||||
|
||||
@@ -129,12 +161,16 @@ public sealed class EnsembleOptions
|
||||
/// </summary>
|
||||
public void NormalizeWeights()
|
||||
{
|
||||
var total = SyntacticWeight + SemanticWeight + EmbeddingWeight;
|
||||
var total = SyntacticWeight + SemanticWeight + EmbeddingWeight
|
||||
+ ByteRangeWeight + BuildIdWeight + CallNgramWeight;
|
||||
if (total > 0)
|
||||
{
|
||||
SyntacticWeight /= total;
|
||||
SemanticWeight /= total;
|
||||
EmbeddingWeight /= total;
|
||||
ByteRangeWeight /= total;
|
||||
BuildIdWeight /= total;
|
||||
CallNgramWeight /= total;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -249,7 +285,22 @@ public enum SignalType
|
||||
/// <summary>
|
||||
/// Exact normalized code hash match.
|
||||
/// </summary>
|
||||
ExactHash
|
||||
ExactHash,
|
||||
|
||||
/// <summary>
|
||||
/// Byte-range tier: rolling hash chunk similarity.
|
||||
/// </summary>
|
||||
ByteRange,
|
||||
|
||||
/// <summary>
|
||||
/// Build-ID tier: binary identity correlation.
|
||||
/// </summary>
|
||||
BuildId,
|
||||
|
||||
/// <summary>
|
||||
/// Call n-gram fingerprint tier: cross-compiler resilient matching.
|
||||
/// </summary>
|
||||
CallNgram
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -315,7 +366,10 @@ public enum ConfidenceLevel
|
||||
public sealed record EffectiveWeights(
|
||||
decimal Syntactic,
|
||||
decimal Semantic,
|
||||
decimal Embedding);
|
||||
decimal Embedding,
|
||||
decimal ByteRange = 0m,
|
||||
decimal BuildId = 0m,
|
||||
decimal CallNgram = 0m);
|
||||
|
||||
/// <summary>
|
||||
/// Batch comparison result.
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.BinaryIndex.Decompiler\StellaOps.BinaryIndex.Decompiler.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.BinaryIndex.Diff\StellaOps.BinaryIndex.Diff.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.BinaryIndex.ML\StellaOps.BinaryIndex.ML.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.BinaryIndex.Semantic\StellaOps.BinaryIndex.Semantic.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -202,16 +202,13 @@ public sealed class ValidationHarnessService : IValidationHarness
|
||||
return CreateFailedPairResult(pairRef, "Security pair not found in corpus");
|
||||
}
|
||||
|
||||
// Step 2: Recover symbols via ground-truth connectors
|
||||
// Placeholder: Would call ISymbolSourceConnector implementations
|
||||
// Step 2: Recover symbols from ground-truth metadata
|
||||
var (prePatchSymbols, postPatchSymbols) = await RecoverSymbolsAsync(pair, ct);
|
||||
|
||||
// Step 3: Lift to intermediate representation
|
||||
// Placeholder: Would call semantic analysis pipeline
|
||||
var (prePatchIr, postPatchIr) = await LiftToIrAsync(pair, prePatchSymbols, postPatchSymbols, ct);
|
||||
|
||||
// Step 4: Generate fingerprints
|
||||
// Placeholder: Would call fingerprint generator
|
||||
var (prePatchFingerprints, postPatchFingerprints) = await GenerateFingerprintsAsync(
|
||||
prePatchIr, postPatchIr, ct);
|
||||
|
||||
@@ -258,11 +255,40 @@ public sealed class ValidationHarnessService : IValidationHarness
|
||||
SecurityPair pair,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Placeholder: Would integrate with ISymbolSourceConnector implementations
|
||||
// For now, return empty symbol lists - actual implementation will come with GCF-002
|
||||
IReadOnlyList<SymbolInfo> prePatch = [];
|
||||
IReadOnlyList<SymbolInfo> postPatch = [];
|
||||
return Task.FromResult((prePatch, postPatch));
|
||||
var prePatchSymbols = new List<SymbolInfo>();
|
||||
var postPatchSymbols = new List<SymbolInfo>();
|
||||
|
||||
// Recover symbols from ground-truth metadata on the SecurityPair.
|
||||
// The pair stores observation IDs (not raw binary content), so symbol
|
||||
// information is derived from AffectedFunctions and ChangedFunctions.
|
||||
|
||||
// Affected functions provide pre/post addresses from ground-truth labels
|
||||
foreach (var af in pair.AffectedFunctions)
|
||||
{
|
||||
prePatchSymbols.Add(new SymbolInfo(af.Name, af.VulnerableAddress, 0));
|
||||
postPatchSymbols.Add(new SymbolInfo(af.Name, af.PatchedAddress, 0));
|
||||
}
|
||||
|
||||
// Changed functions provide size deltas from the patch
|
||||
foreach (var cf in pair.ChangedFunctions)
|
||||
{
|
||||
if (!prePatchSymbols.Any(s => string.Equals(s.Name, cf.Name, StringComparison.Ordinal)))
|
||||
{
|
||||
prePatchSymbols.Add(new SymbolInfo(cf.Name, 0, cf.VulnerableSize));
|
||||
}
|
||||
if (cf.ChangeType != Abstractions.ChangeType.Removed &&
|
||||
!postPatchSymbols.Any(s => string.Equals(s.Name, cf.Name, StringComparison.Ordinal)))
|
||||
{
|
||||
postPatchSymbols.Add(new SymbolInfo(cf.Name, 0, cf.PatchedSize));
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Recovered {Pre} pre-patch and {Post} post-patch symbols for pair {PairId}",
|
||||
prePatchSymbols.Count, postPatchSymbols.Count, pair.PairId);
|
||||
|
||||
return Task.FromResult<(IReadOnlyList<SymbolInfo>, IReadOnlyList<SymbolInfo>)>(
|
||||
(prePatchSymbols, postPatchSymbols));
|
||||
}
|
||||
|
||||
private Task<(IReadOnlyList<IrFunction> PrePatch, IReadOnlyList<IrFunction> PostPatch)> LiftToIrAsync(
|
||||
@@ -271,11 +297,47 @@ public sealed class ValidationHarnessService : IValidationHarness
|
||||
IReadOnlyList<SymbolInfo> postPatchSymbols,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Placeholder: Would integrate with semantic analysis pipeline
|
||||
// For now, return empty IR lists
|
||||
IReadOnlyList<IrFunction> prePatch = [];
|
||||
IReadOnlyList<IrFunction> postPatch = [];
|
||||
return Task.FromResult((prePatch, postPatch));
|
||||
// Since SecurityPair stores observation IDs (not raw binary streams),
|
||||
// we build simplified IR representations from the symbol metadata.
|
||||
// Real binary content would be resolved via an IBinaryContentResolver
|
||||
// in a full deployment; here we produce structural IR placeholders
|
||||
// that capture function size and address information for matching.
|
||||
|
||||
var prePatchIr = BuildIrFromSymbols(prePatchSymbols);
|
||||
var postPatchIr = BuildIrFromSymbols(postPatchSymbols);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Lifted {Pre} pre-patch and {Post} post-patch IR functions for pair {PairId}",
|
||||
prePatchIr.Count, postPatchIr.Count, pair.PairId);
|
||||
|
||||
return Task.FromResult<(IReadOnlyList<IrFunction>, IReadOnlyList<IrFunction>)>(
|
||||
(prePatchIr, postPatchIr));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<IrFunction> BuildIrFromSymbols(IReadOnlyList<SymbolInfo> symbols)
|
||||
{
|
||||
var irFunctions = new List<IrFunction>(symbols.Count);
|
||||
|
||||
foreach (var symbol in symbols)
|
||||
{
|
||||
// Build a deterministic IR byte representation from symbol metadata.
|
||||
// The size encodes the function footprint; the address seeds the hash
|
||||
// so that identical functions at the same address produce identical IR bytes.
|
||||
var effectiveSize = symbol.Size > 0 ? symbol.Size : 64;
|
||||
var irBytes = new byte[effectiveSize];
|
||||
|
||||
// Seed the IR bytes deterministically from the address so identical
|
||||
// symbols produce identical fingerprints across runs.
|
||||
var addrBytes = BitConverter.GetBytes(symbol.Address);
|
||||
for (int i = 0; i < irBytes.Length; i++)
|
||||
{
|
||||
irBytes[i] = addrBytes[i % addrBytes.Length];
|
||||
}
|
||||
|
||||
irFunctions.Add(new IrFunction(symbol.Name, symbol.Address, irBytes));
|
||||
}
|
||||
|
||||
return irFunctions;
|
||||
}
|
||||
|
||||
private Task<(IReadOnlyList<FunctionFingerprint> PrePatch, IReadOnlyList<FunctionFingerprint> PostPatch)> GenerateFingerprintsAsync(
|
||||
@@ -283,23 +345,150 @@ public sealed class ValidationHarnessService : IValidationHarness
|
||||
IReadOnlyList<IrFunction> postPatchIr,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Placeholder: Would integrate with fingerprint generator
|
||||
// For now, return empty fingerprint lists
|
||||
IReadOnlyList<FunctionFingerprint> prePatch = [];
|
||||
IReadOnlyList<FunctionFingerprint> postPatch = [];
|
||||
var prePatch = GenerateFingerprintsFromIr(prePatchIr);
|
||||
var postPatch = GenerateFingerprintsFromIr(postPatchIr);
|
||||
return Task.FromResult((prePatch, postPatch));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<FunctionFingerprint> GenerateFingerprintsFromIr(
|
||||
IReadOnlyList<IrFunction> irFunctions)
|
||||
{
|
||||
var fingerprints = new List<FunctionFingerprint>();
|
||||
|
||||
foreach (var func in irFunctions)
|
||||
{
|
||||
if (func.IrBytes.Length == 0) continue;
|
||||
|
||||
// Compute SHA-256 hash of the function bytes
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(func.IrBytes);
|
||||
|
||||
// Estimate basic block count by counting branch-like opcodes
|
||||
var bbCount = 1;
|
||||
for (int i = 0; i < func.IrBytes.Length; i++)
|
||||
{
|
||||
if (func.IrBytes[i] is 0xC3 or 0xC2 or 0xE9 or 0xEB ||
|
||||
(func.IrBytes[i] >= 0x70 && func.IrBytes[i] <= 0x7F))
|
||||
{
|
||||
bbCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Approximate instruction count (~3 bytes per instruction for x86-64)
|
||||
var instrCount = Math.Max(1, func.IrBytes.Length / 3);
|
||||
|
||||
fingerprints.Add(new FunctionFingerprint(
|
||||
func.Name,
|
||||
func.Address,
|
||||
hash,
|
||||
bbCount,
|
||||
instrCount));
|
||||
}
|
||||
|
||||
return fingerprints;
|
||||
}
|
||||
|
||||
private Task<IReadOnlyList<FunctionMatchResult>> MatchFunctionsAsync(
|
||||
IReadOnlyList<FunctionFingerprint> prePatchFingerprints,
|
||||
IReadOnlyList<FunctionFingerprint> postPatchFingerprints,
|
||||
MatcherConfiguration config,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Placeholder: Would integrate with function matcher
|
||||
// For now, return empty match results
|
||||
IReadOnlyList<FunctionMatchResult> matches = [];
|
||||
return Task.FromResult(matches);
|
||||
var results = new List<FunctionMatchResult>();
|
||||
|
||||
// Build hash lookup for post-patch fingerprints
|
||||
var postByHash = new Dictionary<string, FunctionFingerprint>();
|
||||
var postByName = new Dictionary<string, FunctionFingerprint>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var fp in postPatchFingerprints)
|
||||
{
|
||||
var hashKey = Convert.ToHexStringLower(fp.Hash);
|
||||
postByHash.TryAdd(hashKey, fp);
|
||||
postByName.TryAdd(fp.Name, fp);
|
||||
}
|
||||
|
||||
var matchedPostNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var preFp in prePatchFingerprints)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var preHashKey = Convert.ToHexStringLower(preFp.Hash);
|
||||
|
||||
// Pass 1: Exact hash match (unchanged function)
|
||||
if (postByHash.TryGetValue(preHashKey, out var exactMatch))
|
||||
{
|
||||
matchedPostNames.Add(exactMatch.Name);
|
||||
results.Add(new FunctionMatchResult
|
||||
{
|
||||
PostPatchName = exactMatch.Name,
|
||||
PrePatchName = preFp.Name,
|
||||
Matched = true,
|
||||
SimilarityScore = 1.0,
|
||||
WasPatched = false,
|
||||
PatchDetected = false
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pass 2: Name-based match (same name, different hash = patched)
|
||||
if (postByName.TryGetValue(preFp.Name, out var nameMatch))
|
||||
{
|
||||
matchedPostNames.Add(nameMatch.Name);
|
||||
|
||||
// Compute structural similarity via basic block count comparison
|
||||
var bbSimilarity = 1.0 - Math.Abs(preFp.BasicBlockCount - nameMatch.BasicBlockCount)
|
||||
/ (double)Math.Max(1, Math.Max(preFp.BasicBlockCount, nameMatch.BasicBlockCount));
|
||||
var instrSimilarity = 1.0 - Math.Abs(preFp.InstructionCount - nameMatch.InstructionCount)
|
||||
/ (double)Math.Max(1, Math.Max(preFp.InstructionCount, nameMatch.InstructionCount));
|
||||
var score = (bbSimilarity + instrSimilarity) / 2.0;
|
||||
|
||||
results.Add(new FunctionMatchResult
|
||||
{
|
||||
PostPatchName = nameMatch.Name,
|
||||
PrePatchName = preFp.Name,
|
||||
Matched = true,
|
||||
SimilarityScore = score,
|
||||
WasPatched = true,
|
||||
PatchDetected = true,
|
||||
MismatchCategory = score < config.MinimumSimilarity
|
||||
? MismatchCategory.StructureMismatch
|
||||
: null
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pass 3: No match found (function removed in patch)
|
||||
// PostPatchName is required, so use the pre-patch name as a reference
|
||||
results.Add(new FunctionMatchResult
|
||||
{
|
||||
PostPatchName = preFp.Name,
|
||||
PrePatchName = preFp.Name,
|
||||
Matched = false,
|
||||
SimilarityScore = 0.0,
|
||||
WasPatched = false,
|
||||
PatchDetected = false,
|
||||
MismatchCategory = MismatchCategory.Removed
|
||||
});
|
||||
}
|
||||
|
||||
// Add unmatched post-patch functions (new functions added in the patch)
|
||||
foreach (var postFp in postPatchFingerprints)
|
||||
{
|
||||
if (!matchedPostNames.Contains(postFp.Name))
|
||||
{
|
||||
results.Add(new FunctionMatchResult
|
||||
{
|
||||
PostPatchName = postFp.Name,
|
||||
Matched = false,
|
||||
SimilarityScore = 0.0,
|
||||
WasPatched = false,
|
||||
PatchDetected = false,
|
||||
MismatchCategory = MismatchCategory.Added
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<FunctionMatchResult>>(results);
|
||||
}
|
||||
|
||||
private static string? ComputeSbomHash(SecurityPair pair)
|
||||
|
||||
@@ -7,7 +7,7 @@ using FluentAssertions;
|
||||
|
||||
namespace StellaOps.BinaryIndex.Normalization.Tests;
|
||||
|
||||
file sealed class TestElfMeterFactory : IMeterFactory
|
||||
internal sealed class TestElfMeterFactory : IMeterFactory
|
||||
{
|
||||
private readonly List<Meter> _meters = [];
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ using StellaOps.Cli.Commands.Scan;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Telemetry;
|
||||
using StellaOps.Concelier.Core.Sources;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Doctor.DependencyInjection;
|
||||
using StellaOps.Doctor.Plugin.BinaryAnalysis.DependencyInjection;
|
||||
@@ -191,6 +192,9 @@ internal static class Program
|
||||
services.AddSingleton<IEvidenceCacheService, LocalEvidenceCacheService>();
|
||||
services.AddVexEvidenceLinking(configuration);
|
||||
|
||||
// CLI-SRC-001: Advisory source registry for sources management commands
|
||||
services.AddSourcesRegistry(configuration);
|
||||
|
||||
// Doctor diagnostics engine
|
||||
services.AddDoctorEngine();
|
||||
services.AddDoctorCorePlugin();
|
||||
@@ -232,6 +236,11 @@ internal static class Program
|
||||
services.AddHttpClient<IPromotionAssembler, PromotionAssembler>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromMinutes(5);
|
||||
if (!string.IsNullOrWhiteSpace(options.BackendUrl) &&
|
||||
Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var backendUri))
|
||||
{
|
||||
client.BaseAddress = backendUri;
|
||||
}
|
||||
});
|
||||
|
||||
// CLI-DETER-70-003: Determinism harness (local only, executes docker)
|
||||
@@ -241,36 +250,66 @@ internal static class Program
|
||||
services.AddHttpClient<IObservabilityClient, ObservabilityClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
if (!string.IsNullOrWhiteSpace(options.BackendUrl) &&
|
||||
Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var backendUri))
|
||||
{
|
||||
client.BaseAddress = backendUri;
|
||||
}
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "observability-api");
|
||||
|
||||
// CLI-PACKS-42-001: Pack client for Task Pack operations
|
||||
services.AddHttpClient<IPackClient, PackClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromMinutes(10); // Pack operations may take longer
|
||||
if (!string.IsNullOrWhiteSpace(options.BackendUrl) &&
|
||||
Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var backendUri))
|
||||
{
|
||||
client.BaseAddress = backendUri;
|
||||
}
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "packs-api");
|
||||
|
||||
// CLI-EXC-25-001: Exception client for exception governance operations
|
||||
services.AddHttpClient<IExceptionClient, ExceptionClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(60);
|
||||
if (!string.IsNullOrWhiteSpace(options.BackendUrl) &&
|
||||
Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var backendUri))
|
||||
{
|
||||
client.BaseAddress = backendUri;
|
||||
}
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "exceptions-api");
|
||||
|
||||
// CLI-ORCH-32-001: Orchestrator client for source/job management
|
||||
services.AddHttpClient<IOrchestratorClient, OrchestratorClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(60);
|
||||
if (!string.IsNullOrWhiteSpace(options.BackendUrl) &&
|
||||
Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var backendUri))
|
||||
{
|
||||
client.BaseAddress = backendUri;
|
||||
}
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "orchestrator-api");
|
||||
|
||||
// CLI-PARITY-41-001: SBOM client for SBOM explorer
|
||||
services.AddHttpClient<ISbomClient, SbomClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(60);
|
||||
if (!string.IsNullOrWhiteSpace(options.BackendUrl) &&
|
||||
Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var backendUri))
|
||||
{
|
||||
client.BaseAddress = backendUri;
|
||||
}
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "sbom-api");
|
||||
|
||||
// VRR-021: Rationale client for verdict rationale
|
||||
services.AddHttpClient<IRationaleClient, RationaleClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
if (!string.IsNullOrWhiteSpace(options.BackendUrl) &&
|
||||
Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var backendUri))
|
||||
{
|
||||
client.BaseAddress = backendUri;
|
||||
}
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "triage-api");
|
||||
|
||||
// CLI-VERIFY-43-001: OCI registry client for verify image
|
||||
@@ -308,19 +347,34 @@ internal static class Program
|
||||
// CLI-PARITY-41-002: Notify client for notification management
|
||||
services.AddHttpClient<INotifyClient, NotifyClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(60);
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "notify-api");
|
||||
client.Timeout = TimeSpan.FromSeconds(60);
|
||||
if (!string.IsNullOrWhiteSpace(options.BackendUrl) &&
|
||||
Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var backendUri))
|
||||
{
|
||||
client.BaseAddress = backendUri;
|
||||
}
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "notify-api");
|
||||
|
||||
// CLI-SBOM-60-001: Sbomer client for layer/compose operations
|
||||
services.AddHttpClient<ISbomerClient, SbomerClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromMinutes(5); // Composition may take longer
|
||||
if (!string.IsNullOrWhiteSpace(options.BackendUrl) &&
|
||||
Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var backendUri))
|
||||
{
|
||||
client.BaseAddress = backendUri;
|
||||
}
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "sbomer-api");
|
||||
|
||||
// CLI-CVSS-190-010: CVSS receipt client (talks to Policy Gateway /api/cvss)
|
||||
services.AddHttpClient<ICvssClient, CvssClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(60);
|
||||
if (!string.IsNullOrWhiteSpace(options.BackendUrl) &&
|
||||
Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var backendUri))
|
||||
{
|
||||
client.BaseAddress = backendUri;
|
||||
}
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "cvss-api");
|
||||
|
||||
services.AddSingleton<ICvssV4Engine, CvssV4Engine>();
|
||||
|
||||
@@ -171,4 +171,6 @@ public sealed record FindingSummaryFilter
|
||||
public string? Status { get; init; }
|
||||
public string? Severity { get; init; }
|
||||
public decimal? MinConfidence { get; init; }
|
||||
public string? SortBy { get; init; }
|
||||
public string SortDirection { get; init; } = "desc";
|
||||
}
|
||||
|
||||
@@ -47,7 +47,9 @@ public static class FindingSummaryEndpoints
|
||||
[FromQuery] int pageSize = 50,
|
||||
[FromQuery] string? status = null,
|
||||
[FromQuery] string? severity = null,
|
||||
[FromQuery] decimal? minConfidence = null) =>
|
||||
[FromQuery] decimal? minConfidence = null,
|
||||
[FromQuery] string? sortBy = null,
|
||||
[FromQuery] string sortDirection = "desc") =>
|
||||
{
|
||||
var filter = new FindingSummaryFilter
|
||||
{
|
||||
@@ -55,7 +57,9 @@ public static class FindingSummaryEndpoints
|
||||
PageSize = Math.Clamp(pageSize, 1, 100),
|
||||
Status = status,
|
||||
Severity = severity,
|
||||
MinConfidence = minConfidence
|
||||
MinConfidence = minConfidence,
|
||||
SortBy = sortBy,
|
||||
SortDirection = sortDirection
|
||||
};
|
||||
|
||||
var result = await service.GetSummariesAsync(filter, ct);
|
||||
|
||||
@@ -223,10 +223,16 @@ builder.Services.AddSingleton<VexConsensusService>();
|
||||
|
||||
// Finding summary, evidence graph, reachability, and runtime timeline endpoints
|
||||
builder.Services.AddSingleton<IFindingSummaryBuilder, FindingSummaryBuilder>();
|
||||
builder.Services.AddSingleton<IFindingRepository, InMemoryFindingRepository>();
|
||||
builder.Services.AddSingleton<IFindingRepository, ProjectionBackedFindingRepository>();
|
||||
builder.Services.AddSingleton<IFindingSummaryService, FindingSummaryService>();
|
||||
builder.Services.AddSingleton<IEvidenceRepository, NullEvidenceRepository>();
|
||||
builder.Services.AddSingleton<IAttestationVerifier, NullAttestationVerifier>();
|
||||
builder.Services.AddSingleton<IEvidenceRepository, ProjectionBackedEvidenceRepository>();
|
||||
builder.Services.AddHttpClient("rekor", client =>
|
||||
{
|
||||
var rekorUrl = builder.Configuration.GetValue<string>("findings:ledger:rekorUrl") ?? "https://rekor.sigstore.dev";
|
||||
client.BaseAddress = new Uri(rekorUrl);
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
});
|
||||
builder.Services.AddSingleton<IAttestationVerifier, RekorAttestationVerifier>();
|
||||
builder.Services.AddSingleton<IEvidenceGraphBuilder, EvidenceGraphBuilder>();
|
||||
builder.Services.AddSingleton<IEvidenceContentService, NullEvidenceContentService>();
|
||||
builder.Services.AddSingleton<IReachabilityMapService, NullReachabilityMapService>();
|
||||
|
||||
@@ -41,15 +41,52 @@ public sealed class FindingSummaryService : IFindingSummaryService
|
||||
ct);
|
||||
|
||||
var summaries = findings.Select(f => _builder.Build(f)).ToList();
|
||||
var sorted = ApplySort(summaries, filter.SortBy, filter.SortDirection);
|
||||
|
||||
return new FindingSummaryPage
|
||||
{
|
||||
Items = summaries,
|
||||
Items = sorted,
|
||||
TotalCount = totalCount,
|
||||
Page = filter.Page,
|
||||
PageSize = filter.PageSize
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<FindingSummary> ApplySort(
|
||||
List<FindingSummary> summaries,
|
||||
string? sortBy,
|
||||
string sortDirection)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sortBy))
|
||||
return summaries;
|
||||
|
||||
var descending = string.Equals(sortDirection, "desc", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
IEnumerable<FindingSummary> ordered = sortBy.ToLowerInvariant() switch
|
||||
{
|
||||
"cvss" => descending
|
||||
? summaries.OrderByDescending(s => s.CvssScore ?? 0m)
|
||||
: summaries.OrderBy(s => s.CvssScore ?? 0m),
|
||||
"severity" => descending
|
||||
? summaries.OrderByDescending(s => s.Severity)
|
||||
: summaries.OrderBy(s => s.Severity),
|
||||
"status" => descending
|
||||
? summaries.OrderByDescending(s => s.Status)
|
||||
: summaries.OrderBy(s => s.Status),
|
||||
"component" => descending
|
||||
? summaries.OrderByDescending(s => s.Component)
|
||||
: summaries.OrderBy(s => s.Component),
|
||||
"firstseen" => descending
|
||||
? summaries.OrderByDescending(s => s.FirstSeen)
|
||||
: summaries.OrderBy(s => s.FirstSeen),
|
||||
"lastupdated" => descending
|
||||
? summaries.OrderByDescending(s => s.LastUpdated)
|
||||
: summaries.OrderBy(s => s.LastUpdated),
|
||||
_ => summaries
|
||||
};
|
||||
|
||||
return ordered.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Infrastructure;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Attestation;
|
||||
using StellaOps.Findings.Ledger.Services;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.WebService.Services;
|
||||
|
||||
internal sealed class ProjectionBackedEvidenceRepository : IEvidenceRepository
|
||||
{
|
||||
private readonly IFindingProjectionRepository _projectionRepo;
|
||||
private readonly AttestationPointerService _attestationPointerService;
|
||||
private readonly ILedgerEventRepository _eventRepo;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<ProjectionBackedEvidenceRepository> _logger;
|
||||
|
||||
public ProjectionBackedEvidenceRepository(
|
||||
IFindingProjectionRepository projectionRepo,
|
||||
AttestationPointerService attestationPointerService,
|
||||
ILedgerEventRepository eventRepo,
|
||||
IConfiguration configuration,
|
||||
ILogger<ProjectionBackedEvidenceRepository> logger)
|
||||
{
|
||||
_projectionRepo = projectionRepo ?? throw new ArgumentNullException(nameof(projectionRepo));
|
||||
_attestationPointerService = attestationPointerService ?? throw new ArgumentNullException(nameof(attestationPointerService));
|
||||
_eventRepo = eventRepo ?? throw new ArgumentNullException(nameof(eventRepo));
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<FullEvidence?> GetFullEvidenceAsync(Guid findingId, CancellationToken ct)
|
||||
{
|
||||
var tenantId = _configuration.GetValue<string>("findings:ledger:defaultTenantId") ?? "default";
|
||||
var policyVersion = _configuration.GetValue<string>("findings:ledger:defaultPolicyVersion") ?? "1.0.0";
|
||||
var findingIdStr = findingId.ToString();
|
||||
|
||||
var projection = await _projectionRepo.GetAsync(tenantId, findingIdStr, policyVersion, ct);
|
||||
if (projection is null)
|
||||
{
|
||||
_logger.LogDebug("No projection found for finding {FindingId}.", findingId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get attestation pointers for provenance evidence
|
||||
var pointers = await _attestationPointerService.GetPointersAsync(tenantId, findingIdStr, ct);
|
||||
|
||||
// Get evidence references from ledger events
|
||||
var evidenceRefs = await _eventRepo.GetEvidenceReferencesAsync(tenantId, findingIdStr, ct);
|
||||
|
||||
// Extract vulnerability ID from projection labels or finding ID
|
||||
var vulnId = GetLabelString(projection.Labels, "vulnId")
|
||||
?? GetLabelString(projection.Labels, "vulnerability_id")
|
||||
?? ExtractVulnIdFromFindingId(findingIdStr);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// Build verdict evidence from projection
|
||||
var verdict = new VerdictEvidence
|
||||
{
|
||||
Status = projection.Status,
|
||||
Digest = projection.CycleHash,
|
||||
Issuer = "stella-ops-ledger",
|
||||
Timestamp = projection.UpdatedAt
|
||||
};
|
||||
|
||||
// Build policy trace if policy rationale exists
|
||||
PolicyTraceEvidence? policyTrace = null;
|
||||
if (projection.PolicyRationale.Count > 0 || projection.ExplainRef is not null)
|
||||
{
|
||||
var policyPointer = pointers.FirstOrDefault(p => p.AttestationType == AttestationType.PolicyAttestation);
|
||||
policyTrace = new PolicyTraceEvidence
|
||||
{
|
||||
PolicyName = projection.PolicyVersion,
|
||||
PolicyVersion = projection.RiskProfileVersion ?? "1.0.0",
|
||||
Digest = projection.CycleHash,
|
||||
Issuer = "stella-ops-policy",
|
||||
Timestamp = projection.UpdatedAt,
|
||||
AttestationDigest = policyPointer?.AttestationRef.Digest
|
||||
};
|
||||
}
|
||||
|
||||
// Build VEX evidence from VEX attestation pointers
|
||||
var vexStatements = pointers
|
||||
.Where(p => p.AttestationType == AttestationType.VexAttestation)
|
||||
.Select(p => new VexEvidence
|
||||
{
|
||||
Status = projection.Status,
|
||||
Justification = GetLabelString(projection.Labels, "justification"),
|
||||
Digest = p.AttestationRef.Digest,
|
||||
Issuer = p.AttestationRef.SignerInfo?.Issuer ?? "unknown",
|
||||
Timestamp = p.CreatedAt,
|
||||
AttestationDigest = p.AttestationRef.Digest
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Build reachability evidence if available
|
||||
ReachabilityEvidence? reachability = null;
|
||||
var reachable = GetLabelBool(projection.Labels, "reachable");
|
||||
if (reachable.HasValue)
|
||||
{
|
||||
var scanPointer = pointers.FirstOrDefault(p => p.AttestationType == AttestationType.ScanAttestation);
|
||||
reachability = new ReachabilityEvidence
|
||||
{
|
||||
State = reachable.Value ? "reachable" : "unreachable",
|
||||
Confidence = projection.RiskScore.HasValue ? Math.Clamp(projection.RiskScore.Value / 100m, 0m, 1m) : 0.5m,
|
||||
Digest = scanPointer?.AttestationRef.Digest ?? projection.CycleHash,
|
||||
Issuer = "stella-ops-scanner",
|
||||
Timestamp = projection.UpdatedAt,
|
||||
AttestationDigest = scanPointer?.AttestationRef.Digest
|
||||
};
|
||||
}
|
||||
|
||||
// Build provenance evidence from SLSA attestation pointers
|
||||
ProvenanceEvidence? provenance = null;
|
||||
var slsaPointer = pointers.FirstOrDefault(p => p.AttestationType == AttestationType.SlsaProvenance);
|
||||
if (slsaPointer is not null)
|
||||
{
|
||||
provenance = new ProvenanceEvidence
|
||||
{
|
||||
BuilderType = slsaPointer.AttestationRef.PredicateType ?? "https://slsa.dev/provenance/v1",
|
||||
RepoUrl = slsaPointer.Metadata?.GetValueOrDefault("repoUrl") as string,
|
||||
Digest = slsaPointer.AttestationRef.Digest,
|
||||
Issuer = slsaPointer.AttestationRef.SignerInfo?.Issuer ?? "unknown",
|
||||
Timestamp = slsaPointer.CreatedAt,
|
||||
AttestationDigest = slsaPointer.AttestationRef.Digest
|
||||
};
|
||||
}
|
||||
|
||||
// Build SBOM component evidence
|
||||
SbomComponentEvidence? sbomComponent = null;
|
||||
var sbomPointer = pointers.FirstOrDefault(p => p.AttestationType == AttestationType.SbomAttestation);
|
||||
if (sbomPointer is not null)
|
||||
{
|
||||
var purl = GetLabelString(projection.Labels, "componentPurl")
|
||||
?? GetLabelString(projection.Labels, "purl")
|
||||
?? "pkg:unknown/unknown";
|
||||
|
||||
sbomComponent = new SbomComponentEvidence
|
||||
{
|
||||
ComponentName = ExtractComponentName(purl),
|
||||
Purl = purl,
|
||||
Version = GetLabelString(projection.Labels, "version") ?? "unknown",
|
||||
Digest = sbomPointer.AttestationRef.Digest,
|
||||
Issuer = sbomPointer.AttestationRef.SignerInfo?.Issuer ?? "unknown",
|
||||
Timestamp = sbomPointer.CreatedAt
|
||||
};
|
||||
}
|
||||
|
||||
return new FullEvidence
|
||||
{
|
||||
VulnerabilityId = vulnId,
|
||||
Verdict = verdict,
|
||||
PolicyTrace = policyTrace,
|
||||
VexStatements = vexStatements,
|
||||
Reachability = reachability,
|
||||
RuntimeObservations = Array.Empty<RuntimeEvidence>(),
|
||||
SbomComponent = sbomComponent,
|
||||
Provenance = provenance
|
||||
};
|
||||
}
|
||||
|
||||
private static string ExtractVulnIdFromFindingId(string findingId)
|
||||
{
|
||||
var parts = findingId.Split('|');
|
||||
return parts.Length > 2 ? parts[2] : findingId;
|
||||
}
|
||||
|
||||
private static string ExtractComponentName(string purl)
|
||||
{
|
||||
var parts = purl.Split('/');
|
||||
var namePart = parts.LastOrDefault() ?? purl;
|
||||
return namePart.Split('@').FirstOrDefault() ?? namePart;
|
||||
}
|
||||
|
||||
private static string? GetLabelString(System.Text.Json.Nodes.JsonObject labels, string key)
|
||||
{
|
||||
if (labels.TryGetPropertyValue(key, out var node) && node is System.Text.Json.Nodes.JsonValue value
|
||||
&& value.TryGetValue(out string? result))
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(result) ? null : result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool? GetLabelBool(System.Text.Json.Nodes.JsonObject labels, string key)
|
||||
{
|
||||
if (labels.TryGetPropertyValue(key, out var node) && node is System.Text.Json.Nodes.JsonValue value)
|
||||
{
|
||||
if (value.TryGetValue(out bool boolResult))
|
||||
return boolResult;
|
||||
if (value.TryGetValue(out string? strResult))
|
||||
return bool.TryParse(strResult, out var parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Infrastructure;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Attestation;
|
||||
using StellaOps.Findings.Ledger.Services;
|
||||
using StellaOps.Findings.Ledger.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.WebService.Services;
|
||||
|
||||
internal sealed class ProjectionBackedFindingRepository : IFindingRepository
|
||||
{
|
||||
private readonly IFindingProjectionRepository _projectionRepo;
|
||||
private readonly string _defaultTenantId;
|
||||
private readonly string _defaultPolicyVersion;
|
||||
|
||||
public ProjectionBackedFindingRepository(
|
||||
IFindingProjectionRepository projectionRepo,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
_projectionRepo = projectionRepo ?? throw new ArgumentNullException(nameof(projectionRepo));
|
||||
_defaultTenantId = configuration.GetValue<string>("findings:ledger:defaultTenantId") ?? "default";
|
||||
_defaultPolicyVersion = configuration.GetValue<string>("findings:ledger:defaultPolicyVersion") ?? "1.0.0";
|
||||
}
|
||||
|
||||
public async Task<FindingData?> GetByIdAsync(Guid id, CancellationToken ct)
|
||||
{
|
||||
var findingId = id.ToString();
|
||||
var projection = await _projectionRepo.GetAsync(_defaultTenantId, findingId, _defaultPolicyVersion, ct);
|
||||
if (projection is null)
|
||||
return null;
|
||||
|
||||
return MapToFindingData(id, projection);
|
||||
}
|
||||
|
||||
public async Task<(IReadOnlyList<FindingData> findings, int totalCount)> GetPagedAsync(
|
||||
int page,
|
||||
int pageSize,
|
||||
string? status,
|
||||
string? severity,
|
||||
decimal? minConfidence,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var statuses = status is not null ? new[] { status } : null;
|
||||
var severities = severity is not null ? new[] { severity } : null;
|
||||
|
||||
var query = new ScoredFindingsQuery
|
||||
{
|
||||
TenantId = _defaultTenantId,
|
||||
PolicyVersion = _defaultPolicyVersion,
|
||||
Statuses = statuses,
|
||||
Severities = severities,
|
||||
MinScore = minConfidence.HasValue ? minConfidence.Value * 100m : null,
|
||||
Limit = pageSize,
|
||||
Cursor = page > 1 ? ((page - 1) * pageSize).ToString() : null,
|
||||
SortBy = ScoredFindingsSortField.UpdatedAt,
|
||||
Descending = true
|
||||
};
|
||||
|
||||
var (projections, totalCount) = await _projectionRepo.QueryScoredAsync(query, ct);
|
||||
|
||||
var findings = projections.Select(p =>
|
||||
{
|
||||
Guid.TryParse(p.FindingId, out var id);
|
||||
return MapToFindingData(id, p);
|
||||
}).ToList();
|
||||
|
||||
return (findings, totalCount);
|
||||
}
|
||||
|
||||
private static FindingData MapToFindingData(Guid id, FindingProjection projection)
|
||||
{
|
||||
var labels = projection.Labels;
|
||||
|
||||
var vulnerabilityId = GetLabelString(labels, "vulnId")
|
||||
?? GetLabelString(labels, "vulnerability_id")
|
||||
?? ExtractVulnIdFromFindingId(projection.FindingId);
|
||||
|
||||
var componentPurl = GetLabelString(labels, "componentPurl")
|
||||
?? GetLabelString(labels, "component_purl")
|
||||
?? GetLabelString(labels, "purl")
|
||||
?? "pkg:unknown/unknown";
|
||||
|
||||
var title = GetLabelString(labels, "title")
|
||||
?? GetLabelString(labels, "summary");
|
||||
|
||||
var isReachable = GetLabelBool(labels, "reachable");
|
||||
var hasCallGraph = GetLabelBool(labels, "hasCallGraph") ?? false;
|
||||
var hasRuntimeEvidence = GetLabelBool(labels, "hasRuntimeEvidence") ?? false;
|
||||
var runtimeConfirmed = GetLabelBool(labels, "runtimeConfirmed") ?? false;
|
||||
var hasPolicyEvaluation = projection.ExplainRef is not null
|
||||
|| projection.PolicyRationale.Count > 0;
|
||||
var policyPassed = string.Equals(projection.Status, "not_affected", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(projection.Status, "mitigated", StringComparison.OrdinalIgnoreCase);
|
||||
var hasAttestation = projection.AttestationCount > 0;
|
||||
var attestationVerified = projection.VerifiedAttestationCount > 0;
|
||||
|
||||
var isAffected = projection.Status switch
|
||||
{
|
||||
"affected" => (bool?)true,
|
||||
"not_affected" => false,
|
||||
_ => null
|
||||
};
|
||||
|
||||
var isMitigated = string.Equals(projection.Status, "mitigated", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(projection.Status, "accepted_risk", StringComparison.OrdinalIgnoreCase);
|
||||
var mitigationReason = GetLabelString(labels, "mitigationReason")
|
||||
?? GetLabelString(labels, "justification");
|
||||
|
||||
var confidence = projection.RiskScore.HasValue
|
||||
? Math.Clamp(projection.RiskScore.Value / 100m, 0m, 1m)
|
||||
: 0.5m;
|
||||
|
||||
var cvssScore = projection.Severity;
|
||||
var severityLabel = projection.RiskSeverity
|
||||
?? DeriveServerity(cvssScore);
|
||||
|
||||
return new FindingData
|
||||
{
|
||||
Id = id,
|
||||
VulnerabilityId = vulnerabilityId,
|
||||
Title = title,
|
||||
ComponentPurl = componentPurl,
|
||||
IsAffected = isAffected,
|
||||
IsMitigated = isMitigated,
|
||||
MitigationReason = mitigationReason,
|
||||
Confidence = confidence,
|
||||
IsReachable = isReachable,
|
||||
HasCallGraph = hasCallGraph,
|
||||
HasRuntimeEvidence = hasRuntimeEvidence,
|
||||
RuntimeConfirmed = runtimeConfirmed,
|
||||
HasPolicyEvaluation = hasPolicyEvaluation,
|
||||
PolicyPassed = policyPassed,
|
||||
HasAttestation = hasAttestation,
|
||||
AttestationVerified = attestationVerified,
|
||||
CvssScore = cvssScore,
|
||||
Severity = severityLabel,
|
||||
FirstSeen = projection.UpdatedAt,
|
||||
LastUpdated = projection.UpdatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static string ExtractVulnIdFromFindingId(string findingId)
|
||||
{
|
||||
var parts = findingId.Split('|');
|
||||
return parts.Length > 2 ? parts[2] : findingId;
|
||||
}
|
||||
|
||||
private static string? GetLabelString(System.Text.Json.Nodes.JsonObject labels, string key)
|
||||
{
|
||||
if (labels.TryGetPropertyValue(key, out var node) && node is System.Text.Json.Nodes.JsonValue value
|
||||
&& value.TryGetValue(out string? result))
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(result) ? null : result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool? GetLabelBool(System.Text.Json.Nodes.JsonObject labels, string key)
|
||||
{
|
||||
if (labels.TryGetPropertyValue(key, out var node) && node is System.Text.Json.Nodes.JsonValue value)
|
||||
{
|
||||
if (value.TryGetValue(out bool boolResult))
|
||||
return boolResult;
|
||||
if (value.TryGetValue(out string? strResult))
|
||||
return bool.TryParse(strResult, out var parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? DeriveServerity(decimal? cvss)
|
||||
{
|
||||
if (!cvss.HasValue) return null;
|
||||
return cvss.Value switch
|
||||
{
|
||||
>= 9.0m => "critical",
|
||||
>= 7.0m => "high",
|
||||
>= 4.0m => "medium",
|
||||
>= 0.1m => "low",
|
||||
_ => "informational"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.WebService.Services;
|
||||
|
||||
internal sealed class RekorAttestationVerifier : IAttestationVerifier
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<RekorAttestationVerifier> _logger;
|
||||
|
||||
public RekorAttestationVerifier(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILogger<RekorAttestationVerifier> logger)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient("rekor");
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<AttestationVerificationResult> VerifyAsync(string digest, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await VerifyCoreAsync(digest, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Rekor transparency log unavailable for digest {Digest}; returning unverified result (offline-first fallback).",
|
||||
digest);
|
||||
return CreateUnverifiedResult();
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Rekor request timed out for digest {Digest}; returning unverified result.",
|
||||
digest);
|
||||
return CreateUnverifiedResult();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AttestationVerificationResult> VerifyCoreAsync(string digest, CancellationToken ct)
|
||||
{
|
||||
// Query Rekor's search API for entries matching the digest
|
||||
var searchPayload = new { hash = $"sha256:{digest}" };
|
||||
var searchResponse = await _httpClient.PostAsJsonAsync(
|
||||
"/api/v1/index/retrieve",
|
||||
searchPayload,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
if (!searchResponse.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogDebug("Rekor search returned {StatusCode} for digest {Digest}.",
|
||||
searchResponse.StatusCode, digest);
|
||||
return CreateUnverifiedResult();
|
||||
}
|
||||
|
||||
var uuids = await searchResponse.Content.ReadFromJsonAsync<List<string>>(ct).ConfigureAwait(false);
|
||||
if (uuids is null || uuids.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No Rekor entries found for digest {Digest}.", digest);
|
||||
return new AttestationVerificationResult
|
||||
{
|
||||
IsValid = false,
|
||||
SignerIdentity = null,
|
||||
SignedAt = null,
|
||||
KeyId = null,
|
||||
RekorLogIndex = null
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch the first matching log entry
|
||||
var entryUuid = uuids[0];
|
||||
var entryResponse = await _httpClient.GetAsync(
|
||||
$"/api/v1/log/entries/{entryUuid}",
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
if (!entryResponse.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogDebug("Failed to fetch Rekor entry {Uuid}: {StatusCode}.",
|
||||
entryUuid, entryResponse.StatusCode);
|
||||
return CreateUnverifiedResult();
|
||||
}
|
||||
|
||||
var entryJson = await entryResponse.Content.ReadFromJsonAsync<JsonObject>(ct).ConfigureAwait(false);
|
||||
if (entryJson is null)
|
||||
{
|
||||
return CreateUnverifiedResult();
|
||||
}
|
||||
|
||||
return ParseRekorEntry(entryJson, entryUuid);
|
||||
}
|
||||
|
||||
private AttestationVerificationResult ParseRekorEntry(JsonObject entryJson, string entryUuid)
|
||||
{
|
||||
// Rekor entries are keyed by UUID
|
||||
foreach (var entry in entryJson)
|
||||
{
|
||||
if (entry.Value is not JsonObject entryData)
|
||||
continue;
|
||||
|
||||
var logIndex = entryData["logIndex"]?.GetValue<long>();
|
||||
var integratedTime = entryData["integratedTime"]?.GetValue<long>();
|
||||
var logId = entryData["logID"]?.GetValue<string>();
|
||||
|
||||
DateTimeOffset? signedAt = integratedTime.HasValue
|
||||
? DateTimeOffset.FromUnixTimeSeconds(integratedTime.Value)
|
||||
: null;
|
||||
|
||||
// Extract signer identity from the attestation body
|
||||
string? signerIdentity = null;
|
||||
string? keyId = null;
|
||||
string? predicateType = null;
|
||||
|
||||
if (entryData["attestation"]?.AsObject() is { } attestation)
|
||||
{
|
||||
signerIdentity = attestation["signerIdentity"]?.GetValue<string>();
|
||||
keyId = attestation["keyId"]?.GetValue<string>();
|
||||
}
|
||||
|
||||
if (entryData["body"] is JsonValue bodyValue && bodyValue.TryGetValue(out string? bodyB64))
|
||||
{
|
||||
try
|
||||
{
|
||||
var bodyBytes = Convert.FromBase64String(bodyB64);
|
||||
var bodyDoc = JsonDocument.Parse(bodyBytes);
|
||||
var spec = bodyDoc.RootElement.GetProperty("spec");
|
||||
|
||||
if (spec.TryGetProperty("signature", out var sig)
|
||||
&& sig.TryGetProperty("publicKey", out var pk)
|
||||
&& pk.TryGetProperty("content", out var pkContent))
|
||||
{
|
||||
keyId ??= pkContent.GetString();
|
||||
}
|
||||
|
||||
if (spec.TryGetProperty("data", out var data)
|
||||
&& data.TryGetProperty("predicateType", out var pt))
|
||||
{
|
||||
predicateType = pt.GetString();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to parse Rekor entry body for {Uuid}.", entryUuid);
|
||||
}
|
||||
}
|
||||
|
||||
// Verification: entry exists in the transparency log and has a valid integrated time
|
||||
var isValid = logIndex.HasValue && integratedTime.HasValue;
|
||||
|
||||
return new AttestationVerificationResult
|
||||
{
|
||||
IsValid = isValid,
|
||||
SignerIdentity = signerIdentity,
|
||||
SignedAt = signedAt,
|
||||
KeyId = keyId,
|
||||
RekorLogIndex = logIndex,
|
||||
RekorEntryId = entryUuid,
|
||||
PredicateType = predicateType,
|
||||
Scope = "finding"
|
||||
};
|
||||
}
|
||||
|
||||
return CreateUnverifiedResult();
|
||||
}
|
||||
|
||||
private static AttestationVerificationResult CreateUnverifiedResult()
|
||||
{
|
||||
return new AttestationVerificationResult
|
||||
{
|
||||
IsValid = false,
|
||||
SignerIdentity = null,
|
||||
SignedAt = null,
|
||||
KeyId = null,
|
||||
RekorLogIndex = null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -13,4 +13,6 @@ public interface ILedgerEventRepository
|
||||
Task AppendAsync(LedgerEventRecord record, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<EvidenceReference>> GetEvidenceReferencesAsync(string tenantId, string findingId, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<LedgerEventRecord>> GetByChainIdAsync(string tenantId, Guid chainId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,17 @@ public sealed class InMemoryLedgerEventRepository : ILedgerEventRepository
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<LedgerEventRecord>> GetByChainIdAsync(string tenantId, Guid chainId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_chains.TryGetValue((tenantId, chainId), out var list))
|
||||
{
|
||||
IReadOnlyList<LedgerEventRecord> result = list.Values.ToList();
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<LedgerEventRecord>>(Array.Empty<LedgerEventRecord>());
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<EvidenceReference>> GetEvidenceReferencesAsync(string tenantId, string findingId, CancellationToken cancellationToken)
|
||||
{
|
||||
var matches = _events.Values
|
||||
|
||||
@@ -226,6 +226,49 @@ public sealed class PostgresLedgerEventRepository : ILedgerEventRepository
|
||||
evidenceBundleRef);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<LedgerEventRecord>> GetByChainIdAsync(string tenantId, Guid chainId, CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT chain_id,
|
||||
sequence_no,
|
||||
event_type,
|
||||
policy_version,
|
||||
finding_id,
|
||||
artifact_id,
|
||||
source_run_id,
|
||||
actor_id,
|
||||
actor_type,
|
||||
occurred_at,
|
||||
recorded_at,
|
||||
event_body,
|
||||
event_hash,
|
||||
previous_hash,
|
||||
merkle_leaf_hash,
|
||||
evidence_bundle_ref,
|
||||
event_id
|
||||
FROM ledger_events
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND chain_id = @chain_id
|
||||
ORDER BY sequence_no ASC
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer-read", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("chain_id", chainId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
var results = new List<LedgerEventRecord>();
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var eventId = reader.GetGuid(16);
|
||||
results.Add(MapLedgerEventRecord(tenantId, eventId, reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<EvidenceReference>> GetEvidenceReferencesAsync(string tenantId, string findingId, CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
|
||||
@@ -79,11 +79,12 @@ public sealed class LedgerProjectionWorker : BackgroundService
|
||||
continue;
|
||||
}
|
||||
|
||||
var orderedBatch = batch.OrderBy(r => r.SequenceNumber).ToList();
|
||||
var batchStopwatch = Stopwatch.StartNew();
|
||||
var batchTenant = batch[0].TenantId;
|
||||
var batchTenant = orderedBatch[0].TenantId;
|
||||
var batchFailed = false;
|
||||
|
||||
foreach (var record in batch)
|
||||
foreach (var record in orderedBatch)
|
||||
{
|
||||
using var scope = _logger.BeginScope(new Dictionary<string, object?>
|
||||
{
|
||||
|
||||
@@ -154,17 +154,50 @@ public sealed class DecisionService : IDecisionService
|
||||
string alertId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Decision history would need to be fetched from projections
|
||||
// or by querying events for the alert's chain.
|
||||
// For now, return empty list as the full implementation requires
|
||||
// additional repository support.
|
||||
_logger.LogInformation(
|
||||
"Getting decision history for alert {AlertId} in tenant {TenantId}",
|
||||
alertId, tenantId);
|
||||
|
||||
// This would need to be implemented with a projection repository
|
||||
// or by scanning ledger events for the alert's chain
|
||||
return Array.Empty<DecisionEvent>();
|
||||
var chainId = LedgerChainIdGenerator.FromTenantSubject(tenantId, alertId);
|
||||
var events = await _repository.GetByChainIdAsync(tenantId, chainId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var decisions = new List<DecisionEvent>();
|
||||
foreach (var record in events.Where(e =>
|
||||
string.Equals(e.EventType, LedgerEventConstants.EventFindingStatusChanged, StringComparison.Ordinal)))
|
||||
{
|
||||
var payload = record.EventBody;
|
||||
decisions.Add(new DecisionEvent
|
||||
{
|
||||
Id = payload["decision_id"]?.GetValue<string>() ?? record.EventId.ToString("N"),
|
||||
AlertId = alertId,
|
||||
ArtifactId = payload["artifact_id"]?.GetValue<string>() ?? record.ArtifactId,
|
||||
ActorId = record.ActorId,
|
||||
Timestamp = record.OccurredAt,
|
||||
DecisionStatus = payload["decision_status"]?.GetValue<string>() ?? "unknown",
|
||||
ReasonCode = payload["reason_code"]?.GetValue<string>() ?? "unknown",
|
||||
ReasonText = payload["reason_text"]?.GetValue<string>(),
|
||||
EvidenceHashes = ExtractEvidenceHashes(payload),
|
||||
ReplayToken = payload["replay_token"]?.GetValue<string>() ?? string.Empty,
|
||||
PolicyContext = payload["policy_context"]?.GetValue<string>()
|
||||
});
|
||||
}
|
||||
|
||||
return decisions.OrderBy(d => d.Timestamp).ToList();
|
||||
}
|
||||
|
||||
private static List<string> ExtractEvidenceHashes(JsonObject payload)
|
||||
{
|
||||
var hashes = new List<string>();
|
||||
if (payload["evidence_hashes"] is JsonArray hashArray)
|
||||
{
|
||||
foreach (var item in hashArray)
|
||||
{
|
||||
var value = item?.GetValue<string>();
|
||||
if (value is not null)
|
||||
hashes.Add(value);
|
||||
}
|
||||
}
|
||||
return hashes;
|
||||
}
|
||||
|
||||
private static void ValidateDecision(DecisionEvent decision)
|
||||
|
||||
@@ -196,6 +196,9 @@ public sealed class LedgerEventWriteServiceTests
|
||||
|
||||
public Task<LedgerChainHead?> GetChainHeadAsync(string tenantId, Guid chainId, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<LedgerChainHead?>(null);
|
||||
|
||||
public Task<IReadOnlyList<LedgerEventRecord>> GetByChainIdAsync(string tenantId, Guid chainId, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlyList<LedgerEventRecord>>(Array.Empty<LedgerEventRecord>());
|
||||
}
|
||||
|
||||
private sealed class CapturingMerkleScheduler : IMerkleAnchorScheduler
|
||||
|
||||
@@ -177,7 +177,7 @@ public sealed class PlatformEnvironmentSettingsOptions
|
||||
public string RedirectUri { get; set; } = string.Empty;
|
||||
public string? SilentRefreshRedirectUri { get; set; }
|
||||
public string? PostLogoutRedirectUri { get; set; }
|
||||
public string Scope { get; set; } = "openid profile email ui.read authority:tenants.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve orch:read analytics.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read release:read scheduler:read";
|
||||
public string Scope { get; set; } = "openid profile email ui.read ui.admin authority:tenants.read authority:users.read authority:roles.read authority:clients.read authority:tokens.read authority:branding.read authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve orch:read analytics.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read release:read scheduler:read vuln:view vuln:investigate vuln:operate vuln:audit";
|
||||
public string? Audience { get; set; }
|
||||
public List<string> DpopAlgorithms { get; set; } = new() { "ES256" };
|
||||
public int RefreshLeewaySeconds { get; set; } = 60;
|
||||
|
||||
@@ -67,11 +67,18 @@ public sealed class IdentityHeaderPolicyMiddleware
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 0: Preserve client-sent tenant header before stripping.
|
||||
// When the Gateway runs in AllowAnonymous mode (no JWT validation),
|
||||
// the principal has no claims and we cannot determine tenant from the token.
|
||||
// In that case, we pass through the client-provided value and let the
|
||||
// upstream service validate it against the JWT's tenant claim.
|
||||
var clientTenant = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
|
||||
// Step 1: Strip all reserved identity headers from incoming request
|
||||
StripReservedHeaders(context);
|
||||
|
||||
// Step 2: Extract identity from validated principal
|
||||
var identity = ExtractIdentity(context);
|
||||
var identity = ExtractIdentity(context, clientTenant);
|
||||
|
||||
// Step 3: Store normalized identity in HttpContext.Items
|
||||
StoreIdentityContext(context, identity);
|
||||
@@ -97,17 +104,23 @@ public sealed class IdentityHeaderPolicyMiddleware
|
||||
}
|
||||
}
|
||||
|
||||
private IdentityContext ExtractIdentity(HttpContext context)
|
||||
private IdentityContext ExtractIdentity(HttpContext context, string? clientTenant = null)
|
||||
{
|
||||
var principal = context.User;
|
||||
var isAuthenticated = principal.Identity?.IsAuthenticated == true;
|
||||
|
||||
if (!isAuthenticated)
|
||||
{
|
||||
// In AllowAnonymous mode the Gateway cannot validate identity claims.
|
||||
// Pass through the client-provided tenant so the upstream service
|
||||
// can validate it against the JWT's own tenant claim.
|
||||
var passThruTenant = !string.IsNullOrWhiteSpace(clientTenant) ? clientTenant.Trim() : null;
|
||||
|
||||
return new IdentityContext
|
||||
{
|
||||
IsAnonymous = true,
|
||||
Actor = "anonymous",
|
||||
Tenant = passThruTenant,
|
||||
Scopes = _options.AnonymousScopes ?? []
|
||||
};
|
||||
}
|
||||
@@ -115,9 +128,12 @@ public sealed class IdentityHeaderPolicyMiddleware
|
||||
// Extract subject (actor)
|
||||
var actor = principal.FindFirstValue(StellaOpsClaimTypes.Subject);
|
||||
|
||||
// Extract tenant - try canonical claim first, then legacy 'tid'
|
||||
// Extract tenant - try canonical claim first, then legacy 'tid',
|
||||
// then client-provided header, then fall back to "default"
|
||||
var tenant = principal.FindFirstValue(StellaOpsClaimTypes.Tenant)
|
||||
?? principal.FindFirstValue("tid");
|
||||
?? principal.FindFirstValue("tid")
|
||||
?? (!string.IsNullOrWhiteSpace(clientTenant) ? clientTenant.Trim() : null)
|
||||
?? "default";
|
||||
|
||||
// Extract project (optional)
|
||||
var project = principal.FindFirstValue(StellaOpsClaimTypes.Project);
|
||||
|
||||
@@ -20,6 +20,10 @@ public sealed class RouteDispatchMiddleware
|
||||
"TE", "Trailers", "Transfer-Encoding", "Upgrade"
|
||||
};
|
||||
|
||||
// ReverseProxy paths that are legitimate browser navigation targets (e.g. OIDC flows)
|
||||
// and must NOT be redirected to the SPA fallback.
|
||||
private static readonly string[] BrowserProxyPaths = ["/connect", "/.well-known"];
|
||||
|
||||
public RouteDispatchMiddleware(
|
||||
RequestDelegate next,
|
||||
StellaOpsRouteResolver resolver,
|
||||
@@ -48,6 +52,22 @@ public sealed class RouteDispatchMiddleware
|
||||
return;
|
||||
}
|
||||
|
||||
// SPA fallback: when a ReverseProxy route is matched but the request is a
|
||||
// browser navigation (Accept: text/html, no file extension), serve the SPA
|
||||
// index.html instead of proxying to the backend. This prevents collisions
|
||||
// between Angular SPA routes and backend service proxy prefixes.
|
||||
// Excludes known backend browser-navigation paths (e.g. OIDC /connect).
|
||||
if (route.Type == StellaOpsRouteType.ReverseProxy && IsBrowserNavigation(context.Request))
|
||||
{
|
||||
var spaRoute = _resolver.FindSpaFallbackRoute();
|
||||
if (spaRoute is not null)
|
||||
{
|
||||
_logger.LogDebug("SPA fallback: serving index.html for browser navigation to {Path}", context.Request.Path);
|
||||
await HandleStaticFiles(context, spaRoute);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
switch (route.Type)
|
||||
{
|
||||
case StellaOpsRouteType.StaticFiles:
|
||||
@@ -221,7 +241,8 @@ public sealed class RouteDispatchMiddleware
|
||||
{
|
||||
context.Response.StatusCode = (int)upstreamResponse.StatusCode;
|
||||
|
||||
// Copy response headers
|
||||
// Copy response headers (excluding hop-by-hop and content-length which
|
||||
// we'll set ourselves after reading the body to ensure accuracy)
|
||||
foreach (var header in upstreamResponse.Headers)
|
||||
{
|
||||
if (!HopByHopHeaders.Contains(header.Key))
|
||||
@@ -232,12 +253,22 @@ public sealed class RouteDispatchMiddleware
|
||||
|
||||
foreach (var header in upstreamResponse.Content.Headers)
|
||||
{
|
||||
context.Response.Headers[header.Key] = header.Value.ToArray();
|
||||
if (!string.Equals(header.Key, "Content-Length", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
context.Response.Headers[header.Key] = header.Value.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
// Stream response body
|
||||
await using var responseStream = await upstreamResponse.Content.ReadAsStreamAsync(context.RequestAborted);
|
||||
await responseStream.CopyToAsync(context.Response.Body, context.RequestAborted);
|
||||
// Read the full response body so we can set an accurate Content-Length.
|
||||
// This is necessary because the upstream may use chunked transfer encoding
|
||||
// (which we strip as a hop-by-hop header), and without Content-Length or
|
||||
// Transfer-Encoding the downstream client cannot determine body length.
|
||||
var body = await upstreamResponse.Content.ReadAsByteArrayAsync(context.RequestAborted);
|
||||
if (body.Length > 0)
|
||||
{
|
||||
context.Response.ContentLength = body.Length;
|
||||
await context.Response.Body.WriteAsync(body, context.RequestAborted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,4 +374,28 @@ public sealed class RouteDispatchMiddleware
|
||||
await using var stream = fileInfo.CreateReadStream();
|
||||
await stream.CopyToAsync(context.Response.Body, context.RequestAborted);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if the request is a browser page navigation (as opposed to an XHR/fetch API call).
|
||||
/// Browser navigations send Accept: text/html and target paths without file extensions.
|
||||
/// Known backend browser-navigation paths (OIDC endpoints) are excluded.
|
||||
/// </summary>
|
||||
private static bool IsBrowserNavigation(HttpRequest request)
|
||||
{
|
||||
var path = request.Path.Value ?? string.Empty;
|
||||
|
||||
// Paths with file extensions are static asset requests, not SPA navigation
|
||||
if (System.IO.Path.HasExtension(path))
|
||||
return false;
|
||||
|
||||
// Exclude known backend paths that legitimately receive browser navigations
|
||||
foreach (var excluded in BrowserProxyPaths)
|
||||
{
|
||||
if (path.StartsWith(excluded, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
}
|
||||
|
||||
var accept = request.Headers.Accept.ToString();
|
||||
return accept.Contains("text/html", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +136,7 @@ builder.Services.AddSingleton<IEnumerable<StellaOpsRoute>>(
|
||||
builder.Services.AddHttpClient("RouteDispatch")
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
ServerCertificateCustomValidationCallback =
|
||||
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
|
||||
});
|
||||
|
||||
@@ -54,4 +54,24 @@ public sealed class StellaOpsRouteResolver
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the StaticFiles route configured with x-spa-fallback: true.
|
||||
/// Used to serve index.html for browser navigation requests that would
|
||||
/// otherwise be intercepted by ReverseProxy routes.
|
||||
/// </summary>
|
||||
public StellaOpsRoute? FindSpaFallbackRoute()
|
||||
{
|
||||
foreach (var (route, _) in _routes)
|
||||
{
|
||||
if (route.Type == StellaOpsRouteType.StaticFiles &&
|
||||
route.Headers.TryGetValue("x-spa-fallback", out var value) &&
|
||||
string.Equals(value, "true", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return route;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,9 +65,73 @@
|
||||
"CheckInterval": "5s"
|
||||
},
|
||||
"Routes": [
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/release-orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local/api/v1/release-orchestrator" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/vex", "TranslatesTo": "http://vexhub.stella-ops.local/api/v1/vex" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/vexlens", "TranslatesTo": "http://vexlens.stella-ops.local/api/v1/vexlens" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/notify", "TranslatesTo": "http://notify.stella-ops.local/api/v1/notify" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/notifier", "TranslatesTo": "http://notifier.stella-ops.local/api/v1/notifier" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/concelier", "TranslatesTo": "http://concelier.stella-ops.local/api/v1/concelier" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/cvss", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/cvss" },
|
||||
{ "Type": "ReverseProxy", "Path": "/v1/evidence-packs", "TranslatesTo": "http://evidencelocker.stella-ops.local/v1/evidence-packs" },
|
||||
{ "Type": "ReverseProxy", "Path": "/v1/runs", "TranslatesTo": "http://orchestrator.stella-ops.local/v1/runs" },
|
||||
{ "Type": "ReverseProxy", "Path": "/v1/advisory-ai", "TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai" },
|
||||
{ "Type": "ReverseProxy", "Path": "/v1/audit-bundles", "TranslatesTo": "http://evidencelocker.stella-ops.local/v1/audit-bundles" },
|
||||
{ "Type": "ReverseProxy", "Path": "/policy", "TranslatesTo": "http://policy-gateway.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/policy", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/policy" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/risk", "TranslatesTo": "http://policy-engine.stella-ops.local/api/risk" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/analytics", "TranslatesTo": "http://platform.stella-ops.local/api/analytics" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/release-orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local/api/release-orchestrator" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/releases", "TranslatesTo": "http://orchestrator.stella-ops.local/api/releases" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/approvals", "TranslatesTo": "http://orchestrator.stella-ops.local/api/approvals" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/platform", "TranslatesTo": "http://platform.stella-ops.local/api/v1/platform" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/scanner", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/scanner" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/findings", "TranslatesTo": "http://findings.stella-ops.local/api/v1/findings" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/policy", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/policy" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/reachability", "TranslatesTo": "http://reachgraph.stella-ops.local/api/v1/reachability" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/attestor", "TranslatesTo": "http://attestor.stella-ops.local/api/v1/attestor" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/attestations", "TranslatesTo": "http://attestor.stella-ops.local/api/v1/attestations" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/sbom", "TranslatesTo": "http://sbomservice.stella-ops.local/api/v1/sbom" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/signals", "TranslatesTo": "http://signals.stella-ops.local/api/v1/signals" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/vex", "TranslatesTo": "http://vexhub.stella-ops.local/api/v1/vex" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local/api/v1/orchestrator" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/authority", "TranslatesTo": "https://authority.stella-ops.local/api/v1/authority" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/trust", "TranslatesTo": "https://authority.stella-ops.local/api/v1/trust" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/evidence", "TranslatesTo": "http://evidencelocker.stella-ops.local/api/v1/evidence" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/proofs", "TranslatesTo": "http://evidencelocker.stella-ops.local/api/v1/proofs" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/timeline", "TranslatesTo": "http://timelineindexer.stella-ops.local/api/v1/timeline" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/advisory-ai", "TranslatesTo": "http://advisoryai.stella-ops.local/api/v1/advisory-ai" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/advisory", "TranslatesTo": "http://advisoryai.stella-ops.local/api/v1/advisory" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/concelier", "TranslatesTo": "http://concelier.stella-ops.local/api/v1/concelier" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/vulnerabilities", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/vulnerabilities" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/watchlist", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/watchlist" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/resolve", "TranslatesTo": "http://binaryindex.stella-ops.local/api/v1/resolve" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/ops/binaryindex", "TranslatesTo": "http://binaryindex.stella-ops.local/api/v1/ops/binaryindex" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/verdicts", "TranslatesTo": "http://evidencelocker.stella-ops.local/api/v1/verdicts" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/lineage", "TranslatesTo": "http://sbomservice.stella-ops.local/api/v1/lineage" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/export", "TranslatesTo": "http://exportcenter.stella-ops.local/api/v1/export" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/triage", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/triage" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/governance", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/governance" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/determinization", "TranslatesTo": "http://policy-engine.stella-ops.local/api/v1/determinization" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/opsmemory", "TranslatesTo": "http://opsmemory.stella-ops.local/api/v1/opsmemory" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/secrets", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/secrets" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/sources", "TranslatesTo": "http://sbomservice.stella-ops.local/api/v1/sources" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/workflows", "TranslatesTo": "http://orchestrator.stella-ops.local/api/v1/workflows" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/v1/witnesses", "TranslatesTo": "http://attestor.stella-ops.local/api/v1/witnesses" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/gate", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/gate" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/risk-budget", "TranslatesTo": "http://policy-engine.stella-ops.local/api/risk-budget" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/fix-verification", "TranslatesTo": "http://scanner.stella-ops.local/api/fix-verification" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/compare", "TranslatesTo": "http://sbomservice.stella-ops.local/api/compare" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/change-traces", "TranslatesTo": "http://sbomservice.stella-ops.local/api/change-traces" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/exceptions", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/exceptions" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/verdicts", "TranslatesTo": "http://evidencelocker.stella-ops.local/api/verdicts" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local/api/orchestrator" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/sbomservice", "TranslatesTo": "http://sbomservice.stella-ops.local/api/sbomservice" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/vuln-explorer", "TranslatesTo": "http://vulnexplorer.stella-ops.local/api/vuln-explorer" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/vex", "TranslatesTo": "http://vexhub.stella-ops.local/api/vex" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api/admin", "TranslatesTo": "http://platform.stella-ops.local/api/admin" },
|
||||
{ "Type": "ReverseProxy", "Path": "/api", "TranslatesTo": "http://platform.stella-ops.local/api" },
|
||||
{ "Type": "ReverseProxy", "Path": "/platform", "TranslatesTo": "http://platform.stella-ops.local/platform" },
|
||||
{ "Type": "ReverseProxy", "Path": "/connect", "TranslatesTo": "https://authority.stella-ops.local/connect" },
|
||||
{ "Type": "ReverseProxy", "Path": "/connect", "TranslatesTo": "https://authority.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/.well-known", "TranslatesTo": "https://authority.stella-ops.local/.well-known" },
|
||||
{ "Type": "ReverseProxy", "Path": "/jwks", "TranslatesTo": "https://authority.stella-ops.local/jwks" },
|
||||
{ "Type": "ReverseProxy", "Path": "/authority", "TranslatesTo": "https://authority.stella-ops.local/authority" },
|
||||
@@ -88,7 +152,6 @@
|
||||
{ "Type": "ReverseProxy", "Path": "/vexlens", "TranslatesTo": "http://vexlens.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/taskrunner", "TranslatesTo": "http://taskrunner.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/graph", "TranslatesTo": "http://graph.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/cartographer", "TranslatesTo": "http://cartographer.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/reachgraph", "TranslatesTo": "http://reachgraph.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/doctor", "TranslatesTo": "http://doctor.stella-ops.local" },
|
||||
@@ -103,7 +166,6 @@
|
||||
{ "Type": "ReverseProxy", "Path": "/sbomservice", "TranslatesTo": "http://sbomservice.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/advisoryai", "TranslatesTo": "http://advisoryai.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/unknowns", "TranslatesTo": "http://unknowns.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/timeline", "TranslatesTo": "http://timeline.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/timelineindexer", "TranslatesTo": "http://timelineindexer.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/opsmemory", "TranslatesTo": "http://opsmemory.stella-ops.local" },
|
||||
{ "Type": "ReverseProxy", "Path": "/issuerdirectory", "TranslatesTo": "http://issuerdirectory.stella-ops.local" },
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
|
||||
using Dapper;
|
||||
using StellaOps.Scheduler.Persistence.Postgres;
|
||||
using StellaOps.Scheduler.Worker.Exceptions;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.Exceptions;
|
||||
|
||||
public sealed class PostgresExceptionRepository : IExceptionRepository
|
||||
{
|
||||
private readonly SchedulerDataSource _dataSource;
|
||||
|
||||
public PostgresExceptionRepository(SchedulerDataSource dataSource)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
}
|
||||
|
||||
public async ValueTask<ExceptionRecord?> GetAsync(string exceptionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(exceptionId);
|
||||
await using var conn = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
|
||||
const string sql = """
|
||||
SELECT exception_id, tenant_id, policy_id, vulnerability_id, component_purl,
|
||||
state, created_at, activation_date, expiration_date, activated_at,
|
||||
expired_at, justification, created_by
|
||||
FROM scheduler.scheduler_exceptions
|
||||
WHERE exception_id = @ExceptionId
|
||||
LIMIT 1;
|
||||
""";
|
||||
|
||||
var row = await conn.QuerySingleOrDefaultAsync(sql, new { ExceptionId = exceptionId });
|
||||
return row is null ? null : Map(row);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<ExceptionRecord>> GetPendingActivationsAsync(
|
||||
DateTimeOffset asOf,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var conn = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
|
||||
const string sql = """
|
||||
SELECT exception_id, tenant_id, policy_id, vulnerability_id, component_purl,
|
||||
state, created_at, activation_date, expiration_date, activated_at,
|
||||
expired_at, justification, created_by
|
||||
FROM scheduler.scheduler_exceptions
|
||||
WHERE state = 'pending' AND activation_date <= @AsOf
|
||||
ORDER BY activation_date ASC;
|
||||
""";
|
||||
|
||||
var rows = await conn.QueryAsync(sql, new { AsOf = asOf });
|
||||
return rows.Select(Map).ToList();
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<ExceptionRecord>> GetExpiredExceptionsAsync(
|
||||
DateTimeOffset asOf,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var conn = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
|
||||
const string sql = """
|
||||
SELECT exception_id, tenant_id, policy_id, vulnerability_id, component_purl,
|
||||
state, created_at, activation_date, expiration_date, activated_at,
|
||||
expired_at, justification, created_by
|
||||
FROM scheduler.scheduler_exceptions
|
||||
WHERE state = 'active' AND expiration_date <= @AsOf
|
||||
ORDER BY expiration_date ASC;
|
||||
""";
|
||||
|
||||
var rows = await conn.QueryAsync(sql, new { AsOf = asOf });
|
||||
return rows.Select(Map).ToList();
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<ExceptionRecord>> GetExpiringExceptionsAsync(
|
||||
DateTimeOffset windowStart,
|
||||
DateTimeOffset windowEnd,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var conn = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
|
||||
const string sql = """
|
||||
SELECT exception_id, tenant_id, policy_id, vulnerability_id, component_purl,
|
||||
state, created_at, activation_date, expiration_date, activated_at,
|
||||
expired_at, justification, created_by
|
||||
FROM scheduler.scheduler_exceptions
|
||||
WHERE state = 'active'
|
||||
AND expiration_date > @WindowStart
|
||||
AND expiration_date <= @WindowEnd
|
||||
ORDER BY expiration_date ASC;
|
||||
""";
|
||||
|
||||
var rows = await conn.QueryAsync(sql, new { WindowStart = windowStart, WindowEnd = windowEnd });
|
||||
return rows.Select(Map).ToList();
|
||||
}
|
||||
|
||||
public async ValueTask UpdateAsync(ExceptionRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
await using var conn = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
|
||||
const string sql = """
|
||||
UPDATE scheduler.scheduler_exceptions
|
||||
SET state = @State::scheduler.exception_state,
|
||||
activation_date = @ActivationDate,
|
||||
expiration_date = @ExpirationDate,
|
||||
activated_at = @ActivatedAt,
|
||||
expired_at = @ExpiredAt,
|
||||
justification = @Justification
|
||||
WHERE exception_id = @ExceptionId;
|
||||
""";
|
||||
|
||||
await conn.ExecuteAsync(sql, new
|
||||
{
|
||||
record.ExceptionId,
|
||||
State = record.State.ToString().ToLowerInvariant(),
|
||||
record.ActivationDate,
|
||||
record.ExpirationDate,
|
||||
record.ActivatedAt,
|
||||
record.ExpiredAt,
|
||||
record.Justification
|
||||
});
|
||||
}
|
||||
|
||||
private static ExceptionRecord Map(dynamic row)
|
||||
{
|
||||
return new ExceptionRecord(
|
||||
(string)row.exception_id,
|
||||
(string)row.tenant_id,
|
||||
(string)row.policy_id,
|
||||
(string)row.vulnerability_id,
|
||||
(string?)row.component_purl,
|
||||
Enum.Parse<ExceptionState>((string)row.state, true),
|
||||
DateTime.SpecifyKind(row.created_at, DateTimeKind.Utc),
|
||||
row.activation_date is null ? null : (DateTimeOffset?)DateTime.SpecifyKind(row.activation_date, DateTimeKind.Utc),
|
||||
row.expiration_date is null ? null : (DateTimeOffset?)DateTime.SpecifyKind(row.expiration_date, DateTimeKind.Utc),
|
||||
row.activated_at is null ? null : (DateTimeOffset?)DateTime.SpecifyKind(row.activated_at, DateTimeKind.Utc),
|
||||
row.expired_at is null ? null : (DateTimeOffset?)DateTime.SpecifyKind(row.expired_at, DateTimeKind.Utc),
|
||||
(string?)row.justification,
|
||||
(string?)row.created_by);
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,11 @@ using StellaOps.Scheduler.WebService.PolicyRuns;
|
||||
using StellaOps.Scheduler.WebService.PolicySimulations;
|
||||
using StellaOps.Scheduler.WebService.Runs;
|
||||
using StellaOps.Scheduler.WebService.Schedules;
|
||||
using StellaOps.Scheduler.WebService.Exceptions;
|
||||
using StellaOps.Scheduler.WebService.VulnerabilityResolverJobs;
|
||||
using StellaOps.Scheduler.Worker.Exceptions;
|
||||
using StellaOps.Scheduler.Worker.Observability;
|
||||
using StellaOps.Scheduler.Worker.Options;
|
||||
using System.Linq;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -125,9 +129,21 @@ else
|
||||
builder.Services.AddSingleton<ICartographerWebhookClient, NullCartographerWebhookClient>();
|
||||
}
|
||||
builder.Services.AddScoped<IGraphJobService, GraphJobService>();
|
||||
builder.Services.AddImpactIndexStub();
|
||||
builder.Services.AddImpactIndex();
|
||||
builder.Services.AddResolverJobServices();
|
||||
|
||||
// Exception lifecycle workers (SCHED-WORKER-25-101/25-102)
|
||||
var workerOptions = builder.Configuration.GetSection("Scheduler:Worker").Get<SchedulerWorkerOptions>() ?? new SchedulerWorkerOptions();
|
||||
workerOptions.Validate();
|
||||
builder.Services.AddSingleton(workerOptions);
|
||||
builder.Services.AddSingleton<SchedulerWorkerMetrics>();
|
||||
builder.Services.AddSingleton<IExceptionRepository, PostgresExceptionRepository>();
|
||||
builder.Services.AddSingleton<IExceptionEventPublisher>(NullExceptionEventPublisher.Instance);
|
||||
builder.Services.AddSingleton<IExpiringDigestService>(NullExpiringDigestService.Instance);
|
||||
builder.Services.AddSingleton<IExpiringAlertService>(NullExpiringAlertService.Instance);
|
||||
builder.Services.AddHostedService<ExceptionLifecycleWorker>();
|
||||
builder.Services.AddHostedService<ExpiringNotificationWorker>();
|
||||
|
||||
var schedulerOptions = builder.Configuration.GetSection("Scheduler").Get<SchedulerOptions>() ?? new SchedulerOptions();
|
||||
schedulerOptions.Validate();
|
||||
builder.Services.AddSingleton(schedulerOptions);
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scheduler.ImpactIndex/StellaOps.Scheduler.ImpactIndex.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scheduler.Queue/StellaOps.Scheduler.Queue.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scheduler.Persistence/StellaOps.Scheduler.Persistence.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scheduler.Worker/StellaOps.Scheduler.Worker.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
||||
|
||||
@@ -4,10 +4,26 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
namespace StellaOps.Scheduler.ImpactIndex;
|
||||
|
||||
/// <summary>
|
||||
/// ServiceCollection helpers for wiring the fixture-backed impact index.
|
||||
/// ServiceCollection helpers for wiring the impact index.
|
||||
/// </summary>
|
||||
public static class ImpactIndexServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers the real <see cref="RoaringImpactIndex"/> as the <see cref="IImpactIndex"/> implementation.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddImpactIndex(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.TryAddSingleton<IImpactIndex, RoaringImpactIndex>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the fixture-backed <see cref="FixtureImpactIndex"/> as the <see cref="IImpactIndex"/> implementation.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddImpactIndexStub(
|
||||
this IServiceCollection services,
|
||||
Action<ImpactIndexStubOptions>? configure = null)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
-- Scheduler Schema: Exception Lifecycle
|
||||
-- Adds exception management tables for SCHED-WORKER-25-101/25-102.
|
||||
-- Supports auto-activation, expiry, and expiring notification digests.
|
||||
|
||||
-- ============================================================================
|
||||
-- SECTION 1: Enum Types
|
||||
-- ============================================================================
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE scheduler.exception_state AS ENUM ('pending', 'active', 'expired', 'revoked');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
-- ============================================================================
|
||||
-- SECTION 2: Exception Table
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scheduler.scheduler_exceptions (
|
||||
exception_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
policy_id TEXT NOT NULL,
|
||||
vulnerability_id TEXT NOT NULL,
|
||||
component_purl TEXT,
|
||||
state scheduler.exception_state NOT NULL DEFAULT 'pending',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
activation_date TIMESTAMPTZ,
|
||||
expiration_date TIMESTAMPTZ,
|
||||
activated_at TIMESTAMPTZ,
|
||||
expired_at TIMESTAMPTZ,
|
||||
justification TEXT,
|
||||
created_by TEXT
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- SECTION 3: Indexes
|
||||
-- ============================================================================
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduler_exceptions_tenant
|
||||
ON scheduler.scheduler_exceptions(tenant_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduler_exceptions_state
|
||||
ON scheduler.scheduler_exceptions(state);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduler_exceptions_tenant_state
|
||||
ON scheduler.scheduler_exceptions(tenant_id, state);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduler_exceptions_pending_activation
|
||||
ON scheduler.scheduler_exceptions(activation_date)
|
||||
WHERE state = 'pending';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduler_exceptions_active_expiration
|
||||
ON scheduler.scheduler_exceptions(expiration_date)
|
||||
WHERE state = 'active';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduler_exceptions_policy
|
||||
ON scheduler.scheduler_exceptions(tenant_id, policy_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduler_exceptions_vulnerability
|
||||
ON scheduler.scheduler_exceptions(tenant_id, vulnerability_id);
|
||||
|
||||
-- ============================================================================
|
||||
-- SECTION 4: Row-Level Security
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE scheduler.scheduler_exceptions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE scheduler.scheduler_exceptions FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY scheduler_exceptions_tenant_isolation ON scheduler.scheduler_exceptions FOR ALL
|
||||
USING (tenant_id = scheduler_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = scheduler_app.require_current_tenant());
|
||||
@@ -50,7 +50,7 @@ INSERT INTO scheduler.policy_run_jobs (
|
||||
cancellation_requested, cancellation_requested_at, cancellation_reason, cancelled_at, schema_version)
|
||||
VALUES (
|
||||
@Id, @TenantId, @PolicyId, @PolicyVersion, @Mode, @Priority, @PriorityRank, @RunId, @RequestedBy, @CorrelationId,
|
||||
@Metadata, @Inputs, @QueuedAt, @Status, @AttemptCount, @LastAttemptAt, @LastError,
|
||||
@Metadata, @Inputs, @QueuedAt, @Status::policy_run_status, @AttemptCount, @LastAttemptAt, @LastError,
|
||||
@CreatedAt, @UpdatedAt, @AvailableAt, @SubmittedAt, @CompletedAt, @LeaseOwner, @LeaseExpiresAt,
|
||||
@CancellationRequested, @CancellationRequestedAt, @CancellationReason, @CancelledAt, @SchemaVersion)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
@@ -93,7 +93,7 @@ SET lease_owner = @LeaseOwner,
|
||||
lease_expires_at = @LeaseExpires,
|
||||
attempt_count = j.attempt_count + 1,
|
||||
last_attempt_at = @Now,
|
||||
status = CASE WHEN j.status = 'pending' THEN 'submitted' ELSE 'retrying' END,
|
||||
status = CASE WHEN j.status = 'pending' THEN 'submitted'::policy_run_status ELSE 'retrying'::policy_run_status END,
|
||||
updated_at = @Now
|
||||
FROM candidate c
|
||||
WHERE j.id = c.id
|
||||
@@ -123,7 +123,7 @@ RETURNING j.*;
|
||||
var sql = $"""
|
||||
UPDATE scheduler.policy_run_jobs
|
||||
SET policy_version = @PolicyVersion,
|
||||
status = @Status,
|
||||
status = @Status::policy_run_status,
|
||||
attempt_count = @AttemptCount,
|
||||
last_attempt_at = @LastAttemptAt,
|
||||
last_error = @LastError,
|
||||
|
||||
Reference in New Issue
Block a user