using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Mvc; using StellaOps.Auth.Abstractions; using StellaOps.Auth.ServerIntegration; using StellaOps.Auth.ServerIntegration.Tenancy; using Microsoft.AspNetCore.OpenApi; using Microsoft.Extensions.DependencyInjection; using StellaOps.VulnExplorer.Api.Data; using StellaOps.VulnExplorer.Api.Models; using StellaOps.VulnExplorer.Api.Security; using StellaOps.VulnExplorer.WebService.Contracts; using Swashbuckle.AspNetCore.SwaggerGen; using System.Collections.Generic; using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; using StellaOps.Localization; using static StellaOps.Localization.T; using StellaOps.Router.AspNet; var builder = WebApplication.CreateBuilder(args); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); // Configure JSON serialization with enum string converter builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; }); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => new VexDecisionStore(attestorClient: sp.GetRequiredService())); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Authentication and authorization builder.Services.AddStellaOpsResourceServerAuthentication(builder.Configuration); builder.Services.AddStellaOpsTenantServices(); builder.Services.AddAuthorization(options => { options.AddStellaOpsScopePolicy(VulnExplorerPolicies.View, StellaOpsScopes.VulnView); options.AddStellaOpsScopePolicy(VulnExplorerPolicies.Investigate, StellaOpsScopes.VulnInvestigate); options.AddStellaOpsScopePolicy(VulnExplorerPolicies.Operate, StellaOpsScopes.VulnOperate); options.AddStellaOpsScopePolicy(VulnExplorerPolicies.Audit, StellaOpsScopes.VulnAudit); }); builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration); builder.Services.AddStellaOpsLocalization(builder.Configuration); builder.Services.AddTranslationBundle(System.Reflection.Assembly.GetExecutingAssembly()); // Stella Router integration var routerEnabled = builder.Services.AddRouterMicroservice( builder.Configuration, serviceName: "vulnexplorer", version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0", routerOptionsSection: "Router"); builder.TryAddStellaOpsLocalBinding("vulnexplorer"); var app = builder.Build(); app.LogStellaOpsLocalHostname("vulnexplorer"); app.UseSwagger(); app.UseSwaggerUI(); app.UseStellaOpsCors(); app.UseStellaOpsLocalization(); app.UseAuthentication(); app.UseAuthorization(); app.UseStellaOpsTenantMiddleware(); app.TryUseStellaRouter(routerEnabled); app.MapGet("/v1/vulns", ([AsParameters] VulnFilter filter) => { if (string.IsNullOrWhiteSpace(filter.Tenant)) { return Results.BadRequest(new { error = _t("vulnexplorer.error.tenant_required") }); } var data = ApplyFilter(SampleData.Summaries, filter); var pageSize = Math.Clamp(filter.PageSize ?? 50, 1, 200); var offset = ParsePageToken(filter.PageToken); var page = data.Skip(offset).Take(pageSize).ToArray(); var nextOffset = offset + page.Length; var next = nextOffset < data.Count ? nextOffset.ToString(CultureInfo.InvariantCulture) : null; var response = new VulnListResponse(page, next); return Results.Ok(response); }) .WithName("ListVulns") .WithDescription(_t("vulnexplorer.vuln.list_description")) .RequireAuthorization(VulnExplorerPolicies.View) .RequireTenant(); app.MapGet("/v1/vulns/{id}", ([FromHeader(Name = "x-stella-tenant")] string? tenant, string id) => { if (string.IsNullOrWhiteSpace(tenant)) { return Results.BadRequest(new { error = _t("vulnexplorer.error.tenant_required") }); } return SampleData.TryGetDetail(id, out var detail) && detail is not null ? Results.Ok(detail) : Results.NotFound(); }) .WithName("GetVuln") .WithDescription(_t("vulnexplorer.vuln.get_description")) .RequireAuthorization(VulnExplorerPolicies.View) .RequireTenant(); // ============================================================================ // VEX Decision Endpoints (API-VEX-06-001, API-VEX-06-002, API-VEX-06-003) // ============================================================================ app.MapPost("/v1/vex-decisions", async ( [FromHeader(Name = "x-stella-tenant")] string? tenant, [FromHeader(Name = "x-stella-user-id")] string? userId, [FromHeader(Name = "x-stella-user-name")] string? userName, [FromBody] CreateVexDecisionRequest request, VexDecisionStore store, CancellationToken cancellationToken) => { if (string.IsNullOrWhiteSpace(tenant)) { return Results.BadRequest(new { error = _t("vulnexplorer.error.tenant_required") }); } if (string.IsNullOrWhiteSpace(request.VulnerabilityId)) { return Results.BadRequest(new { error = _t("vulnexplorer.error.vulnerability_id_required") }); } if (request.Subject is null) { return Results.BadRequest(new { error = _t("vulnexplorer.error.subject_required") }); } var effectiveUserId = userId ?? "anonymous"; var effectiveUserName = userName ?? "Anonymous User"; VexDecisionDto decision; if (request.AttestationOptions?.CreateAttestation == true) { var result = await store.CreateWithAttestationAsync( request, effectiveUserId, effectiveUserName, cancellationToken); decision = result.Decision; } else { decision = store.Create(request, effectiveUserId, effectiveUserName); } return Results.Created($"/v1/vex-decisions/{decision.Id}", decision); }) .WithName("CreateVexDecision") .WithDescription(_t("vulnexplorer.vex_decision.create_description")) .RequireAuthorization(VulnExplorerPolicies.Operate) .RequireTenant(); app.MapPatch("/v1/vex-decisions/{id:guid}", ( [FromHeader(Name = "x-stella-tenant")] string? tenant, Guid id, [FromBody] UpdateVexDecisionRequest request, VexDecisionStore store) => { if (string.IsNullOrWhiteSpace(tenant)) { return Results.BadRequest(new { error = _t("vulnexplorer.error.tenant_required") }); } var updated = store.Update(id, request); return updated is not null ? Results.Ok(updated) : Results.NotFound(new { error = _t("vulnexplorer.error.vex_decision_not_found", id) }); }) .WithName("UpdateVexDecision") .WithDescription(_t("vulnexplorer.vex_decision.update_description")) .RequireAuthorization(VulnExplorerPolicies.Operate) .RequireTenant(); app.MapGet("/v1/vex-decisions", ([AsParameters] VexDecisionFilter filter, VexDecisionStore store) => { if (string.IsNullOrWhiteSpace(filter.Tenant)) { return Results.BadRequest(new { error = _t("vulnexplorer.error.tenant_required") }); } var pageSize = Math.Clamp(filter.PageSize ?? 50, 1, 200); var offset = ParsePageToken(filter.PageToken); var decisions = store.Query( vulnerabilityId: filter.VulnerabilityId, subjectName: filter.Subject, status: filter.Status, skip: offset, take: pageSize); var nextOffset = offset + decisions.Count; var next = nextOffset < store.Count() ? nextOffset.ToString(CultureInfo.InvariantCulture) : null; return Results.Ok(new VexDecisionListResponse(decisions, next)); }) .WithName("ListVexDecisions") .WithDescription(_t("vulnexplorer.vex_decision.list_description")) .RequireAuthorization(VulnExplorerPolicies.View) .RequireTenant(); app.MapGet("/v1/vex-decisions/{id:guid}", ( [FromHeader(Name = "x-stella-tenant")] string? tenant, Guid id, VexDecisionStore store) => { if (string.IsNullOrWhiteSpace(tenant)) { return Results.BadRequest(new { error = _t("vulnexplorer.error.tenant_required") }); } var decision = store.Get(id); return decision is not null ? Results.Ok(decision) : Results.NotFound(new { error = _t("vulnexplorer.error.vex_decision_not_found", id) }); }) .WithName("GetVexDecision") .WithDescription(_t("vulnexplorer.vex_decision.get_description")) .RequireAuthorization(VulnExplorerPolicies.View) .RequireTenant(); app.MapGet("/v1/evidence-subgraph/{vulnId}", ( [FromHeader(Name = "x-stella-tenant")] string? tenant, string vulnId, EvidenceSubgraphStore store) => { if (string.IsNullOrWhiteSpace(tenant)) { return Results.BadRequest(new { error = _t("vulnexplorer.error.tenant_required") }); } if (string.IsNullOrWhiteSpace(vulnId)) { return Results.BadRequest(new { error = _t("vulnexplorer.error.vuln_id_required") }); } EvidenceSubgraphResponse response = store.Build(vulnId); return Results.Ok(response); }) .WithName("GetEvidenceSubgraph") .WithDescription(_t("vulnexplorer.evidence_subgraph.get_description")) .RequireAuthorization(VulnExplorerPolicies.View) .RequireTenant(); app.MapPost("/v1/fix-verifications", ( [FromHeader(Name = "x-stella-tenant")] string? tenant, [FromBody] CreateFixVerificationRequest request, FixVerificationStore store) => { if (string.IsNullOrWhiteSpace(tenant)) { return Results.BadRequest(new { error = _t("vulnexplorer.error.tenant_required") }); } if (string.IsNullOrWhiteSpace(request.CveId) || string.IsNullOrWhiteSpace(request.ComponentPurl)) { return Results.BadRequest(new { error = _t("vulnexplorer.error.cve_id_and_purl_required") }); } var created = store.Create(request); return Results.Created($"/v1/fix-verifications/{created.CveId}", created); }) .WithName("CreateFixVerification") .WithDescription(_t("vulnexplorer.fix_verification.create_description")) .RequireAuthorization(VulnExplorerPolicies.Operate) .RequireTenant(); app.MapPatch("/v1/fix-verifications/{cveId}", ( [FromHeader(Name = "x-stella-tenant")] string? tenant, string cveId, [FromBody] UpdateFixVerificationRequest request, FixVerificationStore store) => { if (string.IsNullOrWhiteSpace(tenant)) { return Results.BadRequest(new { error = _t("vulnexplorer.error.tenant_required") }); } if (string.IsNullOrWhiteSpace(request.Verdict)) { return Results.BadRequest(new { error = _t("vulnexplorer.error.verdict_required") }); } var updated = store.Update(cveId, request.Verdict); return updated is not null ? Results.Ok(updated) : Results.NotFound(new { error = _t("vulnexplorer.error.fix_verification_not_found", cveId) }); }) .WithName("UpdateFixVerification") .WithDescription(_t("vulnexplorer.fix_verification.update_description")) .RequireAuthorization(VulnExplorerPolicies.Operate) .RequireTenant(); app.MapPost("/v1/audit-bundles", ( [FromHeader(Name = "x-stella-tenant")] string? tenant, [FromBody] CreateAuditBundleRequest request, VexDecisionStore decisions, AuditBundleStore bundles) => { if (string.IsNullOrWhiteSpace(tenant)) { return Results.BadRequest(new { error = _t("vulnexplorer.error.tenant_required") }); } if (request.DecisionIds is null || request.DecisionIds.Count == 0) { return Results.BadRequest(new { error = _t("vulnexplorer.error.decision_ids_required") }); } var selected = request.DecisionIds .Select(id => decisions.Get(id)) .Where(x => x is not null) .Cast() .ToArray(); if (selected.Length == 0) { return Results.NotFound(new { error = _t("vulnexplorer.error.no_decisions_found") }); } var bundle = bundles.Create(tenant, selected); return Results.Created($"/v1/audit-bundles/{bundle.BundleId}", bundle); }) .WithName("CreateAuditBundle") .WithDescription(_t("vulnexplorer.audit_bundle.create_description")) .RequireAuthorization(VulnExplorerPolicies.Audit) .RequireTenant(); app.TryRefreshStellaRouterEndpoints(routerEnabled); await app.LoadTranslationsAsync(); app.Run(); static int ParsePageToken(string? token) => int.TryParse(token, out var offset) && offset >= 0 ? offset : 0; static IReadOnlyList ApplyFilter(IReadOnlyList source, VulnFilter filter) { IEnumerable query = source; if (filter.Cve is { Length: > 0 }) { var set = filter.Cve.ToHashSet(StringComparer.OrdinalIgnoreCase); query = query.Where(v => v.CveIds.Any(set.Contains)); } if (filter.Purl is { Length: > 0 }) { var set = filter.Purl.ToHashSet(StringComparer.OrdinalIgnoreCase); query = query.Where(v => v.Purls.Any(set.Contains)); } if (filter.Severity is { Length: > 0 }) { var set = filter.Severity.ToHashSet(StringComparer.OrdinalIgnoreCase); query = query.Where(v => set.Contains(v.Severity)); } if (filter.Exploitability is not null) { query = query.Where(v => string.Equals(v.Exploitability, filter.Exploitability, StringComparison.OrdinalIgnoreCase)); } if (filter.FixAvailable is not null) { query = query.Where(v => v.FixAvailable == filter.FixAvailable); } // deterministic ordering: score desc, id asc query = query .OrderByDescending(v => v.Score) .ThenBy(v => v.Id, StringComparer.Ordinal); return query.ToArray(); } public record VulnFilter( [FromHeader(Name = "x-stella-tenant")] string? Tenant, [FromQuery(Name = "policyVersion")] string? PolicyVersion, [FromQuery(Name = "pageSize")] int? PageSize, [FromQuery(Name = "pageToken")] string? PageToken, [FromQuery(Name = "cve")] string[]? Cve, [FromQuery(Name = "purl")] string[]? Purl, [FromQuery(Name = "severity")] string[]? Severity, [FromQuery(Name = "exploitability")] string? Exploitability, [FromQuery(Name = "fixAvailable")] bool? FixAvailable); public record VexDecisionFilter( [FromHeader(Name = "x-stella-tenant")] string? Tenant, [FromQuery(Name = "vulnerabilityId")] string? VulnerabilityId, [FromQuery(Name = "subject")] string? Subject, [FromQuery(Name = "status")] VexStatus? Status, [FromQuery(Name = "pageSize")] int? PageSize, [FromQuery(Name = "pageToken")] string? PageToken); // Program class public for WebApplicationFactory public partial class Program;