Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
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
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
238 lines
7.6 KiB
C#
238 lines
7.6 KiB
C#
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<VexDecisionStore>();
|
|
|
|
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<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);
|
|
|
|
public partial class Program { }
|