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

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