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:
master
2025-12-18 13:15:13 +02:00
parent 7d5250238c
commit 00d2c99af9
118 changed files with 13463 additions and 151 deletions

View File

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