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
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:
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user