using System.Collections.Generic; using Swashbuckle.AspNetCore.SwaggerGen; using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.OpenApi; using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi.Models; using StellaOps.VulnExplorer.Api.Data; using StellaOps.VulnExplorer.Api.Models; var builder = WebApplication.CreateBuilder(args); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(options => { options.SwaggerDoc("v1", new OpenApiInfo { Title = "StellaOps Vuln Explorer API", Version = "v1", Description = "Deterministic vulnerability listing/detail and VEX decision endpoints" }); }); // Configure JSON serialization with enum string converter builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; }); // Register VEX decision store builder.Services.AddSingleton(); var app = builder.Build(); app.UseSwagger(); app.UseSwaggerUI(); app.UseHttpsRedirection(); app.MapGet("/v1/vulns", ([AsParameters] VulnFilter filter) => { if (string.IsNullOrWhiteSpace(filter.Tenant)) { return Results.BadRequest(new { error = "x-stella-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); }) .WithOpenApi(); app.MapGet("/v1/vulns/{id}", ([FromHeader(Name = "x-stella-tenant")] string? tenant, string id) => { if (string.IsNullOrWhiteSpace(tenant)) { return Results.BadRequest(new { error = "x-stella-tenant required" }); } return SampleData.TryGetDetail(id, out var detail) && detail is not null ? Results.Ok(detail) : Results.NotFound(); }) .WithOpenApi(); // ============================================================================ // VEX Decision Endpoints (API-VEX-06-001, API-VEX-06-002, API-VEX-06-003) // ============================================================================ app.MapPost("/v1/vex-decisions", ( [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) => { if (string.IsNullOrWhiteSpace(tenant)) { return Results.BadRequest(new { error = "x-stella-tenant required" }); } if (string.IsNullOrWhiteSpace(request.VulnerabilityId)) { return Results.BadRequest(new { error = "vulnerabilityId is required" }); } if (request.Subject is null) { return Results.BadRequest(new { error = "subject is required" }); } var effectiveUserId = userId ?? "anonymous"; var effectiveUserName = userName ?? "Anonymous User"; var decision = store.Create(request, effectiveUserId, effectiveUserName); return Results.Created($"/v1/vex-decisions/{decision.Id}", decision); }) .WithName("CreateVexDecision") .WithOpenApi(); 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 = "x-stella-tenant required" }); } var updated = store.Update(id, request); return updated is not null ? Results.Ok(updated) : Results.NotFound(new { error = $"VEX decision {id} not found" }); }) .WithName("UpdateVexDecision") .WithOpenApi(); app.MapGet("/v1/vex-decisions", ([AsParameters] VexDecisionFilter filter, VexDecisionStore store) => { if (string.IsNullOrWhiteSpace(filter.Tenant)) { return Results.BadRequest(new { error = "x-stella-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") .WithOpenApi(); 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 = "x-stella-tenant required" }); } var decision = store.Get(id); return decision is not null ? Results.Ok(decision) : Results.NotFound(new { error = $"VEX decision {id} not found" }); }) .WithName("GetVexDecision") .WithOpenApi(); 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); public partial class Program { }