save progress
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.AirGap.Controller.Auth;
|
||||
|
||||
@@ -21,12 +23,28 @@ public sealed class HeaderScopeAuthenticationHandler : AuthenticationHandler<Aut
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
// Accept any request; scopes are read from `scope` header (space-separated)
|
||||
var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, "anonymous") };
|
||||
|
||||
if (Request.Headers.TryGetValue("scope", out var scopeHeader))
|
||||
var scopes = ExtractScopes(Request.Headers);
|
||||
if (scopes.Count == 0)
|
||||
{
|
||||
claims.Add(new("scope", scopeHeader.ToString()));
|
||||
return Task.FromResult(AuthenticateResult.Fail("scope_header_missing"));
|
||||
}
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, "header-scope"),
|
||||
new(StellaOpsClaimTypes.Subject, "header-scope"),
|
||||
new(StellaOpsClaimTypes.Scope, string.Join(' ', scopes))
|
||||
};
|
||||
|
||||
foreach (var scope in scopes)
|
||||
{
|
||||
claims.Add(new Claim(StellaOpsClaimTypes.ScopeItem, scope));
|
||||
}
|
||||
|
||||
if (TryGetTenantHeader(Request.Headers, out var tenantId))
|
||||
{
|
||||
claims.Add(new Claim(StellaOpsClaimTypes.Tenant, tenantId));
|
||||
claims.Add(new Claim("tid", tenantId));
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(claims, SchemeName);
|
||||
@@ -34,4 +52,49 @@ public sealed class HeaderScopeAuthenticationHandler : AuthenticationHandler<Aut
|
||||
var ticket = new AuthenticationTicket(principal, SchemeName);
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
|
||||
private static HashSet<string> ExtractScopes(IHeaderDictionary headers)
|
||||
{
|
||||
var scopes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
AddScopes(headers, "scope", scopes);
|
||||
AddScopes(headers, "scp", scopes);
|
||||
|
||||
return scopes;
|
||||
}
|
||||
|
||||
private static void AddScopes(IHeaderDictionary headers, string headerName, ISet<string> scopes)
|
||||
{
|
||||
if (!headers.TryGetValue(headerName, out var values))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var value in values)
|
||||
{
|
||||
foreach (var scope in value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
scopes.Add(scope);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetTenantHeader(IHeaderDictionary headers, out string tenantId)
|
||||
{
|
||||
tenantId = string.Empty;
|
||||
|
||||
if (headers.TryGetValue("x-tenant-id", out var headerValue) && !string.IsNullOrWhiteSpace(headerValue))
|
||||
{
|
||||
tenantId = headerValue.ToString().Trim();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (headers.TryGetValue("tid", out var legacyValue) && !string.IsNullOrWhiteSpace(legacyValue))
|
||||
{
|
||||
tenantId = legacyValue.ToString().Trim();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ public static class AirGapControllerServiceCollectionExtensions
|
||||
public static IServiceCollection AddAirGapController(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.Configure<AirGapStartupOptions>(configuration.GetSection("AirGap:Startup"));
|
||||
services.Configure<AirGapTelemetryOptions>(configuration.GetSection("AirGap:Telemetry"));
|
||||
|
||||
services.AddSingleton<AirGapTelemetry>();
|
||||
services.AddSingleton<StalenessCalculator>();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.AirGap.Controller.Endpoints.Contracts;
|
||||
using StellaOps.AirGap.Controller.Services;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
@@ -45,7 +46,11 @@ internal static class AirGapEndpoints
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenant(httpContext);
|
||||
if (!TryResolveTenant(httpContext, user, out var tenantId, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var status = await service.GetStatusAsync(tenantId, timeProvider.GetUtcNow(), cancellationToken);
|
||||
telemetry.RecordStatus(tenantId, status);
|
||||
return Results.Ok(AirGapStatusResponse.FromStatus(status));
|
||||
@@ -61,17 +66,29 @@ internal static class AirGapEndpoints
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.PolicyHash))
|
||||
var validation = RequestValidation.ValidateSeal(request);
|
||||
if (validation is not null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "policy_hash_required" });
|
||||
return validation;
|
||||
}
|
||||
|
||||
if (!TryResolveTenant(httpContext, user, out var tenantId, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var tenantId = ResolveTenant(httpContext);
|
||||
var anchor = request.TimeAnchor ?? TimeAnchor.Unknown;
|
||||
var budget = request.StalenessBudget ?? StalenessBudget.Default;
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var state = await service.SealAsync(tenantId, request.PolicyHash!, anchor, budget, now, request.ContentBudgets, cancellationToken);
|
||||
var state = await service.SealAsync(
|
||||
tenantId,
|
||||
request.PolicyHash!.Trim(),
|
||||
anchor,
|
||||
budget,
|
||||
now,
|
||||
request.ContentBudgets,
|
||||
cancellationToken);
|
||||
var staleness = stalenessCalculator.Evaluate(anchor, budget, now);
|
||||
var contentStaleness = stalenessCalculator.EvaluateContent(anchor, state.ContentBudgets, now);
|
||||
var status = new AirGapStatus(state, staleness, contentStaleness, now);
|
||||
@@ -87,7 +104,11 @@ internal static class AirGapEndpoints
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenant(httpContext);
|
||||
if (!TryResolveTenant(httpContext, user, out var tenantId, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var state = await service.UnsealAsync(tenantId, now, cancellationToken);
|
||||
var emptyContentStaleness = new Dictionary<string, StalenessEvaluation>(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -98,12 +119,23 @@ internal static class AirGapEndpoints
|
||||
|
||||
private static async Task<IResult> HandleVerify(
|
||||
VerifyRequest request,
|
||||
ClaimsPrincipal user,
|
||||
ReplayVerificationService verifier,
|
||||
TimeProvider timeProvider,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenant(httpContext);
|
||||
var validation = RequestValidation.ValidateVerify(request);
|
||||
if (validation is not null)
|
||||
{
|
||||
return validation;
|
||||
}
|
||||
|
||||
if (!TryResolveTenant(httpContext, user, out var tenantId, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var result = await verifier.VerifyAsync(tenantId, request, now, cancellationToken);
|
||||
if (!result.IsValid)
|
||||
@@ -114,13 +146,91 @@ internal static class AirGapEndpoints
|
||||
return Results.Ok(new VerifyResponse(true, result.Reason));
|
||||
}
|
||||
|
||||
private static string ResolveTenant(HttpContext httpContext)
|
||||
private static bool TryResolveTenant(
|
||||
HttpContext httpContext,
|
||||
ClaimsPrincipal user,
|
||||
out string tenantId,
|
||||
out IResult? failure)
|
||||
{
|
||||
if (httpContext.Request.Headers.TryGetValue("x-tenant-id", out var tenantHeader) && !string.IsNullOrWhiteSpace(tenantHeader))
|
||||
tenantId = string.Empty;
|
||||
failure = null;
|
||||
|
||||
var claimTenant = NormalizeTenant(user.FindFirstValue(StellaOpsClaimTypes.Tenant))
|
||||
?? NormalizeTenant(user.FindFirstValue("tid"));
|
||||
var headerTenant = NormalizeTenant(ReadTenantHeader(httpContext.Request));
|
||||
|
||||
if (string.IsNullOrEmpty(claimTenant) && string.IsNullOrEmpty(headerTenant))
|
||||
{
|
||||
failure = Results.BadRequest(new { error = "tenant_required" });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(headerTenant) && !IsValidTenantId(headerTenant))
|
||||
{
|
||||
failure = Results.BadRequest(new { error = "tenant_invalid" });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(claimTenant) && !IsValidTenantId(claimTenant))
|
||||
{
|
||||
failure = Results.Forbid();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(headerTenant) && !string.IsNullOrEmpty(claimTenant)
|
||||
&& !string.Equals(headerTenant, claimTenant, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
failure = Results.Forbid();
|
||||
return false;
|
||||
}
|
||||
|
||||
tenantId = claimTenant ?? headerTenant ?? string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? ReadTenantHeader(HttpRequest request)
|
||||
{
|
||||
if (request.Headers.TryGetValue("x-tenant-id", out var tenantHeader)
|
||||
&& !string.IsNullOrWhiteSpace(tenantHeader))
|
||||
{
|
||||
return tenantHeader.ToString();
|
||||
}
|
||||
return "default";
|
||||
|
||||
if (request.Headers.TryGetValue("tid", out var legacyHeader)
|
||||
&& !string.IsNullOrWhiteSpace(legacyHeader))
|
||||
{
|
||||
return legacyHeader.ToString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? NormalizeTenant(string? tenant)
|
||||
=> string.IsNullOrWhiteSpace(tenant) ? null : tenant.Trim();
|
||||
|
||||
private static bool IsValidTenantId(string tenantId)
|
||||
{
|
||||
if (tenantId.Length is 0 or > 128)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var ch in tenantId)
|
||||
{
|
||||
if (ch > 0x7F)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (char.IsLetterOrDigit(ch) || ch is '-' or '_' or '.')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,9 +242,25 @@ internal static class AuthorizationExtensions
|
||||
{
|
||||
policy.RequireAssertion(ctx =>
|
||||
{
|
||||
var scopes = ctx.User.FindFirstValue("scope") ?? ctx.User.FindFirstValue("scp") ?? string.Empty;
|
||||
return scopes.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Contains(requiredScope, StringComparer.OrdinalIgnoreCase);
|
||||
if (ctx.User.HasClaim(c => c.Type == StellaOpsClaimTypes.ScopeItem))
|
||||
{
|
||||
return ctx.User.FindAll(StellaOpsClaimTypes.ScopeItem)
|
||||
.Select(c => c.Value)
|
||||
.Contains(requiredScope, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var scopes = ctx.User.FindAll(StellaOpsClaimTypes.Scope)
|
||||
.SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
.ToArray();
|
||||
|
||||
if (scopes.Length == 0)
|
||||
{
|
||||
scopes = ctx.User.FindAll("scp")
|
||||
.SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
return scopes.Contains(requiredScope, StringComparer.OrdinalIgnoreCase);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
using StellaOps.AirGap.Controller.Endpoints.Contracts;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
|
||||
namespace StellaOps.AirGap.Controller.Endpoints;
|
||||
|
||||
internal static class RequestValidation
|
||||
{
|
||||
public static IResult? ValidateSeal(SealRequest request)
|
||||
{
|
||||
var errors = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.PolicyHash))
|
||||
{
|
||||
errors["policyHash"] = new[] { "required" };
|
||||
}
|
||||
|
||||
if (request.StalenessBudget is not null
|
||||
&& !IsValidBudget(request.StalenessBudget, out var budgetError))
|
||||
{
|
||||
errors["stalenessBudget"] = new[] { budgetError };
|
||||
}
|
||||
|
||||
if (request.ContentBudgets is not null)
|
||||
{
|
||||
foreach (var kvp in request.ContentBudgets)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(kvp.Key))
|
||||
{
|
||||
errors["contentBudgets"] = new[] { "key_required" };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!IsValidBudget(kvp.Value, out var contentError))
|
||||
{
|
||||
errors[$"contentBudgets.{kvp.Key}"] = new[] { contentError };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Count > 0 ? Results.ValidationProblem(errors) : null;
|
||||
}
|
||||
|
||||
public static IResult? ValidateVerify(VerifyRequest request)
|
||||
{
|
||||
var errors = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ManifestSha256))
|
||||
{
|
||||
errors["manifestSha256"] = new[] { "required" };
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.BundleSha256))
|
||||
{
|
||||
errors["bundleSha256"] = new[] { "required" };
|
||||
}
|
||||
|
||||
if (request.ManifestCreatedAt == DateTimeOffset.MinValue)
|
||||
{
|
||||
errors["manifestCreatedAt"] = new[] { "required" };
|
||||
}
|
||||
|
||||
if (request.StalenessWindowHours < 0)
|
||||
{
|
||||
errors["stalenessWindowHours"] = new[] { "must_be_non_negative" };
|
||||
}
|
||||
|
||||
if (request.ComputedManifestSha256 is not null && string.IsNullOrWhiteSpace(request.ComputedManifestSha256))
|
||||
{
|
||||
errors["computedManifestSha256"] = new[] { "invalid" };
|
||||
}
|
||||
|
||||
if (request.ComputedBundleSha256 is not null && string.IsNullOrWhiteSpace(request.ComputedBundleSha256))
|
||||
{
|
||||
errors["computedBundleSha256"] = new[] { "invalid" };
|
||||
}
|
||||
|
||||
if (request.BundlePolicyHash is not null && string.IsNullOrWhiteSpace(request.BundlePolicyHash))
|
||||
{
|
||||
errors["bundlePolicyHash"] = new[] { "invalid" };
|
||||
}
|
||||
|
||||
if (request.SealedPolicyHash is not null && string.IsNullOrWhiteSpace(request.SealedPolicyHash))
|
||||
{
|
||||
errors["sealedPolicyHash"] = new[] { "invalid" };
|
||||
}
|
||||
|
||||
return errors.Count > 0 ? Results.ValidationProblem(errors) : null;
|
||||
}
|
||||
|
||||
private static bool IsValidBudget(StalenessBudget budget, out string error)
|
||||
{
|
||||
if (budget.WarningSeconds < 0 || budget.BreachSeconds < 0)
|
||||
{
|
||||
error = "must_be_non_negative";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (budget.WarningSeconds > budget.BreachSeconds)
|
||||
{
|
||||
error = "warning_exceeds_breach";
|
||||
return false;
|
||||
}
|
||||
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.AirGap.Controller.Options;
|
||||
|
||||
public sealed class AirGapTelemetryOptions
|
||||
{
|
||||
public int MaxTenantEntries { get; set; } = 1000;
|
||||
}
|
||||
@@ -22,5 +22,5 @@ app.MapAirGapEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
// Make Program class file-scoped to prevent it from being exposed to referencing assemblies
|
||||
file sealed partial class Program;
|
||||
// Expose Program class for WebApplicationFactory tests.
|
||||
public partial class Program;
|
||||
|
||||
@@ -59,6 +59,10 @@ internal sealed class AirGapStartupDiagnosticsHostedService : IHostedService
|
||||
{
|
||||
failures.Add("egress-allowlist-missing");
|
||||
}
|
||||
else if (_options.EgressAllowlist.Length == 0)
|
||||
{
|
||||
failures.Add("egress-allowlist-empty");
|
||||
}
|
||||
|
||||
if (state.TimeAnchor == TimeAnchor.Unknown)
|
||||
{
|
||||
@@ -69,7 +73,7 @@ internal sealed class AirGapStartupDiagnosticsHostedService : IHostedService
|
||||
failures.Add("time-anchor-stale");
|
||||
}
|
||||
|
||||
var trustResult = ValidateTrustMaterials(_options.Trust);
|
||||
var trustResult = await ValidateTrustMaterialsAsync(_options.Trust, cancellationToken);
|
||||
if (!trustResult.IsValid)
|
||||
{
|
||||
failures.Add($"trust:{trustResult.Reason}");
|
||||
@@ -99,7 +103,9 @@ internal sealed class AirGapStartupDiagnosticsHostedService : IHostedService
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
private StartupCheckResult ValidateTrustMaterials(TrustMaterialOptions trust)
|
||||
private async Task<StartupCheckResult> ValidateTrustMaterialsAsync(
|
||||
TrustMaterialOptions trust,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!trust.IsConfigured)
|
||||
{
|
||||
@@ -108,16 +114,21 @@ internal sealed class AirGapStartupDiagnosticsHostedService : IHostedService
|
||||
|
||||
try
|
||||
{
|
||||
var rootJson = File.ReadAllText(trust.RootJsonPath);
|
||||
var snapshotJson = File.ReadAllText(trust.SnapshotJsonPath);
|
||||
var timestampJson = File.ReadAllText(trust.TimestampJsonPath);
|
||||
var rootJson = await File.ReadAllTextAsync(trust.RootJsonPath, cancellationToken);
|
||||
var snapshotJson = await File.ReadAllTextAsync(trust.SnapshotJsonPath, cancellationToken);
|
||||
var timestampJson = await File.ReadAllTextAsync(trust.TimestampJsonPath, cancellationToken);
|
||||
var result = _tufValidator.Validate(rootJson, snapshotJson, timestampJson);
|
||||
return result.IsValid
|
||||
? StartupCheckResult.Success()
|
||||
: StartupCheckResult.Failure(result.Reason);
|
||||
if (result.IsValid)
|
||||
{
|
||||
return StartupCheckResult.Success();
|
||||
}
|
||||
|
||||
_logger.LogWarning("AirGap trust validation failed: {Reason}", result.Reason);
|
||||
return StartupCheckResult.Failure(result.Reason);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "AirGap trust validation failed while reading trust material.");
|
||||
return StartupCheckResult.Failure($"trust-read-failed:{ex.GetType().Name.ToLowerInvariant()}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,13 @@ public sealed class AirGapStateService
|
||||
{
|
||||
foreach (var kvp in provided)
|
||||
{
|
||||
result[kvp.Key] = kvp.Value;
|
||||
if (string.IsNullOrWhiteSpace(kvp.Key))
|
||||
{
|
||||
throw new ArgumentException("content-budget-key-invalid", nameof(provided));
|
||||
}
|
||||
|
||||
kvp.Value.Validate();
|
||||
result[kvp.Key.Trim()] = kvp.Value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Threading;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Controller.Options;
|
||||
using StellaOps.AirGap.Controller.Domain;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
|
||||
@@ -19,19 +22,27 @@ public sealed class AirGapTelemetry
|
||||
private static readonly Counter<long> UnsealCounter = Meter.CreateCounter<long>("airgap_unseal_total");
|
||||
private static readonly Counter<long> StartupBlockedCounter = Meter.CreateCounter<long>("airgap_startup_blocked_total");
|
||||
|
||||
private readonly ConcurrentDictionary<string, (long Age, long Budget)> _latestByTenant = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, TelemetryEntry> _latestByTenant = new(StringComparer.Ordinal);
|
||||
private readonly Queue<(string Tenant, long Sequence)> _evictionQueue = new();
|
||||
private readonly object _cacheLock = new();
|
||||
private readonly int _maxTenantEntries;
|
||||
private long _sequence;
|
||||
|
||||
private readonly ObservableGauge<long> _anchorAgeGauge;
|
||||
private readonly ObservableGauge<long> _budgetGauge;
|
||||
private readonly ILogger<AirGapTelemetry> _logger;
|
||||
|
||||
public AirGapTelemetry(ILogger<AirGapTelemetry> logger)
|
||||
public AirGapTelemetry(IOptions<AirGapTelemetryOptions> options, ILogger<AirGapTelemetry> logger)
|
||||
{
|
||||
var maxEntries = options.Value.MaxTenantEntries;
|
||||
_maxTenantEntries = maxEntries > 0 ? maxEntries : 1000;
|
||||
_logger = logger;
|
||||
_anchorAgeGauge = Meter.CreateObservableGauge("airgap_time_anchor_age_seconds", ObserveAges);
|
||||
_budgetGauge = Meter.CreateObservableGauge("airgap_staleness_budget_seconds", ObserveBudgets);
|
||||
}
|
||||
|
||||
internal int TenantCacheCount => _latestByTenant.Count;
|
||||
|
||||
private IEnumerable<Measurement<long>> ObserveAges()
|
||||
{
|
||||
foreach (var kvp in _latestByTenant)
|
||||
@@ -50,7 +61,7 @@ public sealed class AirGapTelemetry
|
||||
|
||||
public void RecordStatus(string tenantId, AirGapStatus status)
|
||||
{
|
||||
_latestByTenant[tenantId] = (status.Staleness.AgeSeconds, status.Staleness.BreachSeconds);
|
||||
UpdateTenant(tenantId, status.Staleness.AgeSeconds, status.Staleness.BreachSeconds);
|
||||
|
||||
using var activity = ActivitySource.StartActivity("airgap.status.read");
|
||||
activity?.SetTag("tenant", tenantId);
|
||||
@@ -95,14 +106,14 @@ public sealed class AirGapTelemetry
|
||||
|
||||
public void RecordStartupBlocked(string tenantId, string reason, StalenessEvaluation staleness)
|
||||
{
|
||||
_latestByTenant[tenantId] = (staleness.AgeSeconds, staleness.BreachSeconds);
|
||||
UpdateTenant(tenantId, staleness.AgeSeconds, staleness.BreachSeconds);
|
||||
StartupBlockedCounter.Add(1, new TagList { { "tenant", tenantId }, { "reason", reason } });
|
||||
_logger.LogCritical("airgap.startup.validation failed tenant={Tenant} reason={Reason}", tenantId, reason);
|
||||
}
|
||||
|
||||
public void RecordStartupPassed(string tenantId, StalenessEvaluation staleness, int allowlistCount)
|
||||
{
|
||||
_latestByTenant[tenantId] = (staleness.AgeSeconds, staleness.BreachSeconds);
|
||||
UpdateTenant(tenantId, staleness.AgeSeconds, staleness.BreachSeconds);
|
||||
using var activity = ActivitySource.StartActivity("airgap.startup.validation");
|
||||
activity?.SetTag("tenant", tenantId);
|
||||
activity?.SetTag("result", "success");
|
||||
@@ -115,4 +126,35 @@ public sealed class AirGapTelemetry
|
||||
allowlistCount,
|
||||
staleness.AgeSeconds);
|
||||
}
|
||||
|
||||
private void UpdateTenant(string tenantId, long ageSeconds, long budgetSeconds)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var sequence = Interlocked.Increment(ref _sequence);
|
||||
_latestByTenant[tenantId] = new TelemetryEntry(ageSeconds, budgetSeconds, sequence);
|
||||
|
||||
lock (_cacheLock)
|
||||
{
|
||||
_evictionQueue.Enqueue((tenantId, sequence));
|
||||
TrimCache();
|
||||
}
|
||||
}
|
||||
|
||||
private void TrimCache()
|
||||
{
|
||||
while (_latestByTenant.Count > _maxTenantEntries && _evictionQueue.Count > 0)
|
||||
{
|
||||
var (tenant, sequence) = _evictionQueue.Dequeue();
|
||||
if (_latestByTenant.TryGetValue(tenant, out var entry) && entry.Sequence == sequence)
|
||||
{
|
||||
_latestByTenant.TryRemove(tenant, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct TelemetryEntry(long Age, long Budget, long Sequence);
|
||||
}
|
||||
|
||||
@@ -8,5 +8,6 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.AirGap.Time/StellaOps.AirGap.Time.csproj" />
|
||||
<ProjectReference Include="../StellaOps.AirGap.Importer/StellaOps.AirGap.Importer.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0024-M | DONE | Maintainability audit for StellaOps.AirGap.Controller. |
|
||||
| AUDIT-0024-T | DONE | Test coverage audit for StellaOps.AirGap.Controller. |
|
||||
| AUDIT-0024-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0024-A | DONE | Applied auth/tenant validation, request validation, telemetry cap, and tests. |
|
||||
|
||||
@@ -108,7 +108,7 @@ public sealed class EvidenceReconciler : IEvidenceReconciler
|
||||
|
||||
var index = new ArtifactIndex();
|
||||
|
||||
// Step 2: Evidence collection (SBOM + attestations). VEX parsing is not yet implemented.
|
||||
// Step 2: Evidence collection (SBOM + attestations).
|
||||
await _sbomCollector.CollectAsync(Path.Combine(inputDirectory, "sboms"), index, ct).ConfigureAwait(false);
|
||||
|
||||
var attestationOptions = new AttestationCollectionOptions
|
||||
@@ -127,11 +127,15 @@ public sealed class EvidenceReconciler : IEvidenceReconciler
|
||||
ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Step 4: Lattice merge (currently no VEX ingestion; returns empty).
|
||||
var mergedStatements = new Dictionary<string, VexStatement>(StringComparer.Ordinal);
|
||||
// Step 4: VEX ingestion + lattice merge.
|
||||
var (mergedStatements, conflictCount) = await MergeVexStatementsAsync(index, options, ct).ConfigureAwait(false);
|
||||
|
||||
// Step 5: Graph emission.
|
||||
var graph = BuildGraph(index, mergedStatements, generatedAtUtc: options.GeneratedAtUtc ?? DeterministicEpoch);
|
||||
var graph = BuildGraph(
|
||||
index,
|
||||
mergedStatements,
|
||||
conflictCount,
|
||||
generatedAtUtc: options.GeneratedAtUtc ?? DeterministicEpoch);
|
||||
await _serializer.WriteAsync(graph, outputDirectory, ct).ConfigureAwait(false);
|
||||
|
||||
if (options.SignOutput)
|
||||
@@ -156,6 +160,7 @@ public sealed class EvidenceReconciler : IEvidenceReconciler
|
||||
private static EvidenceGraph BuildGraph(
|
||||
ArtifactIndex index,
|
||||
IReadOnlyDictionary<string, VexStatement> mergedStatements,
|
||||
int conflictCount,
|
||||
DateTimeOffset generatedAtUtc)
|
||||
{
|
||||
var nodes = new List<EvidenceNode>();
|
||||
@@ -233,9 +238,148 @@ public sealed class EvidenceReconciler : IEvidenceReconciler
|
||||
SbomCount = sbomCount,
|
||||
AttestationCount = attestationCount,
|
||||
VexStatementCount = mergedStatements.Count,
|
||||
ConflictCount = 0,
|
||||
ConflictCount = conflictCount,
|
||||
ReconciliationDurationMs = 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<(Dictionary<string, VexStatement> Statements, int ConflictCount)> MergeVexStatementsAsync(
|
||||
ArtifactIndex index,
|
||||
ReconciliationOptions options,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var lattice = new SourcePrecedenceLattice(options.Lattice);
|
||||
var statementsByKey = new Dictionary<string, List<VexStatement>>(StringComparer.Ordinal);
|
||||
var documentCache = new Dictionary<string, OpenVexDocument>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var (digest, entry) in index.GetAll())
|
||||
{
|
||||
foreach (var vexRef in entry.VexDocuments)
|
||||
{
|
||||
if (!documentCache.TryGetValue(vexRef.FilePath, out var document))
|
||||
{
|
||||
var loaded = await TryLoadOpenVexDocumentAsync(vexRef.FilePath, ct).ConfigureAwait(false);
|
||||
if (loaded is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
documentCache[vexRef.FilePath] = loaded;
|
||||
document = loaded;
|
||||
}
|
||||
|
||||
var source = ResolveSourcePrecedence(document.Author, options.Lattice);
|
||||
var documentRef = document.DocumentId ?? vexRef.FilePath;
|
||||
|
||||
foreach (var statement in document.Statements)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(statement.VulnerabilityId) || string.IsNullOrWhiteSpace(statement.Status))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = $"{digest}:{statement.VulnerabilityId}";
|
||||
if (!statementsByKey.TryGetValue(key, out var list))
|
||||
{
|
||||
list = new List<VexStatement>();
|
||||
statementsByKey[key] = list;
|
||||
}
|
||||
|
||||
list.Add(new VexStatement
|
||||
{
|
||||
VulnerabilityId = statement.VulnerabilityId!,
|
||||
ProductId = digest,
|
||||
Status = MapStatus(statement.Status),
|
||||
Source = source,
|
||||
Justification = statement.Justification,
|
||||
ActionStatement = statement.ActionStatement,
|
||||
Timestamp = statement.Timestamp ?? document.Timestamp,
|
||||
DocumentRef = documentRef
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var merged = new Dictionary<string, VexStatement>(StringComparer.Ordinal);
|
||||
var conflictCount = 0;
|
||||
|
||||
foreach (var (key, statements) in statementsByKey)
|
||||
{
|
||||
if (statements.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var winner = lattice.Merge(statements);
|
||||
if (statements.Count > 1 &&
|
||||
statements.Any(s => !ReferenceEquals(s, winner) && lattice.ResolveConflict(winner, s).HasConflict))
|
||||
{
|
||||
conflictCount++;
|
||||
}
|
||||
|
||||
merged[key] = winner;
|
||||
}
|
||||
|
||||
return (merged, conflictCount);
|
||||
}
|
||||
|
||||
private static async Task<OpenVexDocument?> TryLoadOpenVexDocumentAsync(string filePath, CancellationToken ct)
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
var parser = new DsseAttestationParser();
|
||||
var parseResult = await parser.ParseAsync(stream, ct).ConfigureAwait(false);
|
||||
if (parseResult.IsSuccess && !string.IsNullOrWhiteSpace(parseResult.Statement?.PredicateJson))
|
||||
{
|
||||
if (OpenVexParser.TryParse(parseResult.Statement.PredicateJson, out var document))
|
||||
{
|
||||
return document;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Fallback below.
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(filePath, ct).ConfigureAwait(false);
|
||||
return OpenVexParser.TryParse(json, out var document) ? document : null;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static SourcePrecedence ResolveSourcePrecedence(string? source, LatticeConfiguration config)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(source) && config.SourceMappings.TryGetValue(source, out var mapped))
|
||||
{
|
||||
return mapped;
|
||||
}
|
||||
|
||||
return SourcePrecedence.Unknown;
|
||||
}
|
||||
|
||||
private static VexStatus MapStatus(string status)
|
||||
{
|
||||
var normalized = status.Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"affected" => VexStatus.Affected,
|
||||
"not_affected" => VexStatus.NotAffected,
|
||||
"fixed" => VexStatus.Fixed,
|
||||
"under_investigation" => VexStatus.UnderInvestigation,
|
||||
_ => VexStatus.Unknown
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +167,8 @@ public sealed record InTotoSubject
|
||||
/// <summary>
|
||||
/// Subject digests (algorithm -> hash).
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> Digest { get; init; } = new Dictionary<string, string>();
|
||||
public IReadOnlyDictionary<string, string> Digest { get; init; }
|
||||
= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the normalized SHA-256 digest if available.
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AirGap.Importer.Reconciliation.Parsers;
|
||||
|
||||
internal static class OpenVexParser
|
||||
{
|
||||
public static bool TryParse(string json, out OpenVexDocument document)
|
||||
{
|
||||
document = new OpenVexDocument();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var parsed = JsonDocument.Parse(
|
||||
json,
|
||||
new JsonDocumentOptions
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
CommentHandling = JsonCommentHandling.Skip
|
||||
});
|
||||
|
||||
var root = parsed.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var documentId = GetString(root, "@id");
|
||||
var author = GetString(root, "author");
|
||||
var timestamp = TryParseTimestamp(root, "timestamp");
|
||||
|
||||
var statements = new List<OpenVexStatement>();
|
||||
if (root.TryGetProperty("statements", out var statementsProp) &&
|
||||
statementsProp.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var element in statementsProp.EnumerateArray())
|
||||
{
|
||||
if (TryParseStatement(element, timestamp, out var statement))
|
||||
{
|
||||
statements.Add(statement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document = new OpenVexDocument
|
||||
{
|
||||
DocumentId = documentId,
|
||||
Author = author,
|
||||
Timestamp = timestamp,
|
||||
Statements = statements
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseStatement(
|
||||
JsonElement element,
|
||||
DateTimeOffset? defaultTimestamp,
|
||||
out OpenVexStatement statement)
|
||||
{
|
||||
statement = new OpenVexStatement();
|
||||
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var vulnerabilityId = ResolveVulnerabilityId(element);
|
||||
if (string.IsNullOrWhiteSpace(vulnerabilityId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var status = GetString(element, "status");
|
||||
if (string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var justification = GetString(element, "justification");
|
||||
var actionStatement = GetString(element, "action_statement");
|
||||
|
||||
var timestamp = TryParseTimestamp(element, "timestamp")
|
||||
?? TryParseTimestamp(element, "action_statement_timestamp")
|
||||
?? defaultTimestamp;
|
||||
|
||||
var products = new List<string>();
|
||||
if (element.TryGetProperty("products", out var productsProp) &&
|
||||
productsProp.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var product in productsProp.EnumerateArray())
|
||||
{
|
||||
if (product.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var productId = GetString(product, "@id") ?? GetString(product, "id");
|
||||
if (!string.IsNullOrWhiteSpace(productId))
|
||||
{
|
||||
products.Add(productId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
statement = new OpenVexStatement
|
||||
{
|
||||
VulnerabilityId = vulnerabilityId,
|
||||
Status = status,
|
||||
Justification = justification,
|
||||
ActionStatement = actionStatement,
|
||||
Timestamp = timestamp,
|
||||
Products = products
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? ResolveVulnerabilityId(JsonElement element)
|
||||
{
|
||||
if (!element.TryGetProperty("vulnerability", out var vulnerabilityProp) ||
|
||||
vulnerabilityProp.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return GetString(vulnerabilityProp, "@id")
|
||||
?? GetString(vulnerabilityProp, "id")
|
||||
?? GetString(vulnerabilityProp, "name");
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement element, string propertyName)
|
||||
{
|
||||
if (element.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return prop.GetString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? TryParseTimestamp(JsonElement element, string propertyName)
|
||||
{
|
||||
var value = GetString(element, propertyName);
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var timestamp)
|
||||
? timestamp
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record OpenVexDocument
|
||||
{
|
||||
public string? DocumentId { get; init; }
|
||||
public string? Author { get; init; }
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
public IReadOnlyList<OpenVexStatement> Statements { get; init; } = [];
|
||||
}
|
||||
|
||||
internal sealed record OpenVexStatement
|
||||
{
|
||||
public string? VulnerabilityId { get; init; }
|
||||
public string? Status { get; init; }
|
||||
public string? Justification { get; init; }
|
||||
public string? ActionStatement { get; init; }
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
public IReadOnlyList<string> Products { get; init; } = [];
|
||||
}
|
||||
@@ -7,5 +7,5 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0026-M | DONE | Maintainability audit for StellaOps.AirGap.Importer. |
|
||||
| AUDIT-0026-T | DONE | Test coverage audit for StellaOps.AirGap.Importer. |
|
||||
| AUDIT-0026-A | DOING | Pending approval for changes. |
|
||||
| AUDIT-0026-A | DONE | Applied VEX merge, monotonicity guard, and DSSE PAE alignment. |
|
||||
| VAL-SMOKE-001 | DONE | Resolved DSSE signer ambiguity; smoke build now proceeds. |
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
@@ -508,7 +509,18 @@ public static class RekorOfflineReceiptVerifier
|
||||
|
||||
private static bool LooksLikeDashSignature(string trimmedLine)
|
||||
{
|
||||
return trimmedLine.Length > 0 && trimmedLine[0] == '\u2014';
|
||||
if (trimmedLine.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var first = trimmedLine[0];
|
||||
if (first == '-')
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return CharUnicodeInfo.GetUnicodeCategory(first) == UnicodeCategory.DashPunctuation;
|
||||
}
|
||||
private static bool TryDecodeBase64(string token, out byte[] bytes)
|
||||
{
|
||||
|
||||
@@ -61,6 +61,37 @@ public sealed class PostgresBundleVersionStore : RepositoryBase<AirGapDataSource
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantKey, "writer", ct).ConfigureAwait(false);
|
||||
await using var tx = await connection.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var current = await GetCurrentForUpdateAsync(
|
||||
connection,
|
||||
tx,
|
||||
versionTable,
|
||||
tenantKey,
|
||||
bundleTypeKey,
|
||||
ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (current is not null && !record.WasForceActivated)
|
||||
{
|
||||
var incomingVersion = new BundleVersion(
|
||||
record.Major,
|
||||
record.Minor,
|
||||
record.Patch,
|
||||
record.BundleCreatedAt,
|
||||
record.Prerelease);
|
||||
var currentVersion = new BundleVersion(
|
||||
current.Major,
|
||||
current.Minor,
|
||||
current.Patch,
|
||||
current.BundleCreatedAt,
|
||||
current.Prerelease);
|
||||
|
||||
if (!incomingVersion.IsNewerThan(currentVersion))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Incoming version '{incomingVersion.SemVer}' is not monotonic vs current '{currentVersion.SemVer}'.");
|
||||
}
|
||||
}
|
||||
|
||||
var closeHistorySql = $$"""
|
||||
UPDATE {{historyTable}}
|
||||
SET deactivated_at = @activated_at
|
||||
@@ -224,6 +255,31 @@ public sealed class PostgresBundleVersionStore : RepositoryBase<AirGapDataSource
|
||||
ForceActivateReason: forceActivateReason);
|
||||
}
|
||||
|
||||
private async Task<BundleVersionRecord?> GetCurrentForUpdateAsync(
|
||||
NpgsqlConnection connection,
|
||||
NpgsqlTransaction transaction,
|
||||
string versionTable,
|
||||
string tenantKey,
|
||||
string bundleTypeKey,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var sql = $$"""
|
||||
SELECT tenant_id, bundle_type, version_string, major, minor, patch, prerelease,
|
||||
bundle_created_at, bundle_digest, activated_at, was_force_activated, force_activate_reason
|
||||
FROM {{versionTable}}
|
||||
WHERE tenant_id = @tenant_id AND bundle_type = @bundle_type
|
||||
FOR UPDATE;
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
command.Transaction = transaction;
|
||||
AddParameter(command, "tenant_id", tenantKey);
|
||||
AddParameter(command, "bundle_type", bundleTypeKey);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
return await reader.ReadAsync(ct).ConfigureAwait(false) ? Map(reader) : null;
|
||||
}
|
||||
|
||||
private async ValueTask EnsureTablesAsync(CancellationToken ct)
|
||||
{
|
||||
if (_initialized)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
extern alias AirGapController;
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.AirGap.Controller.Tests;
|
||||
|
||||
public sealed class AirGapEndpointTests : IClassFixture<WebApplicationFactory<AirGapController::Program>>
|
||||
{
|
||||
private readonly WebApplicationFactory<AirGapController::Program> _factory;
|
||||
|
||||
public AirGapEndpointTests(WebApplicationFactory<AirGapController::Program> factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Status_requires_scope_header()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
var response = await client.GetAsync("/system/airgap/status");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Status_requires_tenant_header_or_claim()
|
||||
{
|
||||
using var client = CreateClient(scopes: "airgap:status:read");
|
||||
var response = await client.GetAsync("/system/airgap/status");
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var payload = await ReadErrorAsync(response);
|
||||
Assert.Equal("tenant_required", payload);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Seal_validates_staleness_budget()
|
||||
{
|
||||
using var client = CreateClient(scopes: "airgap:seal", tenantId: "tenant-a");
|
||||
var response = await client.PostAsJsonAsync("/system/airgap/seal", new
|
||||
{
|
||||
policyHash = "policy-1",
|
||||
stalenessBudget = new { warningSeconds = 120, breachSeconds = 60 }
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Verify_rejects_missing_hashes()
|
||||
{
|
||||
using var client = CreateClient(scopes: "airgap:verify", tenantId: "tenant-a");
|
||||
var response = await client.PostAsJsonAsync("/system/airgap/verify", new
|
||||
{
|
||||
manifestCreatedAt = DateTimeOffset.Parse("2025-12-01T00:00:00Z")
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Seal_and_status_round_trip()
|
||||
{
|
||||
using var client = CreateClient(scopes: "airgap:seal airgap:status:read", tenantId: "tenant-ops");
|
||||
var response = await client.PostAsJsonAsync("/system/airgap/seal", new
|
||||
{
|
||||
policyHash = "policy-ops",
|
||||
timeAnchor = new TimeAnchor(DateTimeOffset.Parse("2025-12-10T12:00:00Z"), "rough", "rough", "fp", "digest"),
|
||||
stalenessBudget = new { warningSeconds = 60, breachSeconds = 120 }
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var statusResponse = await client.GetAsync("/system/airgap/status");
|
||||
Assert.Equal(HttpStatusCode.OK, statusResponse.StatusCode);
|
||||
}
|
||||
|
||||
private HttpClient CreateClient(string scopes, string? tenantId = null)
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("scope", scopes);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
client.DefaultRequestHeaders.Add("x-tenant-id", tenantId);
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<string?> ReadErrorAsync(HttpResponseMessage response)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(payload);
|
||||
return doc.RootElement.TryGetProperty("error", out var error)
|
||||
? error.GetString()
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ using StellaOps.AirGap.Importer.Validation;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
using Xunit;
|
||||
using OptionsFactory = Microsoft.Extensions.Options.Options;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
|
||||
@@ -15,11 +16,13 @@ namespace StellaOps.AirGap.Controller.Tests;
|
||||
|
||||
public class AirGapStartupDiagnosticsHostedServiceTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2025, 12, 20, 8, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Blocks_when_allowlist_missing_for_sealed_state()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
var store = new InMemoryAirGapStateStore();
|
||||
await store.SetAsync(new AirGapState
|
||||
{
|
||||
@@ -30,8 +33,8 @@ public class AirGapStartupDiagnosticsHostedServiceTests
|
||||
StalenessBudget = new StalenessBudget(60, 120)
|
||||
});
|
||||
|
||||
var trustDir = CreateTrustMaterial();
|
||||
var options = BuildOptions(trustDir);
|
||||
using var trustDir = CreateTrustMaterial(now);
|
||||
var options = BuildOptions(trustDir.Path);
|
||||
options.EgressAllowlist = null; // simulate missing config section
|
||||
|
||||
var service = CreateService(store, options, now);
|
||||
@@ -44,7 +47,7 @@ public class AirGapStartupDiagnosticsHostedServiceTests
|
||||
[Fact]
|
||||
public async Task Passes_when_materials_present_and_anchor_fresh()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
var store = new InMemoryAirGapStateStore();
|
||||
await store.SetAsync(new AirGapState
|
||||
{
|
||||
@@ -55,8 +58,8 @@ public class AirGapStartupDiagnosticsHostedServiceTests
|
||||
StalenessBudget = new StalenessBudget(300, 600)
|
||||
});
|
||||
|
||||
var trustDir = CreateTrustMaterial();
|
||||
var options = BuildOptions(trustDir, new[] { "127.0.0.1/32" });
|
||||
using var trustDir = CreateTrustMaterial(now);
|
||||
var options = BuildOptions(trustDir.Path, new[] { "127.0.0.1/32" });
|
||||
|
||||
var service = CreateService(store, options, now);
|
||||
|
||||
@@ -67,7 +70,7 @@ public class AirGapStartupDiagnosticsHostedServiceTests
|
||||
[Fact]
|
||||
public async Task Blocks_when_anchor_is_stale()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
var store = new InMemoryAirGapStateStore();
|
||||
await store.SetAsync(new AirGapState
|
||||
{
|
||||
@@ -78,8 +81,8 @@ public class AirGapStartupDiagnosticsHostedServiceTests
|
||||
StalenessBudget = new StalenessBudget(60, 90)
|
||||
});
|
||||
|
||||
var trustDir = CreateTrustMaterial();
|
||||
var options = BuildOptions(trustDir, new[] { "10.0.0.0/24" });
|
||||
using var trustDir = CreateTrustMaterial(now);
|
||||
var options = BuildOptions(trustDir.Path, new[] { "10.0.0.0/24" });
|
||||
|
||||
var service = CreateService(store, options, now);
|
||||
|
||||
@@ -91,7 +94,7 @@ public class AirGapStartupDiagnosticsHostedServiceTests
|
||||
[Fact]
|
||||
public async Task Blocks_when_rotation_pending_without_dual_approval()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
var store = new InMemoryAirGapStateStore();
|
||||
await store.SetAsync(new AirGapState
|
||||
{
|
||||
@@ -102,8 +105,8 @@ public class AirGapStartupDiagnosticsHostedServiceTests
|
||||
StalenessBudget = new StalenessBudget(120, 240)
|
||||
});
|
||||
|
||||
var trustDir = CreateTrustMaterial();
|
||||
var options = BuildOptions(trustDir, new[] { "10.10.0.0/16" });
|
||||
using var trustDir = CreateTrustMaterial(now);
|
||||
var options = BuildOptions(trustDir.Path, new[] { "10.10.0.0/16" });
|
||||
options.Rotation.PendingKeys["k-new"] = Convert.ToBase64String(new byte[] { 1, 2, 3 });
|
||||
options.Rotation.ActiveKeys["k-old"] = Convert.ToBase64String(new byte[] { 9, 9, 9 });
|
||||
options.Rotation.ApproverIds.Add("approver-1");
|
||||
@@ -135,22 +138,22 @@ public class AirGapStartupDiagnosticsHostedServiceTests
|
||||
store,
|
||||
new StalenessCalculator(),
|
||||
new FixedTimeProvider(now),
|
||||
Microsoft.Extensions.Options.Options.Create(options),
|
||||
OptionsFactory.Create(options),
|
||||
NullLogger<AirGapStartupDiagnosticsHostedService>.Instance,
|
||||
new AirGapTelemetry(NullLogger<AirGapTelemetry>.Instance),
|
||||
new AirGapTelemetry(OptionsFactory.Create(new AirGapTelemetryOptions()), NullLogger<AirGapTelemetry>.Instance),
|
||||
new TufMetadataValidator(),
|
||||
new RootRotationPolicy());
|
||||
}
|
||||
|
||||
private static string CreateTrustMaterial()
|
||||
private static TempDirectory CreateTrustMaterial(DateTimeOffset now)
|
||||
{
|
||||
var dir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "airgap-trust-" + Guid.NewGuid().ToString("N"))).FullName;
|
||||
var expires = DateTimeOffset.UtcNow.AddDays(1).ToString("O");
|
||||
var dir = new TempDirectory("airgap-trust");
|
||||
var expires = now.AddDays(1).ToString("O");
|
||||
const string hash = "abc123";
|
||||
|
||||
File.WriteAllText(Path.Combine(dir, "root.json"), $"{{\"version\":1,\"expiresUtc\":\"{expires}\"}}");
|
||||
File.WriteAllText(Path.Combine(dir, "snapshot.json"), $"{{\"version\":1,\"expiresUtc\":\"{expires}\",\"meta\":{{\"snapshot\":{{\"hashes\":{{\"sha256\":\"{hash}\"}}}}}}}}");
|
||||
File.WriteAllText(Path.Combine(dir, "timestamp.json"), $"{{\"version\":1,\"expiresUtc\":\"{expires}\",\"snapshot\":{{\"meta\":{{\"hashes\":{{\"sha256\":\"{hash}\"}}}}}}}}");
|
||||
File.WriteAllText(Path.Combine(dir.Path, "root.json"), $"{{\"version\":1,\"expiresUtc\":\"{expires}\"}}");
|
||||
File.WriteAllText(Path.Combine(dir.Path, "snapshot.json"), $"{{\"version\":1,\"expiresUtc\":\"{expires}\",\"meta\":{{\"snapshot\":{{\"hashes\":{{\"sha256\":\"{hash}\"}}}}}}}}");
|
||||
File.WriteAllText(Path.Combine(dir.Path, "timestamp.json"), $"{{\"version\":1,\"expiresUtc\":\"{expires}\",\"snapshot\":{{\"meta\":{{\"hashes\":{{\"sha256\":\"{hash}\"}}}}}}}}");
|
||||
|
||||
return dir;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ namespace StellaOps.AirGap.Controller.Tests;
|
||||
|
||||
public class AirGapStateServiceTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2025, 12, 10, 9, 0, 0, TimeSpan.Zero);
|
||||
private readonly AirGapStateService _service;
|
||||
private readonly InMemoryAirGapStateStore _store = new();
|
||||
private readonly StalenessCalculator _calculator = new();
|
||||
@@ -23,7 +24,7 @@ public class AirGapStateServiceTests
|
||||
[Fact]
|
||||
public async Task Seal_sets_state_and_computes_staleness()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
var anchor = new TimeAnchor(now.AddMinutes(-2), "roughtime", "roughtime", "fp", "digest");
|
||||
var budget = new StalenessBudget(60, 120);
|
||||
|
||||
@@ -42,7 +43,7 @@ public class AirGapStateServiceTests
|
||||
[Fact]
|
||||
public async Task Unseal_clears_sealed_flag_and_updates_timestamp()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
await _service.SealAsync("default", "hash", TimeAnchor.Unknown, StalenessBudget.Default, now);
|
||||
|
||||
var later = now.AddMinutes(1);
|
||||
@@ -57,7 +58,7 @@ public class AirGapStateServiceTests
|
||||
[Fact]
|
||||
public async Task Seal_persists_drift_baseline_seconds()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
var anchor = new TimeAnchor(now.AddMinutes(-5), "roughtime", "roughtime", "fp", "digest");
|
||||
var budget = StalenessBudget.Default;
|
||||
|
||||
@@ -70,7 +71,7 @@ public class AirGapStateServiceTests
|
||||
[Fact]
|
||||
public async Task Seal_creates_default_content_budgets_when_not_provided()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
var anchor = new TimeAnchor(now.AddMinutes(-1), "roughtime", "roughtime", "fp", "digest");
|
||||
var budget = new StalenessBudget(120, 240);
|
||||
|
||||
@@ -86,7 +87,7 @@ public class AirGapStateServiceTests
|
||||
[Fact]
|
||||
public async Task Seal_uses_provided_content_budgets()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
var anchor = new TimeAnchor(now.AddMinutes(-1), "roughtime", "roughtime", "fp", "digest");
|
||||
var budget = StalenessBudget.Default;
|
||||
var contentBudgets = new Dictionary<string, StalenessBudget>
|
||||
@@ -106,7 +107,7 @@ public class AirGapStateServiceTests
|
||||
[Fact]
|
||||
public async Task GetStatus_returns_per_content_staleness()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
var anchor = new TimeAnchor(now.AddSeconds(-45), "roughtime", "roughtime", "fp", "digest");
|
||||
var budget = StalenessBudget.Default;
|
||||
var contentBudgets = new Dictionary<string, StalenessBudget>
|
||||
@@ -125,4 +126,20 @@ public class AirGapStateServiceTests
|
||||
Assert.False(status.ContentStaleness["vex"].IsWarning); // 45s < 60s warning
|
||||
Assert.False(status.ContentStaleness["policy"].IsWarning); // 45s < 100s warning
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Seal_rejects_invalid_content_budgets()
|
||||
{
|
||||
var now = FixedNow;
|
||||
var anchor = new TimeAnchor(now.AddMinutes(-1), "roughtime", "roughtime", "fp", "digest");
|
||||
var budget = StalenessBudget.Default;
|
||||
var contentBudgets = new Dictionary<string, StalenessBudget>
|
||||
{
|
||||
{ "advisories", new StalenessBudget(120, 60) }
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
|
||||
_service.SealAsync("tenant-invalid", "policy", anchor, budget, now, contentBudgets));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Controller.Domain;
|
||||
using StellaOps.AirGap.Controller.Options;
|
||||
using StellaOps.AirGap.Controller.Services;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using Xunit;
|
||||
using OptionsFactory = Microsoft.Extensions.Options.Options;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.AirGap.Controller.Tests;
|
||||
|
||||
public sealed class AirGapTelemetryTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2025, 12, 12, 10, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Evicts_oldest_tenants_when_over_limit()
|
||||
{
|
||||
var options = OptionsFactory.Create(new AirGapTelemetryOptions { MaxTenantEntries = 2 });
|
||||
var telemetry = new AirGapTelemetry(options, NullLogger<AirGapTelemetry>.Instance);
|
||||
|
||||
telemetry.RecordStatus("tenant-1", BuildStatus("tenant-1"));
|
||||
telemetry.RecordStatus("tenant-2", BuildStatus("tenant-2"));
|
||||
telemetry.RecordStatus("tenant-3", BuildStatus("tenant-3"));
|
||||
|
||||
Assert.Equal(2, telemetry.TenantCacheCount);
|
||||
}
|
||||
|
||||
private static AirGapStatus BuildStatus(string tenantId)
|
||||
{
|
||||
var state = new AirGapState
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Sealed = true,
|
||||
PolicyHash = "policy",
|
||||
TimeAnchor = TimeAnchor.Unknown,
|
||||
StalenessBudget = StalenessBudget.Default,
|
||||
LastTransitionAt = FixedNow
|
||||
};
|
||||
|
||||
var empty = new Dictionary<string, StalenessEvaluation>(StringComparer.OrdinalIgnoreCase);
|
||||
return new AirGapStatus(state, StalenessEvaluation.Unknown, empty, FixedNow);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ namespace StellaOps.AirGap.Controller.Tests;
|
||||
|
||||
public class InMemoryAirGapStateStoreTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2025, 12, 5, 13, 0, 0, TimeSpan.Zero);
|
||||
private readonly InMemoryAirGapStateStore _store = new();
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
@@ -20,9 +21,9 @@ public class InMemoryAirGapStateStoreTests
|
||||
TenantId = "tenant-x",
|
||||
Sealed = true,
|
||||
PolicyHash = "hash-1",
|
||||
TimeAnchor = new TimeAnchor(DateTimeOffset.UtcNow, "roughtime", "roughtime", "fp", "digest"),
|
||||
TimeAnchor = new TimeAnchor(FixedNow, "roughtime", "roughtime", "fp", "digest"),
|
||||
StalenessBudget = new StalenessBudget(10, 20),
|
||||
LastTransitionAt = DateTimeOffset.UtcNow
|
||||
LastTransitionAt = FixedNow
|
||||
};
|
||||
|
||||
await _store.SetAsync(state);
|
||||
@@ -106,7 +107,7 @@ public class InMemoryAirGapStateStoreTests
|
||||
[Fact]
|
||||
public async Task Staleness_round_trip_matches_budget()
|
||||
{
|
||||
var anchor = new TimeAnchor(DateTimeOffset.UtcNow.AddMinutes(-3), "roughtime", "roughtime", "fp", "digest");
|
||||
var anchor = new TimeAnchor(FixedNow.AddMinutes(-3), "roughtime", "roughtime", "fp", "digest");
|
||||
var budget = new StalenessBudget(60, 600);
|
||||
await _store.SetAsync(new AirGapState
|
||||
{
|
||||
@@ -115,7 +116,7 @@ public class InMemoryAirGapStateStoreTests
|
||||
PolicyHash = "hash-s",
|
||||
TimeAnchor = anchor,
|
||||
StalenessBudget = budget,
|
||||
LastTransitionAt = DateTimeOffset.UtcNow
|
||||
LastTransitionAt = FixedNow
|
||||
});
|
||||
|
||||
var stored = await _store.GetAsync("tenant-staleness");
|
||||
@@ -129,7 +130,7 @@ public class InMemoryAirGapStateStoreTests
|
||||
public async Task Multi_tenant_states_preserve_transition_times()
|
||||
{
|
||||
var tenants = new[] { "a", "b", "c" };
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
|
||||
foreach (var t in tenants)
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace StellaOps.AirGap.Controller.Tests;
|
||||
|
||||
public class ReplayVerificationServiceTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2025, 12, 2, 1, 0, 0, TimeSpan.Zero);
|
||||
private readonly ReplayVerificationService _service;
|
||||
private readonly AirGapStateService _stateService;
|
||||
private readonly StalenessCalculator _staleness = new();
|
||||
@@ -28,7 +29,7 @@ public class ReplayVerificationServiceTests
|
||||
[Fact]
|
||||
public async Task Passes_full_recompute_when_hashes_match()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2025-12-02T01:00:00Z");
|
||||
var now = FixedNow;
|
||||
await _stateService.SealAsync("tenant-a", "policy-x", TimeAnchor.Unknown, StalenessBudget.Default, now);
|
||||
|
||||
var request = new VerifyRequest
|
||||
@@ -53,7 +54,7 @@ public class ReplayVerificationServiceTests
|
||||
[Fact]
|
||||
public async Task Detects_stale_manifest()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
var request = new VerifyRequest
|
||||
{
|
||||
Depth = ReplayDepth.HashOnly,
|
||||
@@ -75,7 +76,7 @@ public class ReplayVerificationServiceTests
|
||||
[Fact]
|
||||
public async Task Policy_freeze_requires_matching_policy()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
await _stateService.SealAsync("tenant-b", "sealed-policy", TimeAnchor.Unknown, StalenessBudget.Default, now);
|
||||
|
||||
var request = new VerifyRequest
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.AirGap.Controller/StellaOps.AirGap.Controller.csproj" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||
<ProjectReference Include="../../StellaOps.AirGap.Controller/StellaOps.AirGap.Controller.csproj" Aliases="global,AirGapController" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
<Compile Include="../../shared/*.cs" Link="Shared/%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace StellaOps.AirGap.Controller.Tests;
|
||||
|
||||
internal sealed class TempDirectory : IDisposable
|
||||
{
|
||||
private static int _counter;
|
||||
|
||||
public TempDirectory(string? prefix = null)
|
||||
{
|
||||
var id = Interlocked.Increment(ref _counter);
|
||||
var name = $"{prefix ?? "airgap-test"}-{id:D4}";
|
||||
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), name);
|
||||
Directory.CreateDirectory(Path);
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(Path))
|
||||
{
|
||||
Directory.Delete(Path, true);
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.AirGap.Importer.Reconciliation;
|
||||
using StellaOps.AirGap.Importer.Reconciliation.Parsers;
|
||||
|
||||
namespace StellaOps.AirGap.Importer.Tests.Reconciliation;
|
||||
|
||||
public sealed class EvidenceReconcilerVexTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReconcileAsync_MergesVexStatements_BySourcePrecedence()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "stellaops-airgap-tests", Guid.NewGuid().ToString("N"));
|
||||
var input = Path.Combine(root, "input");
|
||||
var output = Path.Combine(root, "output");
|
||||
Directory.CreateDirectory(Path.Combine(input, "attestations"));
|
||||
Directory.CreateDirectory(Path.Combine(input, "sboms"));
|
||||
|
||||
var digest = "sha256:" + new string('a', 64);
|
||||
|
||||
try
|
||||
{
|
||||
var vendorVex = BuildOpenVexDocument("VendorA", "CVE-2023-99997", "not_affected");
|
||||
var researcherVex = BuildOpenVexDocument("Researcher", "CVE-2023-99997", "affected");
|
||||
|
||||
var vendorEnvelope = BuildDsseEnvelope(vendorVex, digest);
|
||||
var researcherEnvelope = BuildDsseEnvelope(researcherVex, digest);
|
||||
|
||||
var attestations = Path.Combine(input, "attestations");
|
||||
await File.WriteAllTextAsync(Path.Combine(attestations, "vendor.dsse.json"), vendorEnvelope);
|
||||
await File.WriteAllTextAsync(Path.Combine(attestations, "researcher.dsse.json"), researcherEnvelope);
|
||||
|
||||
var reconciler = new EvidenceReconciler();
|
||||
var options = new ReconciliationOptions
|
||||
{
|
||||
VerifySignatures = false,
|
||||
Lattice = new LatticeConfiguration
|
||||
{
|
||||
SourceMappings = new Dictionary<string, SourcePrecedence>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["VendorA"] = SourcePrecedence.Vendor,
|
||||
["Researcher"] = SourcePrecedence.ThirdParty
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var graph = await reconciler.ReconcileAsync(input, output, options);
|
||||
|
||||
graph.Metadata.VexStatementCount.Should().Be(1);
|
||||
graph.Metadata.ConflictCount.Should().Be(0);
|
||||
|
||||
var node = graph.Nodes.Single(n => n.Digest == digest);
|
||||
node.VexStatements.Should().NotBeNull();
|
||||
node.VexStatements!.Should().HaveCount(1);
|
||||
node.VexStatements[0].VulnerabilityId.Should().Be("CVE-2023-99997");
|
||||
node.VexStatements[0].Status.Should().Be(VexStatus.NotAffected.ToString());
|
||||
node.VexStatements[0].Source.Should().Be(SourcePrecedence.Vendor.ToString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildOpenVexDocument(string author, string vulnerabilityId, string status)
|
||||
{
|
||||
var statement = new Dictionary<string, object?>
|
||||
{
|
||||
["vulnerability"] = new Dictionary<string, object?>
|
||||
{
|
||||
["@id"] = vulnerabilityId,
|
||||
["name"] = vulnerabilityId
|
||||
},
|
||||
["products"] = new[]
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["@id"] = "pkg:nuget/Example@1.0.0"
|
||||
}
|
||||
},
|
||||
["status"] = status
|
||||
};
|
||||
|
||||
var document = new Dictionary<string, object?>
|
||||
{
|
||||
["@context"] = "https://openvex.dev/ns/v0.2.0",
|
||||
["@id"] = $"urn:stellaops:vex:{author}:{vulnerabilityId}",
|
||||
["author"] = author,
|
||||
["timestamp"] = "2025-01-15T00:00:00Z",
|
||||
["version"] = 1,
|
||||
["statements"] = new[] { statement }
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(document);
|
||||
}
|
||||
|
||||
private static string BuildDsseEnvelope(string predicateJson, string subjectDigest)
|
||||
{
|
||||
using var predicateDoc = JsonDocument.Parse(predicateJson);
|
||||
var predicateElement = predicateDoc.RootElement.Clone();
|
||||
var digest = subjectDigest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)
|
||||
? subjectDigest["sha256:".Length..]
|
||||
: subjectDigest;
|
||||
|
||||
var statement = new
|
||||
{
|
||||
_type = "https://in-toto.io/Statement/v1",
|
||||
predicateType = PredicateTypes.OpenVex,
|
||||
subject = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
name = "artifact",
|
||||
digest = new Dictionary<string, string> { ["sha256"] = digest }
|
||||
}
|
||||
},
|
||||
predicate = predicateElement
|
||||
};
|
||||
|
||||
var statementJson = JsonSerializer.Serialize(statement);
|
||||
var payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(statementJson));
|
||||
var signature = Convert.ToBase64String(Encoding.UTF8.GetBytes("sig"));
|
||||
|
||||
var envelope = new
|
||||
{
|
||||
payloadType = "application/vnd.in-toto+json",
|
||||
payload,
|
||||
signatures = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
keyid = "test",
|
||||
sig = signature
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(envelope);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Reference in New Issue
Block a user