up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-28 09:40:40 +02:00
parent 1c6730a1d2
commit 05da719048
206 changed files with 34741 additions and 1751 deletions

View File

@@ -1,12 +1,12 @@
using System.Collections.Generic;
using System.Net;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Linq;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Authentication;
using System.Collections.Generic;
using System.Net;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Linq;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
@@ -54,11 +54,11 @@ public sealed class ConsoleEndpointsTests
Assert.Equal(1, tenants.GetArrayLength());
Assert.Equal("tenant-default", tenants[0].GetProperty("id").GetString());
var events = sink.Events;
var authorizeEvent = Assert.Single(events, evt => evt.EventType == "authority.resource.authorize");
Assert.Equal(AuthEventOutcome.Success, authorizeEvent.Outcome);
var consoleEvent = Assert.Single(events, evt => evt.EventType == "authority.console.tenants.read");
var events = sink.Events;
var authorizeEvent = Assert.Single(events, evt => evt.EventType == "authority.resource.authorize");
Assert.Equal(AuthEventOutcome.Success, authorizeEvent.Outcome);
var consoleEvent = Assert.Single(events, evt => evt.EventType == "authority.console.tenants.read");
Assert.Equal(AuthEventOutcome.Success, consoleEvent.Outcome);
Assert.Contains("tenant.resolved", consoleEvent.Properties.Select(property => property.Name));
Assert.Equal(2, events.Count);
@@ -148,17 +148,17 @@ public sealed class ConsoleEndpointsTests
Assert.Equal("tenant-default", json.RootElement.GetProperty("tenant").GetString());
var events = sink.Events;
var authorizeEvent = Assert.Single(events, evt => evt.EventType == "authority.resource.authorize");
var authorizeEvent = Assert.Single(events, evt => evt.EventType == "authority.resource.authorize");
Assert.Equal(AuthEventOutcome.Success, authorizeEvent.Outcome);
var consoleEvent = Assert.Single(events, evt => evt.EventType == "authority.console.profile.read");
var consoleEvent = Assert.Single(events, evt => evt.EventType == "authority.console.profile.read");
Assert.Equal(AuthEventOutcome.Success, consoleEvent.Outcome);
Assert.Equal(2, events.Count);
}
[Fact]
public async Task TokenIntrospect_FlagsInactive_WhenExpired()
{
public async Task TokenIntrospect_FlagsInactive_WhenExpired()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-10-31T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
@@ -186,123 +186,340 @@ public sealed class ConsoleEndpointsTests
Assert.Equal("token-abc", json.RootElement.GetProperty("tokenId").GetString());
var events = sink.Events;
var authorizeEvent = Assert.Single(events, evt => evt.EventType == "authority.resource.authorize");
var authorizeEvent = Assert.Single(events, evt => evt.EventType == "authority.resource.authorize");
Assert.Equal(AuthEventOutcome.Success, authorizeEvent.Outcome);
var consoleEvent = Assert.Single(events, evt => evt.EventType == "authority.console.token.introspect");
var consoleEvent = Assert.Single(events, evt => evt.EventType == "authority.console.token.introspect");
Assert.Equal(AuthEventOutcome.Success, consoleEvent.Outcome);
Assert.Equal(2, events.Count);
}
[Fact]
public async Task VulnerabilityFindings_ReturnsSamplePayload()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = CreatePrincipal(
tenant: "tenant-default",
scopes: new[] { StellaOpsScopes.UiRead, StellaOpsScopes.AdvisoryRead, StellaOpsScopes.VexRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(30));
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
var response = await client.GetAsync("/console/vuln/findings?severity=high");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var items = json.RootElement.GetProperty("items");
Assert.True(items.GetArrayLength() >= 1);
Assert.Equal("CVE-2024-12345", items[0].GetProperty("coordinates").GetProperty("advisoryId").GetString());
}
[Fact]
public async Task VulnerabilityFindingDetail_ReturnsExpandedDocument()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = CreatePrincipal(
tenant: "tenant-default",
scopes: new[] { StellaOpsScopes.UiRead, StellaOpsScopes.AdvisoryRead, StellaOpsScopes.VexRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(30));
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
var response = await client.GetAsync("/console/vuln/tenant-default:advisory-ai:sha256:5d1a");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var summary = json.RootElement.GetProperty("summary");
Assert.Equal("tenant-default:advisory-ai:sha256:5d1a", summary.GetProperty("findingId").GetString());
Assert.Equal("reachable", summary.GetProperty("reachability").GetProperty("status").GetString());
var detailReachability = json.RootElement.GetProperty("reachability");
Assert.Equal("reachable", detailReachability.GetProperty("status").GetString());
}
[Fact]
public async Task VulnerabilityTicket_ReturnsDeterministicPayload()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = CreatePrincipal(
tenant: "tenant-default",
scopes: new[] { StellaOpsScopes.UiRead, StellaOpsScopes.AdvisoryRead, StellaOpsScopes.VexRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(30));
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
var payload = new ConsoleVulnerabilityTicketRequest(
Selection: new[] { "tenant-default:advisory-ai:sha256:5d1a" },
TargetSystem: "servicenow",
Metadata: new Dictionary<string, string> { ["assignmentGroup"] = "runtime-security" });
var response = await client.PostAsJsonAsync("/console/vuln/tickets", payload);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Assert.StartsWith("console-ticket::tenant-default::", json.RootElement.GetProperty("ticketId").GetString());
Assert.Equal("servicenow", payload.TargetSystem);
}
[Fact]
public async Task VexStatements_ReturnsSampleRows()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = CreatePrincipal(
tenant: "tenant-default",
scopes: new[] { StellaOpsScopes.UiRead, StellaOpsScopes.VexRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(30));
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
var response = await client.GetAsync("/console/vex/statements?advisoryId=CVE-2024-12345");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var items = json.RootElement.GetProperty("items");
Assert.True(items.GetArrayLength() >= 1);
Assert.Equal("CVE-2024-12345", items[0].GetProperty("advisoryId").GetString());
}
Assert.Equal(2, events.Count);
}
[Fact]
public async Task VulnerabilityFindings_ReturnsSamplePayload()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = CreatePrincipal(
tenant: "tenant-default",
scopes: new[] { StellaOpsScopes.UiRead, StellaOpsScopes.AdvisoryRead, StellaOpsScopes.VexRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(30));
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
var response = await client.GetAsync("/console/vuln/findings?severity=high");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var items = json.RootElement.GetProperty("items");
Assert.True(items.GetArrayLength() >= 1);
Assert.Equal("CVE-2024-12345", items[0].GetProperty("coordinates").GetProperty("advisoryId").GetString());
}
[Fact]
public async Task VulnerabilityFindingDetail_ReturnsExpandedDocument()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = CreatePrincipal(
tenant: "tenant-default",
scopes: new[] { StellaOpsScopes.UiRead, StellaOpsScopes.AdvisoryRead, StellaOpsScopes.VexRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(30));
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
var response = await client.GetAsync("/console/vuln/tenant-default:advisory-ai:sha256:5d1a");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var summary = json.RootElement.GetProperty("summary");
Assert.Equal("tenant-default:advisory-ai:sha256:5d1a", summary.GetProperty("findingId").GetString());
Assert.Equal("reachable", summary.GetProperty("reachability").GetProperty("status").GetString());
var detailReachability = json.RootElement.GetProperty("reachability");
Assert.Equal("reachable", detailReachability.GetProperty("status").GetString());
}
[Fact]
public async Task VulnerabilityTicket_ReturnsDeterministicPayload()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = CreatePrincipal(
tenant: "tenant-default",
scopes: new[] { StellaOpsScopes.UiRead, StellaOpsScopes.AdvisoryRead, StellaOpsScopes.VexRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(30));
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
var payload = new ConsoleVulnerabilityTicketRequest(
Selection: new[] { "tenant-default:advisory-ai:sha256:5d1a" },
TargetSystem: "servicenow",
Metadata: new Dictionary<string, string> { ["assignmentGroup"] = "runtime-security" });
var response = await client.PostAsJsonAsync("/console/vuln/tickets", payload);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Assert.StartsWith("console-ticket::tenant-default::", json.RootElement.GetProperty("ticketId").GetString());
Assert.Equal("servicenow", payload.TargetSystem);
}
[Fact]
public async Task VexStatements_ReturnsSampleRows()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = CreatePrincipal(
tenant: "tenant-default",
scopes: new[] { StellaOpsScopes.UiRead, StellaOpsScopes.VexRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(30));
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
var response = await client.GetAsync("/console/vex/statements?advisoryId=CVE-2024-12345");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var items = json.RootElement.GetProperty("items");
Assert.True(items.GetArrayLength() >= 1);
Assert.Equal("CVE-2024-12345", items[0].GetProperty("advisoryId").GetString());
}
[Fact]
public async Task Dashboard_ReturnsTenantScopedAggregates()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = CreatePrincipal(
tenant: "tenant-default",
scopes: new[] { StellaOpsScopes.UiRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(30));
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
var response = await client.GetAsync("/console/dashboard");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Assert.Equal("tenant-default", json.RootElement.GetProperty("tenant").GetString());
Assert.True(json.RootElement.TryGetProperty("generatedAt", out _));
Assert.True(json.RootElement.TryGetProperty("findings", out var findings));
Assert.True(findings.TryGetProperty("totalFindings", out _));
Assert.True(json.RootElement.TryGetProperty("vexOverrides", out _));
Assert.True(json.RootElement.TryGetProperty("advisoryDeltas", out _));
Assert.True(json.RootElement.TryGetProperty("runHealth", out _));
Assert.True(json.RootElement.TryGetProperty("policyChanges", out _));
var events = sink.Events;
var consoleEvent = Assert.Single(events, evt => evt.EventType == "authority.console.dashboard");
Assert.Equal(AuthEventOutcome.Success, consoleEvent.Outcome);
}
[Fact]
public async Task Dashboard_ReturnsBadRequest_WhenTenantHeaderMissing()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = CreatePrincipal(
tenant: "tenant-default",
scopes: new[] { StellaOpsScopes.UiRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(30));
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
var response = await client.GetAsync("/console/dashboard");
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Dashboard_ContainsFindingsTrendData()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = CreatePrincipal(
tenant: "tenant-default",
scopes: new[] { StellaOpsScopes.UiRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(30));
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
var response = await client.GetAsync("/console/dashboard");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var findings = json.RootElement.GetProperty("findings");
var trend = findings.GetProperty("trendLast30Days");
Assert.True(trend.GetArrayLength() > 0);
}
[Fact]
public async Task Filters_ReturnsFilterCategories()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = CreatePrincipal(
tenant: "tenant-default",
scopes: new[] { StellaOpsScopes.UiRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(30));
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
var response = await client.GetAsync("/console/filters");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Assert.Equal("tenant-default", json.RootElement.GetProperty("tenant").GetString());
Assert.True(json.RootElement.TryGetProperty("generatedAt", out _));
Assert.True(json.RootElement.TryGetProperty("filtersHash", out _));
var categories = json.RootElement.GetProperty("categories");
Assert.True(categories.GetArrayLength() >= 5);
var events = sink.Events;
var consoleEvent = Assert.Single(events, evt => evt.EventType == "authority.console.filters");
Assert.Equal(AuthEventOutcome.Success, consoleEvent.Outcome);
}
[Fact]
public async Task Filters_ReturnsExpectedCategoryIds()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = CreatePrincipal(
tenant: "tenant-default",
scopes: new[] { StellaOpsScopes.UiRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(30));
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
var response = await client.GetAsync("/console/filters");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var categories = json.RootElement.GetProperty("categories");
var categoryIds = categories.EnumerateArray()
.Select(c => c.GetProperty("categoryId").GetString())
.ToList();
Assert.Contains("severity", categoryIds);
Assert.Contains("policyBadge", categoryIds);
Assert.Contains("reachability", categoryIds);
Assert.Contains("vexState", categoryIds);
Assert.Contains("kev", categoryIds);
}
[Fact]
public async Task Filters_FiltersByScopeParameter()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = CreatePrincipal(
tenant: "tenant-default",
scopes: new[] { StellaOpsScopes.UiRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(30));
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
var response = await client.GetAsync("/console/filters?scope=severity");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var categories = json.RootElement.GetProperty("categories");
Assert.Equal(1, categories.GetArrayLength());
Assert.Equal("severity", categories[0].GetProperty("categoryId").GetString());
}
[Fact]
public async Task Filters_ReturnsBadRequest_WhenTenantHeaderMissing()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = CreatePrincipal(
tenant: "tenant-default",
scopes: new[] { StellaOpsScopes.UiRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(30));
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
var response = await client.GetAsync("/console/filters");
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Filters_ReturnsHashForCacheValidation()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-08T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = CreatePrincipal(
tenant: "tenant-default",
scopes: new[] { StellaOpsScopes.UiRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(30));
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
var response = await client.GetAsync("/console/filters");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var filtersHash = json.RootElement.GetProperty("filtersHash").GetString();
Assert.StartsWith("sha256:", filtersHash);
}
private static ClaimsPrincipal CreatePrincipal(
string tenant,
@@ -371,10 +588,10 @@ public sealed class ConsoleEndpointsTests
builder.Services.AddSingleton<TimeProvider>(timeProvider);
builder.Services.AddSingleton<IAuthEventSink>(sink);
builder.Services.AddSingleton<IAuthorityTenantCatalog>(new FakeTenantCatalog(tenants));
builder.Services.AddSingleton<TestPrincipalAccessor>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<StellaOpsBypassEvaluator>();
builder.Services.AddSingleton<IConsoleWorkspaceService, ConsoleWorkspaceSampleService>();
builder.Services.AddSingleton<TestPrincipalAccessor>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<StellaOpsBypassEvaluator>();
builder.Services.AddSingleton<IConsoleWorkspaceService, ConsoleWorkspaceSampleService>();
var authBuilder = builder.Services.AddAuthentication(options =>
{
@@ -400,7 +617,7 @@ public sealed class ConsoleEndpointsTests
app.UseAuthorization();
app.MapConsoleEndpoints();
await app.StartAsync();
await app.StartAsync();
return app;
}
@@ -434,11 +651,11 @@ public sealed class ConsoleEndpointsTests
private sealed class TestAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public TestAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
public TestAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
@@ -468,4 +685,4 @@ internal static class HostTestClientExtensions
internal static class TestAuthenticationDefaults
{
public const string AuthenticationScheme = "AuthorityConsoleTests";
}
}

View File

@@ -61,7 +61,7 @@ internal sealed class AuthoritySealedModeEvidenceValidator : IAuthoritySealedMod
}
var cacheKey = $"authority:sealed-mode:{sealedOptions.EvidencePath}";
if (memoryCache.TryGetValue(cacheKey, out AuthoritySealedModeValidationResult cached))
if (memoryCache.TryGetValue(cacheKey, out AuthoritySealedModeValidationResult? cached) && cached is not null)
{
return cached;
}

View File

@@ -37,41 +37,54 @@ internal static class ConsoleEndpointExtensions
.WithName("ConsoleProfile")
.WithSummary("Return the authenticated principal profile metadata.");
group.MapPost("/token/introspect", IntrospectToken)
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.UiRead))
.WithName("ConsoleTokenIntrospect")
.WithSummary("Introspect the current access token and return expiry, scope, and tenant metadata.");
var vulnGroup = group.MapGroup("/vuln")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(
StellaOpsScopes.UiRead,
StellaOpsScopes.AdvisoryRead,
StellaOpsScopes.VexRead));
vulnGroup.MapGet("/findings", GetVulnerabilityFindings)
.WithName("ConsoleVulnerabilityFindings")
.WithSummary("List tenant-scoped vulnerability findings with policy/VEX metadata.");
vulnGroup.MapGet("/{findingId}", GetVulnerabilityFindingById)
.WithName("ConsoleVulnerabilityFindingDetail")
.WithSummary("Return the full finding document, including evidence and policy overlays.");
vulnGroup.MapPost("/tickets", CreateVulnerabilityTicket)
.WithName("ConsoleVulnerabilityTickets")
.WithSummary("Generate a signed payload payload for external ticketing workflows.");
var vexGroup = group.MapGroup("/vex")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(
StellaOpsScopes.UiRead,
StellaOpsScopes.VexRead));
vexGroup.MapGet("/statements", GetVexStatements)
.WithName("ConsoleVexStatements")
.WithSummary("List VEX statements impacting the tenant.");
vexGroup.MapGet("/events", StreamVexEvents)
.WithName("ConsoleVexEvents")
.WithSummary("Server-sent events feed for live VEX updates (placeholder).");
group.MapPost("/token/introspect", IntrospectToken)
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.UiRead))
.WithName("ConsoleTokenIntrospect")
.WithSummary("Introspect the current access token and return expiry, scope, and tenant metadata.");
var vulnGroup = group.MapGroup("/vuln")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(
StellaOpsScopes.UiRead,
StellaOpsScopes.AdvisoryRead,
StellaOpsScopes.VexRead));
vulnGroup.MapGet("/findings", GetVulnerabilityFindings)
.WithName("ConsoleVulnerabilityFindings")
.WithSummary("List tenant-scoped vulnerability findings with policy/VEX metadata.");
vulnGroup.MapGet("/{findingId}", GetVulnerabilityFindingById)
.WithName("ConsoleVulnerabilityFindingDetail")
.WithSummary("Return the full finding document, including evidence and policy overlays.");
vulnGroup.MapPost("/tickets", CreateVulnerabilityTicket)
.WithName("ConsoleVulnerabilityTickets")
.WithSummary("Generate a signed payload payload for external ticketing workflows.");
var vexGroup = group.MapGroup("/vex")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(
StellaOpsScopes.UiRead,
StellaOpsScopes.VexRead));
vexGroup.MapGet("/statements", GetVexStatements)
.WithName("ConsoleVexStatements")
.WithSummary("List VEX statements impacting the tenant.");
vexGroup.MapGet("/events", StreamVexEvents)
.WithName("ConsoleVexEvents")
.WithSummary("Server-sent events feed for live VEX updates (placeholder).");
// Dashboard and filters endpoints (WEB-CONSOLE-23-001)
group.MapGet("/dashboard", GetDashboard)
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(
StellaOpsScopes.UiRead))
.WithName("ConsoleDashboard")
.WithSummary("Tenant-scoped aggregates for findings, VEX overrides, advisory deltas, run health, and policy change log.");
group.MapGet("/filters", GetFilters)
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(
StellaOpsScopes.UiRead))
.WithName("ConsoleFilters")
.WithSummary("Available filter categories with options and counts for deterministic console queries.");
}
private static async Task<IResult> GetTenants(
@@ -165,11 +178,11 @@ internal static class ConsoleEndpointExtensions
return Results.Ok(profile);
}
private static async Task<IResult> IntrospectToken(
HttpContext httpContext,
TimeProvider timeProvider,
IAuthEventSink auditSink,
CancellationToken cancellationToken)
private static async Task<IResult> IntrospectToken(
HttpContext httpContext,
TimeProvider timeProvider,
IAuthEventSink auditSink,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(httpContext);
ArgumentNullException.ThrowIfNull(timeProvider);
@@ -183,214 +196,311 @@ internal static class ConsoleEndpointExtensions
var introspection = BuildTokenIntrospection(principal, timeProvider);
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.token.introspect",
AuthEventOutcome.Success,
null,
BuildProperties(
("token.active", introspection.Active ? "true" : "false"),
("token.expires_at", FormatInstant(introspection.ExpiresAt)),
("tenant.resolved", introspection.Tenant)),
cancellationToken).ConfigureAwait(false);
return Results.Ok(introspection);
}
private static async Task<IResult> GetVulnerabilityFindings(
HttpContext httpContext,
IConsoleWorkspaceService workspaceService,
TimeProvider timeProvider,
IAuthEventSink auditSink,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(httpContext);
ArgumentNullException.ThrowIfNull(workspaceService);
var tenant = TenantHeaderFilter.GetTenant(httpContext);
if (string.IsNullOrWhiteSpace(tenant))
{
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.vuln.findings",
AuthEventOutcome.Failure,
"tenant_header_missing",
BuildProperties(("tenant.header", null)),
cancellationToken).ConfigureAwait(false);
return Results.BadRequest(new { error = "tenant_header_missing", message = $"Header '{AuthorityHttpHeaders.Tenant}' is required." });
}
var query = BuildVulnerabilityQuery(httpContext.Request);
var response = await workspaceService.SearchFindingsAsync(tenant, query, cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.vuln.findings",
AuthEventOutcome.Success,
null,
BuildProperties(("tenant.resolved", tenant), ("pagination.next_token", response.NextPageToken)),
cancellationToken).ConfigureAwait(false);
return Results.Ok(response);
}
private static async Task<IResult> GetVulnerabilityFindingById(
HttpContext httpContext,
string findingId,
IConsoleWorkspaceService workspaceService,
TimeProvider timeProvider,
IAuthEventSink auditSink,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(httpContext);
ArgumentNullException.ThrowIfNull(workspaceService);
var tenant = TenantHeaderFilter.GetTenant(httpContext);
if (string.IsNullOrWhiteSpace(tenant))
{
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.vuln.finding",
AuthEventOutcome.Failure,
"tenant_header_missing",
BuildProperties(("tenant.header", null)),
cancellationToken).ConfigureAwait(false);
return Results.BadRequest(new { error = "tenant_header_missing", message = $"Header '{AuthorityHttpHeaders.Tenant}' is required." });
}
var detail = await workspaceService.GetFindingAsync(tenant, findingId, cancellationToken).ConfigureAwait(false);
if (detail is null)
{
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.vuln.finding",
AuthEventOutcome.Failure,
"finding_not_found",
BuildProperties(("tenant.resolved", tenant), ("finding.id", findingId)),
cancellationToken).ConfigureAwait(false);
return Results.NotFound(new { error = "finding_not_found", message = $"Finding '{findingId}' not found." });
}
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.vuln.finding",
AuthEventOutcome.Success,
null,
BuildProperties(("tenant.resolved", tenant), ("finding.id", findingId)),
cancellationToken).ConfigureAwait(false);
return Results.Ok(detail);
}
private static async Task<IResult> CreateVulnerabilityTicket(
HttpContext httpContext,
ConsoleVulnerabilityTicketRequest request,
IConsoleWorkspaceService workspaceService,
TimeProvider timeProvider,
IAuthEventSink auditSink,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(httpContext);
ArgumentNullException.ThrowIfNull(workspaceService);
if (request is null || request.Selection.Count == 0)
{
return Results.BadRequest(new { error = "invalid_request", message = "At least one finding must be selected." });
}
var tenant = TenantHeaderFilter.GetTenant(httpContext);
if (string.IsNullOrWhiteSpace(tenant))
{
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.vuln.ticket",
AuthEventOutcome.Failure,
"tenant_header_missing",
BuildProperties(("tenant.header", null)),
cancellationToken).ConfigureAwait(false);
return Results.BadRequest(new { error = "tenant_header_missing", message = $"Header '{AuthorityHttpHeaders.Tenant}' is required." });
}
var ticket = await workspaceService.CreateTicketAsync(tenant, request, cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.vuln.ticket",
AuthEventOutcome.Success,
null,
BuildProperties(
("tenant.resolved", tenant),
("ticket.id", ticket.TicketId),
("ticket.selection.count", request.Selection.Count.ToString(CultureInfo.InvariantCulture))),
cancellationToken).ConfigureAwait(false);
return Results.Ok(ticket);
}
private static async Task<IResult> GetVexStatements(
HttpContext httpContext,
IConsoleWorkspaceService workspaceService,
TimeProvider timeProvider,
IAuthEventSink auditSink,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(httpContext);
ArgumentNullException.ThrowIfNull(workspaceService);
var tenant = TenantHeaderFilter.GetTenant(httpContext);
if (string.IsNullOrWhiteSpace(tenant))
{
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.vex.statements",
AuthEventOutcome.Failure,
"tenant_header_missing",
BuildProperties(("tenant.header", null)),
cancellationToken).ConfigureAwait(false);
return Results.BadRequest(new { error = "tenant_header_missing", message = $"Header '{AuthorityHttpHeaders.Tenant}' is required." });
}
var query = BuildVexQuery(httpContext.Request);
var response = await workspaceService.GetVexStatementsAsync(tenant, query, cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.vex.statements",
AuthEventOutcome.Success,
null,
BuildProperties(("tenant.resolved", tenant), ("pagination.next_token", response.NextPageToken)),
cancellationToken).ConfigureAwait(false);
return Results.Ok(response);
}
private static IResult StreamVexEvents() =>
Results.StatusCode(StatusCodes.Status501NotImplemented);
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.token.introspect",
AuthEventOutcome.Success,
null,
BuildProperties(
("token.active", introspection.Active ? "true" : "false"),
("token.expires_at", FormatInstant(introspection.ExpiresAt)),
("tenant.resolved", introspection.Tenant)),
cancellationToken).ConfigureAwait(false);
return Results.Ok(introspection);
}
private static async Task<IResult> GetVulnerabilityFindings(
HttpContext httpContext,
IConsoleWorkspaceService workspaceService,
TimeProvider timeProvider,
IAuthEventSink auditSink,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(httpContext);
ArgumentNullException.ThrowIfNull(workspaceService);
var tenant = TenantHeaderFilter.GetTenant(httpContext);
if (string.IsNullOrWhiteSpace(tenant))
{
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.vuln.findings",
AuthEventOutcome.Failure,
"tenant_header_missing",
BuildProperties(("tenant.header", null)),
cancellationToken).ConfigureAwait(false);
return Results.BadRequest(new { error = "tenant_header_missing", message = $"Header '{AuthorityHttpHeaders.Tenant}' is required." });
}
var query = BuildVulnerabilityQuery(httpContext.Request);
var response = await workspaceService.SearchFindingsAsync(tenant, query, cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.vuln.findings",
AuthEventOutcome.Success,
null,
BuildProperties(("tenant.resolved", tenant), ("pagination.next_token", response.NextPageToken)),
cancellationToken).ConfigureAwait(false);
return Results.Ok(response);
}
private static async Task<IResult> GetVulnerabilityFindingById(
HttpContext httpContext,
string findingId,
IConsoleWorkspaceService workspaceService,
TimeProvider timeProvider,
IAuthEventSink auditSink,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(httpContext);
ArgumentNullException.ThrowIfNull(workspaceService);
var tenant = TenantHeaderFilter.GetTenant(httpContext);
if (string.IsNullOrWhiteSpace(tenant))
{
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.vuln.finding",
AuthEventOutcome.Failure,
"tenant_header_missing",
BuildProperties(("tenant.header", null)),
cancellationToken).ConfigureAwait(false);
return Results.BadRequest(new { error = "tenant_header_missing", message = $"Header '{AuthorityHttpHeaders.Tenant}' is required." });
}
var detail = await workspaceService.GetFindingAsync(tenant, findingId, cancellationToken).ConfigureAwait(false);
if (detail is null)
{
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.vuln.finding",
AuthEventOutcome.Failure,
"finding_not_found",
BuildProperties(("tenant.resolved", tenant), ("finding.id", findingId)),
cancellationToken).ConfigureAwait(false);
return Results.NotFound(new { error = "finding_not_found", message = $"Finding '{findingId}' not found." });
}
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.vuln.finding",
AuthEventOutcome.Success,
null,
BuildProperties(("tenant.resolved", tenant), ("finding.id", findingId)),
cancellationToken).ConfigureAwait(false);
return Results.Ok(detail);
}
private static async Task<IResult> CreateVulnerabilityTicket(
HttpContext httpContext,
ConsoleVulnerabilityTicketRequest request,
IConsoleWorkspaceService workspaceService,
TimeProvider timeProvider,
IAuthEventSink auditSink,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(httpContext);
ArgumentNullException.ThrowIfNull(workspaceService);
if (request is null || request.Selection.Count == 0)
{
return Results.BadRequest(new { error = "invalid_request", message = "At least one finding must be selected." });
}
var tenant = TenantHeaderFilter.GetTenant(httpContext);
if (string.IsNullOrWhiteSpace(tenant))
{
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.vuln.ticket",
AuthEventOutcome.Failure,
"tenant_header_missing",
BuildProperties(("tenant.header", null)),
cancellationToken).ConfigureAwait(false);
return Results.BadRequest(new { error = "tenant_header_missing", message = $"Header '{AuthorityHttpHeaders.Tenant}' is required." });
}
var ticket = await workspaceService.CreateTicketAsync(tenant, request, cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.vuln.ticket",
AuthEventOutcome.Success,
null,
BuildProperties(
("tenant.resolved", tenant),
("ticket.id", ticket.TicketId),
("ticket.selection.count", request.Selection.Count.ToString(CultureInfo.InvariantCulture))),
cancellationToken).ConfigureAwait(false);
return Results.Ok(ticket);
}
private static async Task<IResult> GetVexStatements(
HttpContext httpContext,
IConsoleWorkspaceService workspaceService,
TimeProvider timeProvider,
IAuthEventSink auditSink,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(httpContext);
ArgumentNullException.ThrowIfNull(workspaceService);
var tenant = TenantHeaderFilter.GetTenant(httpContext);
if (string.IsNullOrWhiteSpace(tenant))
{
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.vex.statements",
AuthEventOutcome.Failure,
"tenant_header_missing",
BuildProperties(("tenant.header", null)),
cancellationToken).ConfigureAwait(false);
return Results.BadRequest(new { error = "tenant_header_missing", message = $"Header '{AuthorityHttpHeaders.Tenant}' is required." });
}
var query = BuildVexQuery(httpContext.Request);
var response = await workspaceService.GetVexStatementsAsync(tenant, query, cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.vex.statements",
AuthEventOutcome.Success,
null,
BuildProperties(("tenant.resolved", tenant), ("pagination.next_token", response.NextPageToken)),
cancellationToken).ConfigureAwait(false);
return Results.Ok(response);
}
private static IResult StreamVexEvents() =>
Results.StatusCode(StatusCodes.Status501NotImplemented);
private static async Task<IResult> GetDashboard(
HttpContext httpContext,
IConsoleWorkspaceService workspaceService,
TimeProvider timeProvider,
IAuthEventSink auditSink,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(httpContext);
ArgumentNullException.ThrowIfNull(workspaceService);
var tenant = TenantHeaderFilter.GetTenant(httpContext);
if (string.IsNullOrWhiteSpace(tenant))
{
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.dashboard",
AuthEventOutcome.Failure,
"tenant_header_missing",
BuildProperties(("tenant.header", null)),
cancellationToken).ConfigureAwait(false);
return Results.BadRequest(new { error = "tenant_header_missing", message = $"Header '{AuthorityHttpHeaders.Tenant}' is required." });
}
var dashboard = await workspaceService.GetDashboardAsync(tenant, cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.dashboard",
AuthEventOutcome.Success,
null,
BuildProperties(
("tenant.resolved", tenant),
("dashboard.findings_count", dashboard.Findings.TotalFindings.ToString(CultureInfo.InvariantCulture))),
cancellationToken).ConfigureAwait(false);
return Results.Ok(dashboard);
}
private static async Task<IResult> GetFilters(
HttpContext httpContext,
IConsoleWorkspaceService workspaceService,
TimeProvider timeProvider,
IAuthEventSink auditSink,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(httpContext);
ArgumentNullException.ThrowIfNull(workspaceService);
var tenant = TenantHeaderFilter.GetTenant(httpContext);
if (string.IsNullOrWhiteSpace(tenant))
{
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.filters",
AuthEventOutcome.Failure,
"tenant_header_missing",
BuildProperties(("tenant.header", null)),
cancellationToken).ConfigureAwait(false);
return Results.BadRequest(new { error = "tenant_header_missing", message = $"Header '{AuthorityHttpHeaders.Tenant}' is required." });
}
var query = BuildFiltersQuery(httpContext.Request);
var filters = await workspaceService.GetFiltersAsync(tenant, query, cancellationToken).ConfigureAwait(false);
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.filters",
AuthEventOutcome.Success,
null,
BuildProperties(
("tenant.resolved", tenant),
("filters.hash", filters.FiltersHash),
("filters.categories_count", filters.Categories.Count.ToString(CultureInfo.InvariantCulture))),
cancellationToken).ConfigureAwait(false);
return Results.Ok(filters);
}
private static ConsoleFiltersQuery BuildFiltersQuery(HttpRequest request)
{
var scope = request.Query.TryGetValue("scope", out var scopeValues) ? scopeValues.FirstOrDefault() : null;
var includeEmpty = request.Query.TryGetValue("includeEmpty", out var includeValues) &&
bool.TryParse(includeValues.FirstOrDefault(), out var include) && include;
return new ConsoleFiltersQuery(scope, includeEmpty);
}
private static ConsoleProfileResponse BuildProfile(ClaimsPrincipal principal, TimeProvider timeProvider)
{
@@ -455,9 +565,9 @@ internal static class ConsoleEndpointExtensions
FreshAuth: freshAuth);
}
private static bool DetermineFreshAuth(ClaimsPrincipal principal, DateTimeOffset now)
{
var flag = principal.FindFirst("stellaops:fresh_auth") ?? principal.FindFirst("fresh_auth");
private static bool DetermineFreshAuth(ClaimsPrincipal principal, DateTimeOffset now)
{
var flag = principal.FindFirst("stellaops:fresh_auth") ?? principal.FindFirst("fresh_auth");
if (flag is not null && bool.TryParse(flag.Value, out var freshFlag))
{
if (freshFlag)
@@ -478,67 +588,67 @@ internal static class ConsoleEndpointExtensions
return authTime.Value.Add(ttl) > now;
}
const int defaultFreshAuthWindowSeconds = 300;
return authTime.Value.AddSeconds(defaultFreshAuthWindowSeconds) > now;
}
private static ConsoleVulnerabilityQuery BuildVulnerabilityQuery(HttpRequest request)
{
var builder = new ConsoleVulnerabilityQueryBuilder()
.SetPageSize(ParseInt(request.Query["pageSize"], 50))
.SetPageToken(request.Query.TryGetValue("pageToken", out var tokenValues) ? tokenValues.FirstOrDefault() : null)
.AddSeverity(ReadMulti(request, "severity"))
.AddPolicyBadges(ReadMulti(request, "policyBadge"))
.AddReachability(ReadMulti(request, "reachability"))
.AddProducts(ReadMulti(request, "product"))
.AddVexStates(ReadMulti(request, "vexState"));
var search = request.Query.TryGetValue("search", out var searchValues)
? searchValues
.Where(value => !string.IsNullOrWhiteSpace(value))
.SelectMany(value => value!.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
: Array.Empty<string>();
builder.AddSearchTerms(search);
return builder.Build();
}
private static ConsoleVexQuery BuildVexQuery(HttpRequest request)
{
var builder = new ConsoleVexQueryBuilder()
.SetPageSize(ParseInt(request.Query["pageSize"], 50))
.SetPageToken(request.Query.TryGetValue("pageToken", out var pageValues) ? pageValues.FirstOrDefault() : null)
.AddAdvisories(ReadMulti(request, "advisoryId"))
.AddTypes(ReadMulti(request, "statementType"))
.AddStates(ReadMulti(request, "state"));
return builder.Build();
}
private static IEnumerable<string> ReadMulti(HttpRequest request, string key)
{
if (!request.Query.TryGetValue(key, out var values))
{
return Array.Empty<string>();
}
return values
.Where(value => !string.IsNullOrWhiteSpace(value))
.SelectMany(value => value!.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Where(value => value.Length > 0);
}
private static int ParseInt(StringValues values, int fallback)
{
if (values.Count == 0)
{
return fallback;
}
return int.TryParse(values[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var number)
? number
: fallback;
}
const int defaultFreshAuthWindowSeconds = 300;
return authTime.Value.AddSeconds(defaultFreshAuthWindowSeconds) > now;
}
private static ConsoleVulnerabilityQuery BuildVulnerabilityQuery(HttpRequest request)
{
var builder = new ConsoleVulnerabilityQueryBuilder()
.SetPageSize(ParseInt(request.Query["pageSize"], 50))
.SetPageToken(request.Query.TryGetValue("pageToken", out var tokenValues) ? tokenValues.FirstOrDefault() : null)
.AddSeverity(ReadMulti(request, "severity"))
.AddPolicyBadges(ReadMulti(request, "policyBadge"))
.AddReachability(ReadMulti(request, "reachability"))
.AddProducts(ReadMulti(request, "product"))
.AddVexStates(ReadMulti(request, "vexState"));
var search = request.Query.TryGetValue("search", out var searchValues)
? searchValues
.Where(value => !string.IsNullOrWhiteSpace(value))
.SelectMany(value => value!.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
: Array.Empty<string>();
builder.AddSearchTerms(search);
return builder.Build();
}
private static ConsoleVexQuery BuildVexQuery(HttpRequest request)
{
var builder = new ConsoleVexQueryBuilder()
.SetPageSize(ParseInt(request.Query["pageSize"], 50))
.SetPageToken(request.Query.TryGetValue("pageToken", out var pageValues) ? pageValues.FirstOrDefault() : null)
.AddAdvisories(ReadMulti(request, "advisoryId"))
.AddTypes(ReadMulti(request, "statementType"))
.AddStates(ReadMulti(request, "state"));
return builder.Build();
}
private static IEnumerable<string> ReadMulti(HttpRequest request, string key)
{
if (!request.Query.TryGetValue(key, out var values))
{
return Array.Empty<string>();
}
return values
.Where(value => !string.IsNullOrWhiteSpace(value))
.SelectMany(value => value!.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Where(value => value.Length > 0);
}
private static int ParseInt(StringValues values, int fallback)
{
if (values.Count == 0)
{
return fallback;
}
return int.TryParse(values[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var number)
? number
: fallback;
}
private static IReadOnlyList<string> ExtractRoles(ClaimsPrincipal principal)
{

View File

@@ -183,6 +183,22 @@ internal interface IConsoleWorkspaceService
string tenant,
ConsoleVexQuery query,
CancellationToken cancellationToken);
/// <summary>
/// Get tenant-scoped dashboard aggregates including findings, VEX overrides,
/// advisory deltas, run health, and policy change log.
/// </summary>
Task<ConsoleDashboardResponse> GetDashboardAsync(
string tenant,
CancellationToken cancellationToken);
/// <summary>
/// Get available filter categories with options and counts for console queries.
/// </summary>
Task<ConsoleFiltersResponse> GetFiltersAsync(
string tenant,
ConsoleFiltersQuery query,
CancellationToken cancellationToken);
}
internal sealed class ConsoleVulnerabilityQueryBuilder
@@ -302,3 +318,167 @@ internal sealed class ConsoleVexQueryBuilder
_pageSize,
_pageToken);
}
// ============================================================================
// Dashboard Models (WEB-CONSOLE-23-001)
// ============================================================================
/// <summary>
/// Dashboard response containing tenant-scoped aggregates for findings, VEX overrides,
/// advisory deltas, run health, and policy change log.
/// </summary>
internal sealed record ConsoleDashboardResponse(
string Tenant,
DateTimeOffset GeneratedAt,
ConsoleDashboardFindingsSummary Findings,
ConsoleDashboardVexSummary VexOverrides,
ConsoleDashboardAdvisorySummary AdvisoryDeltas,
ConsoleDashboardRunHealth RunHealth,
ConsoleDashboardPolicyChangeLog PolicyChanges);
/// <summary>
/// Aggregated findings summary for dashboard.
/// </summary>
internal sealed record ConsoleDashboardFindingsSummary(
int TotalFindings,
int CriticalCount,
int HighCount,
int MediumCount,
int LowCount,
int InformationalCount,
int NewLastDay,
int NewLastWeek,
int ResolvedLastWeek,
IReadOnlyList<ConsoleDashboardTrendPoint> TrendLast30Days);
/// <summary>
/// A single trend data point.
/// </summary>
internal sealed record ConsoleDashboardTrendPoint(
DateTimeOffset Date,
int Open,
int Resolved,
int New);
/// <summary>
/// VEX overrides summary for dashboard.
/// </summary>
internal sealed record ConsoleDashboardVexSummary(
int TotalStatements,
int NotAffectedCount,
int FixedCount,
int UnderInvestigationCount,
int AffectedCount,
int AutomatedCount,
int ManualCount,
DateTimeOffset? LastStatementUpdated);
/// <summary>
/// Advisory delta summary for dashboard.
/// </summary>
internal sealed record ConsoleDashboardAdvisorySummary(
int TotalAdvisories,
int NewLastDay,
int NewLastWeek,
int UpdatedLastWeek,
int KevCount,
IReadOnlyList<ConsoleDashboardAdvisoryItem> RecentAdvisories);
/// <summary>
/// A recent advisory item for dashboard display.
/// </summary>
internal sealed record ConsoleDashboardAdvisoryItem(
string AdvisoryId,
string Severity,
string Summary,
bool Kev,
int AffectedFindings,
DateTimeOffset PublishedAt);
/// <summary>
/// Run health summary for dashboard.
/// </summary>
internal sealed record ConsoleDashboardRunHealth(
int TotalRuns,
int SuccessfulRuns,
int FailedRuns,
int RunningRuns,
int PendingRuns,
double SuccessRatePercent,
TimeSpan? AverageRunDuration,
DateTimeOffset? LastRunCompletedAt,
IReadOnlyList<ConsoleDashboardRecentRun> RecentRuns);
/// <summary>
/// A recent run item for dashboard display.
/// </summary>
internal sealed record ConsoleDashboardRecentRun(
string RunId,
string RunType,
string Status,
DateTimeOffset StartedAt,
DateTimeOffset? CompletedAt,
TimeSpan? Duration,
int FindingsProcessed);
/// <summary>
/// Policy change log summary for dashboard.
/// </summary>
internal sealed record ConsoleDashboardPolicyChangeLog(
int TotalPolicies,
int ActivePolicies,
int ChangesLastWeek,
DateTimeOffset? LastPolicyUpdated,
IReadOnlyList<ConsoleDashboardPolicyChange> RecentChanges);
/// <summary>
/// A recent policy change for dashboard display.
/// </summary>
internal sealed record ConsoleDashboardPolicyChange(
string PolicyId,
string PolicyName,
string ChangeType,
string ChangedBy,
DateTimeOffset ChangedAt,
string? Description);
// ============================================================================
// Filters Models (WEB-CONSOLE-23-001)
// ============================================================================
/// <summary>
/// Available filters for console queries with counts and deterministic ordering.
/// </summary>
internal sealed record ConsoleFiltersResponse(
string Tenant,
DateTimeOffset GeneratedAt,
string FiltersHash,
IReadOnlyList<ConsoleFilterCategory> Categories);
/// <summary>
/// A filter category with available options.
/// </summary>
internal sealed record ConsoleFilterCategory(
string CategoryId,
string DisplayName,
string FilterType,
bool MultiSelect,
IReadOnlyList<ConsoleFilterOption> Options);
/// <summary>
/// A single filter option with count and metadata.
/// </summary>
internal sealed record ConsoleFilterOption(
string Value,
string DisplayName,
int Count,
bool IsDefault,
string? Description,
string? IconHint);
/// <summary>
/// Query for filters endpoint.
/// </summary>
internal sealed record ConsoleFiltersQuery(
string? Scope,
bool IncludeEmptyCategories);

View File

@@ -277,6 +277,265 @@ internal sealed class ConsoleWorkspaceSampleService : IConsoleWorkspaceService
return Task.FromResult(page);
}
public Task<ConsoleDashboardResponse> GetDashboardAsync(
string tenant,
CancellationToken cancellationToken)
{
var findings = SampleFindings.Where(detail => IsTenantMatch(tenant, detail.Summary)).ToList();
var statements = SampleStatements.Where(s => string.Equals(s.Tenant, tenant, StringComparison.OrdinalIgnoreCase)).ToList();
// Build findings summary
var findingsSummary = new ConsoleDashboardFindingsSummary(
TotalFindings: findings.Count,
CriticalCount: findings.Count(f => string.Equals(f.Summary.Severity, "critical", StringComparison.OrdinalIgnoreCase)),
HighCount: findings.Count(f => string.Equals(f.Summary.Severity, "high", StringComparison.OrdinalIgnoreCase)),
MediumCount: findings.Count(f => string.Equals(f.Summary.Severity, "medium", StringComparison.OrdinalIgnoreCase)),
LowCount: findings.Count(f => string.Equals(f.Summary.Severity, "low", StringComparison.OrdinalIgnoreCase)),
InformationalCount: findings.Count(f => string.Equals(f.Summary.Severity, "info", StringComparison.OrdinalIgnoreCase)),
NewLastDay: 1,
NewLastWeek: 2,
ResolvedLastWeek: 0,
TrendLast30Days: GenerateSampleTrend());
// Build VEX summary
var vexSummary = new ConsoleDashboardVexSummary(
TotalStatements: statements.Count,
NotAffectedCount: statements.Count(s => string.Equals(s.State, "not_affected", StringComparison.OrdinalIgnoreCase)),
FixedCount: statements.Count(s => string.Equals(s.State, "fixed", StringComparison.OrdinalIgnoreCase)),
UnderInvestigationCount: statements.Count(s => string.Equals(s.State, "under_investigation", StringComparison.OrdinalIgnoreCase)),
AffectedCount: statements.Count(s => string.Equals(s.State, "affected", StringComparison.OrdinalIgnoreCase)),
AutomatedCount: statements.Count(s => string.Equals(s.Source.Type, "advisory_ai", StringComparison.OrdinalIgnoreCase)),
ManualCount: statements.Count(s => !string.Equals(s.Source.Type, "advisory_ai", StringComparison.OrdinalIgnoreCase)),
LastStatementUpdated: statements.OrderByDescending(s => s.LastUpdated).FirstOrDefault()?.LastUpdated);
// Build advisory summary
var advisorySummary = new ConsoleDashboardAdvisorySummary(
TotalAdvisories: findings.Select(f => f.Summary.Coordinates.AdvisoryId).Distinct().Count(),
NewLastDay: 1,
NewLastWeek: 2,
UpdatedLastWeek: 1,
KevCount: findings.Count(f => f.Summary.Kev),
RecentAdvisories: findings
.Select(f => new ConsoleDashboardAdvisoryItem(
AdvisoryId: f.Summary.Coordinates.AdvisoryId,
Severity: f.Summary.Severity,
Summary: f.Summary.Summary,
Kev: f.Summary.Kev,
AffectedFindings: 1,
PublishedAt: f.Summary.Timestamps.FirstSeen))
.DistinctBy(a => a.AdvisoryId)
.OrderByDescending(a => a.PublishedAt)
.Take(5)
.ToImmutableArray());
// Build run health
var runHealth = new ConsoleDashboardRunHealth(
TotalRuns: 10,
SuccessfulRuns: 8,
FailedRuns: 1,
RunningRuns: 1,
PendingRuns: 0,
SuccessRatePercent: 80.0,
AverageRunDuration: TimeSpan.FromMinutes(5),
LastRunCompletedAt: DateTimeOffset.Parse("2025-11-08T12:00:00Z"),
RecentRuns: ImmutableArray.Create(
new ConsoleDashboardRecentRun(
RunId: "run::2025-11-08::001",
RunType: "scan",
Status: "completed",
StartedAt: DateTimeOffset.Parse("2025-11-08T11:55:00Z"),
CompletedAt: DateTimeOffset.Parse("2025-11-08T12:00:00Z"),
Duration: TimeSpan.FromMinutes(5),
FindingsProcessed: 150),
new ConsoleDashboardRecentRun(
RunId: "run::2025-11-08::002",
RunType: "policy_eval",
Status: "running",
StartedAt: DateTimeOffset.Parse("2025-11-08T12:05:00Z"),
CompletedAt: null,
Duration: null,
FindingsProcessed: 75)));
// Build policy change log
var policyChangeLog = new ConsoleDashboardPolicyChangeLog(
TotalPolicies: 5,
ActivePolicies: 4,
ChangesLastWeek: 2,
LastPolicyUpdated: DateTimeOffset.Parse("2025-11-07T15:30:00Z"),
RecentChanges: ImmutableArray.Create(
new ConsoleDashboardPolicyChange(
PolicyId: "policy://tenant-default/runtime-hardening",
PolicyName: "Runtime Hardening",
ChangeType: "updated",
ChangedBy: "admin@stella-ops.org",
ChangedAt: DateTimeOffset.Parse("2025-11-07T15:30:00Z"),
Description: "Added KEV check rule"),
new ConsoleDashboardPolicyChange(
PolicyId: "policy://tenant-default/network-hardening",
PolicyName: "Network Hardening",
ChangeType: "activated",
ChangedBy: "admin@stella-ops.org",
ChangedAt: DateTimeOffset.Parse("2025-11-06T10:00:00Z"),
Description: null)));
var dashboard = new ConsoleDashboardResponse(
Tenant: tenant,
GeneratedAt: DateTimeOffset.UtcNow,
Findings: findingsSummary,
VexOverrides: vexSummary,
AdvisoryDeltas: advisorySummary,
RunHealth: runHealth,
PolicyChanges: policyChangeLog);
return Task.FromResult(dashboard);
}
public Task<ConsoleFiltersResponse> GetFiltersAsync(
string tenant,
ConsoleFiltersQuery query,
CancellationToken cancellationToken)
{
var findings = SampleFindings.Where(detail => IsTenantMatch(tenant, detail.Summary)).ToList();
var categories = new List<ConsoleFilterCategory>
{
new ConsoleFilterCategory(
CategoryId: "severity",
DisplayName: "Severity",
FilterType: "enum",
MultiSelect: true,
Options: BuildFilterOptions(findings, f => f.Summary.Severity, new (string, string, string?)[]
{
("critical", "Critical", "critical_icon"),
("high", "High", "high_icon"),
("medium", "Medium", "medium_icon"),
("low", "Low", "low_icon"),
("info", "Informational", "info_icon")
}, query.IncludeEmptyCategories)),
new ConsoleFilterCategory(
CategoryId: "policyBadge",
DisplayName: "Policy Status",
FilterType: "enum",
MultiSelect: true,
Options: BuildFilterOptions(findings, f => f.Summary.PolicyBadge, new (string, string, string?)[]
{
("fail", "Fail", "fail_icon"),
("warn", "Warning", "warn_icon"),
("pass", "Pass", "pass_icon"),
("waived", "Waived", "waived_icon")
}, query.IncludeEmptyCategories)),
new ConsoleFilterCategory(
CategoryId: "reachability",
DisplayName: "Reachability",
FilterType: "enum",
MultiSelect: true,
Options: BuildFilterOptions(findings, f => f.Summary.Reachability?.Status ?? "unknown", new (string, string, string?)[]
{
("reachable", "Reachable", "reachable_icon"),
("unreachable", "Unreachable", "unreachable_icon"),
("unknown", "Unknown", "unknown_icon")
}, query.IncludeEmptyCategories)),
new ConsoleFilterCategory(
CategoryId: "vexState",
DisplayName: "VEX State",
FilterType: "enum",
MultiSelect: true,
Options: BuildFilterOptions(findings, f => f.Summary.Vex?.State ?? "none", new (string, string, string?)[]
{
("not_affected", "Not Affected", "not_affected_icon"),
("fixed", "Fixed", "fixed_icon"),
("under_investigation", "Under Investigation", "investigating_icon"),
("affected", "Affected", "affected_icon"),
("none", "No VEX", null)
}, query.IncludeEmptyCategories)),
new ConsoleFilterCategory(
CategoryId: "kev",
DisplayName: "Known Exploited",
FilterType: "boolean",
MultiSelect: false,
Options: ImmutableArray.Create(
new ConsoleFilterOption("true", "KEV Listed", findings.Count(f => f.Summary.Kev), false, "Known Exploited Vulnerability", "kev_icon"),
new ConsoleFilterOption("false", "Not KEV", findings.Count(f => !f.Summary.Kev), true, null, null)))
};
// Filter by scope if specified
if (!string.IsNullOrWhiteSpace(query.Scope))
{
categories = categories
.Where(c => string.Equals(c.CategoryId, query.Scope, StringComparison.OrdinalIgnoreCase))
.ToList();
}
var filtersHash = ComputeFiltersHash(categories);
var response = new ConsoleFiltersResponse(
Tenant: tenant,
GeneratedAt: DateTimeOffset.UtcNow,
FiltersHash: filtersHash,
Categories: categories.ToImmutableArray());
return Task.FromResult(response);
}
private static ImmutableArray<ConsoleDashboardTrendPoint> GenerateSampleTrend()
{
var points = new List<ConsoleDashboardTrendPoint>();
var baseDate = DateTimeOffset.Parse("2025-10-09T00:00:00Z");
for (int i = 0; i < 30; i++)
{
points.Add(new ConsoleDashboardTrendPoint(
Date: baseDate.AddDays(i),
Open: 2 + (i % 3),
Resolved: i % 5 == 0 ? 1 : 0,
New: i % 7 == 0 ? 1 : 0));
}
return points.ToImmutableArray();
}
private static ImmutableArray<ConsoleFilterOption> BuildFilterOptions(
List<ConsoleVulnerabilityFindingDetail> findings,
Func<ConsoleVulnerabilityFindingDetail, string> selector,
(string value, string displayName, string? icon)[] definitions,
bool includeEmpty)
{
var counts = findings
.GroupBy(selector, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase);
var options = new List<ConsoleFilterOption>();
foreach (var (value, displayName, icon) in definitions)
{
var count = counts.TryGetValue(value, out var c) ? c : 0;
if (count > 0 || includeEmpty)
{
options.Add(new ConsoleFilterOption(
Value: value,
DisplayName: displayName,
Count: count,
IsDefault: false,
Description: null,
IconHint: icon));
}
}
return options.ToImmutableArray();
}
private static string ComputeFiltersHash(List<ConsoleFilterCategory> categories)
{
using var sha256 = SHA256.Create();
var joined = string.Join("|", categories.SelectMany(c =>
c.Options.Select(o => $"{c.CategoryId}:{o.Value}:{o.Count}")));
var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(joined));
return $"sha256:{Convert.ToHexString(hash[..8]).ToLowerInvariant()}";
}
private static bool MatchesSeverity(ConsoleVulnerabilityFindingDetail detail, ConsoleVulnerabilityQuery query) =>
query.Severity.Count == 0 ||
query.Severity.Any(sev => string.Equals(sev, detail.Summary.Severity, StringComparison.OrdinalIgnoreCase));