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

- 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:
StellaOps Bot
2025-12-03 07:51:50 +02:00
parent 37cba83708
commit e923880694
171 changed files with 6567 additions and 2952 deletions

View File

@@ -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>

View File

@@ -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;

View File

@@ -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(

View File

@@ -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);

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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]

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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" />