From 2bd189387e3dc013328112cd59336d1befc48540 Mon Sep 17 00:00:00 2001 From: master <> Date: Wed, 10 Dec 2025 19:15:01 +0200 Subject: [PATCH] up --- .../Endpoints/EvidenceEndpoints.cs | 125 ++++++++++++++++++ .../Endpoints/PolicyEndpoints.cs | 95 +++++++++++-- 2 files changed, 212 insertions(+), 8 deletions(-) diff --git a/src/Excititor/StellaOps.Excititor.WebService/Endpoints/EvidenceEndpoints.cs b/src/Excititor/StellaOps.Excititor.WebService/Endpoints/EvidenceEndpoints.cs index db26de53a..d32889825 100644 --- a/src/Excititor/StellaOps.Excititor.WebService/Endpoints/EvidenceEndpoints.cs +++ b/src/Excititor/StellaOps.Excititor.WebService/Endpoints/EvidenceEndpoints.cs @@ -24,6 +24,123 @@ public static class EvidenceEndpoints { public static void MapEvidenceEndpoints(this WebApplication app) { + // GET /evidence/vex/locker/{bundleId} + app.MapGet("/evidence/vex/locker/{bundleId}", async ( + HttpContext context, + string bundleId, + IOptions airgapOptions, + IOptions storageOptions, + IAirgapImportStore importStore, + CancellationToken cancellationToken) => + { + var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read"); + if (scopeResult is not null) + { + return scopeResult; + } + + if (!TryResolveTenant(context, storageOptions.Value, requireHeader: false, out var tenant, out var tenantError)) + { + return tenantError; + } + + var record = await importStore.FindByBundleIdAsync(tenant!, bundleId, null, cancellationToken).ConfigureAwait(false); + if (record is null) + { + return Results.NotFound(); + } + + if (string.IsNullOrWhiteSpace(airgapOptions.Value.LockerRootPath)) + { + return Results.StatusCode(StatusCodes.Status503ServiceUnavailable); + } + + var manifestPath = Path.Combine(airgapOptions.Value.LockerRootPath!, record.PortableManifestPath ?? string.Empty); + if (!File.Exists(manifestPath)) + { + return Results.NotFound(); + } + + var manifestHash = ComputeSha256(manifestPath, out var manifestSize); + string evidenceHash = "sha256:" + Convert.ToHexString(SHA256.HashData(Array.Empty())).ToLowerInvariant(); + long? evidenceSize = 0; + + if (!string.IsNullOrWhiteSpace(record.EvidenceLockerPath)) + { + var evidencePath = Path.Combine(airgapOptions.Value.LockerRootPath!, record.EvidenceLockerPath); + if (File.Exists(evidencePath)) + { + evidenceHash = ComputeSha256(evidencePath, out var size); + evidenceSize = size; + } + } + + var timeline = record.Timeline + .Select(t => new VexEvidenceLockerTimelineEntry(t.EventType, t.CreatedAt, t.ErrorCode, t.Message, t.StalenessSeconds)) + .ToList(); + + var response = new VexEvidenceLockerResponse( + record.BundleId, + record.MirrorGeneration, + record.TenantId, + record.Publisher, + record.PayloadHash, + record.PortableManifestPath ?? string.Empty, + manifestHash, + record.EvidenceLockerPath ?? string.Empty, + evidenceHash, + manifestSize, + evidenceSize, + record.ImportedAt, + null, + record.TransparencyLog, + timeline); + + return Results.Ok(response); + }).WithName("GetEvidenceLocker"); + + // GET /evidence/vex/locker/{bundleId}/manifest/file + app.MapGet("/evidence/vex/locker/{bundleId}/manifest/file", async ( + HttpContext context, + string bundleId, + IOptions airgapOptions, + IOptions storageOptions, + IAirgapImportStore importStore, + CancellationToken cancellationToken) => + { + var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read"); + if (scopeResult is not null) + { + return scopeResult; + } + + if (!TryResolveTenant(context, storageOptions.Value, requireHeader: false, out var tenant, out var tenantError)) + { + return tenantError; + } + + var record = await importStore.FindByBundleIdAsync(tenant!, bundleId, null, cancellationToken).ConfigureAwait(false); + if (record is null || string.IsNullOrWhiteSpace(record.PortableManifestPath)) + { + return Results.NotFound(); + } + + if (string.IsNullOrWhiteSpace(airgapOptions.Value.LockerRootPath)) + { + return Results.StatusCode(StatusCodes.Status503ServiceUnavailable); + } + + var manifestPath = Path.Combine(airgapOptions.Value.LockerRootPath!, record.PortableManifestPath); + if (!File.Exists(manifestPath)) + { + return Results.NotFound(); + } + + var etag = ComputeSha256(manifestPath, out _); + context.Response.Headers.ETag = $"\"{etag}\""; + return Results.File(manifestPath, "application/json"); + }).WithName("GetEvidenceLockerManifestFile"); + // GET /evidence/vex/list app.MapGet("/evidence/vex/list", async ( HttpContext context, @@ -256,4 +373,12 @@ public static class EvidenceEndpoints return Results.Ok(new EvidenceChunkListResponse(result.Chunks, result.TotalCount, result.Truncated, result.GeneratedAtUtc)); }).WithName("GetVexEvidenceChunks"); } + + private static string ComputeSha256(string path, out long sizeBytes) + { + var data = File.ReadAllBytes(path); + sizeBytes = data.LongLength; + var hash = SHA256.HashData(data); + return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant(); + } } diff --git a/src/Excititor/StellaOps.Excititor.WebService/Endpoints/PolicyEndpoints.cs b/src/Excititor/StellaOps.Excititor.WebService/Endpoints/PolicyEndpoints.cs index 337e6d7ee..89408b089 100644 --- a/src/Excititor/StellaOps.Excititor.WebService/Endpoints/PolicyEndpoints.cs +++ b/src/Excititor/StellaOps.Excititor.WebService/Endpoints/PolicyEndpoints.cs @@ -33,6 +33,7 @@ public static class PolicyEndpoints [FromBody] PolicyVexLookupRequest request, IOptions storageOptions, [FromServices] IGraphOverlayStore overlayStore, + [FromServices] IVexClaimStore? claimStore, TimeProvider timeProvider, CancellationToken cancellationToken) { @@ -85,16 +86,32 @@ public static class PolicyEndpoints .Take(Math.Clamp(request.Limit, 1, 500)) .ToList(); - var grouped = filtered - .GroupBy(o => o.AdvisoryId, StringComparer.OrdinalIgnoreCase) - .Select(group => new PolicyVexLookupItem( - group.Key, - new[] { group.Key }, - group.Select(MapStatement).ToList())) + if (filtered.Count > 0) + { + var grouped = filtered + .GroupBy(o => o.AdvisoryId, StringComparer.OrdinalIgnoreCase) + .Select(group => new PolicyVexLookupItem( + group.Key, + new[] { group.Key }, + group.Select(MapStatement).ToList())) + .ToList(); + + var response = new PolicyVexLookupResponse(grouped, filtered.Count, timeProvider.GetUtcNow()); + return Results.Ok(response); + } + + if (claimStore is null) + { + return Results.Ok(new PolicyVexLookupResponse(Array.Empty(), 0, timeProvider.GetUtcNow())); + } + + var claimResults = await FallbackClaimsAsync(claimStore, advisories, purls, providerFilter, statusFilter, request.Limit, cancellationToken).ConfigureAwait(false); + var groupedClaims = claimResults + .GroupBy(c => c.AdvisoryKey, StringComparer.OrdinalIgnoreCase) + .Select(group => new PolicyVexLookupItem(group.Key, new[] { group.Key }, group.ToList())) .ToList(); - var response = new PolicyVexLookupResponse(grouped, filtered.Count, timeProvider.GetUtcNow()); - return Results.Ok(response); + return Results.Ok(new PolicyVexLookupResponse(groupedClaims, claimResults.Count, timeProvider.GetUtcNow())); } private static async Task> ResolveOverlaysAsync( @@ -167,6 +184,68 @@ public static class PolicyEndpoints Metadata: metadata); } + private static async Task> FallbackClaimsAsync( + IVexClaimStore claimStore, + IReadOnlyList advisories, + IReadOnlyList purls, + ISet providers, + ISet statuses, + int limit, + CancellationToken cancellationToken) + { + var results = new List(); + foreach (var advisory in advisories) + { + var claims = await claimStore.FindByVulnerabilityAsync(advisory, limit, cancellationToken).ConfigureAwait(false); + + var filtered = claims + .Where(c => providers.Count == 0 || providers.Contains(c.ProviderId, StringComparer.OrdinalIgnoreCase)) + .Where(c => statuses.Count == 0 || statuses.Contains(c.Status.ToString().ToLowerInvariant())) + .Where(c => purls.Count == 0 || purls.Contains(c.Product.Key, StringComparer.OrdinalIgnoreCase)) + .OrderByDescending(c => c.LastSeen) + .ThenBy(c => c.ProviderId, StringComparer.Ordinal) + .Take(limit); + + results.AddRange(filtered.Select(MapClaimStatement)); + if (results.Count >= limit) + { + break; + } + } + + return results; + } + + private static PolicyVexStatement MapClaimStatement(VexClaim claim) + { + var observationId = $"{claim.ProviderId}:{claim.Document.Digest}"; + var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["document_digest"] = claim.Document.Digest, + ["document_uri"] = claim.Document.SourceUri.ToString() + }; + + if (!string.IsNullOrWhiteSpace(claim.Document.Revision)) + { + metadata["document_revision"] = claim.Document.Revision!; + } + + return new PolicyVexStatement( + ObservationId: observationId, + ProviderId: claim.ProviderId, + Status: claim.Status.ToString(), + ProductKey: claim.Product.Key, + Purl: claim.Product.Purl, + Cpe: claim.Product.Cpe, + Version: claim.Product.Version, + Justification: claim.Justification?.ToString(), + Detail: claim.Detail, + FirstSeen: claim.FirstSeen, + LastSeen: claim.LastSeen, + Signature: claim.Document.Signature, + Metadata: metadata); + } + private static bool TryResolveTenant( HttpContext context, VexStorageOptions options,