up
This commit is contained in:
@@ -24,6 +24,123 @@ public static class EvidenceEndpoints
|
|||||||
{
|
{
|
||||||
public static void MapEvidenceEndpoints(this WebApplication app)
|
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
|
// GET /evidence/vex/list
|
||||||
app.MapGet("/evidence/vex/list", async (
|
app.MapGet("/evidence/vex/list", async (
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
@@ -256,4 +373,12 @@ public static class EvidenceEndpoints
|
|||||||
return Results.Ok(new EvidenceChunkListResponse(result.Chunks, result.TotalCount, result.Truncated, result.GeneratedAtUtc));
|
return Results.Ok(new EvidenceChunkListResponse(result.Chunks, result.TotalCount, result.Truncated, result.GeneratedAtUtc));
|
||||||
}).WithName("GetVexEvidenceChunks");
|
}).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,
|
[FromBody] PolicyVexLookupRequest request,
|
||||||
IOptions<VexStorageOptions> storageOptions,
|
IOptions<VexStorageOptions> storageOptions,
|
||||||
[FromServices] IGraphOverlayStore overlayStore,
|
[FromServices] IGraphOverlayStore overlayStore,
|
||||||
|
[FromServices] IVexClaimStore? claimStore,
|
||||||
TimeProvider timeProvider,
|
TimeProvider timeProvider,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -85,16 +86,32 @@ public static class PolicyEndpoints
|
|||||||
.Take(Math.Clamp(request.Limit, 1, 500))
|
.Take(Math.Clamp(request.Limit, 1, 500))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var grouped = filtered
|
if (filtered.Count > 0)
|
||||||
.GroupBy(o => o.AdvisoryId, StringComparer.OrdinalIgnoreCase)
|
{
|
||||||
.Select(group => new PolicyVexLookupItem(
|
var grouped = filtered
|
||||||
group.Key,
|
.GroupBy(o => o.AdvisoryId, StringComparer.OrdinalIgnoreCase)
|
||||||
new[] { group.Key },
|
.Select(group => new PolicyVexLookupItem(
|
||||||
group.Select(MapStatement).ToList()))
|
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<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();
|
.ToList();
|
||||||
|
|
||||||
var response = new PolicyVexLookupResponse(grouped, filtered.Count, timeProvider.GetUtcNow());
|
return Results.Ok(new PolicyVexLookupResponse(groupedClaims, claimResults.Count, timeProvider.GetUtcNow()));
|
||||||
return Results.Ok(response);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IReadOnlyList<GraphOverlayItem>> ResolveOverlaysAsync(
|
private static async Task<IReadOnlyList<GraphOverlayItem>> ResolveOverlaysAsync(
|
||||||
@@ -167,6 +184,68 @@ public static class PolicyEndpoints
|
|||||||
Metadata: metadata);
|
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(
|
private static bool TryResolveTenant(
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
VexStorageOptions options,
|
VexStorageOptions options,
|
||||||
|
|||||||
Reference in New Issue
Block a user