Add unit tests for PhpFrameworkSurface and PhpPharScanner
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled

- Implement comprehensive tests for PhpFrameworkSurface, covering scenarios such as empty surfaces, presence of routes, controllers, middlewares, CLI commands, cron jobs, and event listeners.
- Validate metadata creation for route counts, HTTP methods, protected and public routes, and route patterns.
- Introduce tests for PhpPharScanner, including handling of non-existent files, null or empty paths, invalid PHAR files, and minimal PHAR structures.
- Ensure correct computation of SHA256 for valid PHAR files and validate the properties of PhpPharArchive, PhpPharEntry, and PhpPharScanResult.
This commit is contained in:
StellaOps Bot
2025-12-07 13:44:13 +02:00
parent af30fc322f
commit 965cbf9574
49 changed files with 11935 additions and 152 deletions

View File

@@ -0,0 +1,328 @@
using StellaOps.Findings.Ledger.Infrastructure.Attestation;
namespace StellaOps.Findings.Ledger.WebService.Contracts;
/// <summary>
/// Request to create an attestation pointer.
/// </summary>
public sealed record CreateAttestationPointerRequest(
string FindingId,
string AttestationType,
string Relationship,
AttestationRefDto AttestationRef,
VerificationResultDto? VerificationResult = null,
string? CreatedBy = null,
Dictionary<string, object>? Metadata = null);
/// <summary>
/// Reference to an attestation artifact.
/// </summary>
public sealed record AttestationRefDto(
string Digest,
string? AttestationId = null,
string? StorageUri = null,
string? PayloadType = null,
string? PredicateType = null,
IReadOnlyList<string>? SubjectDigests = null,
SignerInfoDto? SignerInfo = null,
RekorEntryRefDto? RekorEntry = null);
/// <summary>
/// Information about the attestation signer.
/// </summary>
public sealed record SignerInfoDto(
string? KeyId = null,
string? Issuer = null,
string? Subject = null,
IReadOnlyList<string>? CertificateChain = null,
DateTimeOffset? SignedAt = null);
/// <summary>
/// Reference to Rekor transparency log entry.
/// </summary>
public sealed record RekorEntryRefDto(
long? LogIndex = null,
string? LogId = null,
string? Uuid = null,
long? IntegratedTime = null);
/// <summary>
/// Result of attestation verification.
/// </summary>
public sealed record VerificationResultDto(
bool Verified,
DateTimeOffset VerifiedAt,
string? Verifier = null,
string? VerifierVersion = null,
string? PolicyRef = null,
IReadOnlyList<VerificationCheckDto>? Checks = null,
IReadOnlyList<string>? Warnings = null,
IReadOnlyList<string>? Errors = null);
/// <summary>
/// Individual verification check result.
/// </summary>
public sealed record VerificationCheckDto(
string CheckType,
bool Passed,
string? Details = null,
Dictionary<string, object>? Evidence = null);
/// <summary>
/// Response for creating an attestation pointer.
/// </summary>
public sealed record CreateAttestationPointerResponse(
bool Success,
string? PointerId,
string? LedgerEventId,
string? Error);
/// <summary>
/// Response for getting attestation pointers.
/// </summary>
public sealed record AttestationPointerResponse(
string PointerId,
string FindingId,
string AttestationType,
string Relationship,
AttestationRefDto AttestationRef,
VerificationResultDto? VerificationResult,
DateTimeOffset CreatedAt,
string CreatedBy,
Dictionary<string, object>? Metadata,
string? LedgerEventId);
/// <summary>
/// Response for attestation summary.
/// </summary>
public sealed record AttestationSummaryResponse(
string FindingId,
int AttestationCount,
int VerifiedCount,
DateTimeOffset? LatestAttestation,
IReadOnlyList<string> AttestationTypes,
string OverallVerificationStatus);
/// <summary>
/// Query parameters for searching attestation pointers.
/// </summary>
public sealed record AttestationPointerSearchRequest(
IReadOnlyList<string>? FindingIds = null,
IReadOnlyList<string>? AttestationTypes = null,
string? VerificationStatus = null,
DateTimeOffset? CreatedAfter = null,
DateTimeOffset? CreatedBefore = null,
string? SignerIdentity = null,
string? PredicateType = null,
int Limit = 100,
int Offset = 0);
/// <summary>
/// Response for searching attestation pointers.
/// </summary>
public sealed record AttestationPointerSearchResponse(
IReadOnlyList<AttestationPointerResponse> Pointers,
int TotalCount);
/// <summary>
/// Request to update verification result.
/// </summary>
public sealed record UpdateVerificationResultRequest(
VerificationResultDto VerificationResult);
/// <summary>
/// Mapping extensions for attestation pointer DTOs.
/// </summary>
public static class AttestationPointerMappings
{
public static AttestationPointerInput ToInput(this CreateAttestationPointerRequest request, string tenantId)
{
if (!Enum.TryParse<AttestationType>(request.AttestationType, ignoreCase: true, out var attestationType))
{
throw new ArgumentException($"Invalid attestation type: {request.AttestationType}");
}
if (!Enum.TryParse<AttestationRelationship>(request.Relationship, ignoreCase: true, out var relationship))
{
throw new ArgumentException($"Invalid relationship: {request.Relationship}");
}
return new AttestationPointerInput(
tenantId,
request.FindingId,
attestationType,
relationship,
request.AttestationRef.ToModel(),
request.VerificationResult?.ToModel(),
request.CreatedBy,
request.Metadata);
}
public static AttestationRef ToModel(this AttestationRefDto dto)
{
return new AttestationRef(
dto.Digest,
dto.AttestationId is not null ? Guid.Parse(dto.AttestationId) : null,
dto.StorageUri,
dto.PayloadType,
dto.PredicateType,
dto.SubjectDigests,
dto.SignerInfo?.ToModel(),
dto.RekorEntry?.ToModel());
}
public static SignerInfo ToModel(this SignerInfoDto dto)
{
return new SignerInfo(
dto.KeyId,
dto.Issuer,
dto.Subject,
dto.CertificateChain,
dto.SignedAt);
}
public static RekorEntryRef ToModel(this RekorEntryRefDto dto)
{
return new RekorEntryRef(
dto.LogIndex,
dto.LogId,
dto.Uuid,
dto.IntegratedTime);
}
public static VerificationResult ToModel(this VerificationResultDto dto)
{
return new VerificationResult(
dto.Verified,
dto.VerifiedAt,
dto.Verifier,
dto.VerifierVersion,
dto.PolicyRef,
dto.Checks?.Select(c => c.ToModel()).ToList(),
dto.Warnings,
dto.Errors);
}
public static VerificationCheck ToModel(this VerificationCheckDto dto)
{
if (!Enum.TryParse<VerificationCheckType>(dto.CheckType, ignoreCase: true, out var checkType))
{
throw new ArgumentException($"Invalid check type: {dto.CheckType}");
}
return new VerificationCheck(checkType, dto.Passed, dto.Details, dto.Evidence);
}
public static AttestationPointerResponse ToResponse(this AttestationPointerRecord record)
{
return new AttestationPointerResponse(
record.PointerId.ToString(),
record.FindingId,
record.AttestationType.ToString(),
record.Relationship.ToString(),
record.AttestationRef.ToDto(),
record.VerificationResult?.ToDto(),
record.CreatedAt,
record.CreatedBy,
record.Metadata,
record.LedgerEventId?.ToString());
}
public static AttestationRefDto ToDto(this AttestationRef model)
{
return new AttestationRefDto(
model.Digest,
model.AttestationId?.ToString(),
model.StorageUri,
model.PayloadType,
model.PredicateType,
model.SubjectDigests,
model.SignerInfo?.ToDto(),
model.RekorEntry?.ToDto());
}
public static SignerInfoDto ToDto(this SignerInfo model)
{
return new SignerInfoDto(
model.KeyId,
model.Issuer,
model.Subject,
model.CertificateChain,
model.SignedAt);
}
public static RekorEntryRefDto ToDto(this RekorEntryRef model)
{
return new RekorEntryRefDto(
model.LogIndex,
model.LogId,
model.Uuid,
model.IntegratedTime);
}
public static VerificationResultDto ToDto(this VerificationResult model)
{
return new VerificationResultDto(
model.Verified,
model.VerifiedAt,
model.Verifier,
model.VerifierVersion,
model.PolicyRef,
model.Checks?.Select(c => c.ToDto()).ToList(),
model.Warnings,
model.Errors);
}
public static VerificationCheckDto ToDto(this VerificationCheck model)
{
return new VerificationCheckDto(
model.CheckType.ToString(),
model.Passed,
model.Details,
model.Evidence);
}
public static AttestationSummaryResponse ToResponse(this FindingAttestationSummary summary)
{
return new AttestationSummaryResponse(
summary.FindingId,
summary.AttestationCount,
summary.VerifiedCount,
summary.LatestAttestation,
summary.AttestationTypes.Select(t => t.ToString()).ToList(),
summary.OverallVerificationStatus.ToString());
}
public static AttestationPointerQuery ToQuery(this AttestationPointerSearchRequest request, string tenantId)
{
IReadOnlyList<AttestationType>? attestationTypes = null;
if (request.AttestationTypes is { Count: > 0 })
{
attestationTypes = request.AttestationTypes
.Where(t => Enum.TryParse<AttestationType>(t, ignoreCase: true, out _))
.Select(t => Enum.Parse<AttestationType>(t, ignoreCase: true))
.ToList();
}
AttestationVerificationFilter? verificationFilter = null;
if (!string.IsNullOrWhiteSpace(request.VerificationStatus))
{
if (Enum.TryParse<AttestationVerificationFilter>(request.VerificationStatus, ignoreCase: true, out var filter))
{
verificationFilter = filter;
}
}
return new AttestationPointerQuery(
tenantId,
request.FindingIds,
attestationTypes,
verificationFilter,
request.CreatedAfter,
request.CreatedBefore,
request.SignerIdentity,
request.PredicateType,
request.Limit,
request.Offset);
}
}

View File

@@ -0,0 +1,460 @@
namespace StellaOps.Findings.Ledger.WebService.Contracts;
using StellaOps.Findings.Ledger.Domain;
using StellaOps.Findings.Ledger.Infrastructure.Snapshot;
// === Snapshot Contracts ===
/// <summary>
/// Request to create a snapshot.
/// </summary>
public sealed record CreateSnapshotRequest(
string? Label = null,
string? Description = null,
DateTimeOffset? AtTimestamp = null,
long? AtSequence = null,
int? ExpiresInHours = null,
IReadOnlyList<string>? IncludeEntityTypes = null,
bool Sign = false,
Dictionary<string, object>? Metadata = null)
{
public CreateSnapshotInput ToInput(string tenantId) => new(
TenantId: tenantId,
Label: Label,
Description: Description,
AtTimestamp: AtTimestamp,
AtSequence: AtSequence,
ExpiresIn: ExpiresInHours.HasValue ? TimeSpan.FromHours(ExpiresInHours.Value) : null,
IncludeEntityTypes: IncludeEntityTypes?.Select(ParseEntityType).ToList(),
Sign: Sign,
Metadata: Metadata);
private static EntityType ParseEntityType(string s) =>
Enum.TryParse<EntityType>(s, true, out var et) ? et : EntityType.Finding;
}
/// <summary>
/// Response for a snapshot.
/// </summary>
public sealed record SnapshotResponse(
Guid SnapshotId,
string? Label,
string? Description,
string Status,
DateTimeOffset CreatedAt,
DateTimeOffset? ExpiresAt,
long SequenceNumber,
DateTimeOffset Timestamp,
SnapshotStatisticsResponse Statistics,
string? MerkleRoot,
string? DsseDigest,
Dictionary<string, object>? Metadata);
/// <summary>
/// Response for snapshot statistics.
/// </summary>
public sealed record SnapshotStatisticsResponse(
long FindingsCount,
long VexStatementsCount,
long AdvisoriesCount,
long SbomsCount,
long EventsCount,
long SizeBytes);
/// <summary>
/// Result of creating a snapshot.
/// </summary>
public sealed record CreateSnapshotResponse(
bool Success,
SnapshotResponse? Snapshot,
string? Error);
/// <summary>
/// Response for listing snapshots.
/// </summary>
public sealed record SnapshotListResponse(
IReadOnlyList<SnapshotResponse> Snapshots,
string? NextPageToken);
// === Time-Travel Contracts ===
/// <summary>
/// Request for historical query.
/// </summary>
public sealed record HistoricalQueryApiRequest(
DateTimeOffset? AtTimestamp = null,
long? AtSequence = null,
Guid? SnapshotId = null,
string? Status = null,
decimal? SeverityMin = null,
decimal? SeverityMax = null,
string? PolicyVersion = null,
string? ArtifactId = null,
string? VulnId = null,
int PageSize = 500,
string? PageToken = null)
{
public HistoricalQueryRequest ToRequest(string tenantId, EntityType entityType) => new(
TenantId: tenantId,
AtTimestamp: AtTimestamp,
AtSequence: AtSequence,
SnapshotId: SnapshotId,
EntityType: entityType,
Filters: new TimeQueryFilters(
Status: Status,
SeverityMin: SeverityMin,
SeverityMax: SeverityMax,
PolicyVersion: PolicyVersion,
ArtifactId: ArtifactId,
VulnId: VulnId),
PageSize: PageSize,
PageToken: PageToken);
}
/// <summary>
/// Response for historical query.
/// </summary>
public sealed record HistoricalQueryApiResponse<T>(
QueryPointResponse QueryPoint,
string EntityType,
IReadOnlyList<T> Items,
string? NextPageToken,
long TotalCount);
/// <summary>
/// Query point response.
/// </summary>
public sealed record QueryPointResponse(
DateTimeOffset Timestamp,
long SequenceNumber,
Guid? SnapshotId);
/// <summary>
/// Finding history item response.
/// </summary>
public sealed record FindingHistoryResponse(
string FindingId,
string ArtifactId,
string VulnId,
string Status,
decimal? Severity,
string? PolicyVersion,
DateTimeOffset FirstSeen,
DateTimeOffset LastUpdated,
Dictionary<string, string>? Labels);
/// <summary>
/// VEX history item response.
/// </summary>
public sealed record VexHistoryResponse(
string StatementId,
string VulnId,
string ProductId,
string Status,
string? Justification,
DateTimeOffset IssuedAt,
DateTimeOffset? ExpiresAt);
/// <summary>
/// Advisory history item response.
/// </summary>
public sealed record AdvisoryHistoryResponse(
string AdvisoryId,
string Source,
string Title,
decimal? CvssScore,
DateTimeOffset PublishedAt,
DateTimeOffset? ModifiedAt);
// === Replay Contracts ===
/// <summary>
/// Request for replaying events.
/// </summary>
public sealed record ReplayApiRequest(
long? FromSequence = null,
long? ToSequence = null,
DateTimeOffset? FromTimestamp = null,
DateTimeOffset? ToTimestamp = null,
IReadOnlyList<Guid>? ChainIds = null,
IReadOnlyList<string>? EventTypes = null,
bool IncludePayload = true,
int PageSize = 1000)
{
public ReplayRequest ToRequest(string tenantId) => new(
TenantId: tenantId,
FromSequence: FromSequence,
ToSequence: ToSequence,
FromTimestamp: FromTimestamp,
ToTimestamp: ToTimestamp,
ChainIds: ChainIds,
EventTypes: EventTypes,
IncludePayload: IncludePayload,
PageSize: PageSize);
}
/// <summary>
/// Response for replay.
/// </summary>
public sealed record ReplayApiResponse(
IReadOnlyList<ReplayEventResponse> Events,
ReplayMetadataResponse Metadata);
/// <summary>
/// Replay event response.
/// </summary>
public sealed record ReplayEventResponse(
Guid EventId,
long SequenceNumber,
Guid ChainId,
int ChainSequence,
string EventType,
DateTimeOffset OccurredAt,
DateTimeOffset RecordedAt,
string? ActorId,
string? ActorType,
string? ArtifactId,
string? FindingId,
string? PolicyVersion,
string EventHash,
string PreviousHash,
object? Payload);
/// <summary>
/// Replay metadata response.
/// </summary>
public sealed record ReplayMetadataResponse(
long FromSequence,
long ToSequence,
long EventsCount,
bool HasMore,
long ReplayDurationMs);
// === Diff Contracts ===
/// <summary>
/// Request for computing diff.
/// </summary>
public sealed record DiffApiRequest(
DiffPointRequest From,
DiffPointRequest To,
IReadOnlyList<string>? EntityTypes = null,
bool IncludeUnchanged = false,
string OutputFormat = "Summary")
{
public DiffRequest ToRequest(string tenantId) => new(
TenantId: tenantId,
From: From.ToDiffPoint(),
To: To.ToDiffPoint(),
EntityTypes: EntityTypes?.Select(ParseEntityType).ToList(),
IncludeUnchanged: IncludeUnchanged,
OutputFormat: Enum.TryParse<DiffOutputFormat>(OutputFormat, true, out var fmt)
? fmt : DiffOutputFormat.Summary);
private static EntityType ParseEntityType(string s) =>
Enum.TryParse<EntityType>(s, true, out var et) ? et : EntityType.Finding;
}
/// <summary>
/// Diff point request.
/// </summary>
public sealed record DiffPointRequest(
DateTimeOffset? Timestamp = null,
long? SequenceNumber = null,
Guid? SnapshotId = null)
{
public DiffPoint ToDiffPoint() => new(Timestamp, SequenceNumber, SnapshotId);
}
/// <summary>
/// Response for diff.
/// </summary>
public sealed record DiffApiResponse(
QueryPointResponse FromPoint,
QueryPointResponse ToPoint,
DiffSummaryResponse Summary,
IReadOnlyList<DiffEntryResponse>? Changes,
string? NextPageToken);
/// <summary>
/// Diff summary response.
/// </summary>
public sealed record DiffSummaryResponse(
int Added,
int Modified,
int Removed,
int Unchanged,
Dictionary<string, DiffCountsResponse>? ByEntityType);
/// <summary>
/// Diff counts response.
/// </summary>
public sealed record DiffCountsResponse(int Added, int Modified, int Removed);
/// <summary>
/// Diff entry response.
/// </summary>
public sealed record DiffEntryResponse(
string EntityType,
string EntityId,
string ChangeType,
object? FromState,
object? ToState,
IReadOnlyList<string>? ChangedFields);
// === Changelog Contracts ===
/// <summary>
/// Changelog entry response.
/// </summary>
public sealed record ChangeLogEntryResponse(
long SequenceNumber,
DateTimeOffset Timestamp,
string EntityType,
string EntityId,
string EventType,
string? EventHash,
string? ActorId,
string? Summary);
// === Staleness Contracts ===
/// <summary>
/// Staleness check response.
/// </summary>
public sealed record StalenessResponse(
bool IsStale,
DateTimeOffset CheckedAt,
DateTimeOffset? LastEventAt,
string StalenessThreshold,
string? StalenessDuration,
Dictionary<string, EntityStalenessResponse>? ByEntityType);
/// <summary>
/// Entity staleness response.
/// </summary>
public sealed record EntityStalenessResponse(
bool IsStale,
DateTimeOffset? LastEventAt,
long EventsBehind);
// === Extension Methods ===
public static class SnapshotExtensions
{
public static SnapshotResponse ToResponse(this LedgerSnapshot snapshot) => new(
SnapshotId: snapshot.SnapshotId,
Label: snapshot.Label,
Description: snapshot.Description,
Status: snapshot.Status.ToString(),
CreatedAt: snapshot.CreatedAt,
ExpiresAt: snapshot.ExpiresAt,
SequenceNumber: snapshot.SequenceNumber,
Timestamp: snapshot.Timestamp,
Statistics: snapshot.Statistics.ToResponse(),
MerkleRoot: snapshot.MerkleRoot,
DsseDigest: snapshot.DsseDigest,
Metadata: snapshot.Metadata);
public static SnapshotStatisticsResponse ToResponse(this SnapshotStatistics stats) => new(
FindingsCount: stats.FindingsCount,
VexStatementsCount: stats.VexStatementsCount,
AdvisoriesCount: stats.AdvisoriesCount,
SbomsCount: stats.SbomsCount,
EventsCount: stats.EventsCount,
SizeBytes: stats.SizeBytes);
public static QueryPointResponse ToResponse(this QueryPoint point) => new(
Timestamp: point.Timestamp,
SequenceNumber: point.SequenceNumber,
SnapshotId: point.SnapshotId);
public static FindingHistoryResponse ToResponse(this FindingHistoryItem item) => new(
FindingId: item.FindingId,
ArtifactId: item.ArtifactId,
VulnId: item.VulnId,
Status: item.Status,
Severity: item.Severity,
PolicyVersion: item.PolicyVersion,
FirstSeen: item.FirstSeen,
LastUpdated: item.LastUpdated,
Labels: item.Labels);
public static VexHistoryResponse ToResponse(this VexHistoryItem item) => new(
StatementId: item.StatementId,
VulnId: item.VulnId,
ProductId: item.ProductId,
Status: item.Status,
Justification: item.Justification,
IssuedAt: item.IssuedAt,
ExpiresAt: item.ExpiresAt);
public static AdvisoryHistoryResponse ToResponse(this AdvisoryHistoryItem item) => new(
AdvisoryId: item.AdvisoryId,
Source: item.Source,
Title: item.Title,
CvssScore: item.CvssScore,
PublishedAt: item.PublishedAt,
ModifiedAt: item.ModifiedAt);
public static ReplayEventResponse ToResponse(this ReplayEvent e) => new(
EventId: e.EventId,
SequenceNumber: e.SequenceNumber,
ChainId: e.ChainId,
ChainSequence: e.ChainSequence,
EventType: e.EventType,
OccurredAt: e.OccurredAt,
RecordedAt: e.RecordedAt,
ActorId: e.ActorId,
ActorType: e.ActorType,
ArtifactId: e.ArtifactId,
FindingId: e.FindingId,
PolicyVersion: e.PolicyVersion,
EventHash: e.EventHash,
PreviousHash: e.PreviousHash,
Payload: e.Payload);
public static ReplayMetadataResponse ToResponse(this ReplayMetadata m) => new(
FromSequence: m.FromSequence,
ToSequence: m.ToSequence,
EventsCount: m.EventsCount,
HasMore: m.HasMore,
ReplayDurationMs: m.ReplayDurationMs);
public static DiffSummaryResponse ToResponse(this DiffSummary summary) => new(
Added: summary.Added,
Modified: summary.Modified,
Removed: summary.Removed,
Unchanged: summary.Unchanged,
ByEntityType: summary.ByEntityType?.ToDictionary(
kv => kv.Key.ToString(),
kv => new DiffCountsResponse(kv.Value.Added, kv.Value.Modified, kv.Value.Removed)));
public static DiffEntryResponse ToResponse(this DiffEntry entry) => new(
EntityType: entry.EntityType.ToString(),
EntityId: entry.EntityId,
ChangeType: entry.ChangeType.ToString(),
FromState: entry.FromState,
ToState: entry.ToState,
ChangedFields: entry.ChangedFields);
public static ChangeLogEntryResponse ToResponse(this ChangeLogEntry entry) => new(
SequenceNumber: entry.SequenceNumber,
Timestamp: entry.Timestamp,
EntityType: entry.EntityType.ToString(),
EntityId: entry.EntityId,
EventType: entry.EventType,
EventHash: entry.EventHash,
ActorId: entry.ActorId,
Summary: entry.Summary);
public static StalenessResponse ToResponse(this StalenessResult result) => new(
IsStale: result.IsStale,
CheckedAt: result.CheckedAt,
LastEventAt: result.LastEventAt,
StalenessThreshold: result.StalenessThreshold.ToString(),
StalenessDuration: result.StalenessDuration?.ToString(),
ByEntityType: result.ByEntityType?.ToDictionary(
kv => kv.Key.ToString(),
kv => new EntityStalenessResponse(kv.Value.IsStale, kv.Value.LastEventAt, kv.Value.EventsBehind)));
}

View File

@@ -155,6 +155,14 @@ builder.Services.AddHostedService<LedgerMerkleAnchorWorker>();
builder.Services.AddHostedService<LedgerProjectionWorker>();
builder.Services.AddSingleton<ExportQueryService>();
builder.Services.AddSingleton<AttestationQueryService>();
builder.Services.AddSingleton<StellaOps.Findings.Ledger.Infrastructure.Attestation.IAttestationPointerRepository,
StellaOps.Findings.Ledger.Infrastructure.Postgres.PostgresAttestationPointerRepository>();
builder.Services.AddSingleton<AttestationPointerService>();
builder.Services.AddSingleton<StellaOps.Findings.Ledger.Infrastructure.Snapshot.ISnapshotRepository,
StellaOps.Findings.Ledger.Infrastructure.Postgres.PostgresSnapshotRepository>();
builder.Services.AddSingleton<StellaOps.Findings.Ledger.Infrastructure.Snapshot.ITimeTravelRepository,
StellaOps.Findings.Ledger.Infrastructure.Postgres.PostgresTimeTravelRepository>();
builder.Services.AddSingleton<SnapshotService>();
var app = builder.Build();
@@ -633,6 +641,206 @@ app.MapPost("/internal/ledger/airgap-import", async Task<Results<Accepted<Airgap
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status409Conflict);
// Attestation Pointer Endpoints (LEDGER-ATTEST-73-001)
app.MapPost("/v1/ledger/attestation-pointers", async Task<Results<Created<CreateAttestationPointerResponse>, Ok<CreateAttestationPointerResponse>, ProblemHttpResult>> (
HttpContext httpContext,
CreateAttestationPointerRequest request,
AttestationPointerService service,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
{
return tenantProblem!;
}
try
{
var input = request.ToInput(tenantId);
var result = await service.CreatePointerAsync(input, cancellationToken).ConfigureAwait(false);
var response = new CreateAttestationPointerResponse(
result.Success,
result.PointerId?.ToString(),
result.LedgerEventId?.ToString(),
result.Error);
if (!result.Success)
{
return TypedResults.Problem(
statusCode: StatusCodes.Status400BadRequest,
title: "attestation_pointer_failed",
detail: result.Error);
}
return TypedResults.Created($"/v1/ledger/attestation-pointers/{result.PointerId}", response);
}
catch (ArgumentException ex)
{
return TypedResults.Problem(
statusCode: StatusCodes.Status400BadRequest,
title: "invalid_request",
detail: ex.Message);
}
})
.WithName("CreateAttestationPointer")
.RequireAuthorization(LedgerWritePolicy)
.Produces(StatusCodes.Status201Created)
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest);
app.MapGet("/v1/ledger/attestation-pointers/{pointerId}", async Task<Results<JsonHttpResult<AttestationPointerResponse>, NotFound, ProblemHttpResult>> (
HttpContext httpContext,
string pointerId,
AttestationPointerService service,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
{
return tenantProblem!;
}
if (!Guid.TryParse(pointerId, out var pointerGuid))
{
return TypedResults.Problem(
statusCode: StatusCodes.Status400BadRequest,
title: "invalid_pointer_id",
detail: "Pointer ID must be a valid GUID.");
}
var pointer = await service.GetPointerAsync(tenantId, pointerGuid, cancellationToken).ConfigureAwait(false);
if (pointer is null)
{
return TypedResults.NotFound();
}
return TypedResults.Json(pointer.ToResponse());
})
.WithName("GetAttestationPointer")
.RequireAuthorization(LedgerExportPolicy)
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status400BadRequest);
app.MapGet("/v1/ledger/findings/{findingId}/attestation-pointers", async Task<Results<JsonHttpResult<IReadOnlyList<AttestationPointerResponse>>, ProblemHttpResult>> (
HttpContext httpContext,
string findingId,
AttestationPointerService service,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
{
return tenantProblem!;
}
var pointers = await service.GetPointersAsync(tenantId, findingId, cancellationToken).ConfigureAwait(false);
IReadOnlyList<AttestationPointerResponse> responseList = pointers.Select(p => p.ToResponse()).ToList();
return TypedResults.Json(responseList);
})
.WithName("GetFindingAttestationPointers")
.RequireAuthorization(LedgerExportPolicy)
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest);
app.MapGet("/v1/ledger/findings/{findingId}/attestation-summary", async Task<Results<JsonHttpResult<AttestationSummaryResponse>, ProblemHttpResult>> (
HttpContext httpContext,
string findingId,
AttestationPointerService service,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
{
return tenantProblem!;
}
var summary = await service.GetSummaryAsync(tenantId, findingId, cancellationToken).ConfigureAwait(false);
return TypedResults.Json(summary.ToResponse());
})
.WithName("GetFindingAttestationSummary")
.RequireAuthorization(LedgerExportPolicy)
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest);
app.MapPost("/v1/ledger/attestation-pointers/search", async Task<Results<JsonHttpResult<AttestationPointerSearchResponse>, ProblemHttpResult>> (
HttpContext httpContext,
AttestationPointerSearchRequest request,
AttestationPointerService service,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
{
return tenantProblem!;
}
try
{
var query = request.ToQuery(tenantId);
var pointers = await service.SearchAsync(query, cancellationToken).ConfigureAwait(false);
var response = new AttestationPointerSearchResponse(
pointers.Select(p => p.ToResponse()).ToList(),
pointers.Count);
return TypedResults.Json(response);
}
catch (ArgumentException ex)
{
return TypedResults.Problem(
statusCode: StatusCodes.Status400BadRequest,
title: "invalid_request",
detail: ex.Message);
}
})
.WithName("SearchAttestationPointers")
.RequireAuthorization(LedgerExportPolicy)
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest);
app.MapPut("/v1/ledger/attestation-pointers/{pointerId}/verification", async Task<Results<NoContent, NotFound, ProblemHttpResult>> (
HttpContext httpContext,
string pointerId,
UpdateVerificationResultRequest request,
AttestationPointerService service,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
{
return tenantProblem!;
}
if (!Guid.TryParse(pointerId, out var pointerGuid))
{
return TypedResults.Problem(
statusCode: StatusCodes.Status400BadRequest,
title: "invalid_pointer_id",
detail: "Pointer ID must be a valid GUID.");
}
try
{
var verificationResult = request.VerificationResult.ToModel();
var success = await service.UpdateVerificationResultAsync(tenantId, pointerGuid, verificationResult, cancellationToken).ConfigureAwait(false);
if (!success)
{
return TypedResults.NotFound();
}
return TypedResults.NoContent();
}
catch (ArgumentException ex)
{
return TypedResults.Problem(
statusCode: StatusCodes.Status400BadRequest,
title: "invalid_request",
detail: ex.Message);
}
})
.WithName("UpdateAttestationPointerVerification")
.RequireAuthorization(LedgerWritePolicy)
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status400BadRequest);
app.MapGet("/.well-known/openapi", () =>
{
var contentRoot = AppContext.BaseDirectory;
@@ -649,6 +857,383 @@ app.MapGet("/.well-known/openapi", () =>
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status500InternalServerError);
// Snapshot Endpoints (LEDGER-PACKS-42-001-DEV)
app.MapPost("/v1/ledger/snapshots", async Task<Results<Created<CreateSnapshotResponse>, ProblemHttpResult>> (
HttpContext httpContext,
CreateSnapshotRequest request,
SnapshotService service,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
{
return tenantProblem!;
}
var input = request.ToInput(tenantId);
var result = await service.CreateSnapshotAsync(input, cancellationToken).ConfigureAwait(false);
var response = new CreateSnapshotResponse(
result.Success,
result.Snapshot?.ToResponse(),
result.Error);
if (!result.Success)
{
return TypedResults.Problem(
statusCode: StatusCodes.Status400BadRequest,
title: "snapshot_creation_failed",
detail: result.Error);
}
return TypedResults.Created($"/v1/ledger/snapshots/{result.Snapshot!.SnapshotId}", response);
})
.WithName("CreateSnapshot")
.RequireAuthorization(LedgerWritePolicy)
.Produces(StatusCodes.Status201Created)
.ProducesProblem(StatusCodes.Status400BadRequest);
app.MapGet("/v1/ledger/snapshots", async Task<Results<JsonHttpResult<SnapshotListResponse>, ProblemHttpResult>> (
HttpContext httpContext,
SnapshotService service,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
{
return tenantProblem!;
}
var statusStr = httpContext.Request.Query["status"].ToString();
Domain.SnapshotStatus? status = null;
if (!string.IsNullOrEmpty(statusStr) && Enum.TryParse<Domain.SnapshotStatus>(statusStr, true, out var parsedStatus))
{
status = parsedStatus;
}
var query = new Domain.SnapshotListQuery(
tenantId,
status,
ParseDate(httpContext.Request.Query["created_after"]),
ParseDate(httpContext.Request.Query["created_before"]),
ParseInt(httpContext.Request.Query["page_size"]) ?? 100,
httpContext.Request.Query["page_token"].ToString());
var (snapshots, nextPageToken) = await service.ListSnapshotsAsync(query, cancellationToken).ConfigureAwait(false);
var response = new SnapshotListResponse(
snapshots.Select(s => s.ToResponse()).ToList(),
nextPageToken);
return TypedResults.Json(response);
})
.WithName("ListSnapshots")
.RequireAuthorization(LedgerExportPolicy)
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest);
app.MapGet("/v1/ledger/snapshots/{snapshotId}", async Task<Results<JsonHttpResult<SnapshotResponse>, NotFound, ProblemHttpResult>> (
HttpContext httpContext,
string snapshotId,
SnapshotService service,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
{
return tenantProblem!;
}
if (!Guid.TryParse(snapshotId, out var snapshotGuid))
{
return TypedResults.Problem(
statusCode: StatusCodes.Status400BadRequest,
title: "invalid_snapshot_id",
detail: "Snapshot ID must be a valid GUID.");
}
var snapshot = await service.GetSnapshotAsync(tenantId, snapshotGuid, cancellationToken).ConfigureAwait(false);
if (snapshot is null)
{
return TypedResults.NotFound();
}
return TypedResults.Json(snapshot.ToResponse());
})
.WithName("GetSnapshot")
.RequireAuthorization(LedgerExportPolicy)
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status400BadRequest);
app.MapDelete("/v1/ledger/snapshots/{snapshotId}", async Task<Results<NoContent, NotFound, ProblemHttpResult>> (
HttpContext httpContext,
string snapshotId,
SnapshotService service,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
{
return tenantProblem!;
}
if (!Guid.TryParse(snapshotId, out var snapshotGuid))
{
return TypedResults.Problem(
statusCode: StatusCodes.Status400BadRequest,
title: "invalid_snapshot_id",
detail: "Snapshot ID must be a valid GUID.");
}
var deleted = await service.DeleteSnapshotAsync(tenantId, snapshotGuid, cancellationToken).ConfigureAwait(false);
if (!deleted)
{
return TypedResults.NotFound();
}
return TypedResults.NoContent();
})
.WithName("DeleteSnapshot")
.RequireAuthorization(LedgerWritePolicy)
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status400BadRequest);
// Time-Travel Query Endpoints
app.MapGet("/v1/ledger/time-travel/findings", async Task<Results<JsonHttpResult<HistoricalQueryApiResponse<FindingHistoryResponse>>, ProblemHttpResult>> (
HttpContext httpContext,
SnapshotService service,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
{
return tenantProblem!;
}
var request = new HistoricalQueryApiRequest(
AtTimestamp: ParseDate(httpContext.Request.Query["at_timestamp"]),
AtSequence: ParseLong(httpContext.Request.Query["at_sequence"]),
SnapshotId: ParseGuid(httpContext.Request.Query["snapshot_id"]),
Status: httpContext.Request.Query["status"].ToString(),
SeverityMin: ParseDecimal(httpContext.Request.Query["severity_min"]),
SeverityMax: ParseDecimal(httpContext.Request.Query["severity_max"]),
PolicyVersion: httpContext.Request.Query["policy_version"].ToString(),
ArtifactId: httpContext.Request.Query["artifact_id"].ToString(),
VulnId: httpContext.Request.Query["vuln_id"].ToString(),
PageSize: ParseInt(httpContext.Request.Query["page_size"]) ?? 500,
PageToken: httpContext.Request.Query["page_token"].ToString());
var domainRequest = request.ToRequest(tenantId, Domain.EntityType.Finding);
var result = await service.QueryHistoricalFindingsAsync(domainRequest, cancellationToken).ConfigureAwait(false);
var response = new HistoricalQueryApiResponse<FindingHistoryResponse>(
result.QueryPoint.ToResponse(),
"Finding",
result.Items.Select(i => i.ToResponse()).ToList(),
result.NextPageToken,
result.TotalCount);
return TypedResults.Json(response);
})
.WithName("TimeTravelQueryFindings")
.RequireAuthorization(LedgerExportPolicy)
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest);
app.MapGet("/v1/ledger/time-travel/vex", async Task<Results<JsonHttpResult<HistoricalQueryApiResponse<VexHistoryResponse>>, ProblemHttpResult>> (
HttpContext httpContext,
SnapshotService service,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
{
return tenantProblem!;
}
var request = new HistoricalQueryApiRequest(
AtTimestamp: ParseDate(httpContext.Request.Query["at_timestamp"]),
AtSequence: ParseLong(httpContext.Request.Query["at_sequence"]),
SnapshotId: ParseGuid(httpContext.Request.Query["snapshot_id"]),
PageSize: ParseInt(httpContext.Request.Query["page_size"]) ?? 500,
PageToken: httpContext.Request.Query["page_token"].ToString());
var domainRequest = request.ToRequest(tenantId, Domain.EntityType.Vex);
var result = await service.QueryHistoricalVexAsync(domainRequest, cancellationToken).ConfigureAwait(false);
var response = new HistoricalQueryApiResponse<VexHistoryResponse>(
result.QueryPoint.ToResponse(),
"Vex",
result.Items.Select(i => i.ToResponse()).ToList(),
result.NextPageToken,
result.TotalCount);
return TypedResults.Json(response);
})
.WithName("TimeTravelQueryVex")
.RequireAuthorization(LedgerExportPolicy)
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest);
app.MapGet("/v1/ledger/time-travel/advisories", async Task<Results<JsonHttpResult<HistoricalQueryApiResponse<AdvisoryHistoryResponse>>, ProblemHttpResult>> (
HttpContext httpContext,
SnapshotService service,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
{
return tenantProblem!;
}
var request = new HistoricalQueryApiRequest(
AtTimestamp: ParseDate(httpContext.Request.Query["at_timestamp"]),
AtSequence: ParseLong(httpContext.Request.Query["at_sequence"]),
SnapshotId: ParseGuid(httpContext.Request.Query["snapshot_id"]),
PageSize: ParseInt(httpContext.Request.Query["page_size"]) ?? 500,
PageToken: httpContext.Request.Query["page_token"].ToString());
var domainRequest = request.ToRequest(tenantId, Domain.EntityType.Advisory);
var result = await service.QueryHistoricalAdvisoriesAsync(domainRequest, cancellationToken).ConfigureAwait(false);
var response = new HistoricalQueryApiResponse<AdvisoryHistoryResponse>(
result.QueryPoint.ToResponse(),
"Advisory",
result.Items.Select(i => i.ToResponse()).ToList(),
result.NextPageToken,
result.TotalCount);
return TypedResults.Json(response);
})
.WithName("TimeTravelQueryAdvisories")
.RequireAuthorization(LedgerExportPolicy)
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest);
// Replay Endpoint
app.MapPost("/v1/ledger/replay", async Task<Results<JsonHttpResult<ReplayApiResponse>, ProblemHttpResult>> (
HttpContext httpContext,
ReplayApiRequest request,
SnapshotService service,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
{
return tenantProblem!;
}
var domainRequest = request.ToRequest(tenantId);
var (events, metadata) = await service.ReplayEventsAsync(domainRequest, cancellationToken).ConfigureAwait(false);
var response = new ReplayApiResponse(
events.Select(e => e.ToResponse()).ToList(),
metadata.ToResponse());
return TypedResults.Json(response);
})
.WithName("ReplayEvents")
.RequireAuthorization(LedgerExportPolicy)
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest);
// Diff Endpoint
app.MapPost("/v1/ledger/diff", async Task<Results<JsonHttpResult<DiffApiResponse>, ProblemHttpResult>> (
HttpContext httpContext,
DiffApiRequest request,
SnapshotService service,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
{
return tenantProblem!;
}
var domainRequest = request.ToRequest(tenantId);
var result = await service.ComputeDiffAsync(domainRequest, cancellationToken).ConfigureAwait(false);
var response = new DiffApiResponse(
result.FromPoint.ToResponse(),
result.ToPoint.ToResponse(),
result.Summary.ToResponse(),
result.Changes?.Select(c => c.ToResponse()).ToList(),
result.NextPageToken);
return TypedResults.Json(response);
})
.WithName("ComputeDiff")
.RequireAuthorization(LedgerExportPolicy)
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest);
// Changelog Endpoint
app.MapGet("/v1/ledger/changelog/{entityType}/{entityId}", async Task<Results<JsonHttpResult<IReadOnlyList<ChangeLogEntryResponse>>, ProblemHttpResult>> (
HttpContext httpContext,
string entityType,
string entityId,
SnapshotService service,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
{
return tenantProblem!;
}
if (!Enum.TryParse<Domain.EntityType>(entityType, true, out var parsedEntityType))
{
return TypedResults.Problem(
statusCode: StatusCodes.Status400BadRequest,
title: "invalid_entity_type",
detail: "Entity type must be one of: Finding, Vex, Advisory, Sbom, Evidence.");
}
var limit = ParseInt(httpContext.Request.Query["limit"]) ?? 100;
var changelog = await service.GetChangelogAsync(tenantId, parsedEntityType, entityId, limit, cancellationToken).ConfigureAwait(false);
IReadOnlyList<ChangeLogEntryResponse> response = changelog.Select(e => e.ToResponse()).ToList();
return TypedResults.Json(response);
})
.WithName("GetChangelog")
.RequireAuthorization(LedgerExportPolicy)
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest);
// Staleness Check Endpoint
app.MapGet("/v1/ledger/staleness", async Task<Results<JsonHttpResult<StalenessResponse>, ProblemHttpResult>> (
HttpContext httpContext,
SnapshotService service,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
{
return tenantProblem!;
}
var thresholdMinutes = ParseInt(httpContext.Request.Query["threshold_minutes"]) ?? 60;
var threshold = TimeSpan.FromMinutes(thresholdMinutes);
var result = await service.CheckStalenessAsync(tenantId, threshold, cancellationToken).ConfigureAwait(false);
return TypedResults.Json(result.ToResponse());
})
.WithName("CheckStaleness")
.RequireAuthorization(LedgerExportPolicy)
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest);
// Current Point Endpoint
app.MapGet("/v1/ledger/current-point", async Task<Results<JsonHttpResult<QueryPointResponse>, ProblemHttpResult>> (
HttpContext httpContext,
SnapshotService service,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
{
return tenantProblem!;
}
var point = await service.GetCurrentPointAsync(tenantId, cancellationToken).ConfigureAwait(false);
return TypedResults.Json(point.ToResponse());
})
.WithName("GetCurrentPoint")
.RequireAuthorization(LedgerExportPolicy)
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest);
app.Run();
static Created<LedgerEventResponse> CreateCreatedResponse(LedgerEventRecord record)
@@ -738,3 +1323,8 @@ static bool? ParseBool(string value)
{
return bool.TryParse(value, out var result) ? result : null;
}
static Guid? ParseGuid(string value)
{
return Guid.TryParse(value, out var result) ? result : null;
}