feat: add Attestation Chain and Triage Evidence API clients and models
- Implemented Attestation Chain API client with methods for verifying, fetching, and managing attestation chains. - Created models for Attestation Chain, including DSSE envelope structures and verification results. - Developed Triage Evidence API client for fetching finding evidence, including methods for evidence retrieval by CVE and component. - Added models for Triage Evidence, encapsulating evidence responses, entry points, boundary proofs, and VEX evidence. - Introduced mock implementations for both API clients to facilitate testing and development.
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// WitnessEndpoints.cs
|
||||
// Sprint: SPRINT_3700_0001_0001_witness_foundation
|
||||
// Task: WIT-010
|
||||
// Description: API endpoints for DSSE-signed path witnesses.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
using StellaOps.Scanner.WebService.Security;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||
|
||||
internal static class WitnessEndpoints
|
||||
{
|
||||
public static void MapWitnessEndpoints(this RouteGroupBuilder apiGroup, string witnessSegment = "witnesses")
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(apiGroup);
|
||||
|
||||
var witnesses = apiGroup.MapGroup($"/{witnessSegment.TrimStart('/')}");
|
||||
|
||||
witnesses.MapGet("/{witnessId:guid}", HandleGetWitnessByIdAsync)
|
||||
.WithName("scanner.witnesses.get")
|
||||
.Produces<WitnessResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
witnesses.MapGet("", HandleListWitnessesAsync)
|
||||
.WithName("scanner.witnesses.list")
|
||||
.Produces<WitnessListResponseDto>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
witnesses.MapGet("/by-hash/{witnessHash}", HandleGetWitnessByHashAsync)
|
||||
.WithName("scanner.witnesses.get-by-hash")
|
||||
.Produces<WitnessResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
witnesses.MapPost("/{witnessId:guid}/verify", HandleVerifyWitnessAsync)
|
||||
.WithName("scanner.witnesses.verify")
|
||||
.Produces<WitnessVerificationResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleGetWitnessByIdAsync(
|
||||
Guid witnessId,
|
||||
IWitnessRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(repository);
|
||||
|
||||
var witness = await repository.GetByIdAsync(witnessId, cancellationToken).ConfigureAwait(false);
|
||||
if (witness is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(MapToDto(witness));
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleGetWitnessByHashAsync(
|
||||
string witnessHash,
|
||||
IWitnessRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(repository);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(witnessHash))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var witness = await repository.GetByHashAsync(witnessHash, cancellationToken).ConfigureAwait(false);
|
||||
if (witness is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(MapToDto(witness));
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleListWitnessesAsync(
|
||||
HttpContext context,
|
||||
IWitnessRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(repository);
|
||||
|
||||
var query = context.Request.Query;
|
||||
IReadOnlyList<WitnessRecord> witnesses;
|
||||
|
||||
if (query.TryGetValue("scanId", out var scanIdValue) && Guid.TryParse(scanIdValue, out var scanId))
|
||||
{
|
||||
witnesses = await repository.GetByScanIdAsync(scanId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else if (query.TryGetValue("cve", out var cveValue) && !string.IsNullOrWhiteSpace(cveValue))
|
||||
{
|
||||
witnesses = await repository.GetByCveAsync(cveValue!, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else if (query.TryGetValue("graphHash", out var graphHashValue) && !string.IsNullOrWhiteSpace(graphHashValue))
|
||||
{
|
||||
witnesses = await repository.GetByGraphHashAsync(graphHashValue!, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No filter provided - return empty list (avoid full table scan)
|
||||
witnesses = [];
|
||||
}
|
||||
|
||||
return Results.Ok(new WitnessListResponseDto
|
||||
{
|
||||
Witnesses = witnesses.Select(MapToDto).ToList(),
|
||||
TotalCount = witnesses.Count
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleVerifyWitnessAsync(
|
||||
Guid witnessId,
|
||||
IWitnessRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(repository);
|
||||
|
||||
var witness = await repository.GetByIdAsync(witnessId, cancellationToken).ConfigureAwait(false);
|
||||
if (witness is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
// Basic verification: check if DSSE envelope exists and witness hash is valid
|
||||
var verificationStatus = "valid";
|
||||
string? verificationError = null;
|
||||
|
||||
if (string.IsNullOrEmpty(witness.DsseEnvelope))
|
||||
{
|
||||
verificationStatus = "unsigned";
|
||||
verificationError = "Witness does not have a DSSE envelope";
|
||||
}
|
||||
else
|
||||
{
|
||||
// TODO: WIT-009 - Add actual DSSE signature verification via Attestor
|
||||
// For now, just check the envelope structure
|
||||
try
|
||||
{
|
||||
var envelope = JsonDocument.Parse(witness.DsseEnvelope);
|
||||
if (!envelope.RootElement.TryGetProperty("signatures", out var signatures) ||
|
||||
signatures.GetArrayLength() == 0)
|
||||
{
|
||||
verificationStatus = "invalid";
|
||||
verificationError = "DSSE envelope has no signatures";
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
verificationStatus = "invalid";
|
||||
verificationError = $"Invalid DSSE envelope JSON: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
// Record verification attempt
|
||||
await repository.RecordVerificationAsync(new WitnessVerificationRecord
|
||||
{
|
||||
WitnessId = witnessId,
|
||||
VerifiedAt = DateTimeOffset.UtcNow,
|
||||
VerifiedBy = "api",
|
||||
VerificationStatus = verificationStatus,
|
||||
VerificationError = verificationError
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new WitnessVerificationResponseDto
|
||||
{
|
||||
WitnessId = witnessId,
|
||||
WitnessHash = witness.WitnessHash,
|
||||
Status = verificationStatus,
|
||||
Error = verificationError,
|
||||
VerifiedAt = DateTimeOffset.UtcNow,
|
||||
IsSigned = !string.IsNullOrEmpty(witness.DsseEnvelope)
|
||||
});
|
||||
}
|
||||
|
||||
private static WitnessResponseDto MapToDto(WitnessRecord record)
|
||||
{
|
||||
return new WitnessResponseDto
|
||||
{
|
||||
WitnessId = record.WitnessId,
|
||||
WitnessHash = record.WitnessHash,
|
||||
SchemaVersion = record.SchemaVersion,
|
||||
WitnessType = record.WitnessType,
|
||||
GraphHash = record.GraphHash,
|
||||
ScanId = record.ScanId,
|
||||
RunId = record.RunId,
|
||||
CreatedAt = record.CreatedAt,
|
||||
SignedAt = record.SignedAt,
|
||||
SignerKeyId = record.SignerKeyId,
|
||||
EntrypointFqn = record.EntrypointFqn,
|
||||
SinkCve = record.SinkCve,
|
||||
IsSigned = !string.IsNullOrEmpty(record.DsseEnvelope),
|
||||
Payload = JsonDocument.Parse(record.PayloadJson).RootElement,
|
||||
DsseEnvelope = string.IsNullOrEmpty(record.DsseEnvelope)
|
||||
? null
|
||||
: JsonDocument.Parse(record.DsseEnvelope).RootElement
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response DTO for a single witness.
|
||||
/// </summary>
|
||||
public sealed record WitnessResponseDto
|
||||
{
|
||||
public Guid WitnessId { get; init; }
|
||||
public required string WitnessHash { get; init; }
|
||||
public required string SchemaVersion { get; init; }
|
||||
public required string WitnessType { get; init; }
|
||||
public required string GraphHash { get; init; }
|
||||
public Guid? ScanId { get; init; }
|
||||
public Guid? RunId { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
public string? SignerKeyId { get; init; }
|
||||
public string? EntrypointFqn { get; init; }
|
||||
public string? SinkCve { get; init; }
|
||||
public bool IsSigned { get; init; }
|
||||
public JsonElement Payload { get; init; }
|
||||
public JsonElement? DsseEnvelope { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response DTO for witness list.
|
||||
/// </summary>
|
||||
public sealed record WitnessListResponseDto
|
||||
{
|
||||
public required IReadOnlyList<WitnessResponseDto> Witnesses { get; init; }
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response DTO for witness verification.
|
||||
/// </summary>
|
||||
public sealed record WitnessVerificationResponseDto
|
||||
{
|
||||
public Guid WitnessId { get; init; }
|
||||
public required string WitnessHash { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public string? Error { get; init; }
|
||||
public DateTimeOffset VerifiedAt { get; init; }
|
||||
public bool IsSigned { get; init; }
|
||||
}
|
||||
Reference in New Issue
Block a user