up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled

This commit is contained in:
master
2025-12-10 19:15:01 +02:00
parent 3a92c77a04
commit 2bd189387e
2 changed files with 212 additions and 8 deletions

View File

@@ -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> airgapOptions,
IOptions<VexStorageOptions> 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<byte>())).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> airgapOptions,
IOptions<VexStorageOptions> 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();
}
}

View File

@@ -33,6 +33,7 @@ public static class PolicyEndpoints
[FromBody] PolicyVexLookupRequest request,
IOptions<VexStorageOptions> storageOptions,
[FromServices] IGraphOverlayStore overlayStore,
[FromServices] IVexClaimStore? claimStore,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
@@ -85,6 +86,8 @@ public static class PolicyEndpoints
.Take(Math.Clamp(request.Limit, 1, 500))
.ToList();
if (filtered.Count > 0)
{
var grouped = filtered
.GroupBy(o => o.AdvisoryId, StringComparer.OrdinalIgnoreCase)
.Select(group => new PolicyVexLookupItem(
@@ -97,6 +100,20 @@ public static class PolicyEndpoints
return Results.Ok(response);
}
if (claimStore is null)
{
return Results.Ok(new PolicyVexLookupResponse(Array.Empty<PolicyVexLookupItem>(), 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();
return Results.Ok(new PolicyVexLookupResponse(groupedClaims, claimResults.Count, timeProvider.GetUtcNow()));
}
private static async Task<IReadOnlyList<GraphOverlayItem>> ResolveOverlaysAsync(
IGraphOverlayStore overlayStore,
string tenant,
@@ -167,6 +184,68 @@ public static class PolicyEndpoints
Metadata: metadata);
}
private static async Task<List<PolicyVexStatement>> FallbackClaimsAsync(
IVexClaimStore claimStore,
IReadOnlyList<string> advisories,
IReadOnlyList<string> purls,
ISet<string> providers,
ISet<string> statuses,
int limit,
CancellationToken cancellationToken)
{
var results = new List<PolicyVexStatement>();
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<string, string>(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,