up
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
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
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.VulnExplorer.Api.Models;
|
||||
|
||||
namespace StellaOps.VulnExplorer.Api.Data;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory VEX decision store for development/testing.
|
||||
/// Production would use PostgreSQL repository.
|
||||
/// </summary>
|
||||
public sealed class VexDecisionStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<Guid, VexDecisionDto> _decisions = new();
|
||||
|
||||
public VexDecisionDto Create(CreateVexDecisionRequest request, string userId, string userDisplayName)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var decision = new VexDecisionDto(
|
||||
Id: id,
|
||||
VulnerabilityId: request.VulnerabilityId,
|
||||
Subject: request.Subject,
|
||||
Status: request.Status,
|
||||
JustificationType: request.JustificationType,
|
||||
JustificationText: request.JustificationText,
|
||||
EvidenceRefs: request.EvidenceRefs,
|
||||
Scope: request.Scope,
|
||||
ValidFor: request.ValidFor,
|
||||
AttestationRef: null, // Will be set when attestation is generated
|
||||
SupersedesDecisionId: request.SupersedesDecisionId,
|
||||
CreatedBy: new ActorRefDto(userId, userDisplayName),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: null);
|
||||
|
||||
_decisions[id] = decision;
|
||||
return decision;
|
||||
}
|
||||
|
||||
public VexDecisionDto? Update(Guid id, UpdateVexDecisionRequest request)
|
||||
{
|
||||
if (!_decisions.TryGetValue(id, out var existing))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var updated = existing with
|
||||
{
|
||||
Status = request.Status ?? existing.Status,
|
||||
JustificationType = request.JustificationType ?? existing.JustificationType,
|
||||
JustificationText = request.JustificationText ?? existing.JustificationText,
|
||||
EvidenceRefs = request.EvidenceRefs ?? existing.EvidenceRefs,
|
||||
Scope = request.Scope ?? existing.Scope,
|
||||
ValidFor = request.ValidFor ?? existing.ValidFor,
|
||||
SupersedesDecisionId = request.SupersedesDecisionId ?? existing.SupersedesDecisionId,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_decisions[id] = updated;
|
||||
return updated;
|
||||
}
|
||||
|
||||
public VexDecisionDto? Get(Guid id) =>
|
||||
_decisions.TryGetValue(id, out var decision) ? decision : null;
|
||||
|
||||
public IReadOnlyList<VexDecisionDto> Query(
|
||||
string? vulnerabilityId = null,
|
||||
string? subjectName = null,
|
||||
VexStatus? status = null,
|
||||
int skip = 0,
|
||||
int take = 50)
|
||||
{
|
||||
IEnumerable<VexDecisionDto> query = _decisions.Values;
|
||||
|
||||
if (vulnerabilityId is not null)
|
||||
{
|
||||
query = query.Where(d => string.Equals(d.VulnerabilityId, vulnerabilityId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (subjectName is not null)
|
||||
{
|
||||
query = query.Where(d => d.Subject.Name.Contains(subjectName, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (status is not null)
|
||||
{
|
||||
query = query.Where(d => d.Status == status);
|
||||
}
|
||||
|
||||
// Deterministic ordering: createdAt desc, id asc
|
||||
return query
|
||||
.OrderByDescending(d => d.CreatedAt)
|
||||
.ThenBy(d => d.Id)
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public int Count() => _decisions.Count;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
namespace StellaOps.VulnExplorer.Api.Models;
|
||||
|
||||
/// <summary>
|
||||
/// In-toto style attestation for vulnerability scan results.
|
||||
/// Based on docs/schemas/attestation-vuln-scan.schema.json
|
||||
/// </summary>
|
||||
public sealed record VulnScanAttestationDto(
|
||||
string Type,
|
||||
string PredicateType,
|
||||
IReadOnlyList<AttestationSubjectDto> Subject,
|
||||
VulnScanPredicateDto Predicate,
|
||||
AttestationMetaDto AttestationMeta);
|
||||
|
||||
/// <summary>
|
||||
/// Subject of an attestation (artifact that was scanned).
|
||||
/// </summary>
|
||||
public sealed record AttestationSubjectDto(
|
||||
string Name,
|
||||
IReadOnlyDictionary<string, string> Digest);
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability scan result predicate.
|
||||
/// </summary>
|
||||
public sealed record VulnScanPredicateDto(
|
||||
ScannerInfoDto Scanner,
|
||||
ScannerDbInfoDto? ScannerDb,
|
||||
DateTimeOffset ScanStartedAt,
|
||||
DateTimeOffset ScanCompletedAt,
|
||||
SeverityCountsDto SeverityCounts,
|
||||
FindingReportDto FindingReport);
|
||||
|
||||
/// <summary>
|
||||
/// Scanner information.
|
||||
/// </summary>
|
||||
public sealed record ScannerInfoDto(
|
||||
string Name,
|
||||
string Version);
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability database information.
|
||||
/// </summary>
|
||||
public sealed record ScannerDbInfoDto(
|
||||
DateTimeOffset? LastUpdatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Count of findings by severity.
|
||||
/// </summary>
|
||||
public sealed record SeverityCountsDto(
|
||||
int Critical,
|
||||
int High,
|
||||
int Medium,
|
||||
int Low);
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the full findings report.
|
||||
/// </summary>
|
||||
public sealed record FindingReportDto(
|
||||
string MediaType,
|
||||
string Location,
|
||||
IReadOnlyDictionary<string, string> Digest);
|
||||
|
||||
/// <summary>
|
||||
/// Attestation metadata including signer info.
|
||||
/// </summary>
|
||||
public sealed record AttestationMetaDto(
|
||||
string StatementId,
|
||||
DateTimeOffset CreatedAt,
|
||||
AttestationSignerDto Signer);
|
||||
|
||||
/// <summary>
|
||||
/// Entity that signed an attestation.
|
||||
/// </summary>
|
||||
public sealed record AttestationSignerDto(
|
||||
string Name,
|
||||
string KeyId);
|
||||
|
||||
/// <summary>
|
||||
/// Response for listing attestations.
|
||||
/// </summary>
|
||||
public sealed record AttestationListResponse(
|
||||
IReadOnlyList<AttestationSummaryDto> Items,
|
||||
string? NextPageToken);
|
||||
|
||||
/// <summary>
|
||||
/// Summary view of an attestation for listing.
|
||||
/// </summary>
|
||||
public sealed record AttestationSummaryDto(
|
||||
string Id,
|
||||
AttestationType Type,
|
||||
string SubjectName,
|
||||
IReadOnlyDictionary<string, string> SubjectDigest,
|
||||
string PredicateType,
|
||||
DateTimeOffset CreatedAt,
|
||||
string? SignerName,
|
||||
string? SignerKeyId,
|
||||
bool Verified);
|
||||
|
||||
/// <summary>
|
||||
/// Attestation type enumeration.
|
||||
/// </summary>
|
||||
public enum AttestationType
|
||||
{
|
||||
VulnScan,
|
||||
Sbom,
|
||||
Vex,
|
||||
PolicyEval,
|
||||
Other
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
namespace StellaOps.VulnExplorer.Api.Models;
|
||||
|
||||
/// <summary>
|
||||
/// VEX-style statement attached to a finding + subject, representing a vulnerability exploitability decision.
|
||||
/// Based on docs/schemas/vex-decision.schema.json
|
||||
/// </summary>
|
||||
public sealed record VexDecisionDto(
|
||||
Guid Id,
|
||||
string VulnerabilityId,
|
||||
SubjectRefDto Subject,
|
||||
VexStatus Status,
|
||||
VexJustificationType JustificationType,
|
||||
string? JustificationText,
|
||||
IReadOnlyList<EvidenceRefDto>? EvidenceRefs,
|
||||
VexScopeDto? Scope,
|
||||
ValidForDto? ValidFor,
|
||||
AttestationRefDto? AttestationRef,
|
||||
Guid? SupersedesDecisionId,
|
||||
ActorRefDto CreatedBy,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? UpdatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Reference to an artifact or SBOM component that a VEX decision applies to.
|
||||
/// </summary>
|
||||
public sealed record SubjectRefDto(
|
||||
SubjectType Type,
|
||||
string Name,
|
||||
IReadOnlyDictionary<string, string> Digest,
|
||||
string? SbomNodeId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Reference to evidence supporting a VEX decision (PR, ticket, doc, commit).
|
||||
/// </summary>
|
||||
public sealed record EvidenceRefDto(
|
||||
EvidenceType Type,
|
||||
Uri Url,
|
||||
string? Title = null);
|
||||
|
||||
/// <summary>
|
||||
/// Scope definition for VEX decisions (environments and projects where decision applies).
|
||||
/// </summary>
|
||||
public sealed record VexScopeDto(
|
||||
IReadOnlyList<string>? Environments,
|
||||
IReadOnlyList<string>? Projects);
|
||||
|
||||
/// <summary>
|
||||
/// Validity window for VEX decisions.
|
||||
/// </summary>
|
||||
public sealed record ValidForDto(
|
||||
DateTimeOffset? NotBefore,
|
||||
DateTimeOffset? NotAfter);
|
||||
|
||||
/// <summary>
|
||||
/// Reference to a signed attestation.
|
||||
/// </summary>
|
||||
public sealed record AttestationRefDto(
|
||||
string? Id,
|
||||
IReadOnlyDictionary<string, string>? Digest,
|
||||
string? Storage);
|
||||
|
||||
/// <summary>
|
||||
/// Reference to an actor (user) who created a decision.
|
||||
/// </summary>
|
||||
public sealed record ActorRefDto(
|
||||
string Id,
|
||||
string DisplayName);
|
||||
|
||||
/// <summary>
|
||||
/// VEX status following OpenVEX semantics.
|
||||
/// </summary>
|
||||
public enum VexStatus
|
||||
{
|
||||
NotAffected,
|
||||
AffectedMitigated,
|
||||
AffectedUnmitigated,
|
||||
Fixed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subject type enumeration for VEX decisions.
|
||||
/// </summary>
|
||||
public enum SubjectType
|
||||
{
|
||||
Image,
|
||||
Repo,
|
||||
SbomComponent,
|
||||
Other
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence type enumeration.
|
||||
/// </summary>
|
||||
public enum EvidenceType
|
||||
{
|
||||
Pr,
|
||||
Ticket,
|
||||
Doc,
|
||||
Commit,
|
||||
Other
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Justification type inspired by CSAF/VEX specifications.
|
||||
/// </summary>
|
||||
public enum VexJustificationType
|
||||
{
|
||||
CodeNotPresent,
|
||||
CodeNotReachable,
|
||||
VulnerableCodeNotInExecutePath,
|
||||
ConfigurationNotAffected,
|
||||
OsNotAffected,
|
||||
RuntimeMitigationPresent,
|
||||
CompensatingControls,
|
||||
AcceptedBusinessRisk,
|
||||
Other
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a new VEX decision.
|
||||
/// </summary>
|
||||
public sealed record CreateVexDecisionRequest(
|
||||
string VulnerabilityId,
|
||||
SubjectRefDto Subject,
|
||||
VexStatus Status,
|
||||
VexJustificationType JustificationType,
|
||||
string? JustificationText,
|
||||
IReadOnlyList<EvidenceRefDto>? EvidenceRefs,
|
||||
VexScopeDto? Scope,
|
||||
ValidForDto? ValidFor,
|
||||
Guid? SupersedesDecisionId);
|
||||
|
||||
/// <summary>
|
||||
/// Request to update an existing VEX decision.
|
||||
/// </summary>
|
||||
public sealed record UpdateVexDecisionRequest(
|
||||
VexStatus? Status,
|
||||
VexJustificationType? JustificationType,
|
||||
string? JustificationText,
|
||||
IReadOnlyList<EvidenceRefDto>? EvidenceRefs,
|
||||
VexScopeDto? Scope,
|
||||
ValidForDto? ValidFor,
|
||||
Guid? SupersedesDecisionId);
|
||||
|
||||
/// <summary>
|
||||
/// Response for listing VEX decisions.
|
||||
/// </summary>
|
||||
public sealed record VexDecisionListResponse(
|
||||
IReadOnlyList<VexDecisionDto> Items,
|
||||
string? NextPageToken);
|
||||
@@ -1,6 +1,8 @@
|
||||
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;
|
||||
@@ -17,10 +19,20 @@ builder.Services.AddSwaggerGen(options =>
|
||||
{
|
||||
Title = "StellaOps Vuln Explorer API",
|
||||
Version = "v1",
|
||||
Description = "Deterministic vulnerability listing/detail endpoints"
|
||||
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();
|
||||
@@ -61,6 +73,103 @@ app.MapGet("/v1/vulns/{id}", ([FromHeader(Name = "x-stella-tenant")] string? ten
|
||||
})
|
||||
.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) =>
|
||||
@@ -117,6 +226,12 @@ public record VulnFilter(
|
||||
[FromQuery(Name = "exploitability")] string? Exploitability,
|
||||
[FromQuery(Name = "fixAvailable")] bool? FixAvailable);
|
||||
|
||||
public partial class Program { }
|
||||
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 { }
|
||||
|
||||
Reference in New Issue
Block a user