sprints work

This commit is contained in:
master
2026-01-11 11:19:40 +02:00
parent f6ef1ef337
commit 582a41d7a9
72 changed files with 2680 additions and 390 deletions

View File

@@ -0,0 +1,775 @@
// Copyright (c) StellaOps. All rights reserved.
// Licensed under AGPL-3.0-or-later. See LICENSE in the project root.
using System.Collections.Immutable;
using Microsoft.AspNetCore.Mvc;
using StellaOps.BinaryIndex.GoldenSet;
namespace StellaOps.BinaryIndex.WebService.Controllers;
/// <summary>
/// API endpoints for golden set curation and management.
/// </summary>
/// <remarks>
/// Provides CRUD operations for golden set definitions, review workflow,
/// and audit log access. Used by experts to author and maintain ground-truth
/// vulnerability signatures.
/// </remarks>
[ApiController]
[Route("api/v1/golden-sets")]
[Produces("application/json")]
public sealed class GoldenSetController : ControllerBase
{
private readonly IGoldenSetStore _store;
private readonly IGoldenSetValidator _validator;
private readonly ILogger<GoldenSetController> _logger;
public GoldenSetController(
IGoldenSetStore store,
IGoldenSetValidator validator,
ILogger<GoldenSetController> logger)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_validator = validator ?? throw new ArgumentNullException(nameof(validator));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// List golden sets with optional filtering.
/// </summary>
/// <remarks>
/// Returns paginated list of golden set summaries matching the specified filters.
///
/// Sample request:
///
/// GET /api/v1/golden-sets?component=openssl&amp;status=Approved&amp;limit=20
///
/// Sample response:
///
/// {
/// "items": [
/// {
/// "id": "CVE-2024-0727",
/// "component": "openssl",
/// "status": "Approved",
/// "targetCount": 3,
/// "createdAt": "2024-01-15T10:30:00Z",
/// "reviewedAt": "2024-01-16T14:00:00Z",
/// "contentDigest": "sha256:abc123...",
/// "tags": ["memory-corruption", "heap-overflow"]
/// }
/// ],
/// "totalCount": 42,
/// "offset": 0,
/// "limit": 20
/// }
/// </remarks>
/// <param name="component">Optional component name filter.</param>
/// <param name="status">Optional status filter (Draft, InReview, Approved, Deprecated, Archived).</param>
/// <param name="tags">Optional tags filter (comma-separated).</param>
/// <param name="limit">Maximum results to return (1-500, default 100).</param>
/// <param name="offset">Pagination offset (default 0).</param>
/// <param name="orderBy">Sort order (IdAsc, IdDesc, CreatedAtAsc, CreatedAtDesc, ComponentAsc, ComponentDesc).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Paginated list of golden set summaries.</returns>
/// <response code="200">Returns the list of golden sets.</response>
/// <response code="400">Invalid parameters.</response>
[HttpGet]
[ProducesResponseType<GoldenSetListResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<GoldenSetListResponse>> ListAsync(
[FromQuery] string? component = null,
[FromQuery] string? status = null,
[FromQuery] string? tags = null,
[FromQuery] int limit = 100,
[FromQuery] int offset = 0,
[FromQuery] string? orderBy = null,
CancellationToken ct = default)
{
if (limit < 1 || limit > 500)
{
return BadRequest(CreateProblem(
"Limit must be between 1 and 500.",
"InvalidLimit",
StatusCodes.Status400BadRequest));
}
if (offset < 0)
{
return BadRequest(CreateProblem(
"Offset must be non-negative.",
"InvalidOffset",
StatusCodes.Status400BadRequest));
}
GoldenSetStatus? statusFilter = null;
if (!string.IsNullOrWhiteSpace(status))
{
if (!Enum.TryParse<GoldenSetStatus>(status, true, out var parsedStatus))
{
return BadRequest(CreateProblem(
"Invalid status. Must be one of: Draft, InReview, Approved, Deprecated, Archived.",
"InvalidStatus",
StatusCodes.Status400BadRequest));
}
statusFilter = parsedStatus;
}
GoldenSetOrderBy orderByValue = GoldenSetOrderBy.CreatedAtDesc;
if (!string.IsNullOrWhiteSpace(orderBy))
{
if (!Enum.TryParse<GoldenSetOrderBy>(orderBy, true, out var parsedOrderBy))
{
return BadRequest(CreateProblem(
"Invalid orderBy. Must be one of: IdAsc, IdDesc, CreatedAtAsc, CreatedAtDesc, ComponentAsc, ComponentDesc.",
"InvalidOrderBy",
StatusCodes.Status400BadRequest));
}
orderByValue = parsedOrderBy;
}
ImmutableArray<string>? tagsFilter = null;
if (!string.IsNullOrWhiteSpace(tags))
{
tagsFilter = tags.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToImmutableArray();
}
_logger.LogInformation(
"ListGoldenSets: component={Component}, status={Status}, tags={Tags}, limit={Limit}, offset={Offset}",
component, status, tags, limit, offset);
try
{
var query = new GoldenSetListQuery
{
ComponentFilter = component,
StatusFilter = statusFilter,
TagsFilter = tagsFilter,
Limit = limit,
Offset = offset,
OrderBy = orderByValue
};
var items = await _store.ListAsync(query, ct);
return Ok(new GoldenSetListResponse
{
Items = items,
TotalCount = items.Length, // Note: For proper pagination, store should return total count
Offset = offset,
Limit = limit
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to list golden sets");
return StatusCode(StatusCodes.Status500InternalServerError,
CreateProblem("Internal server error.", "ListError", StatusCodes.Status500InternalServerError));
}
}
/// <summary>
/// Get a golden set by ID.
/// </summary>
/// <remarks>
/// Returns the full golden set definition with current status.
/// </remarks>
/// <param name="id">Golden set ID (CVE/GHSA ID).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The golden set with status.</returns>
/// <response code="200">Returns the golden set.</response>
/// <response code="404">Golden set not found.</response>
[HttpGet("{id}")]
[ProducesResponseType<GoldenSetResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<GoldenSetResponse>> GetByIdAsync(
[FromRoute] string id,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(id))
{
return BadRequest(CreateProblem(
"Golden set ID is required.",
"MissingId",
StatusCodes.Status400BadRequest));
}
_logger.LogInformation("GetGoldenSet: id={Id}", id);
try
{
var stored = await _store.GetAsync(id, ct);
if (stored is null)
{
return NotFound(CreateProblem(
$"Golden set '{id}' not found.",
"NotFound",
StatusCodes.Status404NotFound));
}
return Ok(new GoldenSetResponse
{
Definition = stored.Definition,
Status = stored.Status,
CreatedAt = stored.CreatedAt,
UpdatedAt = stored.UpdatedAt
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get golden set {Id}", id);
return StatusCode(StatusCodes.Status500InternalServerError,
CreateProblem("Internal server error.", "GetError", StatusCodes.Status500InternalServerError));
}
}
/// <summary>
/// Create a new golden set.
/// </summary>
/// <remarks>
/// Creates a new golden set definition in Draft status.
/// The definition is validated before storage.
///
/// Sample request:
///
/// POST /api/v1/golden-sets
/// {
/// "id": "CVE-2024-0727",
/// "component": "openssl",
/// "targets": [
/// {
/// "functionName": "PKCS7_verify",
/// "sinks": ["memcpy"],
/// "edges": [{"from": "bb3", "to": "bb7"}],
/// "taintInvariant": "attacker-controlled input reaches memcpy without bounds check"
/// }
/// ],
/// "metadata": {
/// "authorId": "user@example.com",
/// "sourceRef": "https://nvd.nist.gov/vuln/detail/CVE-2024-0727",
/// "tags": ["memory-corruption"]
/// }
/// }
/// </remarks>
/// <param name="request">Golden set creation request.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Created golden set with content digest.</returns>
/// <response code="201">Golden set created successfully.</response>
/// <response code="400">Validation failed.</response>
/// <response code="409">Golden set with this ID already exists.</response>
[HttpPost]
[ProducesResponseType<GoldenSetCreateResponse>(StatusCodes.Status201Created)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
public async Task<ActionResult<GoldenSetCreateResponse>> CreateAsync(
[FromBody] GoldenSetCreateRequest request,
CancellationToken ct = default)
{
if (request is null)
{
return BadRequest(CreateProblem(
"Request body is required.",
"MissingBody",
StatusCodes.Status400BadRequest));
}
_logger.LogInformation("CreateGoldenSet: id={Id}, component={Component}", request.Id, request.Component);
try
{
// Check if already exists
var existing = await _store.GetByIdAsync(request.Id, ct);
if (existing is not null)
{
return Conflict(CreateProblem(
$"Golden set '{request.Id}' already exists.",
"AlreadyExists",
StatusCodes.Status409Conflict));
}
// Build definition
var definition = new GoldenSetDefinition
{
Id = request.Id,
Component = request.Component,
Targets = request.Targets.Select(t => new VulnerableTarget
{
FunctionName = t.FunctionName,
Sinks = t.Sinks?.ToImmutableArray() ?? [],
Edges = t.Edges?.Select(e => new BasicBlockEdge { From = e.From, To = e.To }).ToImmutableArray() ?? [],
Constants = t.Constants?.ToImmutableArray() ?? [],
TaintInvariant = t.TaintInvariant,
SourceFile = t.SourceFile,
SourceLine = t.SourceLine
}).ToImmutableArray(),
Witness = request.Witness is not null ? new WitnessInput
{
Arguments = request.Witness.Arguments?.ToImmutableArray() ?? [],
Invariant = request.Witness.Invariant,
PocFileRef = request.Witness.PocFileRef
} : null,
Metadata = new GoldenSetMetadata
{
AuthorId = request.Metadata.AuthorId,
CreatedAt = DateTimeOffset.UtcNow,
SourceRef = request.Metadata.SourceRef,
Tags = request.Metadata.Tags?.ToImmutableArray() ?? []
}
};
// Validate
var validationResult = _validator.Validate(definition);
if (!validationResult.IsValid)
{
return BadRequest(CreateProblem(
$"Validation failed: {string.Join("; ", validationResult.Errors)}",
"ValidationFailed",
StatusCodes.Status400BadRequest));
}
// Store
var result = await _store.StoreAsync(definition, GoldenSetStatus.Draft, ct);
if (!result.Success)
{
return BadRequest(CreateProblem(
result.Error ?? "Failed to store golden set.",
"StoreError",
StatusCodes.Status400BadRequest));
}
var response = new GoldenSetCreateResponse
{
Id = definition.Id,
ContentDigest = result.ContentDigest,
Status = GoldenSetStatus.Draft
};
return CreatedAtAction(nameof(GetByIdAsync), new { id = definition.Id }, response);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create golden set {Id}", request.Id);
return StatusCode(StatusCodes.Status500InternalServerError,
CreateProblem("Internal server error.", "CreateError", StatusCodes.Status500InternalServerError));
}
}
/// <summary>
/// Update golden set status (workflow transition).
/// </summary>
/// <remarks>
/// Transitions a golden set through the review workflow:
/// Draft -> InReview -> Approved -> Deprecated/Archived
///
/// Sample request:
///
/// PATCH /api/v1/golden-sets/CVE-2024-0727/status
/// {
/// "status": "InReview",
/// "actorId": "reviewer@example.com",
/// "comment": "Submitting for expert review"
/// }
/// </remarks>
/// <param name="id">Golden set ID.</param>
/// <param name="request">Status update request.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Updated status confirmation.</returns>
/// <response code="200">Status updated successfully.</response>
/// <response code="400">Invalid status transition.</response>
/// <response code="404">Golden set not found.</response>
[HttpPatch("{id}/status")]
[ProducesResponseType<GoldenSetStatusResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<GoldenSetStatusResponse>> UpdateStatusAsync(
[FromRoute] string id,
[FromBody] GoldenSetStatusRequest request,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(id))
{
return BadRequest(CreateProblem(
"Golden set ID is required.",
"MissingId",
StatusCodes.Status400BadRequest));
}
if (request is null)
{
return BadRequest(CreateProblem(
"Request body is required.",
"MissingBody",
StatusCodes.Status400BadRequest));
}
_logger.LogInformation(
"UpdateGoldenSetStatus: id={Id}, status={Status}, actor={Actor}",
id, request.Status, request.ActorId);
try
{
var existing = await _store.GetAsync(id, ct);
if (existing is null)
{
return NotFound(CreateProblem(
$"Golden set '{id}' not found.",
"NotFound",
StatusCodes.Status404NotFound));
}
// Validate transition
if (!IsValidTransition(existing.Status, request.Status))
{
return BadRequest(CreateProblem(
$"Invalid status transition from {existing.Status} to {request.Status}.",
"InvalidTransition",
StatusCodes.Status400BadRequest));
}
var result = await _store.UpdateStatusAsync(
id,
request.Status,
request.ActorId,
request.Comment ?? string.Empty,
ct);
return Ok(new GoldenSetStatusResponse
{
Id = id,
PreviousStatus = existing.Status,
CurrentStatus = request.Status,
ContentDigest = result.ContentDigest
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update status for {Id}", id);
return StatusCode(StatusCodes.Status500InternalServerError,
CreateProblem("Internal server error.", "UpdateStatusError", StatusCodes.Status500InternalServerError));
}
}
/// <summary>
/// Get audit log for a golden set.
/// </summary>
/// <remarks>
/// Returns the full audit history of status changes and modifications.
/// </remarks>
/// <param name="id">Golden set ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Audit log entries.</returns>
/// <response code="200">Returns the audit log.</response>
/// <response code="404">Golden set not found.</response>
[HttpGet("{id}/audit")]
[ProducesResponseType<GoldenSetAuditResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<GoldenSetAuditResponse>> GetAuditLogAsync(
[FromRoute] string id,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(id))
{
return BadRequest(CreateProblem(
"Golden set ID is required.",
"MissingId",
StatusCodes.Status400BadRequest));
}
_logger.LogInformation("GetGoldenSetAudit: id={Id}", id);
try
{
var existing = await _store.GetByIdAsync(id, ct);
if (existing is null)
{
return NotFound(CreateProblem(
$"Golden set '{id}' not found.",
"NotFound",
StatusCodes.Status404NotFound));
}
var entries = await _store.GetAuditLogAsync(id, ct);
return Ok(new GoldenSetAuditResponse
{
Id = id,
Entries = entries
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get audit log for {Id}", id);
return StatusCode(StatusCodes.Status500InternalServerError,
CreateProblem("Internal server error.", "AuditLogError", StatusCodes.Status500InternalServerError));
}
}
/// <summary>
/// Delete (archive) a golden set.
/// </summary>
/// <remarks>
/// Soft deletes a golden set by moving it to Archived status.
/// </remarks>
/// <param name="id">Golden set ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>No content on success.</returns>
/// <response code="204">Golden set archived successfully.</response>
/// <response code="404">Golden set not found.</response>
[HttpDelete("{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteAsync(
[FromRoute] string id,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(id))
{
return BadRequest(CreateProblem(
"Golden set ID is required.",
"MissingId",
StatusCodes.Status400BadRequest));
}
_logger.LogInformation("DeleteGoldenSet: id={Id}", id);
try
{
var deleted = await _store.DeleteAsync(id, ct);
if (!deleted)
{
return NotFound(CreateProblem(
$"Golden set '{id}' not found.",
"NotFound",
StatusCodes.Status404NotFound));
}
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete golden set {Id}", id);
return StatusCode(StatusCodes.Status500InternalServerError,
CreateProblem("Internal server error.", "DeleteError", StatusCodes.Status500InternalServerError));
}
}
private static bool IsValidTransition(GoldenSetStatus from, GoldenSetStatus to)
{
return (from, to) switch
{
(GoldenSetStatus.Draft, GoldenSetStatus.InReview) => true,
(GoldenSetStatus.InReview, GoldenSetStatus.Approved) => true,
(GoldenSetStatus.InReview, GoldenSetStatus.Draft) => true, // Reject back to draft
(GoldenSetStatus.Approved, GoldenSetStatus.Deprecated) => true,
(GoldenSetStatus.Approved, GoldenSetStatus.Archived) => true,
(GoldenSetStatus.Deprecated, GoldenSetStatus.Archived) => true,
(GoldenSetStatus.Draft, GoldenSetStatus.Archived) => true, // Can archive drafts
_ => false
};
}
private static ProblemDetails CreateProblem(string detail, string type, int statusCode)
{
return new ProblemDetails
{
Title = "Golden Set Error",
Detail = detail,
Type = $"https://stellaops.dev/errors/{type}",
Status = statusCode
};
}
}
#region DTOs
/// <summary>
/// Response for listing golden sets.
/// </summary>
public sealed record GoldenSetListResponse
{
/// <summary>List of golden set summaries.</summary>
public required ImmutableArray<GoldenSetSummary> Items { get; init; }
/// <summary>Total count (for pagination).</summary>
public required int TotalCount { get; init; }
/// <summary>Current offset.</summary>
public required int Offset { get; init; }
/// <summary>Current limit.</summary>
public required int Limit { get; init; }
}
/// <summary>
/// Response for getting a single golden set.
/// </summary>
public sealed record GoldenSetResponse
{
/// <summary>The golden set definition.</summary>
public required GoldenSetDefinition Definition { get; init; }
/// <summary>Current status.</summary>
public required GoldenSetStatus Status { get; init; }
/// <summary>Creation timestamp.</summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>Last update timestamp.</summary>
public required DateTimeOffset UpdatedAt { get; init; }
}
/// <summary>
/// Request to create a golden set.
/// </summary>
public sealed record GoldenSetCreateRequest
{
/// <summary>Golden set ID (CVE/GHSA ID).</summary>
public required string Id { get; init; }
/// <summary>Component name.</summary>
public required string Component { get; init; }
/// <summary>Vulnerable targets.</summary>
public required IReadOnlyList<VulnerableTargetDto> Targets { get; init; }
/// <summary>Optional witness input.</summary>
public WitnessInputDto? Witness { get; init; }
/// <summary>Metadata.</summary>
public required GoldenSetMetadataDto Metadata { get; init; }
}
/// <summary>
/// Vulnerable target DTO for API.
/// </summary>
public sealed record VulnerableTargetDto
{
/// <summary>Function name.</summary>
public required string FunctionName { get; init; }
/// <summary>Sink functions.</summary>
public IReadOnlyList<string>? Sinks { get; init; }
/// <summary>Basic block edges.</summary>
public IReadOnlyList<BasicBlockEdgeDto>? Edges { get; init; }
/// <summary>Constants/magic values.</summary>
public IReadOnlyList<string>? Constants { get; init; }
/// <summary>Taint invariant description.</summary>
public string? TaintInvariant { get; init; }
/// <summary>Source file hint.</summary>
public string? SourceFile { get; init; }
/// <summary>Source line hint.</summary>
public int? SourceLine { get; init; }
}
/// <summary>
/// Basic block edge DTO.
/// </summary>
public sealed record BasicBlockEdgeDto
{
/// <summary>Source block.</summary>
public required string From { get; init; }
/// <summary>Target block.</summary>
public required string To { get; init; }
}
/// <summary>
/// Witness input DTO.
/// </summary>
public sealed record WitnessInputDto
{
/// <summary>Command-line arguments.</summary>
public IReadOnlyList<string>? Arguments { get; init; }
/// <summary>Invariant/precondition.</summary>
public string? Invariant { get; init; }
/// <summary>PoC file reference.</summary>
public string? PocFileRef { get; init; }
}
/// <summary>
/// Metadata DTO.
/// </summary>
public sealed record GoldenSetMetadataDto
{
/// <summary>Author ID.</summary>
public required string AuthorId { get; init; }
/// <summary>Source reference URL.</summary>
public required string SourceRef { get; init; }
/// <summary>Classification tags.</summary>
public IReadOnlyList<string>? Tags { get; init; }
}
/// <summary>
/// Response after creating a golden set.
/// </summary>
public sealed record GoldenSetCreateResponse
{
/// <summary>Golden set ID.</summary>
public required string Id { get; init; }
/// <summary>Content digest.</summary>
public required string ContentDigest { get; init; }
/// <summary>Initial status.</summary>
public required GoldenSetStatus Status { get; init; }
}
/// <summary>
/// Request to update golden set status.
/// </summary>
public sealed record GoldenSetStatusRequest
{
/// <summary>New status.</summary>
public required GoldenSetStatus Status { get; init; }
/// <summary>Actor performing the change.</summary>
public required string ActorId { get; init; }
/// <summary>Comment explaining the change.</summary>
public string? Comment { get; init; }
}
/// <summary>
/// Response after status update.
/// </summary>
public sealed record GoldenSetStatusResponse
{
/// <summary>Golden set ID.</summary>
public required string Id { get; init; }
/// <summary>Previous status.</summary>
public required GoldenSetStatus PreviousStatus { get; init; }
/// <summary>New current status.</summary>
public required GoldenSetStatus CurrentStatus { get; init; }
/// <summary>Content digest.</summary>
public required string ContentDigest { get; init; }
}
/// <summary>
/// Response with audit log.
/// </summary>
public sealed record GoldenSetAuditResponse
{
/// <summary>Golden set ID.</summary>
public required string Id { get; init; }
/// <summary>Audit log entries.</summary>
public required ImmutableArray<GoldenSetAuditEntry> Entries { get; init; }
}
#endregion

View File

@@ -21,6 +21,7 @@
<ProjectReference Include="../__Libraries/StellaOps.BinaryIndex.Contracts/StellaOps.BinaryIndex.Contracts.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.BinaryIndex.Persistence/StellaOps.BinaryIndex.Persistence.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.BinaryIndex.VexBridge/StellaOps.BinaryIndex.VexBridge.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.BinaryIndex.GoldenSet/StellaOps.BinaryIndex.GoldenSet.csproj" />
</ItemGroup>
</Project>