consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
409
src/Findings/StellaOps.VulnExplorer.Api/Program.cs
Normal file
409
src/Findings/StellaOps.VulnExplorer.Api/Program.cs
Normal file
@@ -0,0 +1,409 @@
|
||||
|
||||
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<IVexOverrideAttestorClient, StubVexOverrideAttestorClient>();
|
||||
builder.Services.AddSingleton<VexDecisionStore>(sp =>
|
||||
new VexDecisionStore(attestorClient: sp.GetRequiredService<IVexOverrideAttestorClient>()));
|
||||
builder.Services.AddSingleton<FixVerificationStore>();
|
||||
builder.Services.AddSingleton<AuditBundleStore>();
|
||||
builder.Services.AddSingleton<EvidenceSubgraphStore>();
|
||||
|
||||
// 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.AssemblyInformationalVersionAttribute>(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<VexDecisionDto>()
|
||||
.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<VulnSummary> ApplyFilter(IReadOnlyList<VulnSummary> source, VulnFilter filter)
|
||||
{
|
||||
IEnumerable<VulnSummary> 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<Program>
|
||||
public partial class Program;
|
||||
|
||||
Reference in New Issue
Block a user