work
This commit is contained in:
@@ -2,7 +2,6 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
@@ -54,8 +53,10 @@ services.AddCycloneDxNormalizer();
|
||||
services.AddOpenVexNormalizer();
|
||||
services.AddSingleton<IVexSignatureVerifier, NoopVexSignatureVerifier>();
|
||||
// TODO: replace NoopVexSignatureVerifier with hardened verifier once portable bundle signatures are finalized.
|
||||
services.Configure<AirgapOptions>(configuration.GetSection(AirgapOptions.SectionName));
|
||||
services.AddSingleton<AirgapImportValidator>();
|
||||
services.AddSingleton<AirgapSignerTrustService>();
|
||||
services.AddSingleton<AirgapModeEnforcer>();
|
||||
services.AddSingleton<ConsoleTelemetry>();
|
||||
services.AddMemoryCache();
|
||||
services.AddScoped<IVexIngestOrchestrator, VexIngestOrchestrator>();
|
||||
@@ -185,7 +186,7 @@ app.MapGet("/openapi/excititor.json", () =>
|
||||
get = new
|
||||
{
|
||||
summary = "Service status (aggregation-only metadata)",
|
||||
responses = new
|
||||
responses = new Dictionary<string, object>
|
||||
{
|
||||
["200"] = new
|
||||
{
|
||||
@@ -219,7 +220,7 @@ app.MapGet("/openapi/excititor.json", () =>
|
||||
get = new
|
||||
{
|
||||
summary = "Health check",
|
||||
responses = new
|
||||
responses = new Dictionary<string, object>
|
||||
{
|
||||
["200"] = new
|
||||
{
|
||||
@@ -254,7 +255,7 @@ app.MapGet("/openapi/excititor.json", () =>
|
||||
new { name = "cursor", @in = "query", schema = new { type = "string" }, required = false, description = "Numeric cursor or Last-Event-ID" },
|
||||
new { name = "limit", @in = "query", schema = new { type = "integer", minimum = 1, maximum = 100 }, required = false }
|
||||
},
|
||||
responses = new
|
||||
responses = new Dictionary<string, object>
|
||||
{
|
||||
["200"] = new
|
||||
{
|
||||
@@ -331,7 +332,7 @@ app.MapGet("/openapi/excititor.json", () =>
|
||||
}
|
||||
}
|
||||
},
|
||||
responses = new
|
||||
responses = new Dictionary<string, object>
|
||||
{
|
||||
["200"] = new { description = "Accepted" },
|
||||
["400"] = new
|
||||
@@ -448,16 +449,47 @@ app.MapGet("/openapi/excititor.json", () =>
|
||||
app.MapPost("/airgap/v1/vex/import", async (
|
||||
[FromServices] AirgapImportValidator validator,
|
||||
[FromServices] AirgapSignerTrustService trustService,
|
||||
[FromServices] AirgapModeEnforcer modeEnforcer,
|
||||
[FromServices] IAirgapImportStore store,
|
||||
[FromServices] ILoggerFactory loggerFactory,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
[FromBody] AirgapImportRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var logger = loggerFactory.CreateLogger("AirgapImport");
|
||||
var nowUtc = timeProvider.GetUtcNow();
|
||||
var tenantId = string.IsNullOrWhiteSpace(request.TenantId)
|
||||
? "default"
|
||||
: request.TenantId!.Trim().ToLowerInvariant();
|
||||
var stalenessSeconds = request.SignedAt is null
|
||||
? (int?)null
|
||||
: (int)Math.Round((nowUtc - request.SignedAt.Value).TotalSeconds);
|
||||
|
||||
var timeline = new List<AirgapTimelineEntry>();
|
||||
void RecordEvent(string eventType, string? code = null, string? message = null)
|
||||
{
|
||||
var entry = new AirgapTimelineEntry
|
||||
{
|
||||
EventType = eventType,
|
||||
CreatedAt = nowUtc,
|
||||
TenantId = tenantId,
|
||||
BundleId = request.BundleId ?? string.Empty,
|
||||
MirrorGeneration = request.MirrorGeneration ?? string.Empty,
|
||||
StalenessSeconds = stalenessSeconds,
|
||||
ErrorCode = code,
|
||||
Message = message
|
||||
};
|
||||
timeline.Add(entry);
|
||||
logger.LogInformation("Airgap timeline event {EventType} bundle={BundleId} gen={Gen} tenant={Tenant} code={Code}", eventType, entry.BundleId, entry.MirrorGeneration, tenantId, code);
|
||||
}
|
||||
|
||||
RecordEvent("airgap.import.started");
|
||||
|
||||
var errors = validator.Validate(request, nowUtc);
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
var first = errors[0];
|
||||
RecordEvent("airgap.import.failed", first.Code, first.Message);
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
error = new
|
||||
@@ -468,8 +500,22 @@ app.MapPost("/airgap/v1/vex/import", async (
|
||||
});
|
||||
}
|
||||
|
||||
if (!modeEnforcer.Validate(request, out var sealedCode, out var sealedMessage))
|
||||
{
|
||||
RecordEvent("airgap.import.failed", sealedCode, sealedMessage);
|
||||
return Results.Json(new
|
||||
{
|
||||
error = new
|
||||
{
|
||||
code = sealedCode,
|
||||
message = sealedMessage
|
||||
}
|
||||
}, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
if (!trustService.Validate(request, out var trustCode, out var trustMessage))
|
||||
{
|
||||
RecordEvent("airgap.import.failed", trustCode, trustMessage);
|
||||
return Results.Json(new
|
||||
{
|
||||
error = new
|
||||
@@ -480,9 +526,16 @@ app.MapPost("/airgap/v1/vex/import", async (
|
||||
}, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
var manifestPath = $"mirror/{request.BundleId}/{request.MirrorGeneration}/manifest.json";
|
||||
var evidenceLockerPath = $"evidence/{request.BundleId}/{request.MirrorGeneration}/bundle.ndjson";
|
||||
var manifestHash = ComputeSha256($"{request.BundleId}:{request.MirrorGeneration}:{request.PayloadHash}");
|
||||
|
||||
RecordEvent("airgap.import.completed");
|
||||
|
||||
var record = new AirgapImportRecord
|
||||
{
|
||||
Id = $"{request.BundleId}:{request.MirrorGeneration}",
|
||||
TenantId = tenantId,
|
||||
BundleId = request.BundleId!,
|
||||
MirrorGeneration = request.MirrorGeneration!,
|
||||
SignedAt = request.SignedAt!.Value,
|
||||
@@ -491,7 +544,11 @@ app.MapPost("/airgap/v1/vex/import", async (
|
||||
PayloadUrl = request.PayloadUrl,
|
||||
Signature = request.Signature!,
|
||||
TransparencyLog = request.TransparencyLog,
|
||||
ImportedAt = nowUtc
|
||||
ImportedAt = nowUtc,
|
||||
PortableManifestPath = manifestPath,
|
||||
PortableManifestHash = manifestHash,
|
||||
EvidenceLockerPath = evidenceLockerPath,
|
||||
Timeline = timeline
|
||||
};
|
||||
|
||||
try
|
||||
@@ -500,6 +557,7 @@ app.MapPost("/airgap/v1/vex/import", async (
|
||||
}
|
||||
catch (DuplicateAirgapImportException dup)
|
||||
{
|
||||
RecordEvent("airgap.import.failed", "AIRGAP_IMPORT_DUPLICATE", dup.Message);
|
||||
return Results.Conflict(new
|
||||
{
|
||||
error = new
|
||||
@@ -513,10 +571,20 @@ app.MapPost("/airgap/v1/vex/import", async (
|
||||
return Results.Accepted($"/airgap/v1/vex/import/{request.BundleId}", new
|
||||
{
|
||||
bundleId = request.BundleId,
|
||||
generation = request.MirrorGeneration
|
||||
generation = request.MirrorGeneration,
|
||||
manifest = manifestPath,
|
||||
evidence = evidenceLockerPath,
|
||||
manifestSha256 = manifestHash
|
||||
});
|
||||
});
|
||||
|
||||
static string ComputeSha256(string value)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(value);
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
|
||||
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
app.MapPost("/v1/attestations/verify", async (
|
||||
[FromServices] IVexAttestationClient attestationClient,
|
||||
[FromBody] AttestationVerifyRequest request,
|
||||
@@ -1548,6 +1616,15 @@ app.MapGet("/v1/vex/linksets", async (HttpContext _, CancellationToken __) =>
|
||||
|
||||
app.Run();
|
||||
|
||||
internal sealed record ExcititorTimelineEvent(
|
||||
string Type,
|
||||
string Tenant,
|
||||
string Source,
|
||||
int Count,
|
||||
int Errors,
|
||||
string? TraceId,
|
||||
string OccurredAt);
|
||||
|
||||
public partial class Program;
|
||||
|
||||
internal sealed record StatusResponse(DateTimeOffset UtcNow, string MongoBucket, int InlineThreshold, string[] ArtifactStores);
|
||||
|
||||
Reference in New Issue
Block a user