sprints work
This commit is contained in:
@@ -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&status=Approved&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
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
|
||||
// Sprint: SPRINT_20260110_012_004_BINDEX
|
||||
// Task: GSD-007 - IDiffResultStore Interface
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.BinaryIndex.Diff;
|
||||
|
||||
/// <summary>
|
||||
/// Storage interface for patch diff results.
|
||||
/// Provides persistence and caching for verification results.
|
||||
/// </summary>
|
||||
public interface IDiffResultStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Stores a patch diff result.
|
||||
/// </summary>
|
||||
/// <param name="result">The diff result to store.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Unique ID of the stored result.</returns>
|
||||
Task<Guid> StoreAsync(PatchDiffResult result, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a diff result by ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The result ID.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The diff result, or null if not found.</returns>
|
||||
Task<PatchDiffResult?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Finds diff results for a specific binary pair.
|
||||
/// </summary>
|
||||
/// <param name="preBinaryDigest">Pre-patch binary digest.</param>
|
||||
/// <param name="postBinaryDigest">Post-patch binary digest.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of matching diff results.</returns>
|
||||
Task<ImmutableArray<StoredDiffResult>> FindByBinariesAsync(
|
||||
string preBinaryDigest,
|
||||
string postBinaryDigest,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Finds diff results for a specific golden set.
|
||||
/// </summary>
|
||||
/// <param name="goldenSetId">Golden set ID.</param>
|
||||
/// <param name="limit">Maximum results to return.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of diff results for the golden set.</returns>
|
||||
Task<ImmutableArray<StoredDiffResult>> FindByGoldenSetAsync(
|
||||
string goldenSetId,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a cached single binary check result.
|
||||
/// </summary>
|
||||
/// <param name="binaryDigest">Binary digest.</param>
|
||||
/// <param name="vulnerabilityId">Vulnerability ID (golden set ID).</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Cached result, or null if not cached.</returns>
|
||||
Task<SingleBinaryCheckResult?> GetCachedCheckAsync(
|
||||
string binaryDigest,
|
||||
string vulnerabilityId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Caches a single binary check result.
|
||||
/// </summary>
|
||||
/// <param name="binaryDigest">Binary digest.</param>
|
||||
/// <param name="vulnerabilityId">Vulnerability ID (golden set ID).</param>
|
||||
/// <param name="result">Check result to cache.</param>
|
||||
/// <param name="ttl">Time-to-live for the cache entry.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task CacheCheckAsync(
|
||||
string binaryDigest,
|
||||
string vulnerabilityId,
|
||||
SingleBinaryCheckResult result,
|
||||
TimeSpan? ttl = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Queries stored diff results.
|
||||
/// </summary>
|
||||
/// <param name="query">Query parameters.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Matching diff results.</returns>
|
||||
Task<DiffResultQueryResponse> QueryAsync(
|
||||
DiffResultQuery query,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets statistics about stored diff results.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Storage statistics.</returns>
|
||||
Task<DiffResultStoreStats> GetStatsAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stored diff result with metadata.
|
||||
/// </summary>
|
||||
public sealed record StoredDiffResult
|
||||
{
|
||||
/// <summary>Unique ID.</summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>The diff result.</summary>
|
||||
public required PatchDiffResult Result { get; init; }
|
||||
|
||||
/// <summary>When the result was stored.</summary>
|
||||
public required DateTimeOffset StoredAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for searching diff results.
|
||||
/// </summary>
|
||||
public sealed record DiffResultQuery
|
||||
{
|
||||
/// <summary>Filter by golden set ID.</summary>
|
||||
public string? GoldenSetId { get; init; }
|
||||
|
||||
/// <summary>Filter by verdict.</summary>
|
||||
public PatchVerdict? Verdict { get; init; }
|
||||
|
||||
/// <summary>Filter by minimum confidence.</summary>
|
||||
public decimal? MinConfidence { get; init; }
|
||||
|
||||
/// <summary>Filter by pre-binary digest.</summary>
|
||||
public string? PreBinaryDigest { get; init; }
|
||||
|
||||
/// <summary>Filter by post-binary digest.</summary>
|
||||
public string? PostBinaryDigest { get; init; }
|
||||
|
||||
/// <summary>Filter by comparison date (after).</summary>
|
||||
public DateTimeOffset? ComparedAfter { get; init; }
|
||||
|
||||
/// <summary>Filter by comparison date (before).</summary>
|
||||
public DateTimeOffset? ComparedBefore { get; init; }
|
||||
|
||||
/// <summary>Maximum results to return.</summary>
|
||||
public int Limit { get; init; } = 100;
|
||||
|
||||
/// <summary>Pagination offset.</summary>
|
||||
public int Offset { get; init; } = 0;
|
||||
|
||||
/// <summary>Order by field.</summary>
|
||||
public DiffResultOrderBy OrderBy { get; init; } = DiffResultOrderBy.ComparedAtDesc;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from a diff result query.
|
||||
/// </summary>
|
||||
public sealed record DiffResultQueryResponse
|
||||
{
|
||||
/// <summary>Matching results.</summary>
|
||||
public required ImmutableArray<StoredDiffResult> Results { get; init; }
|
||||
|
||||
/// <summary>Total count of matching results (for pagination).</summary>
|
||||
public required int TotalCount { get; init; }
|
||||
|
||||
/// <summary>Query offset.</summary>
|
||||
public required int Offset { get; init; }
|
||||
|
||||
/// <summary>Query limit.</summary>
|
||||
public required int Limit { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ordering options for diff result queries.
|
||||
/// </summary>
|
||||
public enum DiffResultOrderBy
|
||||
{
|
||||
/// <summary>Order by comparison date ascending.</summary>
|
||||
ComparedAtAsc,
|
||||
|
||||
/// <summary>Order by comparison date descending.</summary>
|
||||
ComparedAtDesc,
|
||||
|
||||
/// <summary>Order by confidence ascending.</summary>
|
||||
ConfidenceAsc,
|
||||
|
||||
/// <summary>Order by confidence descending.</summary>
|
||||
ConfidenceDesc,
|
||||
|
||||
/// <summary>Order by golden set ID ascending.</summary>
|
||||
GoldenSetIdAsc,
|
||||
|
||||
/// <summary>Order by golden set ID descending.</summary>
|
||||
GoldenSetIdDesc
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics about the diff result store.
|
||||
/// </summary>
|
||||
public sealed record DiffResultStoreStats
|
||||
{
|
||||
/// <summary>Total number of stored results.</summary>
|
||||
public required long TotalResults { get; init; }
|
||||
|
||||
/// <summary>Results by verdict.</summary>
|
||||
public required ImmutableDictionary<PatchVerdict, long> ResultsByVerdict { get; init; }
|
||||
|
||||
/// <summary>Number of unique golden sets.</summary>
|
||||
public required int UniqueGoldenSets { get; init; }
|
||||
|
||||
/// <summary>Number of unique binary pairs.</summary>
|
||||
public required long UniqueBinaryPairs { get; init; }
|
||||
|
||||
/// <summary>Number of cached check results.</summary>
|
||||
public required long CachedChecks { get; init; }
|
||||
|
||||
/// <summary>Oldest result timestamp.</summary>
|
||||
public DateTimeOffset? OldestResult { get; init; }
|
||||
|
||||
/// <summary>Newest result timestamp.</summary>
|
||||
public DateTimeOffset? NewestResult { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
|
||||
// Sprint: SPRINT_20260110_012_004_BINDEX
|
||||
// Task: GSD-007 - IDiffResultStore Interface - InMemory Implementation
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.BinaryIndex.Diff;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IDiffResultStore"/> for testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryDiffResultStore : IDiffResultStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<Guid, StoredDiffResult> _results = new();
|
||||
private readonly ConcurrentDictionary<string, SingleBinaryCheckResult> _checkCache = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryDiffResultStore(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Guid> StoreAsync(PatchDiffResult result, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var id = Guid.NewGuid();
|
||||
var stored = new StoredDiffResult
|
||||
{
|
||||
Id = id,
|
||||
Result = result,
|
||||
StoredAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
_results[id] = stored;
|
||||
return Task.FromResult(id);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<PatchDiffResult?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(_results.TryGetValue(id, out var stored) ? stored.Result : null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ImmutableArray<StoredDiffResult>> FindByBinariesAsync(
|
||||
string preBinaryDigest,
|
||||
string postBinaryDigest,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(preBinaryDigest);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(postBinaryDigest);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var matches = _results.Values
|
||||
.Where(s => string.Equals(s.Result.PreBinaryDigest, preBinaryDigest, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(s.Result.PostBinaryDigest, postBinaryDigest, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(s => s.StoredAt)
|
||||
.ToImmutableArray();
|
||||
|
||||
return Task.FromResult(matches);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ImmutableArray<StoredDiffResult>> FindByGoldenSetAsync(
|
||||
string goldenSetId,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(goldenSetId);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var matches = _results.Values
|
||||
.Where(s => string.Equals(s.Result.GoldenSetId, goldenSetId, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(s => s.StoredAt)
|
||||
.Take(limit)
|
||||
.ToImmutableArray();
|
||||
|
||||
return Task.FromResult(matches);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SingleBinaryCheckResult?> GetCachedCheckAsync(
|
||||
string binaryDigest,
|
||||
string vulnerabilityId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(binaryDigest);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityId);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var key = GetCacheKey(binaryDigest, vulnerabilityId);
|
||||
return Task.FromResult(_checkCache.TryGetValue(key, out var result) ? result : null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task CacheCheckAsync(
|
||||
string binaryDigest,
|
||||
string vulnerabilityId,
|
||||
SingleBinaryCheckResult result,
|
||||
TimeSpan? ttl = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(binaryDigest);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityId);
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var key = GetCacheKey(binaryDigest, vulnerabilityId);
|
||||
_checkCache[key] = result;
|
||||
|
||||
// Note: TTL not implemented for in-memory store (testing only)
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DiffResultQueryResponse> QueryAsync(
|
||||
DiffResultQuery query,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
IEnumerable<StoredDiffResult> results = _results.Values;
|
||||
|
||||
// Apply filters
|
||||
if (!string.IsNullOrWhiteSpace(query.GoldenSetId))
|
||||
{
|
||||
results = results.Where(r =>
|
||||
string.Equals(r.Result.GoldenSetId, query.GoldenSetId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (query.Verdict.HasValue)
|
||||
{
|
||||
results = results.Where(r => r.Result.Verdict == query.Verdict.Value);
|
||||
}
|
||||
|
||||
if (query.MinConfidence.HasValue)
|
||||
{
|
||||
results = results.Where(r => r.Result.Confidence >= query.MinConfidence.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.PreBinaryDigest))
|
||||
{
|
||||
results = results.Where(r =>
|
||||
string.Equals(r.Result.PreBinaryDigest, query.PreBinaryDigest, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.PostBinaryDigest))
|
||||
{
|
||||
results = results.Where(r =>
|
||||
string.Equals(r.Result.PostBinaryDigest, query.PostBinaryDigest, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (query.ComparedAfter.HasValue)
|
||||
{
|
||||
results = results.Where(r => r.Result.Metadata.ComparedAt >= query.ComparedAfter.Value);
|
||||
}
|
||||
|
||||
if (query.ComparedBefore.HasValue)
|
||||
{
|
||||
results = results.Where(r => r.Result.Metadata.ComparedAt <= query.ComparedBefore.Value);
|
||||
}
|
||||
|
||||
// Apply ordering
|
||||
results = query.OrderBy switch
|
||||
{
|
||||
DiffResultOrderBy.ComparedAtAsc => results.OrderBy(r => r.Result.Metadata.ComparedAt),
|
||||
DiffResultOrderBy.ComparedAtDesc => results.OrderByDescending(r => r.Result.Metadata.ComparedAt),
|
||||
DiffResultOrderBy.ConfidenceAsc => results.OrderBy(r => r.Result.Confidence),
|
||||
DiffResultOrderBy.ConfidenceDesc => results.OrderByDescending(r => r.Result.Confidence),
|
||||
DiffResultOrderBy.GoldenSetIdAsc => results.OrderBy(r => r.Result.GoldenSetId, StringComparer.OrdinalIgnoreCase),
|
||||
DiffResultOrderBy.GoldenSetIdDesc => results.OrderByDescending(r => r.Result.GoldenSetId, StringComparer.OrdinalIgnoreCase),
|
||||
_ => results.OrderByDescending(r => r.StoredAt)
|
||||
};
|
||||
|
||||
var allResults = results.ToList();
|
||||
var totalCount = allResults.Count;
|
||||
|
||||
var pagedResults = allResults
|
||||
.Skip(query.Offset)
|
||||
.Take(query.Limit)
|
||||
.ToImmutableArray();
|
||||
|
||||
return Task.FromResult(new DiffResultQueryResponse
|
||||
{
|
||||
Results = pagedResults,
|
||||
TotalCount = totalCount,
|
||||
Offset = query.Offset,
|
||||
Limit = query.Limit
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DiffResultStoreStats> GetStatsAsync(CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var results = _results.Values.ToList();
|
||||
|
||||
var resultsByVerdict = results
|
||||
.GroupBy(r => r.Result.Verdict)
|
||||
.ToImmutableDictionary(g => g.Key, g => (long)g.Count());
|
||||
|
||||
var uniqueGoldenSets = results
|
||||
.Select(r => r.Result.GoldenSetId)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Count();
|
||||
|
||||
var uniqueBinaryPairs = results
|
||||
.Select(r => $"{r.Result.PreBinaryDigest}:{r.Result.PostBinaryDigest}")
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Count();
|
||||
|
||||
var oldestResult = results.MinBy(r => r.StoredAt)?.StoredAt;
|
||||
var newestResult = results.MaxBy(r => r.StoredAt)?.StoredAt;
|
||||
|
||||
return Task.FromResult(new DiffResultStoreStats
|
||||
{
|
||||
TotalResults = results.Count,
|
||||
ResultsByVerdict = resultsByVerdict,
|
||||
UniqueGoldenSets = uniqueGoldenSets,
|
||||
UniqueBinaryPairs = uniqueBinaryPairs,
|
||||
CachedChecks = _checkCache.Count,
|
||||
OldestResult = oldestResult,
|
||||
NewestResult = newestResult
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all stored results and cache.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_results.Clear();
|
||||
_checkCache.Clear();
|
||||
}
|
||||
|
||||
private static string GetCacheKey(string binaryDigest, string vulnerabilityId)
|
||||
=> $"{binaryDigest}:{vulnerabilityId}";
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
|
||||
// Sprint: SPRINT_20260110_012_002_BINDEX
|
||||
// Task: GSA-009 - Integration Tests for Golden Set Authoring Flow
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.BinaryIndex.GoldenSet.Tests.Integration.Authoring;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the golden set authoring workflow.
|
||||
/// Tests the end-to-end flow from extraction to review.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class GoldenSetAuthoringIntegrationTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly GoldenSetValidator _validator;
|
||||
private readonly SinkRegistry _sinkRegistry;
|
||||
private readonly GoldenSetEnrichmentService _enrichmentService;
|
||||
private readonly GoldenSetReviewService _reviewService;
|
||||
|
||||
public GoldenSetAuthoringIntegrationTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
_validator = new GoldenSetValidator(new CveValidator());
|
||||
_sinkRegistry = new SinkRegistry(
|
||||
Options.Create(new SinkRegistryOptions()),
|
||||
NullLogger<SinkRegistry>.Instance);
|
||||
_enrichmentService = new GoldenSetEnrichmentService(
|
||||
_sinkRegistry,
|
||||
NullLogger<GoldenSetEnrichmentService>.Instance);
|
||||
_reviewService = new GoldenSetReviewService(
|
||||
_validator,
|
||||
_timeProvider,
|
||||
NullLogger<GoldenSetReviewService>.Instance);
|
||||
}
|
||||
|
||||
#region Full Authoring Workflow Tests
|
||||
|
||||
[Fact]
|
||||
public async Task FullAuthoringWorkflow_ValidCve_CompletesSuccessfully()
|
||||
{
|
||||
// Step 1: Create initial definition
|
||||
var definition = new GoldenSetDefinition
|
||||
{
|
||||
Id = "CVE-2024-TEST-001",
|
||||
Component = "openssl",
|
||||
Targets =
|
||||
[
|
||||
new VulnerableTarget
|
||||
{
|
||||
FunctionName = "PKCS7_verify",
|
||||
Sinks = ["memcpy"],
|
||||
TaintInvariant = "Attacker-controlled PKCS7 data flows to unbounded memcpy"
|
||||
}
|
||||
],
|
||||
Metadata = new GoldenSetMetadata
|
||||
{
|
||||
AuthorId = "author@test.com",
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
SourceRef = "https://nvd.nist.gov/vuln/detail/CVE-2024-TEST-001",
|
||||
Tags = ["memory-corruption"]
|
||||
}
|
||||
};
|
||||
|
||||
// Step 2: Validate initial definition
|
||||
var validationResult = await _validator.ValidateAsync(definition);
|
||||
validationResult.IsValid.Should().BeTrue("initial definition should be valid");
|
||||
|
||||
// Step 3: Enrich with sink context
|
||||
var enriched = await _enrichmentService.EnrichAsync(definition);
|
||||
enriched.Should().NotBeNull();
|
||||
|
||||
// Step 4: Validate enriched definition
|
||||
validationResult = await _validator.ValidateAsync(enriched);
|
||||
validationResult.IsValid.Should().BeTrue("enriched definition should be valid");
|
||||
|
||||
// Step 5: Submit for review
|
||||
var reviewSubmission = await _reviewService.SubmitForReviewAsync(
|
||||
definition,
|
||||
"author@test.com",
|
||||
"Initial submission for review");
|
||||
reviewSubmission.Should().NotBeNull();
|
||||
reviewSubmission.ReviewId.Should().NotBeEmpty();
|
||||
|
||||
// Step 6: Approve review
|
||||
var approval = await _reviewService.ApproveAsync(
|
||||
reviewSubmission.ReviewId,
|
||||
"reviewer@test.com",
|
||||
"LGTM - verified against patch diff");
|
||||
approval.Should().NotBeNull();
|
||||
approval.Approved.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReviewWorkflow_RejectionAndResubmit_CompletesSuccessfully()
|
||||
{
|
||||
// Create initial incomplete definition
|
||||
var definition = new GoldenSetDefinition
|
||||
{
|
||||
Id = "CVE-2024-TEST-002",
|
||||
Component = "glibc",
|
||||
Targets =
|
||||
[
|
||||
new VulnerableTarget
|
||||
{
|
||||
FunctionName = "strcpy", // Missing proper context
|
||||
Sinks = ImmutableArray<string>.Empty // Empty sinks
|
||||
}
|
||||
],
|
||||
Metadata = new GoldenSetMetadata
|
||||
{
|
||||
AuthorId = "author@test.com",
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
SourceRef = "https://nvd.nist.gov/vuln/detail/CVE-2024-TEST-002"
|
||||
}
|
||||
};
|
||||
|
||||
// Submit for review
|
||||
var reviewSubmission = await _reviewService.SubmitForReviewAsync(
|
||||
definition,
|
||||
"author@test.com",
|
||||
"First attempt");
|
||||
|
||||
// Reject with feedback
|
||||
var rejection = await _reviewService.RejectAsync(
|
||||
reviewSubmission.ReviewId,
|
||||
"reviewer@test.com",
|
||||
"Missing sink definitions. Please add vulnerable sinks.");
|
||||
rejection.Should().NotBeNull();
|
||||
rejection.Rejected.Should().BeTrue();
|
||||
|
||||
// Fix the issues and resubmit
|
||||
var fixedDefinition = definition with
|
||||
{
|
||||
Targets =
|
||||
[
|
||||
new VulnerableTarget
|
||||
{
|
||||
FunctionName = "strcpy",
|
||||
Sinks = ["gets", "strcpy"],
|
||||
TaintInvariant = "User input flows to strcpy without length check",
|
||||
SourceFile = "glibc/string/strcpy.c"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Resubmit
|
||||
var resubmission = await _reviewService.SubmitForReviewAsync(
|
||||
fixedDefinition,
|
||||
"author@test.com",
|
||||
"Fixed: Added sinks and taint invariant");
|
||||
resubmission.ReviewId.Should().NotBe(reviewSubmission.ReviewId);
|
||||
|
||||
// Now approve
|
||||
var approval = await _reviewService.ApproveAsync(
|
||||
resubmission.ReviewId,
|
||||
"reviewer@test.com",
|
||||
"Approved after fixes");
|
||||
approval.Approved.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Enrichment Tests
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichAsync_WithKnownSinks_AddsContext()
|
||||
{
|
||||
var definition = new GoldenSetDefinition
|
||||
{
|
||||
Id = "CVE-2024-TEST-003",
|
||||
Component = "openssl",
|
||||
Targets =
|
||||
[
|
||||
new VulnerableTarget
|
||||
{
|
||||
FunctionName = "X509_NAME_oneline",
|
||||
Sinks = ["memcpy"]
|
||||
}
|
||||
],
|
||||
Metadata = new GoldenSetMetadata
|
||||
{
|
||||
AuthorId = "author@test.com",
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
SourceRef = "https://example.com"
|
||||
}
|
||||
};
|
||||
|
||||
var enriched = await _enrichmentService.EnrichAsync(definition);
|
||||
|
||||
enriched.Should().NotBeNull();
|
||||
// Enrichment should preserve original data
|
||||
enriched.Id.Should().Be(definition.Id);
|
||||
enriched.Targets.Should().HaveCount(1);
|
||||
enriched.Targets[0].Sinks.Should().Contain("memcpy");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichAsync_EmptyTargets_ReturnsOriginal()
|
||||
{
|
||||
var definition = new GoldenSetDefinition
|
||||
{
|
||||
Id = "CVE-2024-TEST-004",
|
||||
Component = "unknown-component",
|
||||
Targets = [],
|
||||
Metadata = new GoldenSetMetadata
|
||||
{
|
||||
AuthorId = "author@test.com",
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
SourceRef = "https://example.com"
|
||||
}
|
||||
};
|
||||
|
||||
var enriched = await _enrichmentService.EnrichAsync(definition);
|
||||
|
||||
enriched.Should().NotBeNull();
|
||||
enriched.Targets.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validation Integration Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_InvalidCveId_ReturnsErrors()
|
||||
{
|
||||
var definition = new GoldenSetDefinition
|
||||
{
|
||||
Id = "INVALID-CVE-FORMAT",
|
||||
Component = "openssl",
|
||||
Targets = [],
|
||||
Metadata = new GoldenSetMetadata
|
||||
{
|
||||
AuthorId = "author@test.com",
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
SourceRef = "https://example.com"
|
||||
}
|
||||
};
|
||||
|
||||
var result = await _validator.ValidateAsync(definition);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ContentDigest_IsDeterministic()
|
||||
{
|
||||
var definition = new GoldenSetDefinition
|
||||
{
|
||||
Id = "CVE-2024-TEST-005",
|
||||
Component = "openssl",
|
||||
Targets =
|
||||
[
|
||||
new VulnerableTarget
|
||||
{
|
||||
FunctionName = "SSL_read",
|
||||
Sinks = ["recv"]
|
||||
}
|
||||
],
|
||||
Metadata = new GoldenSetMetadata
|
||||
{
|
||||
AuthorId = "author@test.com",
|
||||
CreatedAt = new DateTimeOffset(2024, 1, 15, 10, 30, 0, TimeSpan.Zero),
|
||||
SourceRef = "https://example.com"
|
||||
}
|
||||
};
|
||||
|
||||
var result1 = await _validator.ValidateAsync(definition);
|
||||
var result2 = await _validator.ValidateAsync(definition);
|
||||
|
||||
result1.ContentDigest.Should().Be(result2.ContentDigest);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Sink Registry Integration Tests
|
||||
|
||||
[Fact]
|
||||
public void SinkRegistry_LookupKnownSink_ReturnsContext()
|
||||
{
|
||||
var context = _sinkRegistry.GetSinkContext("memcpy");
|
||||
|
||||
context.Should().NotBeNull();
|
||||
context!.Category.Should().Be("memory");
|
||||
context.CweIds.Should().Contain("CWE-120");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SinkRegistry_LookupUnknownSink_ReturnsNull()
|
||||
{
|
||||
var context = _sinkRegistry.GetSinkContext("unknown_function_xyz");
|
||||
|
||||
context.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SinkRegistry_GetSinksByCategory_ReturnsMatching()
|
||||
{
|
||||
var memorySinks = _sinkRegistry.GetSinksByCategory("memory");
|
||||
|
||||
memorySinks.Should().NotBeEmpty();
|
||||
memorySinks.Should().Contain("memcpy");
|
||||
memorySinks.Should().Contain("strcpy");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases
|
||||
|
||||
[Fact]
|
||||
public async Task FullWorkflow_WithGhsaId_CompletesSuccessfully()
|
||||
{
|
||||
var definition = new GoldenSetDefinition
|
||||
{
|
||||
Id = "GHSA-abcd-1234-efgh",
|
||||
Component = "nodejs-package",
|
||||
Targets =
|
||||
[
|
||||
new VulnerableTarget
|
||||
{
|
||||
FunctionName = "parseInput",
|
||||
Sinks = ["eval"]
|
||||
}
|
||||
],
|
||||
Metadata = new GoldenSetMetadata
|
||||
{
|
||||
AuthorId = "author@test.com",
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
SourceRef = "https://github.com/advisories/GHSA-abcd-1234-efgh"
|
||||
}
|
||||
};
|
||||
|
||||
var validationResult = await _validator.ValidateAsync(definition);
|
||||
validationResult.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullWorkflow_WithMultipleTargets_CompletesSuccessfully()
|
||||
{
|
||||
var definition = new GoldenSetDefinition
|
||||
{
|
||||
Id = "CVE-2024-TEST-006",
|
||||
Component = "libxml2",
|
||||
Targets =
|
||||
[
|
||||
new VulnerableTarget
|
||||
{
|
||||
FunctionName = "xmlParseEntity",
|
||||
Sinks = ["memcpy"],
|
||||
TaintInvariant = "XML entity expansion leads to buffer overflow"
|
||||
},
|
||||
new VulnerableTarget
|
||||
{
|
||||
FunctionName = "xmlStringGetNodeList",
|
||||
Sinks = ["realloc"],
|
||||
TaintInvariant = "Malformed entity reference causes realloc with wrong size"
|
||||
},
|
||||
new VulnerableTarget
|
||||
{
|
||||
FunctionName = "xmlNodeAddContent",
|
||||
Sinks = ["strcpy"],
|
||||
TaintInvariant = "Entity content copied without bounds check"
|
||||
}
|
||||
],
|
||||
Metadata = new GoldenSetMetadata
|
||||
{
|
||||
AuthorId = "security-team@test.com",
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
SourceRef = "https://nvd.nist.gov/vuln/detail/CVE-2024-TEST-006",
|
||||
Tags = ["xxe", "xml-entity-expansion", "memory-corruption"]
|
||||
}
|
||||
};
|
||||
|
||||
var validationResult = await _validator.ValidateAsync(definition);
|
||||
validationResult.IsValid.Should().BeTrue();
|
||||
validationResult.ContentDigest.Should().NotBeNullOrEmpty();
|
||||
|
||||
var enriched = await _enrichmentService.EnrichAsync(definition);
|
||||
enriched.Targets.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
|
||||
// Sprint: SPRINT_20260110_012_001_BINDEX
|
||||
// Task: GSF-010 - PostgreSQL Integration Tests for Golden Set Store
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Npgsql;
|
||||
using Testcontainers.PostgreSql;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.BinaryIndex.GoldenSet.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for <see cref="PostgresGoldenSetStore"/> using Testcontainers.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class PostgresGoldenSetStoreTests : IAsyncLifetime
|
||||
{
|
||||
private PostgreSqlContainer _postgres = null!;
|
||||
private NpgsqlDataSource _dataSource = null!;
|
||||
private PostgresGoldenSetStore _store = null!;
|
||||
private FakeTimeProvider _timeProvider = null!;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_postgres = new PostgreSqlBuilder()
|
||||
.WithImage("postgres:16-alpine")
|
||||
.WithDatabase("goldensets_test")
|
||||
.WithUsername("test")
|
||||
.WithPassword("test")
|
||||
.Build();
|
||||
|
||||
await _postgres.StartAsync();
|
||||
|
||||
var connectionString = _postgres.GetConnectionString();
|
||||
_dataSource = NpgsqlDataSource.Create(connectionString);
|
||||
|
||||
// Run migration
|
||||
await RunMigrationAsync();
|
||||
|
||||
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
var validator = new GoldenSetValidator(new CveValidator());
|
||||
var options = Options.Create(new GoldenSetOptions());
|
||||
var logger = NullLogger<PostgresGoldenSetStore>.Instance;
|
||||
|
||||
_store = new PostgresGoldenSetStore(
|
||||
_dataSource,
|
||||
validator,
|
||||
_timeProvider,
|
||||
options,
|
||||
logger);
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _dataSource.DisposeAsync();
|
||||
await _postgres.DisposeAsync();
|
||||
}
|
||||
|
||||
private async Task RunMigrationAsync()
|
||||
{
|
||||
var migrationSql = await File.ReadAllTextAsync(GetMigrationPath());
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync();
|
||||
await using var cmd = new NpgsqlCommand(migrationSql, conn);
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
private static string GetMigrationPath()
|
||||
{
|
||||
// Navigate from bin/Debug/net10.0 to the Migrations folder
|
||||
var baseDir = AppContext.BaseDirectory;
|
||||
var projectDir = Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "..", ".."));
|
||||
return Path.Combine(projectDir, "__Libraries", "StellaOps.BinaryIndex.GoldenSet", "Migrations", "V1_0_0__initial_schema.sql");
|
||||
}
|
||||
|
||||
#region Store Tests
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_ValidDefinition_ReturnsSuccessWithDigest()
|
||||
{
|
||||
// Arrange
|
||||
var definition = CreateTestDefinition("CVE-2024-0001");
|
||||
|
||||
// Act
|
||||
var result = await _store.StoreAsync(definition);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.ContentDigest.Should().NotBeNullOrEmpty();
|
||||
result.WasUpdated.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_DuplicateId_UpdatesAndReturnsWasUpdated()
|
||||
{
|
||||
// Arrange
|
||||
var definition1 = CreateTestDefinition("CVE-2024-0002");
|
||||
await _store.StoreAsync(definition1);
|
||||
|
||||
var definition2 = definition1 with
|
||||
{
|
||||
Targets =
|
||||
[
|
||||
new VulnerableTarget
|
||||
{
|
||||
FunctionName = "different_function",
|
||||
Sinks = ["strcat"]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _store.StoreAsync(definition2);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.WasUpdated.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetById Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ExistingId_ReturnsDefinition()
|
||||
{
|
||||
// Arrange
|
||||
var definition = CreateTestDefinition("CVE-2024-0003");
|
||||
await _store.StoreAsync(definition);
|
||||
|
||||
// Act
|
||||
var retrieved = await _store.GetByIdAsync("CVE-2024-0003");
|
||||
|
||||
// Assert
|
||||
retrieved.Should().NotBeNull();
|
||||
retrieved!.Id.Should().Be("CVE-2024-0003");
|
||||
retrieved.Component.Should().Be(definition.Component);
|
||||
retrieved.Targets.Should().HaveCount(definition.Targets.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_NonExistingId_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var retrieved = await _store.GetByIdAsync("CVE-NONEXISTENT");
|
||||
|
||||
// Assert
|
||||
retrieved.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetByDigest Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetByDigestAsync_ExistingDigest_ReturnsDefinition()
|
||||
{
|
||||
// Arrange
|
||||
var definition = CreateTestDefinition("CVE-2024-0004");
|
||||
var storeResult = await _store.StoreAsync(definition);
|
||||
|
||||
// Act
|
||||
var retrieved = await _store.GetByDigestAsync(storeResult.ContentDigest);
|
||||
|
||||
// Assert
|
||||
retrieved.Should().NotBeNull();
|
||||
retrieved!.Id.Should().Be("CVE-2024-0004");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region List Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_WithComponentFilter_ReturnsMatching()
|
||||
{
|
||||
// Arrange
|
||||
await _store.StoreAsync(CreateTestDefinition("CVE-2024-0010", "openssl"));
|
||||
await _store.StoreAsync(CreateTestDefinition("CVE-2024-0011", "glibc"));
|
||||
await _store.StoreAsync(CreateTestDefinition("CVE-2024-0012", "openssl"));
|
||||
|
||||
var query = new GoldenSetListQuery { ComponentFilter = "openssl" };
|
||||
|
||||
// Act
|
||||
var results = await _store.ListAsync(query);
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(2);
|
||||
results.Should().AllSatisfy(r => r.Component.Should().Be("openssl"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_WithStatusFilter_ReturnsMatching()
|
||||
{
|
||||
// Arrange
|
||||
await _store.StoreAsync(CreateTestDefinition("CVE-2024-0020"), GoldenSetStatus.Draft);
|
||||
await _store.StoreAsync(CreateTestDefinition("CVE-2024-0021"), GoldenSetStatus.Approved);
|
||||
await _store.StoreAsync(CreateTestDefinition("CVE-2024-0022"), GoldenSetStatus.Draft);
|
||||
|
||||
var query = new GoldenSetListQuery { StatusFilter = GoldenSetStatus.Draft };
|
||||
|
||||
// Act
|
||||
var results = await _store.ListAsync(query);
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCountGreaterThanOrEqualTo(2);
|
||||
results.Should().AllSatisfy(r => r.Status.Should().Be(GoldenSetStatus.Draft));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_WithPagination_ReturnsCorrectPage()
|
||||
{
|
||||
// Arrange
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
await _store.StoreAsync(CreateTestDefinition($"CVE-2024-003{i}"));
|
||||
}
|
||||
|
||||
var query = new GoldenSetListQuery
|
||||
{
|
||||
Limit = 2,
|
||||
Offset = 2,
|
||||
OrderBy = GoldenSetOrderBy.IdAsc
|
||||
};
|
||||
|
||||
// Act
|
||||
var results = await _store.ListAsync(query);
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateStatus Tests
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateStatusAsync_ValidTransition_UpdatesStatus()
|
||||
{
|
||||
// Arrange
|
||||
await _store.StoreAsync(CreateTestDefinition("CVE-2024-0040"), GoldenSetStatus.Draft);
|
||||
|
||||
// Act
|
||||
var result = await _store.UpdateStatusAsync(
|
||||
"CVE-2024-0040",
|
||||
GoldenSetStatus.InReview,
|
||||
"reviewer@test.com",
|
||||
"Submitting for review");
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
var stored = await _store.GetAsync("CVE-2024-0040");
|
||||
stored.Should().NotBeNull();
|
||||
stored!.Status.Should().Be(GoldenSetStatus.InReview);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateStatusAsync_NonExistingId_ReturnsFailure()
|
||||
{
|
||||
// Act
|
||||
var result = await _store.UpdateStatusAsync(
|
||||
"CVE-NONEXISTENT",
|
||||
GoldenSetStatus.Approved,
|
||||
"reviewer@test.com",
|
||||
"Approving");
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Contain("not found");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetByComponent Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetByComponentAsync_ReturnsMatchingDefinitions()
|
||||
{
|
||||
// Arrange
|
||||
await _store.StoreAsync(CreateTestDefinition("CVE-2024-0050", "libcurl"), GoldenSetStatus.Approved);
|
||||
await _store.StoreAsync(CreateTestDefinition("CVE-2024-0051", "libcurl"), GoldenSetStatus.Approved);
|
||||
await _store.StoreAsync(CreateTestDefinition("CVE-2024-0052", "zlib"), GoldenSetStatus.Approved);
|
||||
|
||||
// Act
|
||||
var results = await _store.GetByComponentAsync("libcurl", GoldenSetStatus.Approved);
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(2);
|
||||
results.Should().AllSatisfy(d => d.Component.Should().Be("libcurl"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Delete Tests
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_ExistingId_ArchivesAndReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
await _store.StoreAsync(CreateTestDefinition("CVE-2024-0060"));
|
||||
|
||||
// Act
|
||||
var deleted = await _store.DeleteAsync("CVE-2024-0060");
|
||||
|
||||
// Assert
|
||||
deleted.Should().BeTrue();
|
||||
|
||||
var stored = await _store.GetAsync("CVE-2024-0060");
|
||||
stored.Should().NotBeNull();
|
||||
stored!.Status.Should().Be(GoldenSetStatus.Archived);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_NonExistingId_ReturnsFalse()
|
||||
{
|
||||
// Act
|
||||
var deleted = await _store.DeleteAsync("CVE-NONEXISTENT");
|
||||
|
||||
// Assert
|
||||
deleted.Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AuditLog Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetAuditLogAsync_ReturnsAuditEntries()
|
||||
{
|
||||
// Arrange
|
||||
await _store.StoreAsync(CreateTestDefinition("CVE-2024-0070"));
|
||||
await _store.UpdateStatusAsync(
|
||||
"CVE-2024-0070",
|
||||
GoldenSetStatus.InReview,
|
||||
"reviewer1@test.com",
|
||||
"First review");
|
||||
await _store.UpdateStatusAsync(
|
||||
"CVE-2024-0070",
|
||||
GoldenSetStatus.Approved,
|
||||
"reviewer2@test.com",
|
||||
"Approved after review");
|
||||
|
||||
// Act
|
||||
var auditLog = await _store.GetAuditLogAsync("CVE-2024-0070");
|
||||
|
||||
// Assert
|
||||
auditLog.Should().HaveCountGreaterThanOrEqualTo(3); // created + 2 status changes
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Content Addressability Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ContentDigest_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var definition = CreateTestDefinition("CVE-2024-0080");
|
||||
|
||||
// Act
|
||||
var result1 = await _store.StoreAsync(definition);
|
||||
var retrieved = await _store.GetByIdAsync("CVE-2024-0080");
|
||||
|
||||
// Delete and re-store the same definition
|
||||
await _store.DeleteAsync("CVE-2024-0080");
|
||||
|
||||
// Need to store again with same content - digest should match
|
||||
var definition2 = retrieved!;
|
||||
var result2 = await _store.StoreAsync(definition2);
|
||||
|
||||
// Assert
|
||||
result1.ContentDigest.Should().Be(result2.ContentDigest);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static GoldenSetDefinition CreateTestDefinition(string id, string component = "openssl")
|
||||
{
|
||||
return new GoldenSetDefinition
|
||||
{
|
||||
Id = id,
|
||||
Component = component,
|
||||
Targets =
|
||||
[
|
||||
new VulnerableTarget
|
||||
{
|
||||
FunctionName = "vulnerable_function",
|
||||
Sinks = ["memcpy", "strcpy"],
|
||||
Edges =
|
||||
[
|
||||
new BasicBlockEdge { From = "bb0", To = "bb1" },
|
||||
new BasicBlockEdge { From = "bb1", To = "bb2" }
|
||||
],
|
||||
TaintInvariant = "attacker-controlled input reaches sink without bounds check"
|
||||
}
|
||||
],
|
||||
Metadata = new GoldenSetMetadata
|
||||
{
|
||||
AuthorId = "test@example.com",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
SourceRef = $"https://nvd.nist.gov/vuln/detail/{id}",
|
||||
Tags = ["memory-corruption", "heap-overflow"]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" />
|
||||
</ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user