feat: Add DigestUpsertRequest and LockEntity models
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
- Introduced DigestUpsertRequest for handling digest upsert requests with properties like ChannelId, Recipient, DigestKey, Events, and CollectUntil. - Created LockEntity to represent a lightweight distributed lock entry with properties such as Id, TenantId, Resource, Owner, ExpiresAt, and CreatedAt. feat: Implement ILockRepository interface and LockRepository class - Defined ILockRepository interface with methods for acquiring and releasing locks. - Implemented LockRepository class with methods to try acquiring a lock and releasing it, using SQL for upsert operations. feat: Add SurfaceManifestPointer record for manifest pointers - Introduced SurfaceManifestPointer to represent a minimal pointer to a Surface.FS manifest associated with an image digest. feat: Create PolicySimulationInputLock and related validation logic - Added PolicySimulationInputLock record to describe policy simulation inputs and expected digests. - Implemented validation logic for policy simulation inputs, including checks for digest drift and shadow mode requirements. test: Add unit tests for ReplayVerificationService and ReplayVerifier - Created ReplayVerificationServiceTests to validate the behavior of the ReplayVerificationService under various scenarios. - Developed ReplayVerifierTests to ensure the correctness of the ReplayVerifier logic. test: Implement PolicySimulationInputLockValidatorTests - Added tests for PolicySimulationInputLockValidator to verify the validation logic against expected inputs and conditions. chore: Add cosign key example and signing scripts - Included a placeholder cosign key example for development purposes. - Added a script for signing Signals artifacts using cosign with support for both v2 and v3. chore: Create script for uploading evidence to the evidence locker - Developed a script to upload evidence to the evidence locker, ensuring required environment variables are set.
This commit is contained in:
@@ -77,7 +77,10 @@ public sealed record MirrorBundleTimelineEntry(
|
||||
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
|
||||
[property: JsonPropertyName("stalenessSeconds")] int? StalenessSeconds,
|
||||
[property: JsonPropertyName("errorCode")] string? ErrorCode,
|
||||
[property: JsonPropertyName("message")] string? Message);
|
||||
[property: JsonPropertyName("message")] string? Message,
|
||||
[property: JsonPropertyName("remediation")] string? Remediation,
|
||||
[property: JsonPropertyName("actor")] string? Actor,
|
||||
[property: JsonPropertyName("scopes")] string? Scopes);
|
||||
|
||||
/// <summary>
|
||||
/// Response for timeline-only query.
|
||||
@@ -96,7 +99,8 @@ public sealed record AirgapErrorResponse(
|
||||
[property: JsonPropertyName("message")] string Message,
|
||||
[property: JsonPropertyName("category")] string Category,
|
||||
[property: JsonPropertyName("retryable")] bool Retryable,
|
||||
[property: JsonPropertyName("details")] IReadOnlyDictionary<string, string>? Details);
|
||||
[property: JsonPropertyName("details")] IReadOnlyDictionary<string, string>? Details,
|
||||
[property: JsonPropertyName("remediation")] string? Remediation);
|
||||
|
||||
/// <summary>
|
||||
/// Maps sealed-mode error codes to structured error responses.
|
||||
@@ -129,7 +133,8 @@ public static class AirgapErrorMapping
|
||||
_ => (CategoryValidation, false),
|
||||
};
|
||||
|
||||
return new AirgapErrorResponse(errorCode, message, category, retryable, details);
|
||||
var remediation = ResolveRemediation(errorCode);
|
||||
return new AirgapErrorResponse(errorCode, message, category, retryable, details, remediation);
|
||||
}
|
||||
|
||||
public static AirgapErrorResponse DuplicateImport(string bundleId, string mirrorGeneration)
|
||||
@@ -142,7 +147,8 @@ public static class AirgapErrorMapping
|
||||
{
|
||||
["bundleId"] = bundleId,
|
||||
["mirrorGeneration"] = mirrorGeneration,
|
||||
});
|
||||
},
|
||||
ResolveRemediation("AIRGAP_DUPLICATE_IMPORT"));
|
||||
|
||||
public static AirgapErrorResponse BundleNotFound(string bundleId, string? mirrorGeneration)
|
||||
=> new(
|
||||
@@ -156,7 +162,21 @@ public static class AirgapErrorMapping
|
||||
{
|
||||
["bundleId"] = bundleId,
|
||||
["mirrorGeneration"] = mirrorGeneration ?? string.Empty,
|
||||
});
|
||||
},
|
||||
ResolveRemediation("AIRGAP_BUNDLE_NOT_FOUND"));
|
||||
|
||||
private static string? ResolveRemediation(string errorCode) =>
|
||||
errorCode switch
|
||||
{
|
||||
"AIRGAP_EGRESS_BLOCKED" => "Stage bundle via mirror or portable media; remove external URLs before retrying.",
|
||||
"AIRGAP_SOURCE_UNTRUSTED" => "Submit from an allowlisted publisher or add the publisher to TrustedPublishers in Excititor:Airgap settings.",
|
||||
"AIRGAP_SIGNATURE_MISSING" => "Provide DSSE signature for the bundle manifest.",
|
||||
"AIRGAP_SIGNATURE_INVALID" => "Re-sign the bundle manifest with a trusted key.",
|
||||
"AIRGAP_PAYLOAD_STALE" => "Regenerate bundle with fresh signedAt closer to import time.",
|
||||
"AIRGAP_PAYLOAD_MISMATCH" => "Recreate bundle; ensure manifest hash matches payload.",
|
||||
"AIRGAP_DUPLICATE_IMPORT" => "Use a new mirrorGeneration or verify the previous import before retrying.",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -521,6 +522,100 @@ public static class EvidenceEndpoints
|
||||
|
||||
return Results.Ok(response);
|
||||
}).WithName("GetVexEvidenceLockerManifest");
|
||||
|
||||
// GET /evidence/vex/locker/{bundleId}/manifest/file
|
||||
app.MapGet("/evidence/vex/locker/{bundleId}/manifest/file", async (
|
||||
HttpContext context,
|
||||
string bundleId,
|
||||
[FromQuery] string? generation,
|
||||
IOptions<VexMongoStorageOptions> storageOptions,
|
||||
IOptions<AirgapOptions> airgapOptions,
|
||||
[FromServices] IAirgapImportStore airgapImportStore,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
var root = airgapOptions.Value.LockerRootPath;
|
||||
if (string.IsNullOrWhiteSpace(root))
|
||||
{
|
||||
return Results.NotFound(new { error = new { code = "ERR_LOCKER_ROOT", message = "LockerRootPath is not configured" } });
|
||||
}
|
||||
|
||||
var record = await airgapImportStore.FindByBundleIdAsync(tenant, bundleId.Trim(), generation?.Trim(), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (record is null)
|
||||
{
|
||||
return Results.NotFound(new { error = new { code = "ERR_NOT_FOUND", message = "Locker manifest not found" } });
|
||||
}
|
||||
|
||||
if (!TryResolveLockerFile(root, record.PortableManifestPath, out var fullPath))
|
||||
{
|
||||
return Results.NotFound(new { error = new { code = "ERR_MANIFEST_FILE", message = "Manifest file not available" } });
|
||||
}
|
||||
|
||||
var (digest, size) = ComputeFileHash(fullPath);
|
||||
// Quote the ETag so HttpClient parses it into response.Headers.ETag.
|
||||
context.Response.Headers.ETag = $"\"{digest}\"";
|
||||
context.Response.ContentType = "application/json";
|
||||
context.Response.ContentLength = size;
|
||||
return Results.File(fullPath, "application/json");
|
||||
}).WithName("GetVexEvidenceLockerManifestFile");
|
||||
|
||||
// GET /evidence/vex/locker/{bundleId}/evidence/file
|
||||
app.MapGet("/evidence/vex/locker/{bundleId}/evidence/file", async (
|
||||
HttpContext context,
|
||||
string bundleId,
|
||||
[FromQuery] string? generation,
|
||||
IOptions<VexMongoStorageOptions> storageOptions,
|
||||
IOptions<AirgapOptions> airgapOptions,
|
||||
[FromServices] IAirgapImportStore airgapImportStore,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
var root = airgapOptions.Value.LockerRootPath;
|
||||
if (string.IsNullOrWhiteSpace(root))
|
||||
{
|
||||
return Results.NotFound(new { error = new { code = "ERR_LOCKER_ROOT", message = "LockerRootPath is not configured" } });
|
||||
}
|
||||
|
||||
var record = await airgapImportStore.FindByBundleIdAsync(tenant, bundleId.Trim(), generation?.Trim(), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (record is null)
|
||||
{
|
||||
return Results.NotFound(new { error = new { code = "ERR_NOT_FOUND", message = "Evidence file not found" } });
|
||||
}
|
||||
|
||||
if (!TryResolveLockerFile(root, record.EvidenceLockerPath, out var fullPath))
|
||||
{
|
||||
return Results.NotFound(new { error = new { code = "ERR_EVIDENCE_FILE", message = "Evidence file not available" } });
|
||||
}
|
||||
|
||||
var (digest, size) = ComputeFileHash(fullPath);
|
||||
// Quote the ETag so HttpClient parses it into response.Headers.ETag.
|
||||
context.Response.Headers.ETag = $"\"{digest}\"";
|
||||
context.Response.ContentType = "application/x-ndjson";
|
||||
context.Response.ContentLength = size;
|
||||
return Results.File(fullPath, "application/x-ndjson");
|
||||
}).WithName("GetVexEvidenceLockerEvidenceFile");
|
||||
}
|
||||
|
||||
private static void TryHashFile(string root, string relativePath, IVexHashingService hashingService, out string? digest, out long? size)
|
||||
@@ -534,8 +629,7 @@ public static class EvidenceEndpoints
|
||||
return;
|
||||
}
|
||||
|
||||
var fullPath = Path.GetFullPath(Path.Combine(root, relativePath));
|
||||
if (!File.Exists(fullPath))
|
||||
if (!TryResolveLockerFile(root, relativePath, out var fullPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -550,6 +644,41 @@ public static class EvidenceEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryResolveLockerFile(string root, string relativePath, out string fullPath)
|
||||
{
|
||||
fullPath = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(root) || string.IsNullOrWhiteSpace(relativePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var rootFull = Path.GetFullPath(root);
|
||||
var candidate = Path.GetFullPath(Path.Combine(rootFull, relativePath));
|
||||
|
||||
if (!candidate.StartsWith(rootFull, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!File.Exists(candidate))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
fullPath = candidate;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static (string Digest, long SizeBytes) ComputeFileHash(string path)
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
using var sha = SHA256.Create();
|
||||
var hashBytes = sha.ComputeHash(stream);
|
||||
var digest = "sha256:" + Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
var size = new FileInfo(path).Length;
|
||||
return (digest, size);
|
||||
}
|
||||
|
||||
private static bool TryResolveTenant(HttpContext context, VexMongoStorageOptions options, out string tenant, out IResult? problem)
|
||||
{
|
||||
tenant = options.DefaultTenant;
|
||||
|
||||
@@ -150,7 +150,10 @@ internal static class MirrorRegistrationEndpoints
|
||||
e.CreatedAt,
|
||||
e.StalenessSeconds,
|
||||
e.ErrorCode,
|
||||
e.Message))
|
||||
e.Message,
|
||||
e.Remediation,
|
||||
e.Actor,
|
||||
e.Scopes))
|
||||
.ToList();
|
||||
|
||||
var response = new MirrorBundleDetailResponse(
|
||||
@@ -202,7 +205,10 @@ internal static class MirrorRegistrationEndpoints
|
||||
e.CreatedAt,
|
||||
e.StalenessSeconds,
|
||||
e.ErrorCode,
|
||||
e.Message))
|
||||
e.Message,
|
||||
e.Remediation,
|
||||
e.Actor,
|
||||
e.Scopes))
|
||||
.ToList();
|
||||
|
||||
var response = new MirrorBundleTimelineResponse(
|
||||
|
||||
@@ -5,16 +5,17 @@ using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Excititor.Attestation;
|
||||
using StellaOps.Excititor.Attestation.Dsse;
|
||||
using StellaOps.Excititor.Attestation.Signing;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Policy;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Excititor.Attestation;
|
||||
using StellaOps.Excititor.Attestation.Dsse;
|
||||
using StellaOps.Excititor.Attestation.Signing;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Policy;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
using StellaOps.Excititor.WebService.Services;
|
||||
|
||||
@@ -33,13 +34,12 @@ internal static class ResolveEndpoint
|
||||
HttpContext httpContext,
|
||||
IVexClaimStore claimStore,
|
||||
IVexConsensusStore consensusStore,
|
||||
IVexProviderStore providerStore,
|
||||
IVexPolicyProvider policyProvider,
|
||||
TimeProvider timeProvider,
|
||||
ILoggerFactory loggerFactory,
|
||||
IVexAttestationClient? attestationClient,
|
||||
IVexSigner? signer,
|
||||
CancellationToken cancellationToken)
|
||||
IVexProviderStore providerStore,
|
||||
IVexPolicyProvider policyProvider,
|
||||
TimeProvider timeProvider,
|
||||
ILoggerFactory loggerFactory,
|
||||
IVexAttestationClient? attestationClient,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(httpContext, ReadScope);
|
||||
if (scopeResult is not null)
|
||||
@@ -53,6 +53,7 @@ internal static class ResolveEndpoint
|
||||
}
|
||||
|
||||
var logger = loggerFactory.CreateLogger("ResolveEndpoint");
|
||||
var signer = httpContext.RequestServices.GetService<IVexSigner>();
|
||||
|
||||
var productKeys = NormalizeValues(request.ProductKeys, request.Purls);
|
||||
var vulnerabilityIds = NormalizeValues(request.VulnerabilityIds);
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -922,6 +923,7 @@ app.MapGet("/openapi/excititor.json", () =>
|
||||
});
|
||||
|
||||
app.MapPost("/airgap/v1/vex/import", async (
|
||||
HttpContext httpContext,
|
||||
[FromServices] AirgapImportValidator validator,
|
||||
[FromServices] AirgapSignerTrustService trustService,
|
||||
[FromServices] AirgapModeEnforcer modeEnforcer,
|
||||
@@ -933,6 +935,12 @@ app.MapPost("/airgap/v1/vex/import", async (
|
||||
[FromBody] AirgapImportRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(httpContext, "vex.admin");
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var logger = loggerFactory.CreateLogger("AirgapImport");
|
||||
var nowUtc = timeProvider.GetUtcNow();
|
||||
var tenantId = string.IsNullOrWhiteSpace(request.TenantId)
|
||||
@@ -942,41 +950,61 @@ app.MapPost("/airgap/v1/vex/import", async (
|
||||
? (int?)null
|
||||
: (int)Math.Round((nowUtc - request.SignedAt.Value).TotalSeconds);
|
||||
|
||||
var bundleId = (request.BundleId ?? string.Empty).Trim();
|
||||
var mirrorGeneration = (request.MirrorGeneration ?? string.Empty).Trim();
|
||||
var manifestPath = $"mirror/{bundleId}/{mirrorGeneration}/manifest.json";
|
||||
var evidenceLockerPath = $"evidence/{bundleId}/{mirrorGeneration}/bundle.ndjson";
|
||||
// WEB-AIRGAP-58-001: include manifest hash/path for audit + telemetry (pluggable crypto)
|
||||
var manifestHash = hashingService.ComputeHash($"{bundleId}:{mirrorGeneration}:{request.PayloadHash ?? string.Empty}");
|
||||
var actor = ResolveActor(httpContext);
|
||||
var scopes = ResolveScopes(httpContext);
|
||||
|
||||
var traceId = Activity.Current?.TraceId.ToString();
|
||||
var timeline = new List<AirgapTimelineEntry>();
|
||||
void RecordEvent(string eventType, string? code = null, string? message = null)
|
||||
void RecordEvent(string eventType, string? code = null, string? message = null, string? remediation = null)
|
||||
{
|
||||
var entry = new AirgapTimelineEntry
|
||||
{
|
||||
EventType = eventType,
|
||||
CreatedAt = nowUtc,
|
||||
TenantId = tenantId,
|
||||
BundleId = request.BundleId ?? string.Empty,
|
||||
MirrorGeneration = request.MirrorGeneration ?? string.Empty,
|
||||
BundleId = bundleId,
|
||||
MirrorGeneration = mirrorGeneration,
|
||||
StalenessSeconds = stalenessSeconds,
|
||||
ErrorCode = code,
|
||||
Message = message
|
||||
Message = message,
|
||||
Remediation = remediation,
|
||||
Actor = actor,
|
||||
Scopes = scopes
|
||||
};
|
||||
timeline.Add(entry);
|
||||
logger.LogInformation("Airgap timeline event {EventType} bundle={BundleId} gen={Gen} tenant={Tenant} code={Code}", eventType, entry.BundleId, entry.MirrorGeneration, tenantId, code);
|
||||
logger.LogInformation("Airgap timeline event {EventType} bundle={BundleId} gen={Gen} tenant={Tenant} code={Code} actor={Actor} scopes={Scopes}",
|
||||
eventType, entry.BundleId, entry.MirrorGeneration, tenantId, code, actor, scopes);
|
||||
|
||||
// WEB-AIRGAP-58-001: Emit timeline event to persistent store for SSE streaming
|
||||
_ = EmitTimelineEventAsync(eventType, code, message);
|
||||
_ = EmitTimelineEventAsync(eventType, code, message, remediation);
|
||||
}
|
||||
|
||||
async Task EmitTimelineEventAsync(string eventType, string? code, string? message)
|
||||
async Task EmitTimelineEventAsync(string eventType, string? code, string? message, string? remediation)
|
||||
{
|
||||
try
|
||||
{
|
||||
var attributes = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["bundle_id"] = request.BundleId ?? string.Empty,
|
||||
["mirror_generation"] = request.MirrorGeneration ?? string.Empty
|
||||
["bundle_id"] = bundleId,
|
||||
["mirror_generation"] = mirrorGeneration,
|
||||
["tenant_id"] = tenantId,
|
||||
["publisher"] = request.Publisher ?? string.Empty,
|
||||
["actor"] = actor,
|
||||
["scopes"] = scopes
|
||||
};
|
||||
if (stalenessSeconds.HasValue)
|
||||
{
|
||||
attributes["staleness_seconds"] = stalenessSeconds.Value.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
attributes["portable_manifest_hash"] = manifestHash;
|
||||
attributes["portable_manifest_path"] = manifestPath;
|
||||
attributes["evidence_path"] = evidenceLockerPath;
|
||||
if (!string.IsNullOrEmpty(code))
|
||||
{
|
||||
attributes["error_code"] = code;
|
||||
@@ -985,9 +1013,17 @@ app.MapPost("/airgap/v1/vex/import", async (
|
||||
{
|
||||
attributes["message"] = message;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(remediation))
|
||||
{
|
||||
attributes["remediation"] = remediation;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(request.TransparencyLog))
|
||||
{
|
||||
attributes["transparency_log"] = request.TransparencyLog;
|
||||
}
|
||||
|
||||
var eventId = $"airgap-{request.BundleId}-{request.MirrorGeneration}-{nowUtc:yyyyMMddHHmmssfff}";
|
||||
var streamId = $"airgap:{request.BundleId}:{request.MirrorGeneration}";
|
||||
var eventId = $"airgap-{bundleId}-{mirrorGeneration}-{nowUtc:yyyyMMddHHmmssfff}";
|
||||
var streamId = $"airgap:{bundleId}:{mirrorGeneration}";
|
||||
var evt = new TimelineEvent(
|
||||
eventId,
|
||||
tenantId,
|
||||
@@ -1015,56 +1051,33 @@ app.MapPost("/airgap/v1/vex/import", async (
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
var first = errors[0];
|
||||
RecordEvent("airgap.import.failed", first.Code, first.Message);
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
error = new
|
||||
{
|
||||
code = first.Code,
|
||||
message = first.Message
|
||||
}
|
||||
});
|
||||
var errorResponse = AirgapErrorMapping.FromErrorCode(first.Code, first.Message);
|
||||
RecordEvent("airgap.import.failed", first.Code, first.Message, errorResponse.Remediation);
|
||||
return Results.BadRequest(errorResponse);
|
||||
}
|
||||
|
||||
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);
|
||||
var errorResponse = AirgapErrorMapping.FromErrorCode(sealedCode ?? "AIRGAP_SEALED_MODE", sealedMessage ?? "Sealed mode violation.");
|
||||
RecordEvent("airgap.import.failed", sealedCode, sealedMessage, errorResponse.Remediation);
|
||||
return Results.Json(errorResponse, 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
|
||||
{
|
||||
code = trustCode,
|
||||
message = trustMessage
|
||||
}
|
||||
}, statusCode: StatusCodes.Status403Forbidden);
|
||||
var errorResponse = AirgapErrorMapping.FromErrorCode(trustCode ?? "AIRGAP_TRUST_FAILED", trustMessage ?? "Trust validation failed.");
|
||||
RecordEvent("airgap.import.failed", trustCode, trustMessage, errorResponse.Remediation);
|
||||
return Results.Json(errorResponse, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
var manifestPath = $"mirror/{request.BundleId}/{request.MirrorGeneration}/manifest.json";
|
||||
var evidenceLockerPath = $"evidence/{request.BundleId}/{request.MirrorGeneration}/bundle.ndjson";
|
||||
// CRYPTO-90-001: Use IVexHashingService for pluggable crypto algorithms
|
||||
var manifestHash = hashingService.ComputeHash($"{request.BundleId}:{request.MirrorGeneration}:{request.PayloadHash}");
|
||||
|
||||
RecordEvent("airgap.import.completed");
|
||||
|
||||
var record = new AirgapImportRecord
|
||||
{
|
||||
Id = $"{request.BundleId}:{request.MirrorGeneration}",
|
||||
Id = $"{bundleId}:{mirrorGeneration}",
|
||||
TenantId = tenantId,
|
||||
BundleId = request.BundleId!,
|
||||
MirrorGeneration = request.MirrorGeneration!,
|
||||
BundleId = bundleId,
|
||||
MirrorGeneration = mirrorGeneration,
|
||||
SignedAt = request.SignedAt!.Value,
|
||||
Publisher = request.Publisher!,
|
||||
PayloadHash = request.PayloadHash!,
|
||||
@@ -1075,7 +1088,9 @@ app.MapPost("/airgap/v1/vex/import", async (
|
||||
PortableManifestPath = manifestPath,
|
||||
PortableManifestHash = manifestHash,
|
||||
EvidenceLockerPath = evidenceLockerPath,
|
||||
Timeline = timeline
|
||||
Timeline = timeline,
|
||||
ImportActor = actor,
|
||||
ImportScopes = scopes
|
||||
};
|
||||
|
||||
try
|
||||
@@ -1095,10 +1110,10 @@ app.MapPost("/airgap/v1/vex/import", async (
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Accepted($"/airgap/v1/vex/import/{request.BundleId}", new
|
||||
return Results.Accepted($"/airgap/v1/vex/import/{bundleId}", new
|
||||
{
|
||||
bundleId = request.BundleId,
|
||||
generation = request.MirrorGeneration,
|
||||
bundleId,
|
||||
generation = mirrorGeneration,
|
||||
manifest = manifestPath,
|
||||
evidence = evidenceLockerPath,
|
||||
manifestSha256 = manifestHash
|
||||
@@ -1107,6 +1122,26 @@ app.MapPost("/airgap/v1/vex/import", async (
|
||||
|
||||
// CRYPTO-90-001: ComputeSha256 removed - now using IVexHashingService for pluggable crypto
|
||||
|
||||
static string ResolveActor(HttpContext context)
|
||||
{
|
||||
var user = context.User;
|
||||
var actor = user?.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||
?? user?.FindFirst("sub")?.Value
|
||||
?? "unknown";
|
||||
return actor;
|
||||
}
|
||||
|
||||
static string ResolveScopes(HttpContext context)
|
||||
{
|
||||
var user = context.User;
|
||||
var scopes = user?.FindAll("scope")
|
||||
.Concat(user.FindAll("scp"))
|
||||
.SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray() ?? Array.Empty<string>();
|
||||
return scopes.Length == 0 ? string.Empty : string.Join(' ', scopes);
|
||||
}
|
||||
|
||||
app.MapPost("/v1/attestations/verify", async (
|
||||
[FromServices] IVexAttestationClient attestationClient,
|
||||
[FromBody] AttestationVerifyRequest request,
|
||||
|
||||
@@ -24,4 +24,13 @@ public sealed class AirgapTimelineEntry
|
||||
|
||||
public string? Message { get; set; }
|
||||
= null;
|
||||
|
||||
public string? Remediation { get; set; }
|
||||
= null;
|
||||
|
||||
public string? Actor { get; set; }
|
||||
= null;
|
||||
|
||||
public string? Scopes { get; set; }
|
||||
= null;
|
||||
}
|
||||
|
||||
@@ -343,6 +343,12 @@ public sealed class AirgapImportRecord
|
||||
public string EvidenceLockerPath { get; set; } = string.Empty;
|
||||
|
||||
public List<AirgapTimelineEntry> Timeline { get; set; } = new();
|
||||
|
||||
public string? ImportActor { get; set; }
|
||||
= null;
|
||||
|
||||
public string? ImportScopes { get; set; }
|
||||
= null;
|
||||
}
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
using StellaOps.Excititor.WebService.Contracts;
|
||||
using StellaOps.Excititor.WebService.Services;
|
||||
using Xunit;
|
||||
@@ -25,21 +33,113 @@ public class AirgapImportEndpointTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Import_accepts_valid_payload()
|
||||
public async Task Import_records_actor_and_scope_and_timeline()
|
||||
{
|
||||
var validator = new AirgapImportValidator();
|
||||
var store = new CapturingAirgapStore();
|
||||
using var factory = new TestWebApplicationFactory(
|
||||
configureConfiguration: config =>
|
||||
{
|
||||
config.AddInMemoryCollection(new[]
|
||||
{
|
||||
new KeyValuePair<string, string?>("Excititor:Airgap:SealedMode", "false"),
|
||||
new KeyValuePair<string, string?>("Excititor:Airgap:MirrorOnly", "false"),
|
||||
});
|
||||
},
|
||||
configureServices: services =>
|
||||
{
|
||||
TestServiceOverrides.Apply(services);
|
||||
services.RemoveAll<IAirgapImportStore>();
|
||||
services.AddSingleton<IAirgapImportStore>(store);
|
||||
services.AddTestAuthentication();
|
||||
});
|
||||
|
||||
using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "vex.admin");
|
||||
|
||||
var request = new AirgapImportRequest
|
||||
{
|
||||
BundleId = "bundle-123",
|
||||
BundleId = "bundle-abc",
|
||||
MirrorGeneration = "1",
|
||||
SignedAt = DateTimeOffset.UtcNow,
|
||||
Publisher = "mirror-test",
|
||||
PayloadHash = "sha256:" + new string('a', 64),
|
||||
PayloadHash = "sha256:" + new string('b', 64),
|
||||
Signature = Convert.ToBase64String(new byte[] { 1, 2, 3 })
|
||||
};
|
||||
|
||||
var errors = validator.Validate(request, DateTimeOffset.UtcNow);
|
||||
var response = await client.PostAsJsonAsync("/airgap/v1/vex/import", request);
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
|
||||
Assert.Empty(errors);
|
||||
var saved = store.LastSaved;
|
||||
Assert.NotNull(saved);
|
||||
Assert.Equal("test-user", saved!.ImportActor);
|
||||
Assert.Equal("vex.admin", saved.ImportScopes);
|
||||
Assert.All(saved.Timeline, e =>
|
||||
{
|
||||
Assert.Equal("test-user", e.Actor);
|
||||
Assert.Equal("vex.admin", e.Scopes);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_returns_remediation_for_sealed_mode_violation()
|
||||
{
|
||||
var store = new CapturingAirgapStore();
|
||||
using var factory = new TestWebApplicationFactory(
|
||||
configureConfiguration: config =>
|
||||
{
|
||||
config.AddInMemoryCollection(new[]
|
||||
{
|
||||
new KeyValuePair<string, string?>("Excititor:Airgap:SealedMode", "true"),
|
||||
new KeyValuePair<string, string?>("Excititor:Airgap:MirrorOnly", "true"),
|
||||
});
|
||||
},
|
||||
configureServices: services =>
|
||||
{
|
||||
TestServiceOverrides.Apply(services);
|
||||
services.RemoveAll<IAirgapImportStore>();
|
||||
services.AddSingleton<IAirgapImportStore>(store);
|
||||
services.AddTestAuthentication();
|
||||
});
|
||||
|
||||
using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "vex.admin");
|
||||
|
||||
var request = new AirgapImportRequest
|
||||
{
|
||||
BundleId = "bundle-xyz",
|
||||
MirrorGeneration = "2",
|
||||
SignedAt = DateTimeOffset.UtcNow,
|
||||
Publisher = "mirror-test",
|
||||
PayloadHash = "sha256:" + new string('c', 64),
|
||||
PayloadUrl = "https://example.com/payload.tgz",
|
||||
Signature = Convert.ToBase64String(new byte[] { 9, 9, 9 })
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/airgap/v1/vex/import", request);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
|
||||
var raw = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("AIRGAP_EGRESS_BLOCKED", raw);
|
||||
Assert.Contains("remediation", raw);
|
||||
}
|
||||
|
||||
private sealed class CapturingAirgapStore : IAirgapImportStore
|
||||
{
|
||||
public AirgapImportRecord? LastSaved { get; private set; }
|
||||
|
||||
public Task SaveAsync(AirgapImportRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
LastSaved = record;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<AirgapImportRecord?> FindByBundleIdAsync(string tenantId, string bundleId, string? mirrorGeneration, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(LastSaved);
|
||||
|
||||
public Task<IReadOnlyList<AirgapImportRecord>> ListAsync(string tenantId, string? publisherFilter, DateTimeOffset? importedAfter, int limit, int offset, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlyList<AirgapImportRecord>>(LastSaved is null ? Array.Empty<AirgapImportRecord>() : new[] { LastSaved });
|
||||
|
||||
public Task<int> CountAsync(string tenantId, string? publisherFilter, DateTimeOffset? importedAfter, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(LastSaved is null ? 0 : 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ public sealed class EvidenceLockerEndpointTests : IAsyncLifetime
|
||||
{
|
||||
private readonly string _tempDir = Path.Combine(Path.GetTempPath(), "excititor-locker-tests-" + Guid.NewGuid());
|
||||
private TestWebApplicationFactory _factory = null!;
|
||||
private StubAirgapImportStore _stubStore = null!;
|
||||
|
||||
[Fact]
|
||||
public async Task LockerEndpoint_ReturnsHashesFromLocalFiles_WhenLockerRootConfigured()
|
||||
@@ -48,8 +49,7 @@ public sealed class EvidenceLockerEndpointTests : IAsyncLifetime
|
||||
SignedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
var stub = (StubAirgapImportStore)_factory.Services.GetRequiredService<IAirgapImportStore>();
|
||||
await stub.SaveAsync(record, CancellationToken.None);
|
||||
await _stubStore.SaveAsync(record, CancellationToken.None);
|
||||
|
||||
using var client = _factory.WithWebHostBuilder(_ => { }).CreateClient();
|
||||
|
||||
@@ -68,21 +68,61 @@ public sealed class EvidenceLockerEndpointTests : IAsyncLifetime
|
||||
Assert.Equal(12, payload.EvidenceSizeBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LockerManifestFile_StreamsContent_WithETag()
|
||||
{
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
var manifestRel = Path.Combine("locker", "bundle-2", "g2", "manifest.json");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Path.Combine(_tempDir, manifestRel))!);
|
||||
var manifestBody = "{\"hello\":\"world\"}\n";
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, manifestRel), manifestBody);
|
||||
|
||||
var record = new AirgapImportRecord
|
||||
{
|
||||
Id = "bundle-2:g2",
|
||||
TenantId = "test",
|
||||
BundleId = "bundle-2",
|
||||
MirrorGeneration = "g2",
|
||||
Publisher = "pub",
|
||||
PayloadHash = "sha256:payload",
|
||||
Signature = "sig",
|
||||
PortableManifestPath = manifestRel,
|
||||
PortableManifestHash = "sha256:old",
|
||||
EvidenceLockerPath = "locker/bundle-2/g2/bundle.ndjson",
|
||||
ImportedAt = DateTimeOffset.UtcNow,
|
||||
SignedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
await _stubStore.SaveAsync(record, CancellationToken.None);
|
||||
using var client = _factory.WithWebHostBuilder(_ => { }).CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "vex.read");
|
||||
|
||||
var response = await client.GetAsync($"/evidence/vex/locker/{record.BundleId}/manifest/file");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("application/json", response.Content.Headers.ContentType!.MediaType);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.Equal(manifestBody, body);
|
||||
Assert.Equal("sha256:6a47c31b7b7c3b9a1dbc960669f4674ce088c8fc9d9a4f7e9fcc3f6a81f7b86c", response.Headers.ETag?.Tag?.Trim('"'));
|
||||
}
|
||||
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
_factory = new TestWebApplicationFactory(
|
||||
configureConfiguration: config =>
|
||||
{
|
||||
config.AddInMemoryCollection(new[]
|
||||
_stubStore = new StubAirgapImportStore();
|
||||
_factory = new TestWebApplicationFactory(
|
||||
configureConfiguration: config =>
|
||||
{
|
||||
new KeyValuePair<string, string?>("Excititor:Airgap:LockerRootPath", _tempDir)
|
||||
config.AddInMemoryCollection(new[]
|
||||
{
|
||||
new KeyValuePair<string, string?>("Excititor:Airgap:LockerRootPath", _tempDir)
|
||||
});
|
||||
},
|
||||
configureServices: services =>
|
||||
{
|
||||
// Enable test authentication so evidence endpoints that enforce scopes accept the bearer header set in the tests.
|
||||
services.AddTestAuthentication();
|
||||
services.RemoveAll<IAirgapImportStore>();
|
||||
services.AddSingleton<IAirgapImportStore>(_stubStore);
|
||||
});
|
||||
},
|
||||
configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<IAirgapImportStore>();
|
||||
services.AddSingleton<IAirgapImportStore>(new StubAirgapImportStore());
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
<Compile Include="AirgapImportValidatorTests.cs" />
|
||||
<Compile Include="AirgapModeEnforcerTests.cs" />
|
||||
<Compile Include="EvidenceTelemetryTests.cs" />
|
||||
<Compile Include="EvidenceLockerEndpointTests.cs" />
|
||||
<Compile Include="DevRuntimeEnvironmentStub.cs" />
|
||||
<Compile Include="TestAuthentication.cs" />
|
||||
<Compile Include="TestServiceOverrides.cs" />
|
||||
|
||||
Reference in New Issue
Block a user