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

This commit is contained in:
StellaOps Bot
2025-12-13 00:20:26 +02:00
parent e1f1bef4c1
commit 564df71bfb
2376 changed files with 334389 additions and 328032 deletions

View File

@@ -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;
}

View File

@@ -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
}

View File

@@ -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);

View File

@@ -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 { }