up
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user