doctor enhancements, setup, enhancements, ui functionality and design consolidation and , test projects fixes , product advisory attestation/rekor and delta verfications enhancements
This commit is contained in:
629
src/Policy/StellaOps.Policy.Api/Endpoints/ReplayEndpoints.cs
Normal file
629
src/Policy/StellaOps.Policy.Api/Endpoints/ReplayEndpoints.cs
Normal file
@@ -0,0 +1,629 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ReplayEndpoints.cs
|
||||
// Sprint: SPRINT_20260118_019_Policy_gate_replay_api_exposure
|
||||
// Task: GR-002 - Implement POST /replay endpoint
|
||||
// Description: API endpoints for policy decision replay and verification
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Policy.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for policy decision replay.
|
||||
/// Enables "months later you can re-prove why a release passed/failed".
|
||||
/// </summary>
|
||||
public static class ReplayEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps replay API endpoints.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapReplayEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/v1/replay")
|
||||
.WithTags("Replay")
|
||||
.WithOpenApi();
|
||||
|
||||
// POST /api/v1/replay - Replay a policy decision
|
||||
group.MapPost("/", ReplayDecisionAsync)
|
||||
.WithName("ReplayDecision")
|
||||
.WithSummary("Replay a historical policy decision")
|
||||
.WithDescription("Re-evaluates a policy decision using frozen snapshots to verify determinism")
|
||||
.Produces<ReplayResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
|
||||
|
||||
// POST /api/v1/replay/batch - Batch replay
|
||||
group.MapPost("/batch", BatchReplayAsync)
|
||||
.WithName("BatchReplay")
|
||||
.WithSummary("Replay multiple policy decisions")
|
||||
.Produces<BatchReplayResponse>(StatusCodes.Status200OK);
|
||||
|
||||
// GET /api/v1/replay/{replayId} - Get replay result
|
||||
group.MapGet("/{replayId}", GetReplayResultAsync)
|
||||
.WithName("GetReplayResult")
|
||||
.WithSummary("Get the result of a replay operation");
|
||||
|
||||
// POST /api/v1/replay/verify-determinism - Verify replay determinism
|
||||
group.MapPost("/verify-determinism", VerifyDeterminismAsync)
|
||||
.WithName("VerifyDeterminism")
|
||||
.WithSummary("Verify that a decision can be deterministically replayed");
|
||||
|
||||
// GET /api/v1/replay/audit - Query replay audit trail
|
||||
group.MapGet("/audit", QueryReplayAuditAsync)
|
||||
.WithName("QueryReplayAudit")
|
||||
.WithSummary("Query replay audit records")
|
||||
.WithDescription("Returns paginated list of replay audit records for compliance and debugging");
|
||||
|
||||
// GET /api/v1/replay/audit/metrics - Get replay metrics
|
||||
group.MapGet("/audit/metrics", GetReplayMetricsAsync)
|
||||
.WithName("GetReplayMetrics")
|
||||
.WithSummary("Get aggregated replay metrics")
|
||||
.WithDescription("Returns replay_attempts_total and replay_match_rate metrics");
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ReplayDecisionAsync(
|
||||
ReplayRequest request,
|
||||
IReplayEngine replayEngine,
|
||||
IVerifierIdentityProvider verifierIdentity,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(request.VerdictHash) && string.IsNullOrEmpty(request.RekorUuid))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Either verdictHash or rekorUuid must be provided"
|
||||
});
|
||||
}
|
||||
|
||||
var result = await replayEngine.ReplayAsync(new ReplayContext
|
||||
{
|
||||
VerdictHash = request.VerdictHash,
|
||||
RekorUuid = request.RekorUuid,
|
||||
PolicyBundleDigest = request.PolicyBundleDigest,
|
||||
FeedSnapshotDigest = request.FeedSnapshotDigest,
|
||||
ExpectedVerdictDigest = request.ExpectedVerdictDigest,
|
||||
VerifierImageDigest = verifierIdentity.GetImageDigest()
|
||||
}, ct);
|
||||
|
||||
return Results.Ok(new ReplayResponse
|
||||
{
|
||||
ReplayId = result.ReplayId,
|
||||
Success = result.Success,
|
||||
DeterminismVerified = result.DeterminismVerified,
|
||||
ComputedVerdictDigest = result.ComputedVerdictDigest,
|
||||
ExpectedVerdictDigest = result.ExpectedVerdictDigest,
|
||||
VerdictMatch = result.VerdictMatch,
|
||||
ReplayDuration = result.Duration,
|
||||
Details = result.Details
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> BatchReplayAsync(
|
||||
BatchReplayRequest request,
|
||||
IReplayEngine replayEngine,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var results = new List<ReplayResponse>();
|
||||
|
||||
foreach (var item in request.Items)
|
||||
{
|
||||
var result = await replayEngine.ReplayAsync(new ReplayContext
|
||||
{
|
||||
VerdictHash = item.VerdictHash,
|
||||
RekorUuid = item.RekorUuid
|
||||
}, ct);
|
||||
|
||||
results.Add(new ReplayResponse
|
||||
{
|
||||
ReplayId = result.ReplayId,
|
||||
Success = result.Success,
|
||||
DeterminismVerified = result.DeterminismVerified,
|
||||
VerdictMatch = result.VerdictMatch
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(new BatchReplayResponse
|
||||
{
|
||||
Total = results.Count,
|
||||
Successful = results.Count(r => r.Success),
|
||||
Failed = results.Count(r => !r.Success),
|
||||
Results = results
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetReplayResultAsync(
|
||||
string replayId,
|
||||
IReplayAuditStore auditStore,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await auditStore.GetAsync(replayId, ct);
|
||||
|
||||
if (result == null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> VerifyDeterminismAsync(
|
||||
VerifyDeterminismRequest request,
|
||||
IReplayEngine replayEngine,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await replayEngine.CheckDeterminismAsync(
|
||||
request.VerdictHash,
|
||||
request.NumIterations,
|
||||
ct);
|
||||
|
||||
return Results.Ok(new VerifyDeterminismResponse
|
||||
{
|
||||
VerdictHash = request.VerdictHash,
|
||||
IsDeterministic = result.IsDeterministic,
|
||||
Iterations = result.Iterations,
|
||||
UniqueResults = result.UniqueResults,
|
||||
Details = result.Details
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/v1/replay/audit
|
||||
/// Query replay audit records.
|
||||
/// Sprint: SPRINT_20260118_019 (GR-007)
|
||||
/// </summary>
|
||||
private static async Task<IResult> QueryReplayAuditAsync(
|
||||
string? bom_ref,
|
||||
string? verdict_hash,
|
||||
string? rekor_uuid,
|
||||
string? from_date,
|
||||
string? to_date,
|
||||
bool? match_only,
|
||||
string? actor,
|
||||
int? limit,
|
||||
string? continuation_token,
|
||||
IReplayAuditRepository auditRepository,
|
||||
HttpContext httpContext,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var tenantId = httpContext.Items.TryGetValue("TenantId", out var tid) && tid is Guid g
|
||||
? g
|
||||
: Guid.Empty;
|
||||
|
||||
var query = new ReplayAuditQuery
|
||||
{
|
||||
TenantId = tenantId,
|
||||
BomRef = bom_ref,
|
||||
VerdictHash = verdict_hash,
|
||||
RekorUuid = rekor_uuid,
|
||||
FromDate = string.IsNullOrEmpty(from_date) ? null : DateTimeOffset.Parse(from_date),
|
||||
ToDate = string.IsNullOrEmpty(to_date) ? null : DateTimeOffset.Parse(to_date),
|
||||
MatchOnly = match_only,
|
||||
Actor = actor,
|
||||
Limit = limit ?? 50,
|
||||
ContinuationToken = continuation_token
|
||||
};
|
||||
|
||||
var result = await auditRepository.QueryAsync(query, ct);
|
||||
|
||||
var response = new ReplayAuditResponse
|
||||
{
|
||||
Records = result.Records.Select(r => new ReplayAuditRecordDto
|
||||
{
|
||||
ReplayId = r.ReplayId,
|
||||
BomRef = r.BomRef,
|
||||
VerdictHash = r.VerdictHash,
|
||||
RekorUuid = r.RekorUuid,
|
||||
ReplayedAt = new DateTimeOffset(r.ReplayedAt, TimeSpan.Zero),
|
||||
Match = r.Match,
|
||||
OriginalHash = r.OriginalHash,
|
||||
ReplayedHash = r.ReplayedHash,
|
||||
MismatchReason = r.MismatchReason,
|
||||
PolicyBundleId = r.PolicyBundleId,
|
||||
PolicyBundleHash = r.PolicyBundleHash,
|
||||
VerifierDigest = r.VerifierDigest,
|
||||
DurationMs = r.DurationMs,
|
||||
Actor = r.Actor,
|
||||
Source = r.Source
|
||||
}).ToList(),
|
||||
Total = result.Total,
|
||||
ContinuationToken = result.ContinuationToken
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/v1/replay/audit/metrics
|
||||
/// Get aggregated replay metrics.
|
||||
/// Sprint: SPRINT_20260118_019 (GR-007)
|
||||
/// </summary>
|
||||
private static async Task<IResult> GetReplayMetricsAsync(
|
||||
string? from_date,
|
||||
string? to_date,
|
||||
IReplayAuditRepository auditRepository,
|
||||
HttpContext httpContext,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var tenantId = httpContext.Items.TryGetValue("TenantId", out var tid) && tid is Guid g
|
||||
? g
|
||||
: Guid.Empty;
|
||||
|
||||
var fromDate = string.IsNullOrEmpty(from_date) ? null : (DateTimeOffset?)DateTimeOffset.Parse(from_date);
|
||||
var toDate = string.IsNullOrEmpty(to_date) ? null : (DateTimeOffset?)DateTimeOffset.Parse(to_date);
|
||||
|
||||
var metrics = await auditRepository.GetMetricsAsync(tenantId, fromDate, toDate, ct);
|
||||
|
||||
return Results.Ok(new ReplayMetricsResponse
|
||||
{
|
||||
TotalAttempts = metrics.TotalAttempts,
|
||||
SuccessfulMatches = metrics.SuccessfulMatches,
|
||||
Mismatches = metrics.Mismatches,
|
||||
MatchRate = metrics.MatchRate,
|
||||
AverageDurationMs = metrics.AverageDurationMs
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to replay a policy decision.
|
||||
/// </summary>
|
||||
public sealed record ReplayRequest
|
||||
{
|
||||
/// <summary>Verdict hash to replay.</summary>
|
||||
public string? VerdictHash { get; init; }
|
||||
|
||||
/// <summary>Rekor UUID to replay.</summary>
|
||||
public string? RekorUuid { get; init; }
|
||||
|
||||
/// <summary>Policy bundle digest to use (optional, uses recorded if null).</summary>
|
||||
public string? PolicyBundleDigest { get; init; }
|
||||
|
||||
/// <summary>Feed snapshot digest to use (optional, uses recorded if null).</summary>
|
||||
public string? FeedSnapshotDigest { get; init; }
|
||||
|
||||
/// <summary>Expected verdict digest for verification.</summary>
|
||||
public string? ExpectedVerdictDigest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from replay operation.
|
||||
/// </summary>
|
||||
public sealed record ReplayResponse
|
||||
{
|
||||
/// <summary>Unique replay ID.</summary>
|
||||
public required string ReplayId { get; init; }
|
||||
|
||||
/// <summary>Whether replay succeeded.</summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>Whether determinism was verified.</summary>
|
||||
public bool DeterminismVerified { get; init; }
|
||||
|
||||
/// <summary>Computed verdict digest from replay.</summary>
|
||||
public string? ComputedVerdictDigest { get; init; }
|
||||
|
||||
/// <summary>Expected verdict digest.</summary>
|
||||
public string? ExpectedVerdictDigest { get; init; }
|
||||
|
||||
/// <summary>Whether computed matches expected.</summary>
|
||||
public bool VerdictMatch { get; init; }
|
||||
|
||||
/// <summary>Replay duration.</summary>
|
||||
public TimeSpan ReplayDuration { get; init; }
|
||||
|
||||
/// <summary>Additional details.</summary>
|
||||
public string? Details { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch replay request.
|
||||
/// </summary>
|
||||
public sealed record BatchReplayRequest
|
||||
{
|
||||
/// <summary>Items to replay.</summary>
|
||||
public required IReadOnlyList<ReplayRequest> Items { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch replay response.
|
||||
/// </summary>
|
||||
public sealed record BatchReplayResponse
|
||||
{
|
||||
/// <summary>Total items processed.</summary>
|
||||
public int Total { get; init; }
|
||||
|
||||
/// <summary>Successful replays.</summary>
|
||||
public int Successful { get; init; }
|
||||
|
||||
/// <summary>Failed replays.</summary>
|
||||
public int Failed { get; init; }
|
||||
|
||||
/// <summary>Individual results.</summary>
|
||||
public required IReadOnlyList<ReplayResponse> Results { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to verify determinism.
|
||||
/// </summary>
|
||||
public sealed record VerifyDeterminismRequest
|
||||
{
|
||||
/// <summary>Verdict hash to verify.</summary>
|
||||
public required string VerdictHash { get; init; }
|
||||
|
||||
/// <summary>Number of replay iterations.</summary>
|
||||
public int NumIterations { get; init; } = 3;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from determinism verification.
|
||||
/// </summary>
|
||||
public sealed record VerifyDeterminismResponse
|
||||
{
|
||||
/// <summary>Verdict hash verified.</summary>
|
||||
public required string VerdictHash { get; init; }
|
||||
|
||||
/// <summary>Whether all iterations produced same result.</summary>
|
||||
public required bool IsDeterministic { get; init; }
|
||||
|
||||
/// <summary>Number of iterations run.</summary>
|
||||
public int Iterations { get; init; }
|
||||
|
||||
/// <summary>Number of unique results (should be 1 if deterministic).</summary>
|
||||
public int UniqueResults { get; init; }
|
||||
|
||||
/// <summary>Details about any non-determinism.</summary>
|
||||
public string? Details { get; init; }
|
||||
}
|
||||
|
||||
// Interfaces
|
||||
|
||||
/// <summary>
|
||||
/// Replay engine interface.
|
||||
/// </summary>
|
||||
public interface IReplayEngine
|
||||
{
|
||||
/// <summary>Replays a policy decision.</summary>
|
||||
Task<ReplayResult> ReplayAsync(ReplayContext context, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Verifies determinism by running multiple iterations.</summary>
|
||||
Task<DeterminismCheckResult> CheckDeterminismAsync(string verdictHash, int iterations, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifier identity provider.
|
||||
/// </summary>
|
||||
public interface IVerifierIdentityProvider
|
||||
{
|
||||
/// <summary>Gets the container image digest of the verifier.</summary>
|
||||
string? GetImageDigest();
|
||||
|
||||
/// <summary>Gets the verifier version.</summary>
|
||||
string GetVersion();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replay audit store.
|
||||
/// </summary>
|
||||
public interface IReplayAuditStore
|
||||
{
|
||||
/// <summary>Gets a replay result by ID.</summary>
|
||||
Task<ReplayResponse?> GetAsync(string replayId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Stores a replay result.</summary>
|
||||
Task StoreAsync(ReplayResponse result, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replay context.
|
||||
/// </summary>
|
||||
public sealed record ReplayContext
|
||||
{
|
||||
/// <summary>Verdict hash.</summary>
|
||||
public string? VerdictHash { get; init; }
|
||||
|
||||
/// <summary>Rekor UUID.</summary>
|
||||
public string? RekorUuid { get; init; }
|
||||
|
||||
/// <summary>Policy bundle digest.</summary>
|
||||
public string? PolicyBundleDigest { get; init; }
|
||||
|
||||
/// <summary>Feed snapshot digest.</summary>
|
||||
public string? FeedSnapshotDigest { get; init; }
|
||||
|
||||
/// <summary>Expected verdict digest.</summary>
|
||||
public string? ExpectedVerdictDigest { get; init; }
|
||||
|
||||
/// <summary>Verifier image digest.</summary>
|
||||
public string? VerifierImageDigest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replay result.
|
||||
/// </summary>
|
||||
public sealed record ReplayResult
|
||||
{
|
||||
/// <summary>Replay ID.</summary>
|
||||
public required string ReplayId { get; init; }
|
||||
|
||||
/// <summary>Success.</summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>Determinism verified.</summary>
|
||||
public bool DeterminismVerified { get; init; }
|
||||
|
||||
/// <summary>Computed verdict digest.</summary>
|
||||
public string? ComputedVerdictDigest { get; init; }
|
||||
|
||||
/// <summary>Expected verdict digest.</summary>
|
||||
public string? ExpectedVerdictDigest { get; init; }
|
||||
|
||||
/// <summary>Verdict match.</summary>
|
||||
public bool VerdictMatch { get; init; }
|
||||
|
||||
/// <summary>Duration.</summary>
|
||||
public TimeSpan Duration { get; init; }
|
||||
|
||||
/// <summary>Details.</summary>
|
||||
public string? Details { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determinism check result.
|
||||
/// </summary>
|
||||
public sealed record DeterminismCheckResult
|
||||
{
|
||||
/// <summary>Is deterministic.</summary>
|
||||
public required bool IsDeterministic { get; init; }
|
||||
|
||||
/// <summary>Iterations run.</summary>
|
||||
public int Iterations { get; init; }
|
||||
|
||||
/// <summary>Unique results count.</summary>
|
||||
public int UniqueResults { get; init; }
|
||||
|
||||
/// <summary>Details.</summary>
|
||||
public string? Details { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Problem details for error responses.
|
||||
/// </summary>
|
||||
public sealed record ProblemDetails
|
||||
{
|
||||
/// <summary>Error title.</summary>
|
||||
public string? Title { get; init; }
|
||||
|
||||
/// <summary>Error detail.</summary>
|
||||
public string? Detail { get; init; }
|
||||
|
||||
/// <summary>Error type.</summary>
|
||||
public string? Type { get; init; }
|
||||
|
||||
/// <summary>HTTP status.</summary>
|
||||
public int Status { get; init; }
|
||||
}
|
||||
|
||||
#region Replay Audit DTOs (GR-007)
|
||||
|
||||
/// <summary>
|
||||
/// Response for replay audit query.
|
||||
/// Sprint: SPRINT_20260118_019_Policy_gate_replay_api_exposure
|
||||
/// Task: GR-007 - Create replay audit trail
|
||||
/// </summary>
|
||||
public sealed record ReplayAuditResponse
|
||||
{
|
||||
/// <summary>List of replay audit records.</summary>
|
||||
[JsonPropertyName("records")]
|
||||
public List<ReplayAuditRecordDto> Records { get; init; } = [];
|
||||
|
||||
/// <summary>Total count of matching records.</summary>
|
||||
[JsonPropertyName("total")]
|
||||
public long Total { get; init; }
|
||||
|
||||
/// <summary>Token for fetching next page.</summary>
|
||||
[JsonPropertyName("continuation_token")]
|
||||
public string? ContinuationToken { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replay audit record DTO for API response.
|
||||
/// </summary>
|
||||
public sealed record ReplayAuditRecordDto
|
||||
{
|
||||
/// <summary>Unique replay ID.</summary>
|
||||
[JsonPropertyName("replay_id")]
|
||||
public Guid ReplayId { get; init; }
|
||||
|
||||
/// <summary>BOM reference.</summary>
|
||||
[JsonPropertyName("bom_ref")]
|
||||
public required string BomRef { get; init; }
|
||||
|
||||
/// <summary>Verdict hash that was replayed.</summary>
|
||||
[JsonPropertyName("verdict_hash")]
|
||||
public required string VerdictHash { get; init; }
|
||||
|
||||
/// <summary>Rekor transparency log UUID.</summary>
|
||||
[JsonPropertyName("rekor_uuid")]
|
||||
public string? RekorUuid { get; init; }
|
||||
|
||||
/// <summary>When the replay was executed.</summary>
|
||||
[JsonPropertyName("replayed_at")]
|
||||
public DateTimeOffset ReplayedAt { get; init; }
|
||||
|
||||
/// <summary>Whether replayed verdict matched original.</summary>
|
||||
[JsonPropertyName("match")]
|
||||
public bool Match { get; init; }
|
||||
|
||||
/// <summary>Original verdict hash.</summary>
|
||||
[JsonPropertyName("original_hash")]
|
||||
public string? OriginalHash { get; init; }
|
||||
|
||||
/// <summary>Computed verdict hash from replay.</summary>
|
||||
[JsonPropertyName("replayed_hash")]
|
||||
public string? ReplayedHash { get; init; }
|
||||
|
||||
/// <summary>Reason for mismatch if match=false.</summary>
|
||||
[JsonPropertyName("mismatch_reason")]
|
||||
public string? MismatchReason { get; init; }
|
||||
|
||||
/// <summary>Policy bundle identifier used.</summary>
|
||||
[JsonPropertyName("policy_bundle_id")]
|
||||
public string? PolicyBundleId { get; init; }
|
||||
|
||||
/// <summary>Policy bundle content hash.</summary>
|
||||
[JsonPropertyName("policy_bundle_hash")]
|
||||
public string? PolicyBundleHash { get; init; }
|
||||
|
||||
/// <summary>Verifier service image digest.</summary>
|
||||
[JsonPropertyName("verifier_digest")]
|
||||
public string? VerifierDigest { get; init; }
|
||||
|
||||
/// <summary>Replay duration in milliseconds.</summary>
|
||||
[JsonPropertyName("duration_ms")]
|
||||
public int? DurationMs { get; init; }
|
||||
|
||||
/// <summary>Actor who triggered the replay.</summary>
|
||||
[JsonPropertyName("actor")]
|
||||
public string? Actor { get; init; }
|
||||
|
||||
/// <summary>Source of replay request (api, cli, scheduled).</summary>
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated metrics for replay operations.
|
||||
/// </summary>
|
||||
public sealed record ReplayMetricsResponse
|
||||
{
|
||||
/// <summary>Total replay attempts.</summary>
|
||||
[JsonPropertyName("total_attempts")]
|
||||
public long TotalAttempts { get; init; }
|
||||
|
||||
/// <summary>Successful matches.</summary>
|
||||
[JsonPropertyName("successful_matches")]
|
||||
public long SuccessfulMatches { get; init; }
|
||||
|
||||
/// <summary>Mismatches.</summary>
|
||||
[JsonPropertyName("mismatches")]
|
||||
public long Mismatches { get; init; }
|
||||
|
||||
/// <summary>Match rate (0.0-1.0).</summary>
|
||||
[JsonPropertyName("match_rate")]
|
||||
public double MatchRate { get; init; }
|
||||
|
||||
/// <summary>Average duration in milliseconds.</summary>
|
||||
[JsonPropertyName("average_duration_ms")]
|
||||
public double? AverageDurationMs { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,445 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) 2026 StellaOps
|
||||
// Sprint: SPRINT_20260118_030_LIB_verdict_rekor_gate_api
|
||||
// Task: TASK-030-006 - Gate Decision API Endpoint
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request for score-based gate evaluation.
|
||||
/// Used by CI/CD pipelines to evaluate individual findings.
|
||||
/// </summary>
|
||||
public sealed record ScoreGateEvaluateRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Finding identifier (CVE@PURL format).
|
||||
/// Example: "CVE-2024-1234@pkg:npm/lodash@4.17.20"
|
||||
/// </summary>
|
||||
[JsonPropertyName("finding_id")]
|
||||
public required string FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVSS base score [0, 10].
|
||||
/// </summary>
|
||||
[JsonPropertyName("cvss_base")]
|
||||
public double CvssBase { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVSS version (3.0, 3.1, 4.0). Defaults to 3.1.
|
||||
/// </summary>
|
||||
[JsonPropertyName("cvss_version")]
|
||||
public string? CvssVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EPSS probability [0, 1].
|
||||
/// </summary>
|
||||
[JsonPropertyName("epss")]
|
||||
public double Epss { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EPSS model date (ISO 8601 date).
|
||||
/// </summary>
|
||||
[JsonPropertyName("epss_model_date")]
|
||||
public DateOnly? EpssModelDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reachability level: "none", "package", "function", "caller".
|
||||
/// </summary>
|
||||
[JsonPropertyName("reachability")]
|
||||
public string? Reachability { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Exploit maturity: "none", "poc", "functional", "high".
|
||||
/// </summary>
|
||||
[JsonPropertyName("exploit_maturity")]
|
||||
public string? ExploitMaturity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Patch proof confidence [0, 1].
|
||||
/// </summary>
|
||||
[JsonPropertyName("patch_proof_confidence")]
|
||||
public double PatchProofConfidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX status: "affected", "not_affected", "fixed", "under_investigation".
|
||||
/// </summary>
|
||||
[JsonPropertyName("vex_status")]
|
||||
public string? VexStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX statement source/issuer.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vex_source")]
|
||||
public string? VexSource { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to anchor the verdict to Rekor transparency log.
|
||||
/// Default: false (async anchoring).
|
||||
/// </summary>
|
||||
[JsonPropertyName("anchor_to_rekor")]
|
||||
public bool AnchorToRekor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include the full verdict bundle in the response.
|
||||
/// </summary>
|
||||
[JsonPropertyName("include_verdict")]
|
||||
public bool IncludeVerdict { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional policy profile to use ("advisory", "legacy", "custom").
|
||||
/// Default: "advisory".
|
||||
/// </summary>
|
||||
[JsonPropertyName("policy_profile")]
|
||||
public string? PolicyProfile { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from score-based gate evaluation.
|
||||
/// </summary>
|
||||
public sealed record ScoreGateEvaluateResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Gate action: "pass", "warn", "block".
|
||||
/// </summary>
|
||||
[JsonPropertyName("action")]
|
||||
public required string Action { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Final score [0, 1].
|
||||
/// </summary>
|
||||
[JsonPropertyName("score")]
|
||||
public required double Score { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Threshold that triggered the action.
|
||||
/// </summary>
|
||||
[JsonPropertyName("threshold")]
|
||||
public required double Threshold { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable reason for the gate decision.
|
||||
/// </summary>
|
||||
[JsonPropertyName("reason")]
|
||||
public required string Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Verdict bundle ID (SHA256 digest).
|
||||
/// </summary>
|
||||
[JsonPropertyName("verdict_bundle_id")]
|
||||
public required string VerdictBundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor entry UUID (if anchored).
|
||||
/// </summary>
|
||||
[JsonPropertyName("rekor_uuid")]
|
||||
public string? RekorUuid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor log index (if anchored).
|
||||
/// </summary>
|
||||
[JsonPropertyName("rekor_log_index")]
|
||||
public long? RekorLogIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the verdict was computed (ISO 8601).
|
||||
/// </summary>
|
||||
[JsonPropertyName("computed_at")]
|
||||
public required DateTimeOffset ComputedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Matched rules that influenced the decision.
|
||||
/// </summary>
|
||||
[JsonPropertyName("matched_rules")]
|
||||
public IReadOnlyList<string> MatchedRules { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Suggestions for resolving the gate decision.
|
||||
/// </summary>
|
||||
[JsonPropertyName("suggestions")]
|
||||
public IReadOnlyList<string> Suggestions { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// CI/CD exit code: 0=pass, 1=warn, 2=block.
|
||||
/// </summary>
|
||||
[JsonPropertyName("exit_code")]
|
||||
public required int ExitCode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Score breakdown by dimension (optional).
|
||||
/// </summary>
|
||||
[JsonPropertyName("breakdown")]
|
||||
public IReadOnlyList<ScoreDimensionBreakdown>? Breakdown { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Full verdict bundle JSON (if include_verdict=true).
|
||||
/// </summary>
|
||||
[JsonPropertyName("verdict_bundle")]
|
||||
public object? VerdictBundle { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-dimension score breakdown.
|
||||
/// </summary>
|
||||
public sealed record ScoreDimensionBreakdown
|
||||
{
|
||||
/// <summary>
|
||||
/// Dimension name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("dimension")]
|
||||
public required string Dimension { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Dimension symbol.
|
||||
/// </summary>
|
||||
[JsonPropertyName("symbol")]
|
||||
public required string Symbol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Raw input value.
|
||||
/// </summary>
|
||||
[JsonPropertyName("value")]
|
||||
public required double Value { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Weight applied.
|
||||
/// </summary>
|
||||
[JsonPropertyName("weight")]
|
||||
public required double Weight { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Contribution to final score.
|
||||
/// </summary>
|
||||
[JsonPropertyName("contribution")]
|
||||
public required double Contribution { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is a subtractive dimension.
|
||||
/// </summary>
|
||||
[JsonPropertyName("is_subtractive")]
|
||||
public bool IsSubtractive { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gate action types.
|
||||
/// </summary>
|
||||
public static class ScoreGateActions
|
||||
{
|
||||
public const string Pass = "pass";
|
||||
public const string Warn = "warn";
|
||||
public const string Block = "block";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CI exit codes for gate evaluation.
|
||||
/// </summary>
|
||||
public static class ScoreGateExitCodes
|
||||
{
|
||||
/// <summary>Gate passed.</summary>
|
||||
public const int Pass = 0;
|
||||
|
||||
/// <summary>Gate warned.</summary>
|
||||
public const int Warn = 1;
|
||||
|
||||
/// <summary>Gate blocked.</summary>
|
||||
public const int Block = 2;
|
||||
}
|
||||
|
||||
#region Batch Evaluation Contracts
|
||||
|
||||
/// <summary>
|
||||
/// Request for batch score-based gate evaluation.
|
||||
/// Sprint: SPRINT_20260118_030_LIB_verdict_rekor_gate_api
|
||||
/// Task: TASK-030-007 - Batch Gate Evaluation API
|
||||
/// </summary>
|
||||
public sealed record ScoreGateBatchEvaluateRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// List of findings to evaluate.
|
||||
/// </summary>
|
||||
[JsonPropertyName("findings")]
|
||||
public required IReadOnlyList<ScoreGateEvaluateRequest> Findings { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Batch evaluation options.
|
||||
/// </summary>
|
||||
[JsonPropertyName("options")]
|
||||
public ScoreGateBatchOptions? Options { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for batch gate evaluation.
|
||||
/// </summary>
|
||||
public sealed record ScoreGateBatchOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Stop evaluation on first block.
|
||||
/// Default: false
|
||||
/// </summary>
|
||||
[JsonPropertyName("fail_fast")]
|
||||
public bool FailFast { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Include full verdict bundles in response.
|
||||
/// Default: false
|
||||
/// </summary>
|
||||
[JsonPropertyName("include_verdicts")]
|
||||
public bool IncludeVerdicts { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Anchor each verdict to Rekor (slower but auditable).
|
||||
/// Default: false
|
||||
/// </summary>
|
||||
[JsonPropertyName("anchor_to_rekor")]
|
||||
public bool AnchorToRekor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy profile to use for all evaluations.
|
||||
/// Default: "advisory"
|
||||
/// </summary>
|
||||
[JsonPropertyName("policy_profile")]
|
||||
public string? PolicyProfile { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum parallelism for evaluation.
|
||||
/// Default: 10
|
||||
/// </summary>
|
||||
[JsonPropertyName("max_parallelism")]
|
||||
public int MaxParallelism { get; init; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from batch gate evaluation.
|
||||
/// </summary>
|
||||
public sealed record ScoreGateBatchEvaluateResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Summary statistics for the batch evaluation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("summary")]
|
||||
public required ScoreGateBatchSummary Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overall action: worst-case across all findings.
|
||||
/// "block" if any blocked, "warn" if any warned but none blocked, "pass" otherwise.
|
||||
/// </summary>
|
||||
[JsonPropertyName("overall_action")]
|
||||
public required string OverallAction { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CI/CD exit code based on overall action.
|
||||
/// </summary>
|
||||
[JsonPropertyName("exit_code")]
|
||||
public required int ExitCode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Individual decisions for each finding.
|
||||
/// </summary>
|
||||
[JsonPropertyName("decisions")]
|
||||
public required IReadOnlyList<ScoreGateBatchDecision> Decisions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evaluation duration in milliseconds.
|
||||
/// </summary>
|
||||
[JsonPropertyName("duration_ms")]
|
||||
public required long DurationMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether evaluation was stopped early due to fail-fast.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fail_fast_triggered")]
|
||||
public bool FailFastTriggered { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary statistics for batch evaluation.
|
||||
/// </summary>
|
||||
public sealed record ScoreGateBatchSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Total number of findings evaluated.
|
||||
/// </summary>
|
||||
[JsonPropertyName("total")]
|
||||
public required int Total { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of findings that passed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("passed")]
|
||||
public required int Passed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of findings that warned.
|
||||
/// </summary>
|
||||
[JsonPropertyName("warned")]
|
||||
public required int Warned { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of findings that blocked.
|
||||
/// </summary>
|
||||
[JsonPropertyName("blocked")]
|
||||
public required int Blocked { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of findings that errored.
|
||||
/// </summary>
|
||||
[JsonPropertyName("errored")]
|
||||
public int Errored { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual decision in a batch evaluation.
|
||||
/// </summary>
|
||||
public sealed record ScoreGateBatchDecision
|
||||
{
|
||||
/// <summary>
|
||||
/// Finding identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("finding_id")]
|
||||
public required string FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gate action: "pass", "warn", "block", or "error".
|
||||
/// </summary>
|
||||
[JsonPropertyName("action")]
|
||||
public required string Action { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Final score [0, 1].
|
||||
/// </summary>
|
||||
[JsonPropertyName("score")]
|
||||
public double? Score { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Threshold that triggered the action.
|
||||
/// </summary>
|
||||
[JsonPropertyName("threshold")]
|
||||
public double? Threshold { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for the decision.
|
||||
/// </summary>
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Verdict bundle ID if created.
|
||||
/// </summary>
|
||||
[JsonPropertyName("verdict_bundle_id")]
|
||||
public string? VerdictBundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Full verdict bundle (if include_verdicts=true).
|
||||
/// </summary>
|
||||
[JsonPropertyName("verdict_bundle")]
|
||||
public object? VerdictBundle { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if evaluation failed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
891
src/Policy/StellaOps.Policy.Gateway/Endpoints/GatesEndpoints.cs
Normal file
891
src/Policy/StellaOps.Policy.Gateway/Endpoints/GatesEndpoints.cs
Normal file
@@ -0,0 +1,891 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// GatesEndpoints.cs
|
||||
// Sprint: SPRINT_20260118_018_Unknowns_queue_enhancement
|
||||
// Task: UQ-006 - Implement GET /gates/{bom_ref} endpoint
|
||||
// Description: REST endpoint for gate check with unknowns state
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using StellaOps.Policy.Gates;
|
||||
using StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// REST endpoints for gate checks.
|
||||
/// </summary>
|
||||
public static class GatesEndpoints
|
||||
{
|
||||
private const string CachePrefix = "gates:";
|
||||
private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Maps gate endpoints.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapGatesEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/v1/gates")
|
||||
.WithTags("Gates")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapGet("/{bomRef}", GetGateStatus)
|
||||
.WithName("GetGateStatus")
|
||||
.WithSummary("Get gate check result for a component")
|
||||
.WithDescription("Returns the current unknowns state and gate decision for a BOM reference.");
|
||||
|
||||
group.MapPost("/{bomRef}/check", CheckGate)
|
||||
.WithName("CheckGate")
|
||||
.WithSummary("Perform gate check for a component")
|
||||
.WithDescription("Performs a fresh gate check with optional verdict.");
|
||||
|
||||
group.MapPost("/{bomRef}/exception", RequestException)
|
||||
.WithName("RequestGateException")
|
||||
.WithSummary("Request an exception to bypass the gate")
|
||||
.WithDescription("Requests approval to bypass blocking unknowns.");
|
||||
|
||||
group.MapGet("/{gateId}/decisions", GetGateDecisionHistory)
|
||||
.WithName("GetGateDecisionHistory")
|
||||
.WithSummary("Get historical gate decisions")
|
||||
.WithDescription("Returns paginated list of historical gate decisions for audit and debugging.");
|
||||
|
||||
group.MapGet("/decisions/{decisionId}", GetGateDecisionById)
|
||||
.WithName("GetGateDecisionById")
|
||||
.WithSummary("Get a specific gate decision by ID")
|
||||
.WithDescription("Returns full details of a specific gate decision.");
|
||||
|
||||
group.MapGet("/decisions/{decisionId}/export", ExportGateDecision)
|
||||
.WithName("ExportGateDecision")
|
||||
.WithSummary("Export gate decision in CI/CD format")
|
||||
.WithDescription("Exports gate decision in JUnit, SARIF, or JSON format for CI/CD integration.");
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /gates/{bom_ref}
|
||||
/// Returns the current unknowns state for a component.
|
||||
/// </summary>
|
||||
private static async Task<IResult> GetGateStatus(
|
||||
[FromRoute] string bomRef,
|
||||
[FromServices] IUnknownsGateChecker gateChecker,
|
||||
[FromServices] IMemoryCache cache,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Decode the bom_ref (URL encoded)
|
||||
var decodedBomRef = Uri.UnescapeDataString(bomRef);
|
||||
|
||||
// Check cache
|
||||
var cacheKey = $"{CachePrefix}{decodedBomRef}";
|
||||
if (cache.TryGetValue<GateStatusResponse>(cacheKey, out var cached) && cached != null)
|
||||
{
|
||||
return Results.Ok(cached);
|
||||
}
|
||||
|
||||
// Get unknowns and gate decision
|
||||
var unknowns = await gateChecker.GetUnknownsAsync(decodedBomRef, ct);
|
||||
var checkResult = await gateChecker.CheckAsync(decodedBomRef, null, ct);
|
||||
|
||||
// Build response
|
||||
var response = new GateStatusResponse
|
||||
{
|
||||
BomRef = decodedBomRef,
|
||||
State = DetermineAggregateState(unknowns),
|
||||
VerdictHash = checkResult.Decision == GateDecision.Pass
|
||||
? ComputeVerdictHash(decodedBomRef, unknowns)
|
||||
: null,
|
||||
Unknowns = unknowns.Select(u => new UnknownDto
|
||||
{
|
||||
UnknownId = u.UnknownId,
|
||||
CveId = u.CveId,
|
||||
Band = u.Band,
|
||||
SlaRemainingHours = u.SlaRemainingHours,
|
||||
State = u.State
|
||||
}).ToList(),
|
||||
GateDecision = checkResult.Decision.ToString().ToLowerInvariant(),
|
||||
CheckedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Cache the response
|
||||
cache.Set(cacheKey, response, CacheTtl);
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST /gates/{bom_ref}/check
|
||||
/// Performs a gate check with optional verdict.
|
||||
/// </summary>
|
||||
private static async Task<IResult> CheckGate(
|
||||
[FromRoute] string bomRef,
|
||||
[FromBody] GateCheckRequest request,
|
||||
[FromServices] IUnknownsGateChecker gateChecker,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var decodedBomRef = Uri.UnescapeDataString(bomRef);
|
||||
|
||||
var result = await gateChecker.CheckAsync(
|
||||
decodedBomRef,
|
||||
request.ProposedVerdict,
|
||||
ct);
|
||||
|
||||
var response = new GateCheckResponse
|
||||
{
|
||||
BomRef = decodedBomRef,
|
||||
Decision = result.Decision.ToString().ToLowerInvariant(),
|
||||
State = result.State,
|
||||
BlockingUnknownIds = result.BlockingUnknownIds.ToList(),
|
||||
Reason = result.Reason,
|
||||
ExceptionGranted = result.ExceptionGranted,
|
||||
ExceptionRef = result.ExceptionRef,
|
||||
CheckedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var statusCode = result.Decision switch
|
||||
{
|
||||
GateDecision.Pass => StatusCodes.Status200OK,
|
||||
GateDecision.Warn => StatusCodes.Status200OK,
|
||||
GateDecision.Block => StatusCodes.Status403Forbidden,
|
||||
_ => StatusCodes.Status200OK
|
||||
};
|
||||
|
||||
return Results.Json(response, statusCode: statusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST /gates/{bom_ref}/exception
|
||||
/// Requests an exception to bypass blocking unknowns.
|
||||
/// </summary>
|
||||
private static async Task<IResult> RequestException(
|
||||
[FromRoute] string bomRef,
|
||||
[FromBody] ExceptionRequest request,
|
||||
[FromServices] IUnknownsGateChecker gateChecker,
|
||||
HttpContext httpContext,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var decodedBomRef = Uri.UnescapeDataString(bomRef);
|
||||
var requestedBy = httpContext.User.Identity?.Name ?? "anonymous";
|
||||
|
||||
var result = await gateChecker.RequestExceptionAsync(
|
||||
decodedBomRef,
|
||||
request.UnknownIds,
|
||||
request.Justification,
|
||||
requestedBy,
|
||||
ct);
|
||||
|
||||
var response = new ExceptionResponse
|
||||
{
|
||||
Granted = result.Granted,
|
||||
ExceptionRef = result.ExceptionRef,
|
||||
DenialReason = result.DenialReason,
|
||||
ExpiresAt = result.ExpiresAt,
|
||||
RequestedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
return result.Granted
|
||||
? Results.Ok(response)
|
||||
: Results.Json(response, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /gates/{gateId}/decisions
|
||||
/// Returns historical gate decisions for a gate.
|
||||
/// </summary>
|
||||
private static async Task<IResult> GetGateDecisionHistory(
|
||||
[FromRoute] string gateId,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] string? from_date,
|
||||
[FromQuery] string? to_date,
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] string? actor,
|
||||
[FromQuery] string? bom_ref,
|
||||
[FromQuery] string? continuation_token,
|
||||
[FromServices] IGateDecisionHistoryRepository historyRepository,
|
||||
HttpContext httpContext,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Parse tenant from context (simplified - would come from auth)
|
||||
var tenantId = httpContext.Items.TryGetValue("TenantId", out var tid) && tid is Guid g
|
||||
? g
|
||||
: Guid.Empty;
|
||||
|
||||
var query = new GateDecisionHistoryQuery
|
||||
{
|
||||
TenantId = tenantId,
|
||||
GateId = Uri.UnescapeDataString(gateId),
|
||||
BomRef = bom_ref,
|
||||
Limit = limit ?? 50,
|
||||
ContinuationToken = continuation_token,
|
||||
Status = status,
|
||||
Actor = actor,
|
||||
FromDate = string.IsNullOrEmpty(from_date) ? null : DateTimeOffset.Parse(from_date),
|
||||
ToDate = string.IsNullOrEmpty(to_date) ? null : DateTimeOffset.Parse(to_date)
|
||||
};
|
||||
|
||||
var result = await historyRepository.GetDecisionsAsync(query, ct);
|
||||
|
||||
var response = new GateDecisionHistoryResponse
|
||||
{
|
||||
Decisions = result.Decisions.Select(d => new GateDecisionDto
|
||||
{
|
||||
DecisionId = d.DecisionId,
|
||||
BomRef = d.BomRef,
|
||||
ImageDigest = d.ImageDigest,
|
||||
GateStatus = d.GateStatus,
|
||||
VerdictHash = d.VerdictHash,
|
||||
PolicyBundleId = d.PolicyBundleId,
|
||||
PolicyBundleHash = d.PolicyBundleHash,
|
||||
EvaluatedAt = new DateTimeOffset(d.EvaluatedAt, TimeSpan.Zero),
|
||||
CiContext = d.CiContext,
|
||||
Actor = d.Actor,
|
||||
BlockingUnknownIds = d.BlockingUnknownIds,
|
||||
Warnings = d.Warnings
|
||||
}).ToList(),
|
||||
Total = result.Total,
|
||||
ContinuationToken = result.ContinuationToken
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /gates/decisions/{decisionId}
|
||||
/// Returns a specific gate decision by ID.
|
||||
/// </summary>
|
||||
private static async Task<IResult> GetGateDecisionById(
|
||||
[FromRoute] Guid decisionId,
|
||||
[FromServices] IGateDecisionHistoryRepository historyRepository,
|
||||
HttpContext httpContext,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var tenantId = httpContext.Items.TryGetValue("TenantId", out var tid) && tid is Guid g
|
||||
? g
|
||||
: Guid.Empty;
|
||||
|
||||
var decision = await historyRepository.GetDecisionByIdAsync(decisionId, tenantId, ct);
|
||||
|
||||
if (decision is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "Decision not found", decision_id = decisionId });
|
||||
}
|
||||
|
||||
var response = new GateDecisionDto
|
||||
{
|
||||
DecisionId = decision.DecisionId,
|
||||
BomRef = decision.BomRef,
|
||||
ImageDigest = decision.ImageDigest,
|
||||
GateStatus = decision.GateStatus,
|
||||
VerdictHash = decision.VerdictHash,
|
||||
PolicyBundleId = decision.PolicyBundleId,
|
||||
PolicyBundleHash = decision.PolicyBundleHash,
|
||||
EvaluatedAt = new DateTimeOffset(decision.EvaluatedAt, TimeSpan.Zero),
|
||||
CiContext = decision.CiContext,
|
||||
Actor = decision.Actor,
|
||||
BlockingUnknownIds = decision.BlockingUnknownIds,
|
||||
Warnings = decision.Warnings
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /gates/decisions/{decisionId}/export
|
||||
/// Exports a gate decision in CI/CD format.
|
||||
/// Sprint: SPRINT_20260118_019 (GR-008)
|
||||
/// </summary>
|
||||
private static async Task<IResult> ExportGateDecision(
|
||||
[FromRoute] Guid decisionId,
|
||||
[FromQuery] string? format,
|
||||
[FromServices] IGateDecisionHistoryRepository historyRepository,
|
||||
HttpContext httpContext,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var tenantId = httpContext.Items.TryGetValue("TenantId", out var tid) && tid is Guid g
|
||||
? g
|
||||
: Guid.Empty;
|
||||
|
||||
var decision = await historyRepository.GetDecisionByIdAsync(decisionId, tenantId, ct);
|
||||
|
||||
if (decision is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "Decision not found", decision_id = decisionId });
|
||||
}
|
||||
|
||||
var exportFormat = (format?.ToLowerInvariant()) switch
|
||||
{
|
||||
"junit" => ExportFormat.JUnit,
|
||||
"sarif" => ExportFormat.Sarif,
|
||||
"json" => ExportFormat.Json,
|
||||
_ => ExportFormat.Json
|
||||
};
|
||||
|
||||
return exportFormat switch
|
||||
{
|
||||
ExportFormat.JUnit => ExportAsJUnit(decision),
|
||||
ExportFormat.Sarif => ExportAsSarif(decision),
|
||||
_ => ExportAsJson(decision)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports decision as JUnit XML.
|
||||
/// </summary>
|
||||
private static IResult ExportAsJUnit(GateDecisionRecord decision)
|
||||
{
|
||||
var passed = decision.GateStatus.Equals("pass", StringComparison.OrdinalIgnoreCase);
|
||||
var testCaseName = $"gate-check-{decision.BomRef}";
|
||||
var suiteName = "StellaOps Gate Check";
|
||||
|
||||
var xml = $"""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<testsuites name="{suiteName}" tests="1" failures="{(passed ? 0 : 1)}" errors="0" time="0">
|
||||
<testsuite name="{suiteName}" tests="1" failures="{(passed ? 0 : 1)}" errors="0">
|
||||
<testcase name="{System.Security.SecurityElement.Escape(testCaseName)}" classname="gates">
|
||||
{(passed ? "" : $"""
|
||||
<failure message="Gate blocked: {decision.GateStatus}" type="GateFailure">
|
||||
Decision ID: {decision.DecisionId}
|
||||
BOM Reference: {decision.BomRef}
|
||||
Status: {decision.GateStatus}
|
||||
Evaluated At: {decision.EvaluatedAt:O}
|
||||
Policy Bundle: {decision.PolicyBundleId ?? "N/A"}
|
||||
Blocking Unknowns: {decision.BlockingUnknownIds.Count}
|
||||
Warnings: {string.Join(", ", decision.Warnings)}
|
||||
</failure>
|
||||
""")}
|
||||
</testcase>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
""";
|
||||
|
||||
return Results.Content(xml, "application/xml");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports decision as SARIF 2.1.0.
|
||||
/// </summary>
|
||||
private static IResult ExportAsSarif(GateDecisionRecord decision)
|
||||
{
|
||||
var passed = decision.GateStatus.Equals("pass", StringComparison.OrdinalIgnoreCase);
|
||||
var level = decision.GateStatus.ToLowerInvariant() switch
|
||||
{
|
||||
"pass" => "none",
|
||||
"warn" => "warning",
|
||||
"block" => "error",
|
||||
_ => "note"
|
||||
};
|
||||
|
||||
var results = new List<object>();
|
||||
|
||||
// Add blocking unknowns as results
|
||||
foreach (var unknownId in decision.BlockingUnknownIds)
|
||||
{
|
||||
results.Add(new
|
||||
{
|
||||
ruleId = "GATE001",
|
||||
level = level,
|
||||
message = new { text = $"Blocking unknown: {unknownId}" },
|
||||
locations = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
physicalLocation = new
|
||||
{
|
||||
artifactLocation = new { uri = decision.BomRef }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add warnings as results
|
||||
foreach (var warning in decision.Warnings)
|
||||
{
|
||||
results.Add(new
|
||||
{
|
||||
ruleId = "GATE002",
|
||||
level = "warning",
|
||||
message = new { text = warning },
|
||||
locations = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
physicalLocation = new
|
||||
{
|
||||
artifactLocation = new { uri = decision.BomRef }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// If no results, add a summary result
|
||||
if (results.Count == 0)
|
||||
{
|
||||
results.Add(new
|
||||
{
|
||||
ruleId = "GATE000",
|
||||
level = level,
|
||||
message = new { text = $"Gate check {decision.GateStatus}: {decision.BomRef}" },
|
||||
locations = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
physicalLocation = new
|
||||
{
|
||||
artifactLocation = new { uri = decision.BomRef }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var sarif = new
|
||||
{
|
||||
version = "2.1.0",
|
||||
schema = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
|
||||
runs = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
tool = new
|
||||
{
|
||||
driver = new
|
||||
{
|
||||
name = "StellaOps Gate",
|
||||
version = "1.0.0",
|
||||
informationUri = "https://stella-ops.org",
|
||||
rules = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
id = "GATE000",
|
||||
shortDescription = new { text = "Gate Check Result" },
|
||||
fullDescription = new { text = "Summary of gate check result" }
|
||||
},
|
||||
new
|
||||
{
|
||||
id = "GATE001",
|
||||
shortDescription = new { text = "Blocking Unknown" },
|
||||
fullDescription = new { text = "A security unknown is blocking the release" }
|
||||
},
|
||||
new
|
||||
{
|
||||
id = "GATE002",
|
||||
shortDescription = new { text = "Gate Warning" },
|
||||
fullDescription = new { text = "A warning was generated during gate evaluation" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
results = results,
|
||||
invocations = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
executionSuccessful = passed,
|
||||
endTimeUtc = decision.EvaluatedAt.ToString("O"),
|
||||
properties = new
|
||||
{
|
||||
decisionId = decision.DecisionId.ToString(),
|
||||
bomRef = decision.BomRef,
|
||||
gateStatus = decision.GateStatus,
|
||||
verdictHash = decision.VerdictHash,
|
||||
policyBundleId = decision.PolicyBundleId,
|
||||
policyBundleHash = decision.PolicyBundleHash,
|
||||
actor = decision.Actor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(sarif, new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
});
|
||||
|
||||
return Results.Content(json, "application/sarif+json");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports decision as JSON.
|
||||
/// </summary>
|
||||
private static IResult ExportAsJson(GateDecisionRecord decision)
|
||||
{
|
||||
var response = new GateDecisionExportJson
|
||||
{
|
||||
DecisionId = decision.DecisionId,
|
||||
BomRef = decision.BomRef,
|
||||
ImageDigest = decision.ImageDigest,
|
||||
GateStatus = decision.GateStatus,
|
||||
VerdictHash = decision.VerdictHash,
|
||||
PolicyBundleId = decision.PolicyBundleId,
|
||||
PolicyBundleHash = decision.PolicyBundleHash,
|
||||
EvaluatedAt = new DateTimeOffset(decision.EvaluatedAt, TimeSpan.Zero),
|
||||
CiContext = decision.CiContext,
|
||||
Actor = decision.Actor,
|
||||
BlockingUnknownIds = decision.BlockingUnknownIds,
|
||||
Warnings = decision.Warnings,
|
||||
ExitCode = decision.GateStatus.ToLowerInvariant() switch
|
||||
{
|
||||
"pass" => 0,
|
||||
"warn" => 1,
|
||||
"block" => 2,
|
||||
_ => 1
|
||||
}
|
||||
};
|
||||
|
||||
return Results.Json(response, contentType: "application/json");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines the worst-case state across all unknowns.
|
||||
/// </summary>
|
||||
private static string DetermineAggregateState(IReadOnlyList<UnknownState> unknowns)
|
||||
{
|
||||
if (unknowns.Count == 0)
|
||||
{
|
||||
return "resolved";
|
||||
}
|
||||
|
||||
// Priority: escalated > under_review > pending > resolved
|
||||
if (unknowns.Any(u => u.State == "escalated"))
|
||||
{
|
||||
return "escalated";
|
||||
}
|
||||
if (unknowns.Any(u => u.State == "under_review"))
|
||||
{
|
||||
return "under_review";
|
||||
}
|
||||
if (unknowns.Any(u => u.State == "pending"))
|
||||
{
|
||||
return "pending";
|
||||
}
|
||||
|
||||
return "resolved";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic verdict hash for caching/verification.
|
||||
/// </summary>
|
||||
private static string ComputeVerdictHash(string bomRef, IReadOnlyList<UnknownState> unknowns)
|
||||
{
|
||||
var input = $"{bomRef}:{unknowns.Count}:{DateTimeOffset.UtcNow:yyyyMMddHH}";
|
||||
var bytes = System.Security.Cryptography.SHA256.HashData(
|
||||
System.Text.Encoding.UTF8.GetBytes(input));
|
||||
return $"sha256:{Convert.ToHexString(bytes).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
|
||||
#region Request/Response DTOs
|
||||
|
||||
/// <summary>
|
||||
/// Gate status response.
|
||||
/// </summary>
|
||||
public sealed record GateStatusResponse
|
||||
{
|
||||
/// <summary>BOM reference.</summary>
|
||||
[JsonPropertyName("bom_ref")]
|
||||
public required string BomRef { get; init; }
|
||||
|
||||
/// <summary>Aggregate state: resolved, pending, under_review, escalated, rejected.</summary>
|
||||
[JsonPropertyName("state")]
|
||||
public required string State { get; init; }
|
||||
|
||||
/// <summary>Verdict hash if resolved.</summary>
|
||||
[JsonPropertyName("verdict_hash")]
|
||||
public string? VerdictHash { get; init; }
|
||||
|
||||
/// <summary>Individual unknowns.</summary>
|
||||
[JsonPropertyName("unknowns")]
|
||||
public List<UnknownDto> Unknowns { get; init; } = [];
|
||||
|
||||
/// <summary>Gate decision: pass, warn, block.</summary>
|
||||
[JsonPropertyName("gate_decision")]
|
||||
public required string GateDecision { get; init; }
|
||||
|
||||
/// <summary>When checked.</summary>
|
||||
[JsonPropertyName("checked_at")]
|
||||
public DateTimeOffset CheckedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unknown DTO for API response.
|
||||
/// </summary>
|
||||
public sealed record UnknownDto
|
||||
{
|
||||
/// <summary>Unknown ID.</summary>
|
||||
[JsonPropertyName("unknown_id")]
|
||||
public Guid UnknownId { get; init; }
|
||||
|
||||
/// <summary>CVE ID if applicable.</summary>
|
||||
[JsonPropertyName("cve_id")]
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>Priority band: hot, warm, cold.</summary>
|
||||
[JsonPropertyName("band")]
|
||||
public required string Band { get; init; }
|
||||
|
||||
/// <summary>SLA remaining hours.</summary>
|
||||
[JsonPropertyName("sla_remaining_hours")]
|
||||
public double? SlaRemainingHours { get; init; }
|
||||
|
||||
/// <summary>State: pending, under_review, escalated, resolved, rejected.</summary>
|
||||
[JsonPropertyName("state")]
|
||||
public required string State { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gate check request.
|
||||
/// </summary>
|
||||
public sealed record GateCheckRequest
|
||||
{
|
||||
/// <summary>Proposed VEX verdict (e.g., "not_affected").</summary>
|
||||
[JsonPropertyName("proposed_verdict")]
|
||||
public string? ProposedVerdict { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gate check response.
|
||||
/// </summary>
|
||||
public sealed record GateCheckResponse
|
||||
{
|
||||
/// <summary>BOM reference.</summary>
|
||||
[JsonPropertyName("bom_ref")]
|
||||
public required string BomRef { get; init; }
|
||||
|
||||
/// <summary>Decision: pass, warn, block.</summary>
|
||||
[JsonPropertyName("decision")]
|
||||
public required string Decision { get; init; }
|
||||
|
||||
/// <summary>Current state.</summary>
|
||||
[JsonPropertyName("state")]
|
||||
public required string State { get; init; }
|
||||
|
||||
/// <summary>Blocking unknown IDs.</summary>
|
||||
[JsonPropertyName("blocking_unknown_ids")]
|
||||
public List<Guid> BlockingUnknownIds { get; init; } = [];
|
||||
|
||||
/// <summary>Reason for decision.</summary>
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>Whether exception was granted.</summary>
|
||||
[JsonPropertyName("exception_granted")]
|
||||
public bool ExceptionGranted { get; init; }
|
||||
|
||||
/// <summary>Exception reference if granted.</summary>
|
||||
[JsonPropertyName("exception_ref")]
|
||||
public string? ExceptionRef { get; init; }
|
||||
|
||||
/// <summary>When checked.</summary>
|
||||
[JsonPropertyName("checked_at")]
|
||||
public DateTimeOffset CheckedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception request.
|
||||
/// </summary>
|
||||
public sealed record ExceptionRequest
|
||||
{
|
||||
/// <summary>Unknown IDs to bypass.</summary>
|
||||
[JsonPropertyName("unknown_ids")]
|
||||
public List<Guid> UnknownIds { get; init; } = [];
|
||||
|
||||
/// <summary>Justification for bypass.</summary>
|
||||
[JsonPropertyName("justification")]
|
||||
public required string Justification { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception response.
|
||||
/// </summary>
|
||||
public sealed record ExceptionResponse
|
||||
{
|
||||
/// <summary>Whether exception was granted.</summary>
|
||||
[JsonPropertyName("granted")]
|
||||
public bool Granted { get; init; }
|
||||
|
||||
/// <summary>Exception reference.</summary>
|
||||
[JsonPropertyName("exception_ref")]
|
||||
public string? ExceptionRef { get; init; }
|
||||
|
||||
/// <summary>Denial reason if not granted.</summary>
|
||||
[JsonPropertyName("denial_reason")]
|
||||
public string? DenialReason { get; init; }
|
||||
|
||||
/// <summary>When exception expires.</summary>
|
||||
[JsonPropertyName("expires_at")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>When requested.</summary>
|
||||
[JsonPropertyName("requested_at")]
|
||||
public DateTimeOffset RequestedAt { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Gate Decision History DTOs
|
||||
|
||||
/// <summary>
|
||||
/// Response for gate decision history query.
|
||||
/// Sprint: SPRINT_20260118_019_Policy_gate_replay_api_exposure
|
||||
/// Task: GR-005 - Add gate decision history endpoint
|
||||
/// </summary>
|
||||
public sealed record GateDecisionHistoryResponse
|
||||
{
|
||||
/// <summary>List of gate decisions.</summary>
|
||||
[JsonPropertyName("decisions")]
|
||||
public List<GateDecisionDto> Decisions { get; init; } = [];
|
||||
|
||||
/// <summary>Total count of matching decisions.</summary>
|
||||
[JsonPropertyName("total")]
|
||||
public long Total { get; init; }
|
||||
|
||||
/// <summary>Token for fetching next page.</summary>
|
||||
[JsonPropertyName("continuation_token")]
|
||||
public string? ContinuationToken { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gate decision DTO for API response.
|
||||
/// </summary>
|
||||
public sealed record GateDecisionDto
|
||||
{
|
||||
/// <summary>Unique decision ID.</summary>
|
||||
[JsonPropertyName("decision_id")]
|
||||
public Guid DecisionId { get; init; }
|
||||
|
||||
/// <summary>BOM reference.</summary>
|
||||
[JsonPropertyName("bom_ref")]
|
||||
public required string BomRef { get; init; }
|
||||
|
||||
/// <summary>Image digest if applicable.</summary>
|
||||
[JsonPropertyName("image_digest")]
|
||||
public string? ImageDigest { get; init; }
|
||||
|
||||
/// <summary>Gate decision: pass, warn, block.</summary>
|
||||
[JsonPropertyName("gate_status")]
|
||||
public required string GateStatus { get; init; }
|
||||
|
||||
/// <summary>Verdict hash for replay verification.</summary>
|
||||
[JsonPropertyName("verdict_hash")]
|
||||
public string? VerdictHash { get; init; }
|
||||
|
||||
/// <summary>Policy bundle ID used for evaluation.</summary>
|
||||
[JsonPropertyName("policy_bundle_id")]
|
||||
public string? PolicyBundleId { get; init; }
|
||||
|
||||
/// <summary>Policy bundle content hash.</summary>
|
||||
[JsonPropertyName("policy_bundle_hash")]
|
||||
public string? PolicyBundleHash { get; init; }
|
||||
|
||||
/// <summary>When the evaluation occurred.</summary>
|
||||
[JsonPropertyName("evaluated_at")]
|
||||
public DateTimeOffset EvaluatedAt { get; init; }
|
||||
|
||||
/// <summary>CI/CD context (branch, commit, pipeline).</summary>
|
||||
[JsonPropertyName("ci_context")]
|
||||
public string? CiContext { get; init; }
|
||||
|
||||
/// <summary>Actor who triggered evaluation.</summary>
|
||||
[JsonPropertyName("actor")]
|
||||
public string? Actor { get; init; }
|
||||
|
||||
/// <summary>IDs of unknowns that blocked the release.</summary>
|
||||
[JsonPropertyName("blocking_unknown_ids")]
|
||||
public List<Guid> BlockingUnknownIds { get; init; } = [];
|
||||
|
||||
/// <summary>Warning messages.</summary>
|
||||
[JsonPropertyName("warnings")]
|
||||
public List<string> Warnings { get; init; } = [];
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CI/CD Export DTOs (GR-008)
|
||||
|
||||
/// <summary>
|
||||
/// Export format for gate decisions.
|
||||
/// Sprint: SPRINT_20260118_019_Policy_gate_replay_api_exposure
|
||||
/// Task: GR-008 - Implement CI/CD status export formats
|
||||
/// </summary>
|
||||
public enum ExportFormat
|
||||
{
|
||||
/// <summary>JSON format for custom integrations.</summary>
|
||||
Json,
|
||||
|
||||
/// <summary>JUnit XML format for Jenkins, GitHub Actions, GitLab CI.</summary>
|
||||
JUnit,
|
||||
|
||||
/// <summary>SARIF 2.1.0 format for GitHub Code Scanning, VS Code.</summary>
|
||||
Sarif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gate decision export JSON format for CI/CD integration.
|
||||
/// </summary>
|
||||
public sealed record GateDecisionExportJson
|
||||
{
|
||||
/// <summary>Unique decision ID.</summary>
|
||||
[JsonPropertyName("decision_id")]
|
||||
public Guid DecisionId { get; init; }
|
||||
|
||||
/// <summary>BOM reference.</summary>
|
||||
[JsonPropertyName("bom_ref")]
|
||||
public required string BomRef { get; init; }
|
||||
|
||||
/// <summary>Image digest if applicable.</summary>
|
||||
[JsonPropertyName("image_digest")]
|
||||
public string? ImageDigest { get; init; }
|
||||
|
||||
/// <summary>Gate decision: pass, warn, block.</summary>
|
||||
[JsonPropertyName("gate_status")]
|
||||
public required string GateStatus { get; init; }
|
||||
|
||||
/// <summary>Verdict hash for replay verification.</summary>
|
||||
[JsonPropertyName("verdict_hash")]
|
||||
public string? VerdictHash { get; init; }
|
||||
|
||||
/// <summary>Policy bundle ID used for evaluation.</summary>
|
||||
[JsonPropertyName("policy_bundle_id")]
|
||||
public string? PolicyBundleId { get; init; }
|
||||
|
||||
/// <summary>Policy bundle content hash.</summary>
|
||||
[JsonPropertyName("policy_bundle_hash")]
|
||||
public string? PolicyBundleHash { get; init; }
|
||||
|
||||
/// <summary>When the evaluation occurred.</summary>
|
||||
[JsonPropertyName("evaluated_at")]
|
||||
public DateTimeOffset EvaluatedAt { get; init; }
|
||||
|
||||
/// <summary>CI/CD context (branch, commit, pipeline).</summary>
|
||||
[JsonPropertyName("ci_context")]
|
||||
public string? CiContext { get; init; }
|
||||
|
||||
/// <summary>Actor who triggered evaluation.</summary>
|
||||
[JsonPropertyName("actor")]
|
||||
public string? Actor { get; init; }
|
||||
|
||||
/// <summary>IDs of unknowns that blocked the release.</summary>
|
||||
[JsonPropertyName("blocking_unknown_ids")]
|
||||
public List<Guid> BlockingUnknownIds { get; init; } = [];
|
||||
|
||||
/// <summary>Warning messages.</summary>
|
||||
[JsonPropertyName("warnings")]
|
||||
public List<string> Warnings { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Exit code for CI/CD script integration.
|
||||
/// 0 = pass, 1 = warn, 2 = block
|
||||
/// </summary>
|
||||
[JsonPropertyName("exit_code")]
|
||||
public int ExitCode { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,538 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) 2026 StellaOps
|
||||
// Sprint: SPRINT_20260118_030_LIB_verdict_rekor_gate_api
|
||||
// Task: TASK-030-006 - Gate Decision API Endpoint
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.DeltaVerdict.Bundles;
|
||||
using StellaOps.Policy.Gateway.Contracts;
|
||||
using StellaOps.Signals.EvidenceWeightedScore;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Score-based gate API endpoints for CI/CD release gating.
|
||||
/// Provides advisory-style score-based gate evaluation with verdict bundles.
|
||||
/// </summary>
|
||||
public static class ScoreGateEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps score-based gate endpoints to the application.
|
||||
/// </summary>
|
||||
public static void MapScoreGateEndpoints(this WebApplication app)
|
||||
{
|
||||
var gates = app.MapGroup("/api/v1/gate")
|
||||
.WithTags("Score Gates");
|
||||
|
||||
// POST /api/v1/gate/evaluate - Evaluate score-based gate for a finding
|
||||
gates.MapPost("/evaluate", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
ScoreGateEvaluateRequest request,
|
||||
IEvidenceWeightedScoreCalculator ewsCalculator,
|
||||
IVerdictBundleBuilder verdictBuilder,
|
||||
IVerdictSigningService signingService,
|
||||
IVerdictRekorAnchorService anchorService,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
ILogger<ScoreGateEndpoints> logger,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Request body required",
|
||||
Status = 400
|
||||
});
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.FindingId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Finding ID is required",
|
||||
Status = 400,
|
||||
Detail = "Provide a valid finding identifier (e.g., CVE-2024-1234@pkg:npm/lodash@4.17.20)"
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Step 1: Build EWS input from request
|
||||
var ewsInput = BuildEwsInput(request);
|
||||
|
||||
// Step 2: Get policy (default to advisory)
|
||||
var policy = GetPolicy(request.PolicyProfile);
|
||||
|
||||
// Step 3: Calculate score
|
||||
var ewsResult = ewsCalculator.Calculate(ewsInput, policy);
|
||||
|
||||
// Step 4: Build verdict bundle (includes gate evaluation)
|
||||
var gateConfig = GateConfiguration.Default;
|
||||
var verdictBundle = verdictBuilder.Build(ewsResult, ewsInput, policy, gateConfig);
|
||||
|
||||
logger.LogInformation(
|
||||
"Gate evaluated for {FindingId}: action={Action}, score={Score:F2}",
|
||||
request.FindingId,
|
||||
verdictBundle.Gate.Action,
|
||||
verdictBundle.FinalScore);
|
||||
|
||||
// Step 5: Sign the bundle
|
||||
var signingOptions = new VerdictSigningOptions
|
||||
{
|
||||
KeyId = "stella-gate-api",
|
||||
Algorithm = VerdictSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = GetSigningSecret()
|
||||
};
|
||||
var signedBundle = await signingService.SignAsync(verdictBundle, signingOptions, cancellationToken);
|
||||
|
||||
// Step 6: Optionally anchor to Rekor
|
||||
VerdictBundle finalBundle = signedBundle;
|
||||
string? rekorUuid = null;
|
||||
long? rekorLogIndex = null;
|
||||
|
||||
if (request.AnchorToRekor)
|
||||
{
|
||||
var anchorOptions = new VerdictAnchorOptions
|
||||
{
|
||||
RekorUrl = GetRekorUrl()
|
||||
};
|
||||
|
||||
var anchorResult = await anchorService.AnchorAsync(signedBundle, anchorOptions, cancellationToken);
|
||||
if (anchorResult.IsSuccess)
|
||||
{
|
||||
finalBundle = anchorResult.AnchoredBundle!;
|
||||
rekorUuid = anchorResult.Linkage?.Uuid;
|
||||
rekorLogIndex = anchorResult.Linkage?.LogIndex;
|
||||
|
||||
logger.LogInformation(
|
||||
"Verdict anchored to Rekor: uuid={Uuid}, logIndex={LogIndex}",
|
||||
rekorUuid,
|
||||
rekorLogIndex);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Rekor anchoring failed: {Error}",
|
||||
anchorResult.Error);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 7: Build response
|
||||
var response = BuildResponse(
|
||||
finalBundle,
|
||||
ewsResult,
|
||||
rekorUuid,
|
||||
rekorLogIndex,
|
||||
request.IncludeVerdict);
|
||||
|
||||
// Return appropriate status code based on action
|
||||
return finalBundle.Gate.Action switch
|
||||
{
|
||||
GateAction.Block => Results.Json(response, statusCode: 403),
|
||||
GateAction.Warn => Results.Ok(response),
|
||||
_ => Results.Ok(response)
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Gate evaluation failed for {FindingId}", request.FindingId);
|
||||
return Results.Problem(new ProblemDetails
|
||||
{
|
||||
Title = "Gate evaluation failed",
|
||||
Status = 500,
|
||||
Detail = "An error occurred during gate evaluation"
|
||||
});
|
||||
}
|
||||
})
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRun))
|
||||
.WithName("EvaluateScoreGate")
|
||||
.WithDescription("Evaluate score-based CI/CD gate for a finding")
|
||||
.WithOpenApi();
|
||||
|
||||
// GET /api/v1/gate/health - Health check for gate service
|
||||
gates.MapGet("/health", ([FromServices] TimeProvider timeProvider) =>
|
||||
Results.Ok(new { status = "healthy", timestamp = timeProvider.GetUtcNow() }))
|
||||
.WithName("ScoreGateHealth")
|
||||
.WithDescription("Health check for the score-based gate evaluation service");
|
||||
|
||||
// POST /api/v1/gate/evaluate-batch - Batch evaluation for multiple findings
|
||||
gates.MapPost("/evaluate-batch", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
ScoreGateBatchEvaluateRequest request,
|
||||
IEvidenceWeightedScoreCalculator ewsCalculator,
|
||||
IVerdictBundleBuilder verdictBuilder,
|
||||
IVerdictSigningService signingService,
|
||||
IVerdictRekorAnchorService anchorService,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
ILogger<ScoreGateEndpoints> logger,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (request is null || request.Findings is null || request.Findings.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Request body required",
|
||||
Status = 400,
|
||||
Detail = "Provide at least one finding to evaluate"
|
||||
});
|
||||
}
|
||||
|
||||
const int maxBatchSize = 500;
|
||||
if (request.Findings.Count > maxBatchSize)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Batch size exceeded",
|
||||
Status = 400,
|
||||
Detail = $"Maximum batch size is {maxBatchSize}, got {request.Findings.Count}"
|
||||
});
|
||||
}
|
||||
|
||||
var options = request.Options ?? new ScoreGateBatchOptions();
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
var results = await EvaluateBatchAsync(
|
||||
request.Findings,
|
||||
options,
|
||||
ewsCalculator,
|
||||
verdictBuilder,
|
||||
signingService,
|
||||
anchorService,
|
||||
logger,
|
||||
cancellationToken);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
var summary = new ScoreGateBatchSummary
|
||||
{
|
||||
Total = results.Count,
|
||||
Passed = results.Count(r => r.Action == ScoreGateActions.Pass),
|
||||
Warned = results.Count(r => r.Action == ScoreGateActions.Warn),
|
||||
Blocked = results.Count(r => r.Action == ScoreGateActions.Block),
|
||||
Errored = results.Count(r => r.Action == "error")
|
||||
};
|
||||
|
||||
// Determine overall action (worst case)
|
||||
var overallAction = summary.Blocked > 0 ? ScoreGateActions.Block
|
||||
: summary.Warned > 0 ? ScoreGateActions.Warn
|
||||
: ScoreGateActions.Pass;
|
||||
|
||||
var exitCode = overallAction switch
|
||||
{
|
||||
ScoreGateActions.Block => ScoreGateExitCodes.Block,
|
||||
ScoreGateActions.Warn => ScoreGateExitCodes.Warn,
|
||||
_ => ScoreGateExitCodes.Pass
|
||||
};
|
||||
|
||||
var response = new ScoreGateBatchEvaluateResponse
|
||||
{
|
||||
Summary = summary,
|
||||
OverallAction = overallAction,
|
||||
ExitCode = exitCode,
|
||||
Decisions = results,
|
||||
DurationMs = stopwatch.ElapsedMilliseconds,
|
||||
FailFastTriggered = options.FailFast && summary.Blocked > 0 && results.Count < request.Findings.Count
|
||||
};
|
||||
|
||||
logger.LogInformation(
|
||||
"Batch gate evaluated: total={Total}, passed={Passed}, warned={Warned}, blocked={Blocked}, duration={Duration}ms",
|
||||
summary.Total, summary.Passed, summary.Warned, summary.Blocked, stopwatch.ElapsedMilliseconds);
|
||||
|
||||
// Return appropriate status based on overall action
|
||||
return overallAction == ScoreGateActions.Block
|
||||
? Results.Json(response, statusCode: 403)
|
||||
: Results.Ok(response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Batch gate evaluation failed");
|
||||
return Results.Problem(new ProblemDetails
|
||||
{
|
||||
Title = "Batch evaluation failed",
|
||||
Status = 500,
|
||||
Detail = "An error occurred during batch gate evaluation"
|
||||
});
|
||||
}
|
||||
})
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRun))
|
||||
.WithName("EvaluateScoreGateBatch")
|
||||
.WithDescription("Batch evaluate score-based CI/CD gates for multiple findings")
|
||||
.WithOpenApi();
|
||||
}
|
||||
|
||||
private static async Task<List<ScoreGateBatchDecision>> EvaluateBatchAsync(
|
||||
IReadOnlyList<ScoreGateEvaluateRequest> findings,
|
||||
ScoreGateBatchOptions options,
|
||||
IEvidenceWeightedScoreCalculator ewsCalculator,
|
||||
IVerdictBundleBuilder verdictBuilder,
|
||||
IVerdictSigningService signingService,
|
||||
IVerdictRekorAnchorService anchorService,
|
||||
ILogger logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new List<ScoreGateBatchDecision>();
|
||||
var policy = GetPolicy(options.PolicyProfile);
|
||||
var gateConfig = GateConfiguration.Default;
|
||||
|
||||
var parallelism = Math.Clamp(options.MaxParallelism, 1, 20);
|
||||
var semaphore = new SemaphoreSlim(parallelism);
|
||||
var failFastToken = new CancellationTokenSource();
|
||||
|
||||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, failFastToken.Token);
|
||||
|
||||
var tasks = findings.Select(async finding =>
|
||||
{
|
||||
await semaphore.WaitAsync(linkedCts.Token);
|
||||
try
|
||||
{
|
||||
if (linkedCts.Token.IsCancellationRequested)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var decision = await EvaluateSingleAsync(
|
||||
finding,
|
||||
options,
|
||||
policy,
|
||||
gateConfig,
|
||||
ewsCalculator,
|
||||
verdictBuilder,
|
||||
signingService,
|
||||
anchorService,
|
||||
logger,
|
||||
linkedCts.Token);
|
||||
|
||||
// Check fail-fast
|
||||
if (options.FailFast && decision.Action == ScoreGateActions.Block)
|
||||
{
|
||||
failFastToken.Cancel();
|
||||
}
|
||||
|
||||
return decision;
|
||||
}
|
||||
finally
|
||||
{
|
||||
semaphore.Release();
|
||||
}
|
||||
}).ToList();
|
||||
|
||||
var completedTasks = await Task.WhenAll(tasks);
|
||||
results.AddRange(completedTasks.Where(d => d is not null).Cast<ScoreGateBatchDecision>());
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static async Task<ScoreGateBatchDecision> EvaluateSingleAsync(
|
||||
ScoreGateEvaluateRequest request,
|
||||
ScoreGateBatchOptions batchOptions,
|
||||
EvidenceWeightPolicy policy,
|
||||
GateConfiguration gateConfig,
|
||||
IEvidenceWeightedScoreCalculator ewsCalculator,
|
||||
IVerdictBundleBuilder verdictBuilder,
|
||||
IVerdictSigningService signingService,
|
||||
IVerdictRekorAnchorService anchorService,
|
||||
ILogger logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Build EWS input
|
||||
var ewsInput = BuildEwsInput(request);
|
||||
|
||||
// Calculate score
|
||||
var ewsResult = ewsCalculator.Calculate(ewsInput, policy);
|
||||
|
||||
// Build verdict bundle
|
||||
var verdictBundle = verdictBuilder.Build(ewsResult, ewsInput, policy, gateConfig);
|
||||
|
||||
// Sign the bundle
|
||||
var signingOptions = new VerdictSigningOptions
|
||||
{
|
||||
KeyId = "stella-gate-api",
|
||||
Algorithm = VerdictSigningAlgorithm.HmacSha256,
|
||||
SecretBase64 = GetSigningSecret()
|
||||
};
|
||||
var signedBundle = await signingService.SignAsync(verdictBundle, signingOptions, cancellationToken);
|
||||
|
||||
// Optionally anchor to Rekor
|
||||
VerdictBundle finalBundle = signedBundle;
|
||||
if (batchOptions.AnchorToRekor)
|
||||
{
|
||||
var anchorOptions = new VerdictAnchorOptions { RekorUrl = GetRekorUrl() };
|
||||
var anchorResult = await anchorService.AnchorAsync(signedBundle, anchorOptions, cancellationToken);
|
||||
if (anchorResult.IsSuccess)
|
||||
{
|
||||
finalBundle = anchorResult.AnchoredBundle!;
|
||||
}
|
||||
}
|
||||
|
||||
var action = finalBundle.Gate.Action switch
|
||||
{
|
||||
GateAction.Pass => ScoreGateActions.Pass,
|
||||
GateAction.Warn => ScoreGateActions.Warn,
|
||||
GateAction.Block => ScoreGateActions.Block,
|
||||
_ => ScoreGateActions.Pass
|
||||
};
|
||||
|
||||
return new ScoreGateBatchDecision
|
||||
{
|
||||
FindingId = request.FindingId,
|
||||
Action = action,
|
||||
Score = finalBundle.FinalScore,
|
||||
Threshold = finalBundle.Gate.Threshold,
|
||||
Reason = finalBundle.Gate.Reason,
|
||||
VerdictBundleId = finalBundle.BundleId,
|
||||
VerdictBundle = batchOptions.IncludeVerdicts ? finalBundle : null
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to evaluate finding {FindingId}", request.FindingId);
|
||||
return new ScoreGateBatchDecision
|
||||
{
|
||||
FindingId = request.FindingId,
|
||||
Action = "error",
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static EvidenceWeightedScoreInput BuildEwsInput(ScoreGateEvaluateRequest request)
|
||||
{
|
||||
// Parse reachability level to normalized value
|
||||
var reachabilityValue = ParseReachabilityLevel(request.Reachability);
|
||||
|
||||
// Parse exploit maturity level
|
||||
var exploitMaturity = ParseExploitMaturity(request.ExploitMaturity);
|
||||
|
||||
return new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = request.FindingId,
|
||||
CvssBase = request.CvssBase,
|
||||
CvssVersion = request.CvssVersion ?? "3.1",
|
||||
EpssScore = request.Epss,
|
||||
ExploitMaturity = exploitMaturity,
|
||||
PatchProofConfidence = request.PatchProofConfidence,
|
||||
VexStatus = request.VexStatus,
|
||||
VexSource = request.VexSource,
|
||||
// Map reachability to legacy Rch field (used by advisory formula)
|
||||
Rch = reachabilityValue,
|
||||
// Legacy fields with safe defaults
|
||||
Rts = 0.0,
|
||||
Bkp = request.PatchProofConfidence,
|
||||
Xpl = request.Epss,
|
||||
Src = 0.5,
|
||||
Mit = 0.0
|
||||
};
|
||||
}
|
||||
|
||||
private static double ParseReachabilityLevel(string? level)
|
||||
{
|
||||
return level?.ToLowerInvariant() switch
|
||||
{
|
||||
"caller" => 0.9,
|
||||
"function" or "function_level" => 0.7,
|
||||
"package" or "package_level" => 0.3,
|
||||
"none" => 0.0,
|
||||
_ => 0.0
|
||||
};
|
||||
}
|
||||
|
||||
private static ExploitMaturityLevel ParseExploitMaturity(string? maturity)
|
||||
{
|
||||
return maturity?.ToLowerInvariant() switch
|
||||
{
|
||||
"high" or "active" or "kev" => ExploitMaturityLevel.High,
|
||||
"functional" => ExploitMaturityLevel.Functional,
|
||||
"poc" or "proof_of_concept" or "proofofconcept" => ExploitMaturityLevel.ProofOfConcept,
|
||||
"none" => ExploitMaturityLevel.None,
|
||||
_ => ExploitMaturityLevel.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
private static EvidenceWeightPolicy GetPolicy(string? profile)
|
||||
{
|
||||
return profile?.ToLowerInvariant() switch
|
||||
{
|
||||
"legacy" => EvidenceWeightPolicy.DefaultProduction,
|
||||
"advisory" or null => EvidenceWeightPolicy.AdvisoryProduction,
|
||||
_ => EvidenceWeightPolicy.AdvisoryProduction
|
||||
};
|
||||
}
|
||||
|
||||
private static ScoreGateEvaluateResponse BuildResponse(
|
||||
VerdictBundle bundle,
|
||||
EvidenceWeightedScoreResult ewsResult,
|
||||
string? rekorUuid,
|
||||
long? rekorLogIndex,
|
||||
bool includeVerdict)
|
||||
{
|
||||
var action = bundle.Gate.Action switch
|
||||
{
|
||||
GateAction.Pass => ScoreGateActions.Pass,
|
||||
GateAction.Warn => ScoreGateActions.Warn,
|
||||
GateAction.Block => ScoreGateActions.Block,
|
||||
_ => ScoreGateActions.Pass
|
||||
};
|
||||
|
||||
var exitCode = bundle.Gate.Action switch
|
||||
{
|
||||
GateAction.Pass => ScoreGateExitCodes.Pass,
|
||||
GateAction.Warn => ScoreGateExitCodes.Warn,
|
||||
GateAction.Block => ScoreGateExitCodes.Block,
|
||||
_ => ScoreGateExitCodes.Pass
|
||||
};
|
||||
|
||||
var breakdown = ewsResult.Breakdown
|
||||
.Select(b => new ScoreDimensionBreakdown
|
||||
{
|
||||
Dimension = b.Dimension,
|
||||
Symbol = b.Symbol,
|
||||
Value = b.InputValue,
|
||||
Weight = b.Weight,
|
||||
Contribution = b.Contribution,
|
||||
IsSubtractive = b.IsSubtractive
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new ScoreGateEvaluateResponse
|
||||
{
|
||||
Action = action,
|
||||
Score = bundle.FinalScore,
|
||||
Threshold = bundle.Gate.Threshold,
|
||||
Reason = bundle.Gate.Reason,
|
||||
VerdictBundleId = bundle.BundleId,
|
||||
RekorUuid = rekorUuid,
|
||||
RekorLogIndex = rekorLogIndex,
|
||||
ComputedAt = bundle.ComputedAt,
|
||||
MatchedRules = bundle.Gate.MatchedRules.ToList(),
|
||||
Suggestions = bundle.Gate.Suggestions.ToList(),
|
||||
ExitCode = exitCode,
|
||||
Breakdown = breakdown,
|
||||
VerdictBundle = includeVerdict ? bundle : null
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetSigningSecret()
|
||||
{
|
||||
// In production, this should come from configuration/secrets management
|
||||
// For now, return a placeholder that should be overridden
|
||||
return Environment.GetEnvironmentVariable("STELLA_GATE_SIGNING_SECRET")
|
||||
?? Convert.ToBase64String(new byte[32]);
|
||||
}
|
||||
|
||||
private static string GetRekorUrl()
|
||||
{
|
||||
return Environment.GetEnvironmentVariable("STELLA_REKOR_URL")
|
||||
?? "https://rekor.sigstore.dev";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logging category for score gate endpoints.
|
||||
/// </summary>
|
||||
public sealed class ScoreGateEndpoints { }
|
||||
@@ -163,6 +163,20 @@ builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.GateBypassAuditOp
|
||||
builder.Services.AddScoped<StellaOps.Policy.Engine.Services.IGateBypassAuditor,
|
||||
StellaOps.Policy.Engine.Services.GateBypassAuditor>();
|
||||
|
||||
// Score-based gate services (Sprint: SPRINT_20260118_030_LIB_verdict_rekor_gate_api)
|
||||
builder.Services.AddSingleton<StellaOps.Signals.EvidenceWeightedScore.IEvidenceWeightedScoreCalculator,
|
||||
StellaOps.Signals.EvidenceWeightedScore.EvidenceWeightedScoreCalculator>();
|
||||
builder.Services.AddSingleton<StellaOps.DeltaVerdict.Bundles.IGateEvaluator,
|
||||
StellaOps.DeltaVerdict.Bundles.GateEvaluator>();
|
||||
builder.Services.AddSingleton<StellaOps.DeltaVerdict.Bundles.IVerdictBundleBuilder,
|
||||
StellaOps.DeltaVerdict.Bundles.VerdictBundleBuilder>();
|
||||
builder.Services.AddSingleton<StellaOps.DeltaVerdict.Bundles.IVerdictSigningService,
|
||||
StellaOps.DeltaVerdict.Bundles.VerdictSigningService>();
|
||||
builder.Services.AddSingleton<StellaOps.DeltaVerdict.Bundles.IRekorSubmissionClient,
|
||||
StellaOps.DeltaVerdict.Bundles.StubVerdictRekorClient>();
|
||||
builder.Services.AddSingleton<StellaOps.DeltaVerdict.Bundles.IVerdictRekorAnchorService,
|
||||
StellaOps.DeltaVerdict.Bundles.VerdictRekorAnchorService>();
|
||||
|
||||
// Exception approval services (Sprint: SPRINT_20251226_003_BE_exception_approval)
|
||||
builder.Services.Configure<StellaOps.Policy.Engine.Services.ExceptionApprovalRulesOptions>(
|
||||
builder.Configuration.GetSection(StellaOps.Policy.Engine.Services.ExceptionApprovalRulesOptions.SectionName));
|
||||
@@ -546,6 +560,9 @@ app.MapDeltasEndpoints();
|
||||
// Gate evaluation endpoints (Sprint: SPRINT_20251226_001_BE_cicd_gate_integration)
|
||||
app.MapGateEndpoints();
|
||||
|
||||
// Score-based gate evaluation endpoints (Sprint: SPRINT_20260118_030_LIB_verdict_rekor_gate_api)
|
||||
app.MapScoreGateEndpoints();
|
||||
|
||||
// Registry webhook endpoints (Sprint: SPRINT_20251226_001_BE_cicd_gate_integration)
|
||||
app.MapRegistryWebhooks();
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
-- Policy Schema Migration 002: Trusted Keys and Gate Bypass Audit
|
||||
-- Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
|
||||
-- Tasks: TASK-017-005, TASK-017-006
|
||||
-- Description: Adds trusted key registry and gate bypass audit tables
|
||||
|
||||
-- ============================================================================
|
||||
-- Trusted Keys Table
|
||||
-- ============================================================================
|
||||
-- Stores trusted signing keys for attestation verification.
|
||||
-- Keys can be looked up by keyid, fingerprint, or issuer pattern.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS policy.trusted_keys (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL,
|
||||
key_id TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
algorithm TEXT NOT NULL,
|
||||
public_key_pem TEXT,
|
||||
owner TEXT,
|
||||
issuer_pattern TEXT,
|
||||
purposes JSONB DEFAULT '[]',
|
||||
valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
valid_until TIMESTAMPTZ,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
revoked_reason TEXT,
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by TEXT,
|
||||
UNIQUE(tenant_id, key_id),
|
||||
UNIQUE(tenant_id, fingerprint)
|
||||
);
|
||||
|
||||
-- Indexes for trusted_keys
|
||||
CREATE INDEX IF NOT EXISTS idx_trusted_keys_tenant
|
||||
ON policy.trusted_keys(tenant_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_trusted_keys_fingerprint
|
||||
ON policy.trusted_keys(tenant_id, fingerprint);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_trusted_keys_active
|
||||
ON policy.trusted_keys(tenant_id, is_active)
|
||||
WHERE is_active = true AND revoked_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_trusted_keys_issuer_pattern
|
||||
ON policy.trusted_keys(tenant_id, issuer_pattern)
|
||||
WHERE issuer_pattern IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_trusted_keys_purposes
|
||||
ON policy.trusted_keys USING gin(purposes);
|
||||
|
||||
-- Trigger for updated_at
|
||||
CREATE OR REPLACE TRIGGER trigger_trusted_keys_updated_at
|
||||
BEFORE UPDATE ON policy.trusted_keys
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION policy.update_updated_at();
|
||||
|
||||
-- ============================================================================
|
||||
-- Gate Bypass Audit Table
|
||||
-- ============================================================================
|
||||
-- Immutable audit log for gate bypass events.
|
||||
-- No UPDATE or DELETE allowed - append-only for compliance (7-year retention).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS policy.gate_bypass_audit (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL,
|
||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
decision_id TEXT NOT NULL,
|
||||
image_digest TEXT NOT NULL,
|
||||
repository TEXT,
|
||||
tag TEXT,
|
||||
baseline_ref TEXT,
|
||||
original_decision TEXT NOT NULL,
|
||||
final_decision TEXT NOT NULL,
|
||||
bypassed_gates JSONB NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
actor_subject TEXT,
|
||||
actor_email TEXT,
|
||||
actor_ip_address INET,
|
||||
justification TEXT NOT NULL,
|
||||
policy_id TEXT,
|
||||
source TEXT,
|
||||
ci_context TEXT,
|
||||
attestation_digest TEXT,
|
||||
rekor_uuid TEXT,
|
||||
bypass_type TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ,
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indexes for gate_bypass_audit
|
||||
CREATE INDEX IF NOT EXISTS idx_gate_bypass_audit_tenant_timestamp
|
||||
ON policy.gate_bypass_audit(tenant_id, timestamp DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gate_bypass_audit_decision_id
|
||||
ON policy.gate_bypass_audit(tenant_id, decision_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gate_bypass_audit_actor
|
||||
ON policy.gate_bypass_audit(tenant_id, actor, timestamp DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gate_bypass_audit_image_digest
|
||||
ON policy.gate_bypass_audit(tenant_id, image_digest, timestamp DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gate_bypass_audit_bypass_type
|
||||
ON policy.gate_bypass_audit(tenant_id, bypass_type);
|
||||
|
||||
-- Partial index for active time-limited bypasses
|
||||
CREATE INDEX IF NOT EXISTS idx_gate_bypass_audit_active_bypasses
|
||||
ON policy.gate_bypass_audit(tenant_id, expires_at)
|
||||
WHERE expires_at IS NOT NULL AND expires_at > NOW();
|
||||
|
||||
-- ============================================================================
|
||||
-- Row Level Security (RLS) Policies
|
||||
-- ============================================================================
|
||||
|
||||
-- Enable RLS on trusted_keys
|
||||
ALTER TABLE policy.trusted_keys ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY trusted_keys_tenant_isolation ON policy.trusted_keys
|
||||
USING (tenant_id = policy_app.require_current_tenant());
|
||||
|
||||
CREATE POLICY trusted_keys_insert_tenant ON policy.trusted_keys
|
||||
FOR INSERT
|
||||
WITH CHECK (tenant_id = policy_app.require_current_tenant());
|
||||
|
||||
CREATE POLICY trusted_keys_update_tenant ON policy.trusted_keys
|
||||
FOR UPDATE
|
||||
USING (tenant_id = policy_app.require_current_tenant());
|
||||
|
||||
-- Enable RLS on gate_bypass_audit
|
||||
ALTER TABLE policy.gate_bypass_audit ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY gate_bypass_audit_tenant_isolation ON policy.gate_bypass_audit
|
||||
USING (tenant_id = policy_app.require_current_tenant());
|
||||
|
||||
CREATE POLICY gate_bypass_audit_insert_tenant ON policy.gate_bypass_audit
|
||||
FOR INSERT
|
||||
WITH CHECK (tenant_id = policy_app.require_current_tenant());
|
||||
|
||||
-- ============================================================================
|
||||
-- Prevent Mutation of Audit Records
|
||||
-- ============================================================================
|
||||
-- Gate bypass audit records are immutable for compliance.
|
||||
-- This trigger prevents UPDATE and DELETE operations.
|
||||
|
||||
CREATE OR REPLACE FUNCTION policy.prevent_audit_mutation()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'Gate bypass audit records are immutable. UPDATE and DELETE are not allowed.'
|
||||
USING HINT = 'Audit records must be preserved for compliance (7-year retention)',
|
||||
ERRCODE = '42501';
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE TRIGGER trigger_gate_bypass_audit_immutable
|
||||
BEFORE UPDATE OR DELETE ON policy.gate_bypass_audit
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION policy.prevent_audit_mutation();
|
||||
|
||||
-- ============================================================================
|
||||
-- Comments
|
||||
-- ============================================================================
|
||||
|
||||
COMMENT ON TABLE policy.trusted_keys IS
|
||||
'Registry of trusted signing keys for attestation verification';
|
||||
|
||||
COMMENT ON TABLE policy.gate_bypass_audit IS
|
||||
'Immutable audit log of gate bypass events for compliance (7-year retention)';
|
||||
|
||||
COMMENT ON COLUMN policy.trusted_keys.fingerprint IS
|
||||
'SHA-256 fingerprint of the DER-encoded public key';
|
||||
|
||||
COMMENT ON COLUMN policy.trusted_keys.issuer_pattern IS
|
||||
'Wildcard pattern for keyless signing issuers (e.g., *@example.com)';
|
||||
|
||||
COMMENT ON COLUMN policy.trusted_keys.purposes IS
|
||||
'Allowed purposes: sbom-signing, vex-signing, release-signing, etc.';
|
||||
|
||||
COMMENT ON COLUMN policy.gate_bypass_audit.bypass_type IS
|
||||
'Classification: WarningOverride, BlockOverride, EmergencyBypass, TimeLimitedApproval';
|
||||
|
||||
COMMENT ON COLUMN policy.gate_bypass_audit.expires_at IS
|
||||
'For time-limited bypasses, when the bypass expires';
|
||||
@@ -0,0 +1,149 @@
|
||||
-- Policy Schema Migration 003: Gate Decisions History
|
||||
-- Sprint: SPRINT_20260118_019_Policy_gate_replay_api_exposure
|
||||
-- Tasks: GR-005 - Add gate decision history endpoint
|
||||
-- Description: Adds gate decisions history table for audit and replay
|
||||
|
||||
-- ============================================================================
|
||||
-- Gate Decisions Table
|
||||
-- ============================================================================
|
||||
-- Stores historical gate decisions for audit, debugging, and replay.
|
||||
-- Each decision record captures the full context of a gate evaluation.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS policy.gate_decisions (
|
||||
decision_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
gate_id TEXT NOT NULL,
|
||||
bom_ref TEXT NOT NULL,
|
||||
image_digest TEXT,
|
||||
gate_status TEXT NOT NULL,
|
||||
verdict_hash TEXT,
|
||||
policy_bundle_id TEXT,
|
||||
policy_bundle_hash TEXT,
|
||||
evaluated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ci_context TEXT,
|
||||
actor TEXT,
|
||||
blocking_unknown_ids JSONB DEFAULT '[]',
|
||||
warnings JSONB DEFAULT '[]',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- Indexes for Gate Decisions
|
||||
-- ============================================================================
|
||||
-- Optimized for the primary query patterns:
|
||||
-- 1. Time-range queries by tenant (audit queries)
|
||||
-- 2. Status filtering (find failures)
|
||||
-- 3. Actor filtering (who triggered)
|
||||
-- 4. BOM reference lookups (specific component history)
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gate_decisions_tenant_evaluated
|
||||
ON policy.gate_decisions(tenant_id, evaluated_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gate_decisions_gate_evaluated
|
||||
ON policy.gate_decisions(tenant_id, gate_id, evaluated_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gate_decisions_status
|
||||
ON policy.gate_decisions(tenant_id, gate_status, evaluated_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gate_decisions_actor
|
||||
ON policy.gate_decisions(tenant_id, actor, evaluated_at DESC)
|
||||
WHERE actor IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gate_decisions_bom_ref
|
||||
ON policy.gate_decisions(tenant_id, bom_ref, evaluated_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gate_decisions_verdict_hash
|
||||
ON policy.gate_decisions(tenant_id, verdict_hash)
|
||||
WHERE verdict_hash IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gate_decisions_policy_bundle
|
||||
ON policy.gate_decisions(tenant_id, policy_bundle_id, evaluated_at DESC)
|
||||
WHERE policy_bundle_id IS NOT NULL;
|
||||
|
||||
-- Partial index for blocked decisions (commonly queried)
|
||||
CREATE INDEX IF NOT EXISTS idx_gate_decisions_blocked
|
||||
ON policy.gate_decisions(tenant_id, evaluated_at DESC)
|
||||
WHERE gate_status = 'block';
|
||||
|
||||
-- ============================================================================
|
||||
-- Row Level Security (RLS) Policies
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE policy.gate_decisions ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Tenant isolation policy
|
||||
CREATE POLICY gate_decisions_tenant_isolation ON policy.gate_decisions
|
||||
USING (tenant_id::text = policy_app.require_current_tenant());
|
||||
|
||||
-- Insert policy
|
||||
CREATE POLICY gate_decisions_insert_tenant ON policy.gate_decisions
|
||||
FOR INSERT
|
||||
WITH CHECK (tenant_id::text = policy_app.require_current_tenant());
|
||||
|
||||
-- ============================================================================
|
||||
-- Retention Policy Helper
|
||||
-- ============================================================================
|
||||
-- Function to purge old gate decisions (configurable retention, default 90 days)
|
||||
|
||||
CREATE OR REPLACE FUNCTION policy.purge_old_gate_decisions(
|
||||
p_tenant_id UUID,
|
||||
p_retention_days INTEGER DEFAULT 90
|
||||
)
|
||||
RETURNS BIGINT AS $$
|
||||
DECLARE
|
||||
deleted_count BIGINT;
|
||||
BEGIN
|
||||
DELETE FROM policy.gate_decisions
|
||||
WHERE tenant_id = p_tenant_id
|
||||
AND evaluated_at < NOW() - (p_retention_days || ' days')::INTERVAL;
|
||||
|
||||
GET DIAGNOSTICS deleted_count = ROW_COUNT;
|
||||
RETURN deleted_count;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- ============================================================================
|
||||
-- Comments
|
||||
-- ============================================================================
|
||||
|
||||
COMMENT ON TABLE policy.gate_decisions IS
|
||||
'Historical gate decisions for audit, debugging, and deterministic replay';
|
||||
|
||||
COMMENT ON COLUMN policy.gate_decisions.decision_id IS
|
||||
'Unique identifier for this gate decision';
|
||||
|
||||
COMMENT ON COLUMN policy.gate_decisions.gate_id IS
|
||||
'Identifier for the gate that was evaluated';
|
||||
|
||||
COMMENT ON COLUMN policy.gate_decisions.bom_ref IS
|
||||
'Package URL (PURL) or component reference being evaluated';
|
||||
|
||||
COMMENT ON COLUMN policy.gate_decisions.image_digest IS
|
||||
'Container image digest if applicable (sha256:...)';
|
||||
|
||||
COMMENT ON COLUMN policy.gate_decisions.gate_status IS
|
||||
'Decision result: pass, warn, or block';
|
||||
|
||||
COMMENT ON COLUMN policy.gate_decisions.verdict_hash IS
|
||||
'Content-addressable hash for replay verification (sha256:...)';
|
||||
|
||||
COMMENT ON COLUMN policy.gate_decisions.policy_bundle_id IS
|
||||
'Policy bundle identifier used for this evaluation';
|
||||
|
||||
COMMENT ON COLUMN policy.gate_decisions.policy_bundle_hash IS
|
||||
'Content hash of the policy bundle for exact replay';
|
||||
|
||||
COMMENT ON COLUMN policy.gate_decisions.ci_context IS
|
||||
'CI/CD context JSON (branch, commit, pipeline_id)';
|
||||
|
||||
COMMENT ON COLUMN policy.gate_decisions.actor IS
|
||||
'User or service account that triggered the evaluation';
|
||||
|
||||
COMMENT ON COLUMN policy.gate_decisions.blocking_unknown_ids IS
|
||||
'Array of unknown IDs that caused a block decision';
|
||||
|
||||
COMMENT ON COLUMN policy.gate_decisions.warnings IS
|
||||
'Array of warning messages generated during evaluation';
|
||||
|
||||
COMMENT ON FUNCTION policy.purge_old_gate_decisions IS
|
||||
'Purges gate decisions older than retention period (default 90 days)';
|
||||
@@ -0,0 +1,168 @@
|
||||
-- Policy Schema Migration 004: Replay Audit Trail
|
||||
-- Sprint: SPRINT_20260118_019_Policy_gate_replay_api_exposure
|
||||
-- Task: GR-007 - Create replay audit trail
|
||||
-- Description: Adds replay audit table for compliance tracking
|
||||
|
||||
-- ============================================================================
|
||||
-- Replay Audit Table
|
||||
-- ============================================================================
|
||||
-- Records all replay attempts for compliance and debugging.
|
||||
-- Tracks original vs replayed verdict hashes and any mismatches.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS policy.replay_audit (
|
||||
replay_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
bom_ref VARCHAR(512) NOT NULL,
|
||||
verdict_hash VARCHAR(128) NOT NULL,
|
||||
rekor_uuid VARCHAR(128),
|
||||
replayed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
match BOOLEAN NOT NULL,
|
||||
original_hash VARCHAR(128),
|
||||
replayed_hash VARCHAR(128),
|
||||
mismatch_reason TEXT,
|
||||
policy_bundle_id TEXT,
|
||||
policy_bundle_hash VARCHAR(128),
|
||||
verifier_digest VARCHAR(128),
|
||||
duration_ms INT,
|
||||
actor VARCHAR(256),
|
||||
source VARCHAR(64),
|
||||
request_context JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- Indexes for Replay Audit
|
||||
-- ============================================================================
|
||||
-- Optimized for common query patterns:
|
||||
-- 1. Time-range queries by tenant
|
||||
-- 2. BOM reference lookups
|
||||
-- 3. Finding mismatches for investigation
|
||||
-- 4. Actor filtering
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_replay_audit_tenant_replayed
|
||||
ON policy.replay_audit(tenant_id, replayed_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_replay_audit_bom_ref
|
||||
ON policy.replay_audit(tenant_id, bom_ref, replayed_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_replay_audit_verdict_hash
|
||||
ON policy.replay_audit(tenant_id, verdict_hash);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_replay_audit_rekor_uuid
|
||||
ON policy.replay_audit(tenant_id, rekor_uuid)
|
||||
WHERE rekor_uuid IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_replay_audit_actor
|
||||
ON policy.replay_audit(tenant_id, actor, replayed_at DESC)
|
||||
WHERE actor IS NOT NULL;
|
||||
|
||||
-- Partial index for mismatches (high priority for investigation)
|
||||
CREATE INDEX IF NOT EXISTS idx_replay_audit_mismatches
|
||||
ON policy.replay_audit(tenant_id, replayed_at DESC)
|
||||
WHERE match = false;
|
||||
|
||||
-- ============================================================================
|
||||
-- Row Level Security (RLS) Policies
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE policy.replay_audit ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY replay_audit_tenant_isolation ON policy.replay_audit
|
||||
USING (tenant_id::text = policy_app.require_current_tenant());
|
||||
|
||||
CREATE POLICY replay_audit_insert_tenant ON policy.replay_audit
|
||||
FOR INSERT
|
||||
WITH CHECK (tenant_id::text = policy_app.require_current_tenant());
|
||||
|
||||
-- ============================================================================
|
||||
-- Retention Policy Helper
|
||||
-- ============================================================================
|
||||
-- Function to purge old replay audit records (default 90 days)
|
||||
|
||||
CREATE OR REPLACE FUNCTION policy.purge_old_replay_audit(
|
||||
p_tenant_id UUID,
|
||||
p_retention_days INTEGER DEFAULT 90
|
||||
)
|
||||
RETURNS BIGINT AS $$
|
||||
DECLARE
|
||||
deleted_count BIGINT;
|
||||
BEGIN
|
||||
DELETE FROM policy.replay_audit
|
||||
WHERE tenant_id = p_tenant_id
|
||||
AND replayed_at < NOW() - (p_retention_days || ' days')::INTERVAL;
|
||||
|
||||
GET DIAGNOSTICS deleted_count = ROW_COUNT;
|
||||
RETURN deleted_count;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- ============================================================================
|
||||
-- Metrics Views
|
||||
-- ============================================================================
|
||||
-- Views to support Prometheus metrics: replay_attempts_total, replay_match_rate
|
||||
|
||||
CREATE OR REPLACE VIEW policy.replay_metrics_hourly AS
|
||||
SELECT
|
||||
tenant_id,
|
||||
date_trunc('hour', replayed_at) AS hour,
|
||||
COUNT(*) AS total_attempts,
|
||||
COUNT(*) FILTER (WHERE match = true) AS successful_matches,
|
||||
COUNT(*) FILTER (WHERE match = false) AS mismatches,
|
||||
CASE
|
||||
WHEN COUNT(*) > 0
|
||||
THEN (COUNT(*) FILTER (WHERE match = true))::decimal / COUNT(*)
|
||||
ELSE 0
|
||||
END AS match_rate,
|
||||
AVG(duration_ms) AS avg_duration_ms,
|
||||
MAX(duration_ms) AS max_duration_ms
|
||||
FROM policy.replay_audit
|
||||
GROUP BY tenant_id, date_trunc('hour', replayed_at);
|
||||
|
||||
-- ============================================================================
|
||||
-- Comments
|
||||
-- ============================================================================
|
||||
|
||||
COMMENT ON TABLE policy.replay_audit IS
|
||||
'Audit trail of all replay attempts for compliance (90-day default retention)';
|
||||
|
||||
COMMENT ON COLUMN policy.replay_audit.replay_id IS
|
||||
'Unique identifier for this replay attempt';
|
||||
|
||||
COMMENT ON COLUMN policy.replay_audit.bom_ref IS
|
||||
'Package URL or component reference that was replayed';
|
||||
|
||||
COMMENT ON COLUMN policy.replay_audit.verdict_hash IS
|
||||
'Original verdict hash that was requested for replay';
|
||||
|
||||
COMMENT ON COLUMN policy.replay_audit.rekor_uuid IS
|
||||
'Rekor transparency log UUID for verification';
|
||||
|
||||
COMMENT ON COLUMN policy.replay_audit.match IS
|
||||
'Whether the replayed verdict matched the original';
|
||||
|
||||
COMMENT ON COLUMN policy.replay_audit.original_hash IS
|
||||
'Hash from original verdict submission';
|
||||
|
||||
COMMENT ON COLUMN policy.replay_audit.replayed_hash IS
|
||||
'Hash computed during replay';
|
||||
|
||||
COMMENT ON COLUMN policy.replay_audit.mismatch_reason IS
|
||||
'Explanation if match=false, e.g., "policy_changed", "feed_drift"';
|
||||
|
||||
COMMENT ON COLUMN policy.replay_audit.policy_bundle_hash IS
|
||||
'Content-addressable hash of policy bundle used';
|
||||
|
||||
COMMENT ON COLUMN policy.replay_audit.verifier_digest IS
|
||||
'Container image digest of verifier service';
|
||||
|
||||
COMMENT ON COLUMN policy.replay_audit.duration_ms IS
|
||||
'Time taken to complete replay in milliseconds';
|
||||
|
||||
COMMENT ON COLUMN policy.replay_audit.source IS
|
||||
'Request source: api, cli, scheduled';
|
||||
|
||||
COMMENT ON VIEW policy.replay_metrics_hourly IS
|
||||
'Aggregated metrics for replay operations by hour';
|
||||
|
||||
COMMENT ON FUNCTION policy.purge_old_replay_audit IS
|
||||
'Purges replay audit records older than retention period (default 90 days)';
|
||||
@@ -0,0 +1,140 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// GateBypassAuditEntity.cs
|
||||
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
|
||||
// Task: TASK-017-006 - Gate Bypass Audit Persistence
|
||||
// Description: Entity model for gate bypass audit entries in PostgreSQL
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Policy.Persistence.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL entity for gate bypass audit entries.
|
||||
/// Records are immutable for compliance (7-year retention).
|
||||
/// </summary>
|
||||
public sealed class GateBypassAuditEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Primary key (UUID).
|
||||
/// </summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier for multi-tenancy.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the bypass occurred.
|
||||
/// </summary>
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The gate decision ID that was bypassed.
|
||||
/// </summary>
|
||||
public required string DecisionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The image digest being evaluated (sha256:...).
|
||||
/// </summary>
|
||||
public required string ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The repository name.
|
||||
/// </summary>
|
||||
public string? Repository { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The image tag, if any.
|
||||
/// </summary>
|
||||
public string? Tag { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The baseline reference used for comparison.
|
||||
/// </summary>
|
||||
public string? BaselineRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The original gate decision before bypass (e.g., "Block", "Warn").
|
||||
/// </summary>
|
||||
public required string OriginalDecision { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The decision after bypass (typically "Allow").
|
||||
/// </summary>
|
||||
public required string FinalDecision { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Which gate(s) were bypassed (JSON array).
|
||||
/// </summary>
|
||||
public required string BypassedGates { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The identity of the user/service that requested the bypass.
|
||||
/// </summary>
|
||||
public required string Actor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The subject identifier from the auth token.
|
||||
/// </summary>
|
||||
public string? ActorSubject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The email associated with the actor.
|
||||
/// </summary>
|
||||
public string? ActorEmail { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The IP address of the requester.
|
||||
/// </summary>
|
||||
public string? ActorIpAddress { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The justification provided for the bypass.
|
||||
/// </summary>
|
||||
public required string Justification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The policy ID that was being evaluated.
|
||||
/// </summary>
|
||||
public string? PolicyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The source of the gate request (e.g., "cli", "api", "webhook").
|
||||
/// </summary>
|
||||
public string? Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The CI/CD context (e.g., "github-actions", "gitlab-ci").
|
||||
/// </summary>
|
||||
public string? CiContext { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Attestation digest reference, if applicable.
|
||||
/// </summary>
|
||||
public string? AttestationDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor log UUID, if applicable.
|
||||
/// </summary>
|
||||
public string? RekorUuid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bypass type classification.
|
||||
/// </summary>
|
||||
public required string BypassType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the bypass expires (for time-limited approvals).
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata (JSON).
|
||||
/// </summary>
|
||||
public string? Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the record was created (should match Timestamp).
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TrustedKeyEntity.cs
|
||||
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
|
||||
// Task: TASK-017-005 - Trusted Key Registry
|
||||
// Description: Entity model for trusted signing keys in PostgreSQL
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Policy.Persistence.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL entity for trusted signing keys used in attestation verification.
|
||||
/// </summary>
|
||||
public sealed class TrustedKeyEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Primary key (UUID).
|
||||
/// </summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier for multi-tenancy.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unique key identifier (e.g., Sigstore keyid, fingerprint reference).
|
||||
/// </summary>
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 fingerprint of the DER-encoded public key.
|
||||
/// Used for fast lookups and uniqueness.
|
||||
/// </summary>
|
||||
public required string Fingerprint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key algorithm (e.g., "ECDSA_P256", "Ed25519", "RSA_2048", "RSA_4096").
|
||||
/// </summary>
|
||||
public required string Algorithm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PEM-encoded public key material.
|
||||
/// </summary>
|
||||
public string? PublicKeyPem { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key owner or issuer identity (e.g., email, OIDC subject).
|
||||
/// </summary>
|
||||
public string? Owner { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Issuer pattern for keyless signing (e.g., "*@example.com", "https://accounts.google.com").
|
||||
/// Supports wildcard matching.
|
||||
/// </summary>
|
||||
public string? IssuerPattern { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Allowed purposes for this key (JSON array: ["sbom-signing", "vex-signing", "release-signing"]).
|
||||
/// </summary>
|
||||
public string? Purposes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this key was first trusted.
|
||||
/// </summary>
|
||||
public DateTimeOffset ValidFrom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this key expires (null = no expiry).
|
||||
/// </summary>
|
||||
public DateTimeOffset? ValidUntil { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the key is currently active.
|
||||
/// </summary>
|
||||
public bool IsActive { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the key was revoked (null = not revoked).
|
||||
/// </summary>
|
||||
public DateTimeOffset? RevokedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for revocation.
|
||||
/// </summary>
|
||||
public string? RevokedReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata (JSON).
|
||||
/// </summary>
|
||||
public string? Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the record was created.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the record was last updated.
|
||||
/// </summary>
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User who created this key trust.
|
||||
/// </summary>
|
||||
public string? CreatedBy { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PostgresGateBypassAuditRepository.cs
|
||||
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
|
||||
// Task: TASK-017-006 - Gate Bypass Audit Persistence
|
||||
// Description: PostgreSQL-backed implementation of IGateBypassAuditRepository
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Policy.Audit;
|
||||
using StellaOps.Policy.Persistence.Postgres.Models;
|
||||
using StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Policy.Persistence.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL-backed implementation of <see cref="IGateBypassAuditRepository"/>.
|
||||
/// Provides a tenant-aware adapter over the underlying repository.
|
||||
/// </summary>
|
||||
public sealed class PostgresGateBypassAuditRepository : IGateBypassAuditRepository
|
||||
{
|
||||
private readonly IGateBypassAuditPersistence _repository;
|
||||
private readonly ILogger<PostgresGateBypassAuditRepository> _logger;
|
||||
private readonly string _tenantId;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
|
||||
public PostgresGateBypassAuditRepository(
|
||||
IGateBypassAuditPersistence repository,
|
||||
ILogger<PostgresGateBypassAuditRepository> logger,
|
||||
string tenantId)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddAsync(GateBypassAuditEntry entry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
var entity = MapToEntity(entry);
|
||||
await _repository.CreateAsync(entity, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Recorded gate bypass audit: DecisionId={DecisionId}, Actor={Actor}, Gates=[{Gates}]",
|
||||
entry.DecisionId,
|
||||
entry.Actor,
|
||||
string.Join(", ", entry.BypassedGates));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<GateBypassAuditEntry?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _repository.GetByIdAsync(_tenantId, id, cancellationToken).ConfigureAwait(false);
|
||||
return entity is null ? null : MapFromEntity(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<GateBypassAuditEntry>> GetByDecisionIdAsync(
|
||||
string decisionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entities = await _repository.GetByDecisionIdAsync(_tenantId, decisionId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return entities.Select(MapFromEntity).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<GateBypassAuditEntry>> GetByActorAsync(
|
||||
string actor,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entities = await _repository.GetByActorAsync(_tenantId, actor, limit, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return entities.Select(MapFromEntity).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<GateBypassAuditEntry>> GetByImageDigestAsync(
|
||||
string imageDigest,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entities = await _repository.GetByImageDigestAsync(_tenantId, imageDigest, limit, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return entities.Select(MapFromEntity).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<GateBypassAuditEntry>> ListRecentAsync(
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entities = await _repository.ListRecentAsync(_tenantId, limit, offset, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return entities.Select(MapFromEntity).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<GateBypassAuditEntry>> ListByTimeRangeAsync(
|
||||
DateTimeOffset from,
|
||||
DateTimeOffset to,
|
||||
int limit = 1000,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entities = await _repository.ListByTimeRangeAsync(_tenantId, from, to, limit, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return entities.Select(MapFromEntity).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> CountByActorSinceAsync(
|
||||
string actor,
|
||||
DateTimeOffset since,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _repository.CountByActorSinceAsync(_tenantId, actor, since, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private GateBypassAuditEntity MapToEntity(GateBypassAuditEntry entry) => new()
|
||||
{
|
||||
Id = entry.Id,
|
||||
TenantId = _tenantId,
|
||||
Timestamp = entry.Timestamp,
|
||||
DecisionId = entry.DecisionId,
|
||||
ImageDigest = entry.ImageDigest,
|
||||
Repository = entry.Repository,
|
||||
Tag = entry.Tag,
|
||||
BaselineRef = entry.BaselineRef,
|
||||
OriginalDecision = entry.OriginalDecision,
|
||||
FinalDecision = entry.FinalDecision,
|
||||
BypassedGates = JsonSerializer.Serialize(entry.BypassedGates, JsonOptions),
|
||||
Actor = entry.Actor,
|
||||
ActorSubject = entry.ActorSubject,
|
||||
ActorEmail = entry.ActorEmail,
|
||||
ActorIpAddress = entry.ActorIpAddress,
|
||||
Justification = entry.Justification,
|
||||
PolicyId = entry.PolicyId,
|
||||
Source = entry.Source,
|
||||
CiContext = entry.CiContext,
|
||||
AttestationDigest = null, // Can be extracted from Metadata if present
|
||||
RekorUuid = null,
|
||||
BypassType = "BlockOverride", // Default type
|
||||
ExpiresAt = null,
|
||||
Metadata = entry.Metadata is not null ? JsonSerializer.Serialize(entry.Metadata, JsonOptions) : null,
|
||||
CreatedAt = entry.Timestamp
|
||||
};
|
||||
|
||||
private static GateBypassAuditEntry MapFromEntity(GateBypassAuditEntity entity) => new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
Timestamp = entity.Timestamp,
|
||||
DecisionId = entity.DecisionId,
|
||||
ImageDigest = entity.ImageDigest,
|
||||
Repository = entity.Repository,
|
||||
Tag = entity.Tag,
|
||||
BaselineRef = entity.BaselineRef,
|
||||
OriginalDecision = entity.OriginalDecision,
|
||||
FinalDecision = entity.FinalDecision,
|
||||
BypassedGates = ParseBypassedGates(entity.BypassedGates),
|
||||
Actor = entity.Actor,
|
||||
ActorSubject = entity.ActorSubject,
|
||||
ActorEmail = entity.ActorEmail,
|
||||
ActorIpAddress = entity.ActorIpAddress,
|
||||
Justification = entity.Justification,
|
||||
PolicyId = entity.PolicyId,
|
||||
Source = entity.Source,
|
||||
CiContext = entity.CiContext,
|
||||
Metadata = ParseMetadata(entity.Metadata)
|
||||
};
|
||||
|
||||
private static IReadOnlyList<string> ParseBypassedGates(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<string>>(json, JsonOptions) ?? [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string>? ParseMetadata(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<Dictionary<string, string>>(json, JsonOptions);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PostgresTrustedKeyRegistry.cs
|
||||
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
|
||||
// Task: TASK-017-005 - Trusted Key Registry
|
||||
// Description: PostgreSQL-backed implementation of ITrustedKeyRegistry with caching
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Policy.Gates.Attestation;
|
||||
using StellaOps.Policy.Persistence.Postgres.Models;
|
||||
using StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Policy.Persistence.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL-backed implementation of <see cref="ITrustedKeyRegistry"/> with in-memory caching.
|
||||
/// </summary>
|
||||
public sealed class PostgresTrustedKeyRegistry : ITrustedKeyRegistry
|
||||
{
|
||||
private readonly ITrustedKeyRepository _repository;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ILogger<PostgresTrustedKeyRegistry> _logger;
|
||||
private readonly PostgresTrustedKeyRegistryOptions _options;
|
||||
private readonly string _tenantId;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
|
||||
public PostgresTrustedKeyRegistry(
|
||||
ITrustedKeyRepository repository,
|
||||
IMemoryCache cache,
|
||||
ILogger<PostgresTrustedKeyRegistry> logger,
|
||||
PostgresTrustedKeyRegistryOptions options,
|
||||
string tenantId)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> IsTrustedAsync(string keyId, CancellationToken ct = default)
|
||||
{
|
||||
var key = await GetKeyAsync(keyId, ct).ConfigureAwait(false);
|
||||
return key is not null && IsKeyValid(key);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TrustedKey?> GetKeyAsync(string keyId, CancellationToken ct = default)
|
||||
{
|
||||
var cacheKey = BuildCacheKey("keyid", keyId);
|
||||
|
||||
if (_cache.TryGetValue(cacheKey, out TrustedKey? cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var entity = await _repository.GetByKeyIdAsync(_tenantId, keyId, ct).ConfigureAwait(false);
|
||||
if (entity is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var key = MapFromEntity(entity);
|
||||
CacheKey(cacheKey, key);
|
||||
return key;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TrustedKey?> GetByFingerprintAsync(string fingerprint, CancellationToken ct = default)
|
||||
{
|
||||
var cacheKey = BuildCacheKey("fingerprint", fingerprint);
|
||||
|
||||
if (_cache.TryGetValue(cacheKey, out TrustedKey? cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var entity = await _repository.GetByFingerprintAsync(_tenantId, fingerprint, ct).ConfigureAwait(false);
|
||||
if (entity is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var key = MapFromEntity(entity);
|
||||
CacheKey(cacheKey, key);
|
||||
return key;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<TrustedKey> ListAsync([EnumeratorCancellation] CancellationToken ct = default)
|
||||
{
|
||||
var entities = await _repository.ListActiveAsync(_tenantId, cancellationToken: ct).ConfigureAwait(false);
|
||||
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
yield return MapFromEntity(entity);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TrustedKey> AddAsync(TrustedKey key, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
var entity = MapToEntity(key);
|
||||
await _repository.CreateAsync(entity, ct).ConfigureAwait(false);
|
||||
|
||||
// Invalidate cache
|
||||
InvalidateCache(key.KeyId, key.Fingerprint);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Added trusted key {KeyId} with fingerprint {Fingerprint} for tenant {TenantId}",
|
||||
key.KeyId, key.Fingerprint, _tenantId);
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RevokeAsync(string keyId, string reason, CancellationToken ct = default)
|
||||
{
|
||||
var key = await GetKeyAsync(keyId, ct).ConfigureAwait(false);
|
||||
if (key is null)
|
||||
{
|
||||
_logger.LogWarning("Attempted to revoke non-existent key {KeyId}", keyId);
|
||||
return;
|
||||
}
|
||||
|
||||
var revoked = await _repository.RevokeAsync(_tenantId, keyId, reason, ct).ConfigureAwait(false);
|
||||
if (revoked)
|
||||
{
|
||||
InvalidateCache(keyId, key.Fingerprint);
|
||||
_logger.LogInformation(
|
||||
"Revoked trusted key {KeyId} for tenant {TenantId}: {Reason}",
|
||||
keyId, _tenantId, reason);
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsKeyValid(TrustedKey key)
|
||||
{
|
||||
if (!key.IsActive)
|
||||
return false;
|
||||
|
||||
if (key.RevokedAt.HasValue)
|
||||
return false;
|
||||
|
||||
if (key.ExpiresAt.HasValue && key.ExpiresAt.Value < DateTimeOffset.UtcNow)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private TrustedKey MapFromEntity(TrustedKeyEntity entity) => new()
|
||||
{
|
||||
KeyId = entity.KeyId,
|
||||
Fingerprint = entity.Fingerprint,
|
||||
Algorithm = entity.Algorithm,
|
||||
PublicKeyPem = entity.PublicKeyPem,
|
||||
Owner = entity.Owner,
|
||||
Purposes = ParsePurposes(entity.Purposes),
|
||||
TrustedAt = entity.ValidFrom,
|
||||
ExpiresAt = entity.ValidUntil,
|
||||
IsActive = entity.IsActive,
|
||||
RevokedReason = entity.RevokedReason,
|
||||
RevokedAt = entity.RevokedAt,
|
||||
TenantId = Guid.TryParse(entity.TenantId, out var tid) ? tid : Guid.Empty
|
||||
};
|
||||
|
||||
private TrustedKeyEntity MapToEntity(TrustedKey key) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
KeyId = key.KeyId,
|
||||
Fingerprint = key.Fingerprint,
|
||||
Algorithm = key.Algorithm,
|
||||
PublicKeyPem = key.PublicKeyPem,
|
||||
Owner = key.Owner,
|
||||
IssuerPattern = null, // Set via separate API if needed
|
||||
Purposes = SerializePurposes(key.Purposes),
|
||||
ValidFrom = key.TrustedAt,
|
||||
ValidUntil = key.ExpiresAt,
|
||||
IsActive = key.IsActive,
|
||||
RevokedAt = key.RevokedAt,
|
||||
RevokedReason = key.RevokedReason,
|
||||
Metadata = null,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
CreatedBy = null
|
||||
};
|
||||
|
||||
private static IReadOnlyList<string> ParsePurposes(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return [];
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<string>>(json, JsonOptions) ?? [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static string? SerializePurposes(IReadOnlyList<string> purposes)
|
||||
{
|
||||
if (purposes.Count == 0)
|
||||
return null;
|
||||
|
||||
return JsonSerializer.Serialize(purposes, JsonOptions);
|
||||
}
|
||||
|
||||
private string BuildCacheKey(string type, string value)
|
||||
=> $"trustedkey:{_tenantId}:{type}:{value}";
|
||||
|
||||
private void CacheKey(string cacheKey, TrustedKey key)
|
||||
{
|
||||
var options = new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(_options.CacheTtlSeconds),
|
||||
SlidingExpiration = TimeSpan.FromSeconds(_options.CacheTtlSeconds / 2)
|
||||
};
|
||||
_cache.Set(cacheKey, key, options);
|
||||
}
|
||||
|
||||
private void InvalidateCache(string keyId, string fingerprint)
|
||||
{
|
||||
_cache.Remove(BuildCacheKey("keyid", keyId));
|
||||
_cache.Remove(BuildCacheKey("fingerprint", fingerprint));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for PostgresTrustedKeyRegistry.
|
||||
/// </summary>
|
||||
public sealed class PostgresTrustedKeyRegistryOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Cache TTL in seconds. Default is 300 (5 minutes).
|
||||
/// </summary>
|
||||
public int CacheTtlSeconds { get; set; } = 300;
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// GateBypassAuditRepository.cs
|
||||
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
|
||||
// Task: TASK-017-006 - Gate Bypass Audit Persistence
|
||||
// Description: PostgreSQL implementation of gate bypass audit repository
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Policy.Persistence.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for gate bypass audit entries.
|
||||
/// Records are immutable (append-only) for compliance requirements.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This table uses insert-only semantics. UPDATE and DELETE operations are not exposed
|
||||
/// to maintain audit integrity for compliance (7-year retention requirement).
|
||||
/// </remarks>
|
||||
public sealed class GateBypassAuditRepository : RepositoryBase<PolicyDataSource>, IGateBypassAuditPersistence
|
||||
{
|
||||
public GateBypassAuditRepository(PolicyDataSource dataSource, ILogger<GateBypassAuditRepository> logger)
|
||||
: base(dataSource, logger) { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Guid> CreateAsync(
|
||||
GateBypassAuditEntity entry,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO policy.gate_bypass_audit (
|
||||
id, tenant_id, timestamp, decision_id, image_digest, repository, tag,
|
||||
baseline_ref, original_decision, final_decision, bypassed_gates,
|
||||
actor, actor_subject, actor_email, actor_ip_address, justification,
|
||||
policy_id, source, ci_context, attestation_digest, rekor_uuid,
|
||||
bypass_type, expires_at, metadata, created_at
|
||||
) VALUES (
|
||||
@id, @tenant_id, @timestamp, @decision_id, @image_digest, @repository, @tag,
|
||||
@baseline_ref, @original_decision, @final_decision, @bypassed_gates::jsonb,
|
||||
@actor, @actor_subject, @actor_email, @actor_ip_address, @justification,
|
||||
@policy_id, @source, @ci_context, @attestation_digest, @rekor_uuid,
|
||||
@bypass_type, @expires_at, @metadata::jsonb, @created_at
|
||||
)
|
||||
RETURNING id
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(entry.TenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "id", entry.Id);
|
||||
AddParameter(command, "tenant_id", entry.TenantId);
|
||||
AddParameter(command, "timestamp", entry.Timestamp);
|
||||
AddParameter(command, "decision_id", entry.DecisionId);
|
||||
AddParameter(command, "image_digest", entry.ImageDigest);
|
||||
AddParameter(command, "repository", entry.Repository);
|
||||
AddParameter(command, "tag", entry.Tag);
|
||||
AddParameter(command, "baseline_ref", entry.BaselineRef);
|
||||
AddParameter(command, "original_decision", entry.OriginalDecision);
|
||||
AddParameter(command, "final_decision", entry.FinalDecision);
|
||||
AddJsonbParameter(command, "bypassed_gates", entry.BypassedGates);
|
||||
AddParameter(command, "actor", entry.Actor);
|
||||
AddParameter(command, "actor_subject", entry.ActorSubject);
|
||||
AddParameter(command, "actor_email", entry.ActorEmail);
|
||||
AddParameter(command, "actor_ip_address", entry.ActorIpAddress);
|
||||
AddParameter(command, "justification", entry.Justification);
|
||||
AddParameter(command, "policy_id", entry.PolicyId);
|
||||
AddParameter(command, "source", entry.Source);
|
||||
AddParameter(command, "ci_context", entry.CiContext);
|
||||
AddParameter(command, "attestation_digest", entry.AttestationDigest);
|
||||
AddParameter(command, "rekor_uuid", entry.RekorUuid);
|
||||
AddParameter(command, "bypass_type", entry.BypassType);
|
||||
AddParameter(command, "expires_at", entry.ExpiresAt);
|
||||
AddJsonbParameter(command, "metadata", entry.Metadata);
|
||||
AddParameter(command, "created_at", entry.CreatedAt);
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return (Guid)result!;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<GateBypassAuditEntity?> GetByIdAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, timestamp, decision_id, image_digest, repository, tag,
|
||||
baseline_ref, original_decision, final_decision, bypassed_gates,
|
||||
actor, actor_subject, actor_email, actor_ip_address, justification,
|
||||
policy_id, source, ci_context, attestation_digest, rekor_uuid,
|
||||
bypass_type, expires_at, metadata, created_at
|
||||
FROM policy.gate_bypass_audit
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
""";
|
||||
|
||||
var results = await QueryAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
}, MapEntity, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return results.Count > 0 ? results[0] : null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<GateBypassAuditEntity>> GetByDecisionIdAsync(
|
||||
string tenantId,
|
||||
string decisionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, timestamp, decision_id, image_digest, repository, tag,
|
||||
baseline_ref, original_decision, final_decision, bypassed_gates,
|
||||
actor, actor_subject, actor_email, actor_ip_address, justification,
|
||||
policy_id, source, ci_context, attestation_digest, rekor_uuid,
|
||||
bypass_type, expires_at, metadata, created_at
|
||||
FROM policy.gate_bypass_audit
|
||||
WHERE tenant_id = @tenant_id AND decision_id = @decision_id
|
||||
ORDER BY timestamp DESC
|
||||
""";
|
||||
|
||||
return await QueryAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "decision_id", decisionId);
|
||||
}, MapEntity, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<GateBypassAuditEntity>> GetByActorAsync(
|
||||
string tenantId,
|
||||
string actor,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, timestamp, decision_id, image_digest, repository, tag,
|
||||
baseline_ref, original_decision, final_decision, bypassed_gates,
|
||||
actor, actor_subject, actor_email, actor_ip_address, justification,
|
||||
policy_id, source, ci_context, attestation_digest, rekor_uuid,
|
||||
bypass_type, expires_at, metadata, created_at
|
||||
FROM policy.gate_bypass_audit
|
||||
WHERE tenant_id = @tenant_id AND actor = @actor
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
return await QueryAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "actor", actor);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
}, MapEntity, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<GateBypassAuditEntity>> GetByImageDigestAsync(
|
||||
string tenantId,
|
||||
string imageDigest,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, timestamp, decision_id, image_digest, repository, tag,
|
||||
baseline_ref, original_decision, final_decision, bypassed_gates,
|
||||
actor, actor_subject, actor_email, actor_ip_address, justification,
|
||||
policy_id, source, ci_context, attestation_digest, rekor_uuid,
|
||||
bypass_type, expires_at, metadata, created_at
|
||||
FROM policy.gate_bypass_audit
|
||||
WHERE tenant_id = @tenant_id AND image_digest = @image_digest
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
return await QueryAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "image_digest", imageDigest);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
}, MapEntity, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<GateBypassAuditEntity>> ListRecentAsync(
|
||||
string tenantId,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, timestamp, decision_id, image_digest, repository, tag,
|
||||
baseline_ref, original_decision, final_decision, bypassed_gates,
|
||||
actor, actor_subject, actor_email, actor_ip_address, justification,
|
||||
policy_id, source, ci_context, attestation_digest, rekor_uuid,
|
||||
bypass_type, expires_at, metadata, created_at
|
||||
FROM policy.gate_bypass_audit
|
||||
WHERE tenant_id = @tenant_id
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT @limit OFFSET @offset
|
||||
""";
|
||||
|
||||
return await QueryAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
AddParameter(cmd, "offset", offset);
|
||||
}, MapEntity, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<GateBypassAuditEntity>> ListByTimeRangeAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset from,
|
||||
DateTimeOffset to,
|
||||
int limit = 1000,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, timestamp, decision_id, image_digest, repository, tag,
|
||||
baseline_ref, original_decision, final_decision, bypassed_gates,
|
||||
actor, actor_subject, actor_email, actor_ip_address, justification,
|
||||
policy_id, source, ci_context, attestation_digest, rekor_uuid,
|
||||
bypass_type, expires_at, metadata, created_at
|
||||
FROM policy.gate_bypass_audit
|
||||
WHERE tenant_id = @tenant_id AND timestamp >= @from AND timestamp < @to
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
return await QueryAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "from", from);
|
||||
AddParameter(cmd, "to", to);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
}, MapEntity, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> CountByActorSinceAsync(
|
||||
string tenantId,
|
||||
string actor,
|
||||
DateTimeOffset since,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT COUNT(*)
|
||||
FROM policy.gate_bypass_audit
|
||||
WHERE tenant_id = @tenant_id AND actor = @actor AND timestamp >= @since
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "tenant_id", tenantId);
|
||||
AddParameter(command, "actor", actor);
|
||||
AddParameter(command, "since", since);
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Convert.ToInt32(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<GateBypassAuditEntity>> ExportForComplianceAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset from,
|
||||
DateTimeOffset to,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Export in chronological order for compliance reporting
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, timestamp, decision_id, image_digest, repository, tag,
|
||||
baseline_ref, original_decision, final_decision, bypassed_gates,
|
||||
actor, actor_subject, actor_email, actor_ip_address, justification,
|
||||
policy_id, source, ci_context, attestation_digest, rekor_uuid,
|
||||
bypass_type, expires_at, metadata, created_at
|
||||
FROM policy.gate_bypass_audit
|
||||
WHERE tenant_id = @tenant_id AND timestamp >= @from AND timestamp < @to
|
||||
ORDER BY timestamp ASC
|
||||
""";
|
||||
|
||||
return await QueryAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "from", from);
|
||||
AddParameter(cmd, "to", to);
|
||||
}, MapEntity, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static GateBypassAuditEntity MapEntity(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(0),
|
||||
TenantId = reader.GetString(1),
|
||||
Timestamp = reader.GetFieldValue<DateTimeOffset>(2),
|
||||
DecisionId = reader.GetString(3),
|
||||
ImageDigest = reader.GetString(4),
|
||||
Repository = GetNullableString(reader, 5),
|
||||
Tag = GetNullableString(reader, 6),
|
||||
BaselineRef = GetNullableString(reader, 7),
|
||||
OriginalDecision = reader.GetString(8),
|
||||
FinalDecision = reader.GetString(9),
|
||||
BypassedGates = reader.GetString(10),
|
||||
Actor = reader.GetString(11),
|
||||
ActorSubject = GetNullableString(reader, 12),
|
||||
ActorEmail = GetNullableString(reader, 13),
|
||||
ActorIpAddress = GetNullableString(reader, 14),
|
||||
Justification = reader.GetString(15),
|
||||
PolicyId = GetNullableString(reader, 16),
|
||||
Source = GetNullableString(reader, 17),
|
||||
CiContext = GetNullableString(reader, 18),
|
||||
AttestationDigest = GetNullableString(reader, 19),
|
||||
RekorUuid = GetNullableString(reader, 20),
|
||||
BypassType = reader.GetString(21),
|
||||
ExpiresAt = GetNullableDateTimeOffset(reader, 22),
|
||||
Metadata = GetNullableString(reader, 23),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(24)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// GateDecisionHistoryRepository.cs
|
||||
// Sprint: SPRINT_20260118_019_Policy_gate_replay_api_exposure
|
||||
// Task: GR-005 - Add gate decision history endpoint
|
||||
// Description: Repository for querying historical gate decisions
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Data;
|
||||
using Npgsql;
|
||||
|
||||
namespace StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for querying historical gate decisions.
|
||||
/// </summary>
|
||||
public sealed class GateDecisionHistoryRepository : IGateDecisionHistoryRepository
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
|
||||
public GateDecisionHistoryRepository(string connectionString)
|
||||
{
|
||||
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<GateDecisionHistoryResult> GetDecisionsAsync(
|
||||
GateDecisionHistoryQuery query,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
// Build query with filters
|
||||
var sql = """
|
||||
SELECT
|
||||
decision_id,
|
||||
bom_ref,
|
||||
image_digest,
|
||||
gate_status,
|
||||
verdict_hash,
|
||||
policy_bundle_id,
|
||||
policy_bundle_hash,
|
||||
evaluated_at,
|
||||
ci_context,
|
||||
actor,
|
||||
blocking_unknown_ids,
|
||||
warnings
|
||||
FROM policy.gate_decisions
|
||||
WHERE tenant_id = @tenant_id
|
||||
""";
|
||||
|
||||
var parameters = new List<NpgsqlParameter>
|
||||
{
|
||||
new("tenant_id", query.TenantId)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(query.GateId))
|
||||
{
|
||||
sql += " AND gate_id = @gate_id";
|
||||
parameters.Add(new NpgsqlParameter("gate_id", query.GateId));
|
||||
}
|
||||
|
||||
if (query.FromDate.HasValue)
|
||||
{
|
||||
sql += " AND evaluated_at >= @from_date";
|
||||
parameters.Add(new NpgsqlParameter("from_date", query.FromDate.Value));
|
||||
}
|
||||
|
||||
if (query.ToDate.HasValue)
|
||||
{
|
||||
sql += " AND evaluated_at <= @to_date";
|
||||
parameters.Add(new NpgsqlParameter("to_date", query.ToDate.Value));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(query.Status))
|
||||
{
|
||||
sql += " AND gate_status = @status";
|
||||
parameters.Add(new NpgsqlParameter("status", query.Status));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(query.Actor))
|
||||
{
|
||||
sql += " AND actor = @actor";
|
||||
parameters.Add(new NpgsqlParameter("actor", query.Actor));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(query.BomRef))
|
||||
{
|
||||
sql += " AND bom_ref = @bom_ref";
|
||||
parameters.Add(new NpgsqlParameter("bom_ref", query.BomRef));
|
||||
}
|
||||
|
||||
// Get total count first
|
||||
var countSql = $"SELECT COUNT(*) FROM ({sql}) AS filtered";
|
||||
await using var countCmd = new NpgsqlCommand(countSql, conn);
|
||||
countCmd.Parameters.AddRange(parameters.ToArray());
|
||||
var totalCount = Convert.ToInt64(await countCmd.ExecuteScalarAsync(ct));
|
||||
|
||||
// Apply pagination
|
||||
sql += " ORDER BY evaluated_at DESC";
|
||||
|
||||
if (!string.IsNullOrEmpty(query.ContinuationToken))
|
||||
{
|
||||
var offset = DecodeContinuationToken(query.ContinuationToken);
|
||||
sql += $" OFFSET {offset}";
|
||||
}
|
||||
|
||||
sql += $" LIMIT {query.Limit + 1}"; // +1 to detect if there are more results
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddRange(parameters.Select(p => p.Clone()).Cast<NpgsqlParameter>().ToArray());
|
||||
|
||||
var decisions = new List<GateDecisionRecord>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
decisions.Add(new GateDecisionRecord
|
||||
{
|
||||
DecisionId = reader.GetGuid(0),
|
||||
BomRef = reader.GetString(1),
|
||||
ImageDigest = reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||
GateStatus = reader.GetString(3),
|
||||
VerdictHash = reader.IsDBNull(4) ? null : reader.GetString(4),
|
||||
PolicyBundleId = reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||
PolicyBundleHash = reader.IsDBNull(6) ? null : reader.GetString(6),
|
||||
EvaluatedAt = reader.GetDateTime(7),
|
||||
CiContext = reader.IsDBNull(8) ? null : reader.GetString(8),
|
||||
Actor = reader.IsDBNull(9) ? null : reader.GetString(9),
|
||||
BlockingUnknownIds = reader.IsDBNull(10) ? [] : ParseGuidArray(reader.GetString(10)),
|
||||
Warnings = reader.IsDBNull(11) ? [] : ParseStringArray(reader.GetString(11))
|
||||
});
|
||||
}
|
||||
|
||||
// Check if there are more results
|
||||
var hasMore = decisions.Count > query.Limit;
|
||||
if (hasMore)
|
||||
{
|
||||
decisions.RemoveAt(decisions.Count - 1);
|
||||
}
|
||||
|
||||
string? nextToken = null;
|
||||
if (hasMore)
|
||||
{
|
||||
var currentOffset = string.IsNullOrEmpty(query.ContinuationToken)
|
||||
? 0
|
||||
: DecodeContinuationToken(query.ContinuationToken);
|
||||
nextToken = EncodeContinuationToken(currentOffset + query.Limit);
|
||||
}
|
||||
|
||||
return new GateDecisionHistoryResult
|
||||
{
|
||||
Decisions = decisions,
|
||||
Total = totalCount,
|
||||
ContinuationToken = nextToken
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<GateDecisionRecord?> GetDecisionByIdAsync(
|
||||
Guid decisionId,
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
const string sql = """
|
||||
SELECT
|
||||
decision_id,
|
||||
bom_ref,
|
||||
image_digest,
|
||||
gate_status,
|
||||
verdict_hash,
|
||||
policy_bundle_id,
|
||||
policy_bundle_hash,
|
||||
evaluated_at,
|
||||
ci_context,
|
||||
actor,
|
||||
blocking_unknown_ids,
|
||||
warnings
|
||||
FROM policy.gate_decisions
|
||||
WHERE decision_id = @decision_id AND tenant_id = @tenant_id
|
||||
""";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("decision_id", decisionId);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (!await reader.ReadAsync(ct))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new GateDecisionRecord
|
||||
{
|
||||
DecisionId = reader.GetGuid(0),
|
||||
BomRef = reader.GetString(1),
|
||||
ImageDigest = reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||
GateStatus = reader.GetString(3),
|
||||
VerdictHash = reader.IsDBNull(4) ? null : reader.GetString(4),
|
||||
PolicyBundleId = reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||
PolicyBundleHash = reader.IsDBNull(6) ? null : reader.GetString(6),
|
||||
EvaluatedAt = reader.GetDateTime(7),
|
||||
CiContext = reader.IsDBNull(8) ? null : reader.GetString(8),
|
||||
Actor = reader.IsDBNull(9) ? null : reader.GetString(9),
|
||||
BlockingUnknownIds = reader.IsDBNull(10) ? [] : ParseGuidArray(reader.GetString(10)),
|
||||
Warnings = reader.IsDBNull(11) ? [] : ParseStringArray(reader.GetString(11))
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RecordDecisionAsync(GateDecisionRecord decision, Guid tenantId, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO policy.gate_decisions (
|
||||
decision_id, tenant_id, bom_ref, image_digest, gate_status, verdict_hash,
|
||||
policy_bundle_id, policy_bundle_hash, evaluated_at, ci_context, actor,
|
||||
blocking_unknown_ids, warnings
|
||||
) VALUES (
|
||||
@decision_id, @tenant_id, @bom_ref, @image_digest, @gate_status, @verdict_hash,
|
||||
@policy_bundle_id, @policy_bundle_hash, @evaluated_at, @ci_context, @actor,
|
||||
@blocking_unknown_ids, @warnings
|
||||
)
|
||||
""";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("decision_id", decision.DecisionId);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
cmd.Parameters.AddWithValue("bom_ref", decision.BomRef);
|
||||
cmd.Parameters.AddWithValue("image_digest", (object?)decision.ImageDigest ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("gate_status", decision.GateStatus);
|
||||
cmd.Parameters.AddWithValue("verdict_hash", (object?)decision.VerdictHash ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("policy_bundle_id", (object?)decision.PolicyBundleId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("policy_bundle_hash", (object?)decision.PolicyBundleHash ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("evaluated_at", decision.EvaluatedAt);
|
||||
cmd.Parameters.AddWithValue("ci_context", (object?)decision.CiContext ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("actor", (object?)decision.Actor ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("blocking_unknown_ids", SerializeGuidArray(decision.BlockingUnknownIds));
|
||||
cmd.Parameters.AddWithValue("warnings", SerializeStringArray(decision.Warnings));
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
private static string EncodeContinuationToken(long offset) =>
|
||||
Convert.ToBase64String(BitConverter.GetBytes(offset));
|
||||
|
||||
private static long DecodeContinuationToken(string token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromBase64String(token);
|
||||
return BitConverter.ToInt64(bytes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Guid> ParseGuidArray(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
return System.Text.Json.JsonSerializer.Deserialize<List<Guid>>(json) ?? [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> ParseStringArray(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
return System.Text.Json.JsonSerializer.Deserialize<List<string>>(json) ?? [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static string SerializeGuidArray(List<Guid> guids) =>
|
||||
System.Text.Json.JsonSerializer.Serialize(guids);
|
||||
|
||||
private static string SerializeStringArray(List<string> strings) =>
|
||||
System.Text.Json.JsonSerializer.Serialize(strings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for gate decision history repository.
|
||||
/// </summary>
|
||||
public interface IGateDecisionHistoryRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Queries historical gate decisions.
|
||||
/// </summary>
|
||||
Task<GateDecisionHistoryResult> GetDecisionsAsync(GateDecisionHistoryQuery query, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific decision by ID.
|
||||
/// </summary>
|
||||
Task<GateDecisionRecord?> GetDecisionByIdAsync(Guid decisionId, Guid tenantId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Records a new gate decision.
|
||||
/// </summary>
|
||||
Task RecordDecisionAsync(GateDecisionRecord decision, Guid tenantId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for gate decision history.
|
||||
/// </summary>
|
||||
public sealed record GateDecisionHistoryQuery
|
||||
{
|
||||
public required Guid TenantId { get; init; }
|
||||
public string? GateId { get; init; }
|
||||
public string? BomRef { get; init; }
|
||||
public DateTimeOffset? FromDate { get; init; }
|
||||
public DateTimeOffset? ToDate { get; init; }
|
||||
public string? Status { get; init; }
|
||||
public string? Actor { get; init; }
|
||||
public int Limit { get; init; } = 50;
|
||||
public string? ContinuationToken { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of gate decision history query.
|
||||
/// </summary>
|
||||
public sealed record GateDecisionHistoryResult
|
||||
{
|
||||
public List<GateDecisionRecord> Decisions { get; init; } = [];
|
||||
public long Total { get; init; }
|
||||
public string? ContinuationToken { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gate decision record.
|
||||
/// </summary>
|
||||
public sealed record GateDecisionRecord
|
||||
{
|
||||
public Guid DecisionId { get; init; }
|
||||
public required string BomRef { get; init; }
|
||||
public string? ImageDigest { get; init; }
|
||||
public required string GateStatus { get; init; }
|
||||
public string? VerdictHash { get; init; }
|
||||
public string? PolicyBundleId { get; init; }
|
||||
public string? PolicyBundleHash { get; init; }
|
||||
public DateTime EvaluatedAt { get; init; }
|
||||
public string? CiContext { get; init; }
|
||||
public string? Actor { get; init; }
|
||||
public List<Guid> BlockingUnknownIds { get; init; } = [];
|
||||
public List<string> Warnings { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IGateBypassAuditRepository.cs
|
||||
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
|
||||
// Task: TASK-017-006 - Gate Bypass Audit Persistence
|
||||
// Description: Repository interface for gate bypass audit persistence
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Policy.Persistence.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for gate bypass audit persistence.
|
||||
/// Records are immutable (append-only) for compliance requirements.
|
||||
/// </summary>
|
||||
public interface IGateBypassAuditPersistence
|
||||
{
|
||||
/// <summary>
|
||||
/// Records a gate bypass audit entry (immutable - no updates allowed).
|
||||
/// </summary>
|
||||
Task<Guid> CreateAsync(
|
||||
GateBypassAuditEntity entry,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a bypass audit entry by ID.
|
||||
/// </summary>
|
||||
Task<GateBypassAuditEntity?> GetByIdAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets bypass audit entries by decision ID.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<GateBypassAuditEntity>> GetByDecisionIdAsync(
|
||||
string tenantId,
|
||||
string decisionId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets bypass audit entries by actor.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<GateBypassAuditEntity>> GetByActorAsync(
|
||||
string tenantId,
|
||||
string actor,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets bypass audit entries by image digest.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<GateBypassAuditEntity>> GetByImageDigestAsync(
|
||||
string tenantId,
|
||||
string imageDigest,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists recent bypass audit entries.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<GateBypassAuditEntity>> ListRecentAsync(
|
||||
string tenantId,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists bypass audit entries within a time range.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<GateBypassAuditEntity>> ListByTimeRangeAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset from,
|
||||
DateTimeOffset to,
|
||||
int limit = 1000,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Counts bypass audit entries for an actor within a time window.
|
||||
/// Used for rate limiting and abuse detection.
|
||||
/// </summary>
|
||||
Task<int> CountByActorSinceAsync(
|
||||
string tenantId,
|
||||
string actor,
|
||||
DateTimeOffset since,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Exports audit entries for compliance reporting.
|
||||
/// Returns entries in chronological order.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<GateBypassAuditEntity>> ExportForComplianceAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset from,
|
||||
DateTimeOffset to,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ITrustedKeyRepository.cs
|
||||
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
|
||||
// Task: TASK-017-005 - Trusted Key Registry
|
||||
// Description: Repository interface for trusted key persistence
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Policy.Persistence.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for trusted signing key persistence.
|
||||
/// </summary>
|
||||
public interface ITrustedKeyRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a trusted key by its key ID.
|
||||
/// </summary>
|
||||
Task<TrustedKeyEntity?> GetByKeyIdAsync(
|
||||
string tenantId,
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a trusted key by its fingerprint.
|
||||
/// </summary>
|
||||
Task<TrustedKeyEntity?> GetByFingerprintAsync(
|
||||
string tenantId,
|
||||
string fingerprint,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Finds keys matching an issuer pattern.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<TrustedKeyEntity>> FindByIssuerPatternAsync(
|
||||
string tenantId,
|
||||
string issuer,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all active trusted keys for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<TrustedKeyEntity>> ListActiveAsync(
|
||||
string tenantId,
|
||||
int limit = 1000,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists trusted keys by purpose.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<TrustedKeyEntity>> ListByPurposeAsync(
|
||||
string tenantId,
|
||||
string purpose,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new trusted key.
|
||||
/// </summary>
|
||||
Task<Guid> CreateAsync(
|
||||
TrustedKeyEntity key,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing trusted key.
|
||||
/// </summary>
|
||||
Task<bool> UpdateAsync(
|
||||
TrustedKeyEntity key,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Revokes a trusted key.
|
||||
/// </summary>
|
||||
Task<bool> RevokeAsync(
|
||||
string tenantId,
|
||||
string keyId,
|
||||
string reason,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a trusted key (hard delete - use sparingly).
|
||||
/// </summary>
|
||||
Task<bool> DeleteAsync(
|
||||
string tenantId,
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Counts active keys for a tenant.
|
||||
/// </summary>
|
||||
Task<int> CountActiveAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ReplayAuditRepository.cs
|
||||
// Sprint: SPRINT_20260118_019_Policy_gate_replay_api_exposure
|
||||
// Task: GR-007 - Create replay audit trail
|
||||
// Description: Repository for recording and querying replay audit records
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using Npgsql;
|
||||
|
||||
namespace StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for recording and querying replay audit records.
|
||||
/// </summary>
|
||||
public sealed class ReplayAuditRepository : IReplayAuditRepository
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
|
||||
public ReplayAuditRepository(string connectionString)
|
||||
{
|
||||
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RecordReplayAsync(ReplayAuditRecord record, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO policy.replay_audit (
|
||||
replay_id, tenant_id, bom_ref, verdict_hash, rekor_uuid, replayed_at,
|
||||
match, original_hash, replayed_hash, mismatch_reason,
|
||||
policy_bundle_id, policy_bundle_hash, verifier_digest,
|
||||
duration_ms, actor, source, request_context
|
||||
) VALUES (
|
||||
@replay_id, @tenant_id, @bom_ref, @verdict_hash, @rekor_uuid, @replayed_at,
|
||||
@match, @original_hash, @replayed_hash, @mismatch_reason,
|
||||
@policy_bundle_id, @policy_bundle_hash, @verifier_digest,
|
||||
@duration_ms, @actor, @source, @request_context::jsonb
|
||||
)
|
||||
""";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("replay_id", record.ReplayId);
|
||||
cmd.Parameters.AddWithValue("tenant_id", record.TenantId);
|
||||
cmd.Parameters.AddWithValue("bom_ref", record.BomRef);
|
||||
cmd.Parameters.AddWithValue("verdict_hash", record.VerdictHash);
|
||||
cmd.Parameters.AddWithValue("rekor_uuid", (object?)record.RekorUuid ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("replayed_at", record.ReplayedAt);
|
||||
cmd.Parameters.AddWithValue("match", record.Match);
|
||||
cmd.Parameters.AddWithValue("original_hash", (object?)record.OriginalHash ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("replayed_hash", (object?)record.ReplayedHash ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("mismatch_reason", (object?)record.MismatchReason ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("policy_bundle_id", (object?)record.PolicyBundleId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("policy_bundle_hash", (object?)record.PolicyBundleHash ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("verifier_digest", (object?)record.VerifierDigest ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("duration_ms", (object?)record.DurationMs ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("actor", (object?)record.Actor ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("source", (object?)record.Source ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("request_context", (object?)record.RequestContext ?? DBNull.Value);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ReplayAuditResult> QueryAsync(ReplayAuditQuery query, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
var sql = """
|
||||
SELECT
|
||||
replay_id, tenant_id, bom_ref, verdict_hash, rekor_uuid, replayed_at,
|
||||
match, original_hash, replayed_hash, mismatch_reason,
|
||||
policy_bundle_id, policy_bundle_hash, verifier_digest,
|
||||
duration_ms, actor, source, request_context
|
||||
FROM policy.replay_audit
|
||||
WHERE tenant_id = @tenant_id
|
||||
""";
|
||||
|
||||
var parameters = new List<NpgsqlParameter>
|
||||
{
|
||||
new("tenant_id", query.TenantId)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(query.BomRef))
|
||||
{
|
||||
sql += " AND bom_ref = @bom_ref";
|
||||
parameters.Add(new NpgsqlParameter("bom_ref", query.BomRef));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(query.VerdictHash))
|
||||
{
|
||||
sql += " AND verdict_hash = @verdict_hash";
|
||||
parameters.Add(new NpgsqlParameter("verdict_hash", query.VerdictHash));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(query.RekorUuid))
|
||||
{
|
||||
sql += " AND rekor_uuid = @rekor_uuid";
|
||||
parameters.Add(new NpgsqlParameter("rekor_uuid", query.RekorUuid));
|
||||
}
|
||||
|
||||
if (query.FromDate.HasValue)
|
||||
{
|
||||
sql += " AND replayed_at >= @from_date";
|
||||
parameters.Add(new NpgsqlParameter("from_date", query.FromDate.Value));
|
||||
}
|
||||
|
||||
if (query.ToDate.HasValue)
|
||||
{
|
||||
sql += " AND replayed_at <= @to_date";
|
||||
parameters.Add(new NpgsqlParameter("to_date", query.ToDate.Value));
|
||||
}
|
||||
|
||||
if (query.MatchOnly.HasValue)
|
||||
{
|
||||
sql += " AND match = @match";
|
||||
parameters.Add(new NpgsqlParameter("match", query.MatchOnly.Value));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(query.Actor))
|
||||
{
|
||||
sql += " AND actor = @actor";
|
||||
parameters.Add(new NpgsqlParameter("actor", query.Actor));
|
||||
}
|
||||
|
||||
// Get total count
|
||||
var countSql = $"SELECT COUNT(*) FROM ({sql}) AS filtered";
|
||||
await using var countCmd = new NpgsqlCommand(countSql, conn);
|
||||
countCmd.Parameters.AddRange(parameters.ToArray());
|
||||
var totalCount = Convert.ToInt64(await countCmd.ExecuteScalarAsync(ct));
|
||||
|
||||
// Apply pagination
|
||||
sql += " ORDER BY replayed_at DESC";
|
||||
|
||||
if (!string.IsNullOrEmpty(query.ContinuationToken))
|
||||
{
|
||||
var offset = DecodeContinuationToken(query.ContinuationToken);
|
||||
sql += $" OFFSET {offset}";
|
||||
}
|
||||
|
||||
sql += $" LIMIT {query.Limit + 1}";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddRange(parameters.Select(p => p.Clone()).Cast<NpgsqlParameter>().ToArray());
|
||||
|
||||
var records = new List<ReplayAuditRecord>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
records.Add(new ReplayAuditRecord
|
||||
{
|
||||
ReplayId = reader.GetGuid(0),
|
||||
TenantId = reader.GetGuid(1),
|
||||
BomRef = reader.GetString(2),
|
||||
VerdictHash = reader.GetString(3),
|
||||
RekorUuid = reader.IsDBNull(4) ? null : reader.GetString(4),
|
||||
ReplayedAt = reader.GetDateTime(5),
|
||||
Match = reader.GetBoolean(6),
|
||||
OriginalHash = reader.IsDBNull(7) ? null : reader.GetString(7),
|
||||
ReplayedHash = reader.IsDBNull(8) ? null : reader.GetString(8),
|
||||
MismatchReason = reader.IsDBNull(9) ? null : reader.GetString(9),
|
||||
PolicyBundleId = reader.IsDBNull(10) ? null : reader.GetString(10),
|
||||
PolicyBundleHash = reader.IsDBNull(11) ? null : reader.GetString(11),
|
||||
VerifierDigest = reader.IsDBNull(12) ? null : reader.GetString(12),
|
||||
DurationMs = reader.IsDBNull(13) ? null : reader.GetInt32(13),
|
||||
Actor = reader.IsDBNull(14) ? null : reader.GetString(14),
|
||||
Source = reader.IsDBNull(15) ? null : reader.GetString(15),
|
||||
RequestContext = reader.IsDBNull(16) ? null : reader.GetString(16)
|
||||
});
|
||||
}
|
||||
|
||||
var hasMore = records.Count > query.Limit;
|
||||
if (hasMore)
|
||||
{
|
||||
records.RemoveAt(records.Count - 1);
|
||||
}
|
||||
|
||||
string? nextToken = null;
|
||||
if (hasMore)
|
||||
{
|
||||
var currentOffset = string.IsNullOrEmpty(query.ContinuationToken)
|
||||
? 0
|
||||
: DecodeContinuationToken(query.ContinuationToken);
|
||||
nextToken = EncodeContinuationToken(currentOffset + query.Limit);
|
||||
}
|
||||
|
||||
return new ReplayAuditResult
|
||||
{
|
||||
Records = records,
|
||||
Total = totalCount,
|
||||
ContinuationToken = nextToken
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ReplayAuditRecord?> GetByIdAsync(
|
||||
Guid replayId,
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
const string sql = """
|
||||
SELECT
|
||||
replay_id, tenant_id, bom_ref, verdict_hash, rekor_uuid, replayed_at,
|
||||
match, original_hash, replayed_hash, mismatch_reason,
|
||||
policy_bundle_id, policy_bundle_hash, verifier_digest,
|
||||
duration_ms, actor, source, request_context
|
||||
FROM policy.replay_audit
|
||||
WHERE replay_id = @replay_id AND tenant_id = @tenant_id
|
||||
""";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("replay_id", replayId);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (!await reader.ReadAsync(ct))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ReplayAuditRecord
|
||||
{
|
||||
ReplayId = reader.GetGuid(0),
|
||||
TenantId = reader.GetGuid(1),
|
||||
BomRef = reader.GetString(2),
|
||||
VerdictHash = reader.GetString(3),
|
||||
RekorUuid = reader.IsDBNull(4) ? null : reader.GetString(4),
|
||||
ReplayedAt = reader.GetDateTime(5),
|
||||
Match = reader.GetBoolean(6),
|
||||
OriginalHash = reader.IsDBNull(7) ? null : reader.GetString(7),
|
||||
ReplayedHash = reader.IsDBNull(8) ? null : reader.GetString(8),
|
||||
MismatchReason = reader.IsDBNull(9) ? null : reader.GetString(9),
|
||||
PolicyBundleId = reader.IsDBNull(10) ? null : reader.GetString(10),
|
||||
PolicyBundleHash = reader.IsDBNull(11) ? null : reader.GetString(11),
|
||||
VerifierDigest = reader.IsDBNull(12) ? null : reader.GetString(12),
|
||||
DurationMs = reader.IsDBNull(13) ? null : reader.GetInt32(13),
|
||||
Actor = reader.IsDBNull(14) ? null : reader.GetString(14),
|
||||
Source = reader.IsDBNull(15) ? null : reader.GetString(15),
|
||||
RequestContext = reader.IsDBNull(16) ? null : reader.GetString(16)
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ReplayMetrics> GetMetricsAsync(
|
||||
Guid tenantId,
|
||||
DateTimeOffset? fromDate,
|
||||
DateTimeOffset? toDate,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
var sql = """
|
||||
SELECT
|
||||
COUNT(*) AS total_attempts,
|
||||
COUNT(*) FILTER (WHERE match = true) AS successful_matches,
|
||||
COUNT(*) FILTER (WHERE match = false) AS mismatches,
|
||||
AVG(duration_ms) AS avg_duration_ms
|
||||
FROM policy.replay_audit
|
||||
WHERE tenant_id = @tenant_id
|
||||
""";
|
||||
|
||||
var parameters = new List<NpgsqlParameter>
|
||||
{
|
||||
new("tenant_id", tenantId)
|
||||
};
|
||||
|
||||
if (fromDate.HasValue)
|
||||
{
|
||||
sql += " AND replayed_at >= @from_date";
|
||||
parameters.Add(new NpgsqlParameter("from_date", fromDate.Value));
|
||||
}
|
||||
|
||||
if (toDate.HasValue)
|
||||
{
|
||||
sql += " AND replayed_at <= @to_date";
|
||||
parameters.Add(new NpgsqlParameter("to_date", toDate.Value));
|
||||
}
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddRange(parameters.ToArray());
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
await reader.ReadAsync(ct);
|
||||
|
||||
var totalAttempts = reader.GetInt64(0);
|
||||
var successfulMatches = reader.GetInt64(1);
|
||||
var mismatches = reader.GetInt64(2);
|
||||
var avgDurationMs = reader.IsDBNull(3) ? null : (double?)reader.GetDouble(3);
|
||||
|
||||
return new ReplayMetrics
|
||||
{
|
||||
TotalAttempts = totalAttempts,
|
||||
SuccessfulMatches = successfulMatches,
|
||||
Mismatches = mismatches,
|
||||
MatchRate = totalAttempts > 0 ? (double)successfulMatches / totalAttempts : 0,
|
||||
AverageDurationMs = avgDurationMs
|
||||
};
|
||||
}
|
||||
|
||||
private static string EncodeContinuationToken(long offset) =>
|
||||
Convert.ToBase64String(BitConverter.GetBytes(offset));
|
||||
|
||||
private static long DecodeContinuationToken(string token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromBase64String(token);
|
||||
return BitConverter.ToInt64(bytes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for replay audit repository.
|
||||
/// </summary>
|
||||
public interface IReplayAuditRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Records a replay attempt.
|
||||
/// </summary>
|
||||
Task RecordReplayAsync(ReplayAuditRecord record, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Queries replay audit records.
|
||||
/// </summary>
|
||||
Task<ReplayAuditResult> QueryAsync(ReplayAuditQuery query, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific replay record by ID.
|
||||
/// </summary>
|
||||
Task<ReplayAuditRecord?> GetByIdAsync(Guid replayId, Guid tenantId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets aggregated metrics for replay operations.
|
||||
/// </summary>
|
||||
Task<ReplayMetrics> GetMetricsAsync(
|
||||
Guid tenantId,
|
||||
DateTimeOffset? fromDate,
|
||||
DateTimeOffset? toDate,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replay audit record.
|
||||
/// </summary>
|
||||
public sealed record ReplayAuditRecord
|
||||
{
|
||||
public Guid ReplayId { get; init; }
|
||||
public Guid TenantId { get; init; }
|
||||
public required string BomRef { get; init; }
|
||||
public required string VerdictHash { get; init; }
|
||||
public string? RekorUuid { get; init; }
|
||||
public DateTime ReplayedAt { get; init; }
|
||||
public bool Match { get; init; }
|
||||
public string? OriginalHash { get; init; }
|
||||
public string? ReplayedHash { get; init; }
|
||||
public string? MismatchReason { get; init; }
|
||||
public string? PolicyBundleId { get; init; }
|
||||
public string? PolicyBundleHash { get; init; }
|
||||
public string? VerifierDigest { get; init; }
|
||||
public int? DurationMs { get; init; }
|
||||
public string? Actor { get; init; }
|
||||
public string? Source { get; init; }
|
||||
public string? RequestContext { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for replay audit.
|
||||
/// </summary>
|
||||
public sealed record ReplayAuditQuery
|
||||
{
|
||||
public required Guid TenantId { get; init; }
|
||||
public string? BomRef { get; init; }
|
||||
public string? VerdictHash { get; init; }
|
||||
public string? RekorUuid { get; init; }
|
||||
public DateTimeOffset? FromDate { get; init; }
|
||||
public DateTimeOffset? ToDate { get; init; }
|
||||
public bool? MatchOnly { get; init; }
|
||||
public string? Actor { get; init; }
|
||||
public int Limit { get; init; } = 50;
|
||||
public string? ContinuationToken { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of replay audit query.
|
||||
/// </summary>
|
||||
public sealed record ReplayAuditResult
|
||||
{
|
||||
public List<ReplayAuditRecord> Records { get; init; } = [];
|
||||
public long Total { get; init; }
|
||||
public string? ContinuationToken { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated metrics for replay operations.
|
||||
/// </summary>
|
||||
public sealed record ReplayMetrics
|
||||
{
|
||||
public long TotalAttempts { get; init; }
|
||||
public long SuccessfulMatches { get; init; }
|
||||
public long Mismatches { get; init; }
|
||||
public double MatchRate { get; init; }
|
||||
public double? AverageDurationMs { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TrustedKeyRepository.cs
|
||||
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
|
||||
// Task: TASK-017-005 - Trusted Key Registry
|
||||
// Description: PostgreSQL implementation of trusted key repository
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Policy.Persistence.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for trusted signing keys.
|
||||
/// </summary>
|
||||
public sealed class TrustedKeyRepository : RepositoryBase<PolicyDataSource>, ITrustedKeyRepository
|
||||
{
|
||||
public TrustedKeyRepository(PolicyDataSource dataSource, ILogger<TrustedKeyRepository> logger)
|
||||
: base(dataSource, logger) { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TrustedKeyEntity?> GetByKeyIdAsync(
|
||||
string tenantId,
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, key_id, fingerprint, algorithm, public_key_pem, owner,
|
||||
issuer_pattern, purposes, valid_from, valid_until, is_active,
|
||||
revoked_at, revoked_reason, metadata, created_at, updated_at, created_by
|
||||
FROM policy.trusted_keys
|
||||
WHERE tenant_id = @tenant_id AND key_id = @key_id
|
||||
""";
|
||||
|
||||
var results = await QueryAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "key_id", keyId);
|
||||
}, MapEntity, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return results.Count > 0 ? results[0] : null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TrustedKeyEntity?> GetByFingerprintAsync(
|
||||
string tenantId,
|
||||
string fingerprint,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, key_id, fingerprint, algorithm, public_key_pem, owner,
|
||||
issuer_pattern, purposes, valid_from, valid_until, is_active,
|
||||
revoked_at, revoked_reason, metadata, created_at, updated_at, created_by
|
||||
FROM policy.trusted_keys
|
||||
WHERE tenant_id = @tenant_id AND fingerprint = @fingerprint
|
||||
""";
|
||||
|
||||
var results = await QueryAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "fingerprint", fingerprint);
|
||||
}, MapEntity, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return results.Count > 0 ? results[0] : null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TrustedKeyEntity>> FindByIssuerPatternAsync(
|
||||
string tenantId,
|
||||
string issuer,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Find keys where the issuer matches the pattern using LIKE
|
||||
// Pattern stored as "*@example.com" is translated to SQL LIKE pattern
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, key_id, fingerprint, algorithm, public_key_pem, owner,
|
||||
issuer_pattern, purposes, valid_from, valid_until, is_active,
|
||||
revoked_at, revoked_reason, metadata, created_at, updated_at, created_by
|
||||
FROM policy.trusted_keys
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND is_active = true
|
||||
AND revoked_at IS NULL
|
||||
AND (valid_until IS NULL OR valid_until > NOW())
|
||||
AND issuer_pattern IS NOT NULL
|
||||
AND @issuer LIKE REPLACE(REPLACE(issuer_pattern, '*', '%'), '?', '_')
|
||||
ORDER BY valid_from DESC
|
||||
""";
|
||||
|
||||
return await QueryAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "issuer", issuer);
|
||||
}, MapEntity, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TrustedKeyEntity>> ListActiveAsync(
|
||||
string tenantId,
|
||||
int limit = 1000,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, key_id, fingerprint, algorithm, public_key_pem, owner,
|
||||
issuer_pattern, purposes, valid_from, valid_until, is_active,
|
||||
revoked_at, revoked_reason, metadata, created_at, updated_at, created_by
|
||||
FROM policy.trusted_keys
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND is_active = true
|
||||
AND revoked_at IS NULL
|
||||
AND (valid_until IS NULL OR valid_until > NOW())
|
||||
ORDER BY created_at DESC
|
||||
LIMIT @limit OFFSET @offset
|
||||
""";
|
||||
|
||||
return await QueryAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
AddParameter(cmd, "offset", offset);
|
||||
}, MapEntity, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TrustedKeyEntity>> ListByPurposeAsync(
|
||||
string tenantId,
|
||||
string purpose,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, key_id, fingerprint, algorithm, public_key_pem, owner,
|
||||
issuer_pattern, purposes, valid_from, valid_until, is_active,
|
||||
revoked_at, revoked_reason, metadata, created_at, updated_at, created_by
|
||||
FROM policy.trusted_keys
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND is_active = true
|
||||
AND revoked_at IS NULL
|
||||
AND (valid_until IS NULL OR valid_until > NOW())
|
||||
AND purposes @> @purpose::jsonb
|
||||
ORDER BY created_at DESC
|
||||
""";
|
||||
|
||||
return await QueryAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddJsonbParameter(cmd, "purpose", JsonSerializer.Serialize(new[] { purpose }));
|
||||
}, MapEntity, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Guid> CreateAsync(
|
||||
TrustedKeyEntity key,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO policy.trusted_keys (
|
||||
id, tenant_id, key_id, fingerprint, algorithm, public_key_pem, owner,
|
||||
issuer_pattern, purposes, valid_from, valid_until, is_active,
|
||||
revoked_at, revoked_reason, metadata, created_at, updated_at, created_by
|
||||
) VALUES (
|
||||
@id, @tenant_id, @key_id, @fingerprint, @algorithm, @public_key_pem, @owner,
|
||||
@issuer_pattern, @purposes::jsonb, @valid_from, @valid_until, @is_active,
|
||||
@revoked_at, @revoked_reason, @metadata::jsonb, @created_at, @updated_at, @created_by
|
||||
)
|
||||
RETURNING id
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(key.TenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "id", key.Id);
|
||||
AddParameter(command, "tenant_id", key.TenantId);
|
||||
AddParameter(command, "key_id", key.KeyId);
|
||||
AddParameter(command, "fingerprint", key.Fingerprint);
|
||||
AddParameter(command, "algorithm", key.Algorithm);
|
||||
AddParameter(command, "public_key_pem", key.PublicKeyPem);
|
||||
AddParameter(command, "owner", key.Owner);
|
||||
AddParameter(command, "issuer_pattern", key.IssuerPattern);
|
||||
AddJsonbParameter(command, "purposes", key.Purposes);
|
||||
AddParameter(command, "valid_from", key.ValidFrom);
|
||||
AddParameter(command, "valid_until", key.ValidUntil);
|
||||
AddParameter(command, "is_active", key.IsActive);
|
||||
AddParameter(command, "revoked_at", key.RevokedAt);
|
||||
AddParameter(command, "revoked_reason", key.RevokedReason);
|
||||
AddJsonbParameter(command, "metadata", key.Metadata);
|
||||
AddParameter(command, "created_at", key.CreatedAt);
|
||||
AddParameter(command, "updated_at", key.UpdatedAt);
|
||||
AddParameter(command, "created_by", key.CreatedBy);
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return (Guid)result!;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> UpdateAsync(
|
||||
TrustedKeyEntity key,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE policy.trusted_keys
|
||||
SET public_key_pem = @public_key_pem,
|
||||
owner = @owner,
|
||||
issuer_pattern = @issuer_pattern,
|
||||
purposes = @purposes::jsonb,
|
||||
valid_until = @valid_until,
|
||||
is_active = @is_active,
|
||||
metadata = @metadata::jsonb,
|
||||
updated_at = NOW()
|
||||
WHERE tenant_id = @tenant_id AND key_id = @key_id
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(key.TenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "tenant_id", key.TenantId);
|
||||
AddParameter(command, "key_id", key.KeyId);
|
||||
AddParameter(command, "public_key_pem", key.PublicKeyPem);
|
||||
AddParameter(command, "owner", key.Owner);
|
||||
AddParameter(command, "issuer_pattern", key.IssuerPattern);
|
||||
AddJsonbParameter(command, "purposes", key.Purposes);
|
||||
AddParameter(command, "valid_until", key.ValidUntil);
|
||||
AddParameter(command, "is_active", key.IsActive);
|
||||
AddJsonbParameter(command, "metadata", key.Metadata);
|
||||
|
||||
var rowsAffected = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
return rowsAffected > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> RevokeAsync(
|
||||
string tenantId,
|
||||
string keyId,
|
||||
string reason,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE policy.trusted_keys
|
||||
SET is_active = false,
|
||||
revoked_at = NOW(),
|
||||
revoked_reason = @reason,
|
||||
updated_at = NOW()
|
||||
WHERE tenant_id = @tenant_id AND key_id = @key_id AND revoked_at IS NULL
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "tenant_id", tenantId);
|
||||
AddParameter(command, "key_id", keyId);
|
||||
AddParameter(command, "reason", reason);
|
||||
|
||||
var rowsAffected = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
return rowsAffected > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> DeleteAsync(
|
||||
string tenantId,
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
DELETE FROM policy.trusted_keys
|
||||
WHERE tenant_id = @tenant_id AND key_id = @key_id
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "tenant_id", tenantId);
|
||||
AddParameter(command, "key_id", keyId);
|
||||
|
||||
var rowsAffected = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
return rowsAffected > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> CountActiveAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT COUNT(*)
|
||||
FROM policy.trusted_keys
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND is_active = true
|
||||
AND revoked_at IS NULL
|
||||
AND (valid_until IS NULL OR valid_until > NOW())
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "tenant_id", tenantId);
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Convert.ToInt32(result);
|
||||
}
|
||||
|
||||
private static TrustedKeyEntity MapEntity(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(0),
|
||||
TenantId = reader.GetString(1),
|
||||
KeyId = reader.GetString(2),
|
||||
Fingerprint = reader.GetString(3),
|
||||
Algorithm = reader.GetString(4),
|
||||
PublicKeyPem = GetNullableString(reader, 5),
|
||||
Owner = GetNullableString(reader, 6),
|
||||
IssuerPattern = GetNullableString(reader, 7),
|
||||
Purposes = GetNullableString(reader, 8),
|
||||
ValidFrom = reader.GetFieldValue<DateTimeOffset>(9),
|
||||
ValidUntil = GetNullableDateTimeOffset(reader, 10),
|
||||
IsActive = reader.GetBoolean(11),
|
||||
RevokedAt = GetNullableDateTimeOffset(reader, 12),
|
||||
RevokedReason = GetNullableString(reader, 13),
|
||||
Metadata = GetNullableString(reader, 14),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(15),
|
||||
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(16),
|
||||
CreatedBy = GetNullableString(reader, 17)
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,8 @@ using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Infrastructure.Postgres;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using StellaOps.Policy.Audit;
|
||||
using StellaOps.Policy.Gates.Attestation;
|
||||
using StellaOps.Policy.Scoring.Receipts;
|
||||
using StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
using IAuditableExceptionRepository = StellaOps.Policy.Exceptions.Repositories.IExceptionRepository;
|
||||
@@ -81,6 +83,65 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<ILedgerExportRepository, LedgerExportRepository>();
|
||||
services.AddScoped<IWorkerResultRepository, WorkerResultRepository>();
|
||||
|
||||
// Sprint 017: Trusted key registry and gate bypass audit
|
||||
services.AddScoped<ITrustedKeyRepository, TrustedKeyRepository>();
|
||||
services.AddScoped<IGateBypassAuditPersistence, GateBypassAuditRepository>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds trusted key registry services with PostgreSQL backend and caching.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="configureOptions">Options configuration action.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddPostgresTrustedKeyRegistry(
|
||||
this IServiceCollection services,
|
||||
Action<PostgresTrustedKeyRegistryOptions>? configureOptions = null)
|
||||
{
|
||||
var options = new PostgresTrustedKeyRegistryOptions();
|
||||
configureOptions?.Invoke(options);
|
||||
|
||||
services.AddSingleton(options);
|
||||
services.AddMemoryCache();
|
||||
|
||||
// Register factory for tenant-scoped registry
|
||||
services.AddScoped<ITrustedKeyRegistry>(sp =>
|
||||
{
|
||||
var repository = sp.GetRequiredService<ITrustedKeyRepository>();
|
||||
var cache = sp.GetRequiredService<Microsoft.Extensions.Caching.Memory.IMemoryCache>();
|
||||
var logger = sp.GetRequiredService<Microsoft.Extensions.Logging.ILogger<PostgresTrustedKeyRegistry>>();
|
||||
var registryOptions = sp.GetRequiredService<PostgresTrustedKeyRegistryOptions>();
|
||||
|
||||
// TODO: Get tenant ID from current context
|
||||
var tenantId = "default";
|
||||
|
||||
return new PostgresTrustedKeyRegistry(repository, cache, logger, registryOptions, tenantId);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds gate bypass audit repository with PostgreSQL backend.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddPostgresGateBypassAudit(
|
||||
this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IGateBypassAuditRepository>(sp =>
|
||||
{
|
||||
var persistence = sp.GetRequiredService<IGateBypassAuditPersistence>();
|
||||
var logger = sp.GetRequiredService<Microsoft.Extensions.Logging.ILogger<PostgresGateBypassAuditRepository>>();
|
||||
|
||||
// TODO: Get tenant ID from current context
|
||||
var tenantId = "default";
|
||||
|
||||
return new PostgresGateBypassAuditRepository(persistence, logger, tenantId);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AttestationVerificationGate.cs
|
||||
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
|
||||
// Task: TASK-017-001 - Attestation Verification Gate
|
||||
// Description: Policy gate for DSSE attestation verification
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Policy.Gates.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Policy gate that validates DSSE attestation envelopes.
|
||||
/// Checks payload type, signature validity, and key trust.
|
||||
/// </summary>
|
||||
public sealed class AttestationVerificationGate : IPolicyGate
|
||||
{
|
||||
/// <summary>
|
||||
/// Gate identifier.
|
||||
/// </summary>
|
||||
public const string GateId = "attestation-verification";
|
||||
|
||||
private readonly ITrustedKeyRegistry _keyRegistry;
|
||||
private readonly AttestationVerificationGateOptions _options;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Id => GateId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DisplayName => "Attestation Verification";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Validates DSSE attestation payloadType, signatures, and key trust";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new attestation verification gate.
|
||||
/// </summary>
|
||||
public AttestationVerificationGate(
|
||||
ITrustedKeyRegistry keyRegistry,
|
||||
AttestationVerificationGateOptions? options = null)
|
||||
{
|
||||
_keyRegistry = keyRegistry ?? throw new ArgumentNullException(nameof(keyRegistry));
|
||||
_options = options ?? new AttestationVerificationGateOptions();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var attestation = context.GetAttestation();
|
||||
if (attestation == null)
|
||||
{
|
||||
return GateResult.Fail(Id, "No attestation found in context");
|
||||
}
|
||||
|
||||
// 1. Validate payload type
|
||||
var payloadTypeResult = ValidatePayloadType(attestation.PayloadType);
|
||||
if (!payloadTypeResult.Passed)
|
||||
{
|
||||
return payloadTypeResult;
|
||||
}
|
||||
|
||||
// 2. Validate signatures
|
||||
var signatureResult = ValidateSignatures(attestation.Signatures);
|
||||
if (!signatureResult.Passed)
|
||||
{
|
||||
return signatureResult;
|
||||
}
|
||||
|
||||
// 3. Validate trusted keys
|
||||
var keyResult = await ValidateTrustedKeysAsync(attestation.Signatures, ct);
|
||||
if (!keyResult.Passed)
|
||||
{
|
||||
return keyResult;
|
||||
}
|
||||
|
||||
return GateResult.Pass(Id, "Attestation verification passed");
|
||||
}
|
||||
|
||||
private GateResult ValidatePayloadType(string? payloadType)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(payloadType))
|
||||
{
|
||||
return GateResult.Fail(Id, "PayloadType is missing");
|
||||
}
|
||||
|
||||
if (!_options.AllowedPayloadTypes.Contains(payloadType))
|
||||
{
|
||||
return GateResult.Fail(Id, $"PayloadType '{payloadType}' is not in allowed list");
|
||||
}
|
||||
|
||||
return GateResult.Pass(Id, $"PayloadType '{payloadType}' is valid");
|
||||
}
|
||||
|
||||
private GateResult ValidateSignatures(IReadOnlyList<AttestationSignature>? signatures)
|
||||
{
|
||||
if (signatures == null || signatures.Count == 0)
|
||||
{
|
||||
return GateResult.Fail(Id, "No signatures present in attestation");
|
||||
}
|
||||
|
||||
if (signatures.Count < _options.MinimumSignatures)
|
||||
{
|
||||
return GateResult.Fail(Id,
|
||||
$"Insufficient signatures: {signatures.Count} < {_options.MinimumSignatures}");
|
||||
}
|
||||
|
||||
// Validate signature algorithms
|
||||
foreach (var sig in signatures)
|
||||
{
|
||||
if (!_options.AllowedAlgorithms.Contains(sig.Algorithm))
|
||||
{
|
||||
return GateResult.Fail(Id,
|
||||
$"Signature algorithm '{sig.Algorithm}' is not in allowed list");
|
||||
}
|
||||
}
|
||||
|
||||
return GateResult.Pass(Id, $"{signatures.Count} valid signatures present");
|
||||
}
|
||||
|
||||
private async Task<GateResult> ValidateTrustedKeysAsync(
|
||||
IReadOnlyList<AttestationSignature>? signatures,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (signatures == null)
|
||||
{
|
||||
return GateResult.Fail(Id, "No signatures to verify keys");
|
||||
}
|
||||
|
||||
var trustedCount = 0;
|
||||
foreach (var sig in signatures)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sig.KeyId))
|
||||
continue;
|
||||
|
||||
var isTrusted = await _keyRegistry.IsTrustedAsync(sig.KeyId, ct);
|
||||
if (isTrusted)
|
||||
{
|
||||
trustedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (trustedCount < _options.MinimumTrustedSignatures)
|
||||
{
|
||||
return GateResult.Fail(Id,
|
||||
$"Insufficient trusted signatures: {trustedCount} < {_options.MinimumTrustedSignatures}");
|
||||
}
|
||||
|
||||
return GateResult.Pass(Id, $"{trustedCount} signatures from trusted keys");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for attestation verification gate.
|
||||
/// </summary>
|
||||
public sealed record AttestationVerificationGateOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Allowed payload types.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> AllowedPayloadTypes { get; init; } = new HashSet<string>
|
||||
{
|
||||
"application/vnd.in-toto+json",
|
||||
"application/vnd.cyclonedx+json",
|
||||
"application/vnd.cyclonedx+json;version=1.6",
|
||||
"application/spdx+json",
|
||||
"application/vnd.openvex+json"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Allowed signature algorithms.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> AllowedAlgorithms { get; init; } = new HashSet<string>
|
||||
{
|
||||
"ES256", "ES384", "ES512", "RS256", "RS384", "RS512", "EdDSA", "Ed25519"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Minimum number of signatures required.
|
||||
/// </summary>
|
||||
public int MinimumSignatures { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum number of signatures from trusted keys.
|
||||
/// </summary>
|
||||
public int MinimumTrustedSignatures { get; init; } = 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attestation model for gate evaluation.
|
||||
/// </summary>
|
||||
public sealed record AttestationEnvelope
|
||||
{
|
||||
/// <summary>DSSE payload type.</summary>
|
||||
public required string PayloadType { get; init; }
|
||||
|
||||
/// <summary>Base64-encoded payload.</summary>
|
||||
public required string Payload { get; init; }
|
||||
|
||||
/// <summary>Signatures on the envelope.</summary>
|
||||
public required IReadOnlyList<AttestationSignature> Signatures { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A signature on an attestation.
|
||||
/// </summary>
|
||||
public sealed record AttestationSignature
|
||||
{
|
||||
/// <summary>Key ID.</summary>
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>Signature algorithm.</summary>
|
||||
public required string Algorithm { get; init; }
|
||||
|
||||
/// <summary>Base64-encoded signature.</summary>
|
||||
public required string Signature { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CompositeAttestationGate.cs
|
||||
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
|
||||
// Task: TASK-017-004 - Composite Attestation Gate
|
||||
// Description: Orchestrates multiple attestation gates with configurable logic
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Policy.Gates.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Composite gate that orchestrates multiple attestation gates.
|
||||
/// Supports AND, OR, and threshold-based composition.
|
||||
/// </summary>
|
||||
public sealed class CompositeAttestationGate : IPolicyGate
|
||||
{
|
||||
/// <summary>
|
||||
/// Gate identifier.
|
||||
/// </summary>
|
||||
public const string GateId = "composite-attestation";
|
||||
|
||||
private readonly IReadOnlyList<IPolicyGate> _gates;
|
||||
private readonly CompositeAttestationGateOptions _options;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Id => _options.CustomId ?? GateId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DisplayName => _options.DisplayName ?? "Composite Attestation";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => _options.Description ?? "Orchestrates multiple attestation verification gates";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new composite attestation gate.
|
||||
/// </summary>
|
||||
public CompositeAttestationGate(
|
||||
IEnumerable<IPolicyGate> gates,
|
||||
CompositeAttestationGateOptions? options = null)
|
||||
{
|
||||
_gates = gates?.ToList() ?? throw new ArgumentNullException(nameof(gates));
|
||||
_options = options ?? new CompositeAttestationGateOptions();
|
||||
|
||||
if (_gates.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one gate is required.", nameof(gates));
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var results = new List<GateResult>();
|
||||
var passed = 0;
|
||||
var failed = 0;
|
||||
|
||||
foreach (var gate in _gates)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var result = await gate.EvaluateAsync(context, ct);
|
||||
results.Add(result);
|
||||
|
||||
if (result.Passed)
|
||||
{
|
||||
passed++;
|
||||
|
||||
// Short-circuit on OR mode
|
||||
if (_options.Mode == CompositeMode.Or)
|
||||
{
|
||||
return GateResult.Pass(Id,
|
||||
$"Composite gate passed (OR mode): {gate.Id} passed",
|
||||
childResults: results);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
failed++;
|
||||
|
||||
// Short-circuit on AND mode
|
||||
if (_options.Mode == CompositeMode.And && !_options.ContinueOnFailure)
|
||||
{
|
||||
return GateResult.Fail(Id,
|
||||
$"Composite gate failed (AND mode): {gate.Id} failed - {result.Message}",
|
||||
childResults: results);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (_options.ContinueOnError)
|
||||
{
|
||||
results.Add(GateResult.Fail(gate.Id, $"Gate error: {ex.Message}"));
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate final result based on mode
|
||||
return EvaluateFinalResult(passed, failed, results);
|
||||
}
|
||||
|
||||
private GateResult EvaluateFinalResult(int passed, int failed, List<GateResult> results)
|
||||
{
|
||||
switch (_options.Mode)
|
||||
{
|
||||
case CompositeMode.And:
|
||||
if (failed == 0)
|
||||
{
|
||||
return GateResult.Pass(Id,
|
||||
$"All {passed} gates passed",
|
||||
childResults: results);
|
||||
}
|
||||
return GateResult.Fail(Id,
|
||||
$"Composite gate failed: {failed} of {_gates.Count} gates failed",
|
||||
childResults: results);
|
||||
|
||||
case CompositeMode.Or:
|
||||
if (passed > 0)
|
||||
{
|
||||
return GateResult.Pass(Id,
|
||||
$"At least one gate passed ({passed} of {_gates.Count})",
|
||||
childResults: results);
|
||||
}
|
||||
return GateResult.Fail(Id,
|
||||
$"Composite gate failed: no gates passed",
|
||||
childResults: results);
|
||||
|
||||
case CompositeMode.Threshold:
|
||||
if (passed >= _options.PassThreshold)
|
||||
{
|
||||
return GateResult.Pass(Id,
|
||||
$"Threshold met: {passed} >= {_options.PassThreshold} gates passed",
|
||||
childResults: results);
|
||||
}
|
||||
return GateResult.Fail(Id,
|
||||
$"Threshold not met: {passed} < {_options.PassThreshold} gates passed",
|
||||
childResults: results);
|
||||
|
||||
default:
|
||||
return GateResult.Fail(Id, $"Unknown composite mode: {_options.Mode}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a composite gate with AND logic.
|
||||
/// </summary>
|
||||
public static CompositeAttestationGate And(params IPolicyGate[] gates)
|
||||
{
|
||||
return new CompositeAttestationGate(gates, new CompositeAttestationGateOptions
|
||||
{
|
||||
Mode = CompositeMode.And
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a composite gate with OR logic.
|
||||
/// </summary>
|
||||
public static CompositeAttestationGate Or(params IPolicyGate[] gates)
|
||||
{
|
||||
return new CompositeAttestationGate(gates, new CompositeAttestationGateOptions
|
||||
{
|
||||
Mode = CompositeMode.Or
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a composite gate with threshold logic.
|
||||
/// </summary>
|
||||
public static CompositeAttestationGate Threshold(int threshold, params IPolicyGate[] gates)
|
||||
{
|
||||
return new CompositeAttestationGate(gates, new CompositeAttestationGateOptions
|
||||
{
|
||||
Mode = CompositeMode.Threshold,
|
||||
PassThreshold = threshold
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for composite attestation gate.
|
||||
/// </summary>
|
||||
public sealed record CompositeAttestationGateOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Composition mode.
|
||||
/// </summary>
|
||||
public CompositeMode Mode { get; init; } = CompositeMode.And;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum number of gates that must pass (for Threshold mode).
|
||||
/// </summary>
|
||||
public int PassThreshold { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to continue evaluating after a failure (for AND mode).
|
||||
/// </summary>
|
||||
public bool ContinueOnFailure { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to continue evaluating after an error.
|
||||
/// </summary>
|
||||
public bool ContinueOnError { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Custom gate ID.
|
||||
/// </summary>
|
||||
public string? CustomId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Custom display name.
|
||||
/// </summary>
|
||||
public string? DisplayName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Custom description.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composite gate evaluation mode.
|
||||
/// </summary>
|
||||
public enum CompositeMode
|
||||
{
|
||||
/// <summary>All gates must pass.</summary>
|
||||
And,
|
||||
|
||||
/// <summary>At least one gate must pass.</summary>
|
||||
Or,
|
||||
|
||||
/// <summary>A threshold number of gates must pass.</summary>
|
||||
Threshold
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ITrustedKeyRegistry.cs
|
||||
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
|
||||
// Task: TASK-017-005 - Trusted Key Registry
|
||||
// Description: Interface and implementation for trusted key management
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Policy.Gates.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Registry of trusted signing keys for attestation verification.
|
||||
/// </summary>
|
||||
public interface ITrustedKeyRegistry
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks if a key ID is trusted.
|
||||
/// </summary>
|
||||
/// <param name="keyId">The key ID to check.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>True if the key is trusted.</returns>
|
||||
Task<bool> IsTrustedAsync(string keyId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a trusted key by ID.
|
||||
/// </summary>
|
||||
/// <param name="keyId">The key ID.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The trusted key or null.</returns>
|
||||
Task<TrustedKey?> GetKeyAsync(string keyId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a trusted key by fingerprint.
|
||||
/// </summary>
|
||||
/// <param name="fingerprint">SHA-256 fingerprint of the public key.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The trusted key or null.</returns>
|
||||
Task<TrustedKey?> GetByFingerprintAsync(string fingerprint, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all trusted keys.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>All trusted keys.</returns>
|
||||
IAsyncEnumerable<TrustedKey> ListAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a trusted key.
|
||||
/// </summary>
|
||||
/// <param name="key">The key to add.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The added key.</returns>
|
||||
Task<TrustedKey> AddAsync(TrustedKey key, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Revokes a trusted key.
|
||||
/// </summary>
|
||||
/// <param name="keyId">The key ID to revoke.</param>
|
||||
/// <param name="reason">Revocation reason.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task RevokeAsync(string keyId, string reason, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A trusted signing key.
|
||||
/// </summary>
|
||||
public sealed record TrustedKey
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique key identifier.
|
||||
/// </summary>
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 fingerprint of the public key.
|
||||
/// </summary>
|
||||
public required string Fingerprint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key algorithm (e.g., "ECDSA_P256", "Ed25519", "RSA_2048").
|
||||
/// </summary>
|
||||
public required string Algorithm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PEM-encoded public key.
|
||||
/// </summary>
|
||||
public string? PublicKeyPem { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key owner/issuer identity.
|
||||
/// </summary>
|
||||
public string? Owner { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key purpose (e.g., "sbom-signing", "vex-signing", "release-signing").
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Purposes { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// When the key was trusted.
|
||||
/// </summary>
|
||||
public DateTimeOffset TrustedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the key expires.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the key is currently active.
|
||||
/// </summary>
|
||||
public bool IsActive { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Revocation reason (if revoked).
|
||||
/// </summary>
|
||||
public string? RevokedReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the key was revoked.
|
||||
/// </summary>
|
||||
public DateTimeOffset? RevokedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID for multi-tenancy.
|
||||
/// </summary>
|
||||
public Guid TenantId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of trusted key registry.
|
||||
/// </summary>
|
||||
public sealed class InMemoryTrustedKeyRegistry : ITrustedKeyRegistry
|
||||
{
|
||||
private readonly Dictionary<string, TrustedKey> _keysByKeyId = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, TrustedKey> _keysByFingerprint = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly object _lock = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> IsTrustedAsync(string keyId, CancellationToken ct = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_keysByKeyId.TryGetValue(keyId, out var key))
|
||||
{
|
||||
var isTrusted = key.IsActive &&
|
||||
key.RevokedAt == null &&
|
||||
(key.ExpiresAt == null || key.ExpiresAt > DateTimeOffset.UtcNow);
|
||||
return Task.FromResult(isTrusted);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TrustedKey?> GetKeyAsync(string keyId, CancellationToken ct = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return Task.FromResult(_keysByKeyId.GetValueOrDefault(keyId));
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TrustedKey?> GetByFingerprintAsync(string fingerprint, CancellationToken ct = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return Task.FromResult(_keysByFingerprint.GetValueOrDefault(fingerprint));
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<TrustedKey> ListAsync(CancellationToken ct = default)
|
||||
{
|
||||
List<TrustedKey> keys;
|
||||
lock (_lock)
|
||||
{
|
||||
keys = _keysByKeyId.Values.ToList();
|
||||
}
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
yield return key;
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TrustedKey> AddAsync(TrustedKey key, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_keysByKeyId[key.KeyId] = key;
|
||||
_keysByFingerprint[key.Fingerprint] = key;
|
||||
}
|
||||
|
||||
return Task.FromResult(key);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RevokeAsync(string keyId, string reason, CancellationToken ct = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_keysByKeyId.TryGetValue(keyId, out var key))
|
||||
{
|
||||
var revokedKey = key with
|
||||
{
|
||||
IsActive = false,
|
||||
RevokedAt = DateTimeOffset.UtcNow,
|
||||
RevokedReason = reason
|
||||
};
|
||||
_keysByKeyId[keyId] = revokedKey;
|
||||
_keysByFingerprint[key.Fingerprint] = revokedKey;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RekorFreshnessGate.cs
|
||||
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
|
||||
// Task: TASK-017-002 - Rekor Freshness Gate
|
||||
// Description: Policy gate for Rekor integratedTime freshness enforcement
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Policy.Gates.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Policy gate that enforces Rekor entry freshness based on integratedTime.
|
||||
/// Rejects attestations older than the configured cutoff.
|
||||
/// </summary>
|
||||
public sealed class RekorFreshnessGate : IPolicyGate
|
||||
{
|
||||
/// <summary>
|
||||
/// Gate identifier.
|
||||
/// </summary>
|
||||
public const string GateId = "rekor-freshness";
|
||||
|
||||
private readonly RekorFreshnessGateOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Id => GateId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DisplayName => "Rekor Freshness";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Enforces Rekor integratedTime freshness cutoffs";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new Rekor freshness gate.
|
||||
/// </summary>
|
||||
public RekorFreshnessGate(
|
||||
RekorFreshnessGateOptions? options = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_options = options ?? new RekorFreshnessGateOptions();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var rekorProof = context.GetRekorProof();
|
||||
if (rekorProof == null)
|
||||
{
|
||||
if (_options.RequireRekorProof)
|
||||
{
|
||||
return Task.FromResult(GateResult.Fail(Id, "No Rekor proof found in context"));
|
||||
}
|
||||
|
||||
return Task.FromResult(GateResult.Pass(Id, "Rekor proof not required, skipping freshness check"));
|
||||
}
|
||||
|
||||
// Get integrated time from proof
|
||||
var integratedTime = rekorProof.IntegratedTime;
|
||||
if (integratedTime == null)
|
||||
{
|
||||
return Task.FromResult(GateResult.Fail(Id, "Rekor proof missing integratedTime"));
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var age = now - integratedTime.Value;
|
||||
|
||||
// Check maximum age
|
||||
if (age > _options.MaxAge)
|
||||
{
|
||||
return Task.FromResult(GateResult.Fail(Id,
|
||||
$"Rekor entry too old: {age.TotalHours:F1}h > {_options.MaxAge.TotalHours:F1}h max"));
|
||||
}
|
||||
|
||||
// Check minimum age (to prevent clock skew issues)
|
||||
if (age < _options.MinAge)
|
||||
{
|
||||
return Task.FromResult(GateResult.Fail(Id,
|
||||
$"Rekor entry too new (possible clock skew): {age.TotalSeconds:F1}s < {_options.MinAge.TotalSeconds:F1}s min"));
|
||||
}
|
||||
|
||||
// Check for future timestamp
|
||||
if (integratedTime.Value > now.Add(_options.FutureTimeTolerance))
|
||||
{
|
||||
return Task.FromResult(GateResult.Fail(Id,
|
||||
$"Rekor entry has future timestamp: {integratedTime.Value:O} > {now:O}"));
|
||||
}
|
||||
|
||||
return Task.FromResult(GateResult.Pass(Id,
|
||||
$"Rekor entry age {age.TotalMinutes:F1}m is within acceptable range"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for Rekor freshness gate.
|
||||
/// </summary>
|
||||
public sealed record RekorFreshnessGateOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum age for Rekor entries.
|
||||
/// Default: 24 hours.
|
||||
/// </summary>
|
||||
public TimeSpan MaxAge { get; init; } = TimeSpan.FromHours(24);
|
||||
|
||||
/// <summary>
|
||||
/// Minimum age for Rekor entries (to account for clock skew).
|
||||
/// Default: 0 (no minimum).
|
||||
/// </summary>
|
||||
public TimeSpan MinAge { get; init; } = TimeSpan.Zero;
|
||||
|
||||
/// <summary>
|
||||
/// Tolerance for future timestamps.
|
||||
/// Default: 5 minutes.
|
||||
/// </summary>
|
||||
public TimeSpan FutureTimeTolerance { get; init; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Whether to require a Rekor proof.
|
||||
/// If false, missing proofs are allowed (gate passes).
|
||||
/// </summary>
|
||||
public bool RequireRekorProof { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rekor proof context for gate evaluation.
|
||||
/// </summary>
|
||||
public sealed record RekorProofContext
|
||||
{
|
||||
/// <summary>Log index.</summary>
|
||||
public long LogIndex { get; init; }
|
||||
|
||||
/// <summary>Integrated timestamp.</summary>
|
||||
public DateTimeOffset? IntegratedTime { get; init; }
|
||||
|
||||
/// <summary>UUID.</summary>
|
||||
public string? Uuid { get; init; }
|
||||
|
||||
/// <summary>Whether the proof has been verified.</summary>
|
||||
public bool IsVerified { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VexStatusPromotionGate.cs
|
||||
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
|
||||
// Task: TASK-017-003 - VEX Status Promotion Gate
|
||||
// Description: Reachability-aware VEX status gate for release blocking
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Policy.Gates.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Policy gate that enforces VEX status requirements with reachability awareness.
|
||||
/// Blocks promotion based on affected + reachable combinations.
|
||||
/// </summary>
|
||||
public sealed class VexStatusPromotionGate : IPolicyGate
|
||||
{
|
||||
/// <summary>
|
||||
/// Gate identifier.
|
||||
/// </summary>
|
||||
public const string GateId = "vex-status-promotion";
|
||||
|
||||
private readonly VexStatusPromotionGateOptions _options;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Id => GateId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DisplayName => "VEX Status Promotion";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Enforces VEX status requirements with reachability awareness";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new VEX status promotion gate.
|
||||
/// </summary>
|
||||
public VexStatusPromotionGate(VexStatusPromotionGateOptions? options = null)
|
||||
{
|
||||
_options = options ?? new VexStatusPromotionGateOptions();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var vexSummary = context.GetVexSummary();
|
||||
if (vexSummary == null)
|
||||
{
|
||||
if (_options.RequireVexSummary)
|
||||
{
|
||||
return Task.FromResult(GateResult.Fail(Id, "No VEX summary found in context"));
|
||||
}
|
||||
return Task.FromResult(GateResult.Pass(Id, "VEX summary not required, skipping"));
|
||||
}
|
||||
|
||||
var findings = new List<string>();
|
||||
|
||||
// Check for blocking combinations
|
||||
foreach (var statement in vexSummary.Statements)
|
||||
{
|
||||
var isBlocking = EvaluateStatement(statement, findings);
|
||||
if (isBlocking && !_options.AllowBlockingVulnerabilities)
|
||||
{
|
||||
return Task.FromResult(GateResult.Fail(Id,
|
||||
$"Blocking vulnerability found: {statement.VulnerabilityId} - {string.Join(", ", findings)}"));
|
||||
}
|
||||
}
|
||||
|
||||
// Check aggregate thresholds
|
||||
if (vexSummary.AffectedReachableCount > _options.MaxAffectedReachable)
|
||||
{
|
||||
return Task.FromResult(GateResult.Fail(Id,
|
||||
$"Too many affected+reachable vulnerabilities: {vexSummary.AffectedReachableCount} > {_options.MaxAffectedReachable}"));
|
||||
}
|
||||
|
||||
if (vexSummary.UnderInvestigationCount > _options.MaxUnderInvestigation)
|
||||
{
|
||||
return Task.FromResult(GateResult.Fail(Id,
|
||||
$"Too many under-investigation vulnerabilities: {vexSummary.UnderInvestigationCount} > {_options.MaxUnderInvestigation}"));
|
||||
}
|
||||
|
||||
return Task.FromResult(GateResult.Pass(Id,
|
||||
$"VEX status check passed: {vexSummary.NotAffectedCount} not_affected, {vexSummary.FixedCount} fixed"));
|
||||
}
|
||||
|
||||
private bool EvaluateStatement(VexStatementSummary statement, List<string> findings)
|
||||
{
|
||||
// Affected + Reachable = Blocking
|
||||
if (statement.Status == VexStatus.Affected && statement.IsReachable)
|
||||
{
|
||||
findings.Add($"{statement.VulnerabilityId}: affected and reachable");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Affected + Unknown reachability with high severity = Blocking
|
||||
if (statement.Status == VexStatus.Affected &&
|
||||
statement.ReachabilityStatus == ReachabilityStatus.Unknown &&
|
||||
statement.Severity >= _options.BlockingSeverityThreshold)
|
||||
{
|
||||
findings.Add($"{statement.VulnerabilityId}: affected with unknown reachability and severity {statement.Severity}");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Under investigation with high severity = Warning (not blocking by default)
|
||||
if (statement.Status == VexStatus.UnderInvestigation &&
|
||||
statement.Severity >= _options.WarningSeverityThreshold)
|
||||
{
|
||||
findings.Add($"{statement.VulnerabilityId}: under investigation with severity {statement.Severity}");
|
||||
// Not blocking by default, but tracked
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for VEX status promotion gate.
|
||||
/// </summary>
|
||||
public sealed record VexStatusPromotionGateOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether to require a VEX summary.
|
||||
/// </summary>
|
||||
public bool RequireVexSummary { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to allow blocking vulnerabilities (gate passes with warning).
|
||||
/// </summary>
|
||||
public bool AllowBlockingVulnerabilities { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of affected+reachable vulnerabilities allowed.
|
||||
/// </summary>
|
||||
public int MaxAffectedReachable { get; init; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of under-investigation vulnerabilities allowed.
|
||||
/// </summary>
|
||||
public int MaxUnderInvestigation { get; init; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Severity threshold for blocking (affected + unknown reachability).
|
||||
/// </summary>
|
||||
public double BlockingSeverityThreshold { get; init; } = 9.0; // Critical
|
||||
|
||||
/// <summary>
|
||||
/// Severity threshold for warnings.
|
||||
/// </summary>
|
||||
public double WarningSeverityThreshold { get; init; } = 7.0; // High
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX summary for gate evaluation.
|
||||
/// </summary>
|
||||
public sealed record VexSummary
|
||||
{
|
||||
/// <summary>Individual VEX statements.</summary>
|
||||
public IReadOnlyList<VexStatementSummary> Statements { get; init; } = [];
|
||||
|
||||
/// <summary>Count of not_affected vulnerabilities.</summary>
|
||||
public int NotAffectedCount { get; init; }
|
||||
|
||||
/// <summary>Count of affected vulnerabilities.</summary>
|
||||
public int AffectedCount { get; init; }
|
||||
|
||||
/// <summary>Count of fixed vulnerabilities.</summary>
|
||||
public int FixedCount { get; init; }
|
||||
|
||||
/// <summary>Count of under_investigation vulnerabilities.</summary>
|
||||
public int UnderInvestigationCount { get; init; }
|
||||
|
||||
/// <summary>Count of affected + reachable vulnerabilities.</summary>
|
||||
public int AffectedReachableCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of a single VEX statement.
|
||||
/// </summary>
|
||||
public sealed record VexStatementSummary
|
||||
{
|
||||
/// <summary>Vulnerability ID (e.g., CVE-2024-12345).</summary>
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>VEX status.</summary>
|
||||
public required VexStatus Status { get; init; }
|
||||
|
||||
/// <summary>Whether the vulnerability is reachable.</summary>
|
||||
public bool IsReachable { get; init; }
|
||||
|
||||
/// <summary>Reachability determination status.</summary>
|
||||
public ReachabilityStatus ReachabilityStatus { get; init; }
|
||||
|
||||
/// <summary>CVSS severity score.</summary>
|
||||
public double Severity { get; init; }
|
||||
|
||||
/// <summary>Justification for the status.</summary>
|
||||
public string? Justification { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX status values.
|
||||
/// </summary>
|
||||
public enum VexStatus
|
||||
{
|
||||
/// <summary>Not affected by the vulnerability.</summary>
|
||||
NotAffected,
|
||||
|
||||
/// <summary>Affected by the vulnerability.</summary>
|
||||
Affected,
|
||||
|
||||
/// <summary>Fixed in this version.</summary>
|
||||
Fixed,
|
||||
|
||||
/// <summary>Under investigation.</summary>
|
||||
UnderInvestigation
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reachability determination status.
|
||||
/// </summary>
|
||||
public enum ReachabilityStatus
|
||||
{
|
||||
/// <summary>Reachability not determined.</summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>Confirmed reachable.</summary>
|
||||
Reachable,
|
||||
|
||||
/// <summary>Confirmed not reachable.</summary>
|
||||
NotReachable,
|
||||
|
||||
/// <summary>Partially reachable (some paths blocked).</summary>
|
||||
PartiallyReachable
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CveDeltaGate.cs
|
||||
// Sprint: SPRINT_20260118_027_Policy_cve_release_gates
|
||||
// Task: TASK-027-05 - CVE Delta Gate
|
||||
// Description: Policy gate that blocks releases introducing new high-severity CVEs
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Policy.Gates.Cve;
|
||||
|
||||
/// <summary>
|
||||
/// Policy gate that blocks releases introducing new high-severity CVEs compared to baseline.
|
||||
/// Prevents security regressions by tracking CVE delta between releases.
|
||||
/// </summary>
|
||||
public sealed class CveDeltaGate : IPolicyGate
|
||||
{
|
||||
/// <summary>
|
||||
/// Gate identifier.
|
||||
/// </summary>
|
||||
public const string GateId = "cve-delta";
|
||||
|
||||
private readonly ICveDeltaProvider? _deltaProvider;
|
||||
private readonly CveDeltaGateOptions _options;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Id => GateId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DisplayName => "CVE Delta";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Blocks releases that introduce new CVEs above severity threshold";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new CVE delta gate.
|
||||
/// </summary>
|
||||
public CveDeltaGate(
|
||||
CveDeltaGateOptions? options = null,
|
||||
ICveDeltaProvider? deltaProvider = null)
|
||||
{
|
||||
_options = options ?? new CveDeltaGateOptions();
|
||||
_deltaProvider = deltaProvider;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
return GateResult.Pass(Id, "CVE delta gate disabled");
|
||||
}
|
||||
|
||||
var envOptions = GetEnvironmentOptions(context.Environment);
|
||||
|
||||
// Get current and baseline CVEs
|
||||
var currentCves = context.GetCveFindings();
|
||||
if (currentCves == null || currentCves.Count == 0)
|
||||
{
|
||||
return GateResult.Pass(Id, "No CVE findings in current release");
|
||||
}
|
||||
|
||||
// Get baseline CVEs
|
||||
IReadOnlyList<CveFinding> baselineCves;
|
||||
if (_deltaProvider != null && !string.IsNullOrWhiteSpace(context.BaselineReference))
|
||||
{
|
||||
baselineCves = await _deltaProvider.GetBaselineCvesAsync(
|
||||
context.BaselineReference,
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
else if (context.BaselineCves != null)
|
||||
{
|
||||
baselineCves = context.BaselineCves;
|
||||
}
|
||||
else
|
||||
{
|
||||
// No baseline available - treat as first release
|
||||
return EvaluateWithoutBaseline(currentCves, envOptions);
|
||||
}
|
||||
|
||||
// Compute delta
|
||||
var baselineCveIds = baselineCves.Select(c => c.CveId).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var currentCveIds = currentCves.Select(c => c.CveId).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var newCves = currentCves
|
||||
.Where(c => !baselineCveIds.Contains(c.CveId))
|
||||
.ToList();
|
||||
|
||||
var fixedCves = baselineCves
|
||||
.Where(c => !currentCveIds.Contains(c.CveId))
|
||||
.ToList();
|
||||
|
||||
var unchangedCves = currentCves
|
||||
.Where(c => baselineCveIds.Contains(c.CveId))
|
||||
.ToList();
|
||||
|
||||
// Check for blocking new CVEs
|
||||
var blockingNewCves = newCves
|
||||
.Where(c => c.CvssScore.HasValue && c.CvssScore.Value >= envOptions.NewCveSeverityThreshold)
|
||||
.ToList();
|
||||
|
||||
// Apply reachability filter if enabled
|
||||
if (envOptions.OnlyBlockReachable)
|
||||
{
|
||||
blockingNewCves = blockingNewCves.Where(c => c.IsReachable).ToList();
|
||||
}
|
||||
|
||||
var warnings = new List<string>();
|
||||
|
||||
// Check remediation SLA for existing CVEs
|
||||
if (envOptions.RemediationSlaDays.HasValue)
|
||||
{
|
||||
var overdueRemediations = CheckRemediationSla(unchangedCves, envOptions.RemediationSlaDays.Value, context);
|
||||
if (overdueRemediations.Count > 0)
|
||||
{
|
||||
warnings.Add($"{overdueRemediations.Count} CVE(s) past remediation SLA: " +
|
||||
string.Join(", ", overdueRemediations.Take(3).Select(c => c.CveId)));
|
||||
}
|
||||
}
|
||||
|
||||
// Report improvements
|
||||
if (fixedCves.Count > 0)
|
||||
{
|
||||
var highFixed = fixedCves.Count(c => c.CvssScore >= 7.0);
|
||||
if (highFixed > 0)
|
||||
{
|
||||
warnings.Add($"Improvement: {highFixed} high+ severity CVE(s) fixed");
|
||||
}
|
||||
}
|
||||
|
||||
if (blockingNewCves.Count > 0)
|
||||
{
|
||||
var message = $"Release introduces {blockingNewCves.Count} new CVE(s) at or above severity {envOptions.NewCveSeverityThreshold:F1}: " +
|
||||
string.Join(", ", blockingNewCves.Take(5).Select(c =>
|
||||
$"{c.CveId} (CVSS: {c.CvssScore:F1}{(c.IsReachable ? ", reachable" : "")})"));
|
||||
|
||||
if (blockingNewCves.Count > 5)
|
||||
{
|
||||
message += $" and {blockingNewCves.Count - 5} more";
|
||||
}
|
||||
|
||||
return GateResult.Fail(Id, message);
|
||||
}
|
||||
|
||||
var passMessage = $"CVE delta check passed. " +
|
||||
$"New: {newCves.Count}, Fixed: {fixedCves.Count}, Unchanged: {unchangedCves.Count}";
|
||||
|
||||
if (newCves.Count > 0)
|
||||
{
|
||||
var lowSeverityNew = newCves.Count(c => !c.CvssScore.HasValue || c.CvssScore.Value < envOptions.NewCveSeverityThreshold);
|
||||
if (lowSeverityNew > 0)
|
||||
{
|
||||
passMessage += $" ({lowSeverityNew} new low-severity allowed)";
|
||||
}
|
||||
}
|
||||
|
||||
return GateResult.Pass(Id, passMessage, warnings: warnings);
|
||||
}
|
||||
|
||||
private GateResult EvaluateWithoutBaseline(
|
||||
IReadOnlyList<CveFinding> currentCves,
|
||||
CveDeltaGateOptions options)
|
||||
{
|
||||
if (options.AllowFirstRelease)
|
||||
{
|
||||
var highSeverity = currentCves.Count(c => c.CvssScore >= options.NewCveSeverityThreshold);
|
||||
var message = $"First release (no baseline). {currentCves.Count} CVE(s) found, {highSeverity} high+ severity.";
|
||||
|
||||
if (highSeverity > 0)
|
||||
{
|
||||
return GateResult.Pass(Id, message,
|
||||
warnings: new[] { $"First release contains {highSeverity} high+ severity CVE(s)" });
|
||||
}
|
||||
|
||||
return GateResult.Pass(Id, message);
|
||||
}
|
||||
|
||||
// Require baseline
|
||||
return GateResult.Fail(Id, "CVE delta gate requires baseline reference but none provided");
|
||||
}
|
||||
|
||||
private static List<CveFinding> CheckRemediationSla(
|
||||
IReadOnlyList<CveFinding> cves,
|
||||
int slaDays,
|
||||
PolicyGateContext context)
|
||||
{
|
||||
var overdue = new List<CveFinding>();
|
||||
|
||||
foreach (var cve in cves)
|
||||
{
|
||||
// Only check high+ severity CVEs for SLA
|
||||
if (!cve.CvssScore.HasValue || cve.CvssScore.Value < 7.0)
|
||||
continue;
|
||||
|
||||
// Get first seen date from context metadata
|
||||
if (context.CveFirstSeenDates?.TryGetValue(cve.CveId, out var firstSeen) == true)
|
||||
{
|
||||
var daysSinceFirstSeen = (DateTimeOffset.UtcNow - firstSeen).TotalDays;
|
||||
if (daysSinceFirstSeen > slaDays)
|
||||
{
|
||||
overdue.Add(cve);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return overdue;
|
||||
}
|
||||
|
||||
private CveDeltaGateOptions GetEnvironmentOptions(string? environment)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(environment))
|
||||
return _options;
|
||||
|
||||
if (_options.Environments.TryGetValue(environment, out var envOverride))
|
||||
{
|
||||
return _options with
|
||||
{
|
||||
Enabled = envOverride.Enabled ?? _options.Enabled,
|
||||
NewCveSeverityThreshold = envOverride.NewCveSeverityThreshold ?? _options.NewCveSeverityThreshold,
|
||||
OnlyBlockReachable = envOverride.OnlyBlockReachable ?? _options.OnlyBlockReachable,
|
||||
RemediationSlaDays = envOverride.RemediationSlaDays ?? _options.RemediationSlaDays,
|
||||
AllowFirstRelease = envOverride.AllowFirstRelease ?? _options.AllowFirstRelease
|
||||
};
|
||||
}
|
||||
|
||||
return _options;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for CVE delta gate.
|
||||
/// </summary>
|
||||
public sealed record CveDeltaGateOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Policy:Gates:CveDelta";
|
||||
|
||||
/// <summary>Whether the gate is enabled.</summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum CVSS severity for new CVEs to trigger a block.
|
||||
/// Only new CVEs at or above this severity are blocked.
|
||||
/// Default: 7.0 (High).
|
||||
/// </summary>
|
||||
public double NewCveSeverityThreshold { get; init; } = 7.0;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to only block reachable new CVEs.
|
||||
/// If true, unreachable new CVEs are allowed regardless of severity.
|
||||
/// </summary>
|
||||
public bool OnlyBlockReachable { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Remediation SLA in days for existing CVEs.
|
||||
/// CVEs present longer than this SLA generate warnings.
|
||||
/// Null to disable SLA checking.
|
||||
/// </summary>
|
||||
public int? RemediationSlaDays { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to allow first release without baseline.
|
||||
/// If false, gate fails when no baseline is available.
|
||||
/// </summary>
|
||||
public bool AllowFirstRelease { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Per-environment configuration overrides.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, CveDeltaGateEnvironmentOverride> Environments { get; init; }
|
||||
= new Dictionary<string, CveDeltaGateEnvironmentOverride>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-environment overrides.
|
||||
/// </summary>
|
||||
public sealed record CveDeltaGateEnvironmentOverride
|
||||
{
|
||||
/// <summary>Override for Enabled.</summary>
|
||||
public bool? Enabled { get; init; }
|
||||
|
||||
/// <summary>Override for NewCveSeverityThreshold.</summary>
|
||||
public double? NewCveSeverityThreshold { get; init; }
|
||||
|
||||
/// <summary>Override for OnlyBlockReachable.</summary>
|
||||
public bool? OnlyBlockReachable { get; init; }
|
||||
|
||||
/// <summary>Override for RemediationSlaDays.</summary>
|
||||
public int? RemediationSlaDays { get; init; }
|
||||
|
||||
/// <summary>Override for AllowFirstRelease.</summary>
|
||||
public bool? AllowFirstRelease { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provider for CVE delta data.
|
||||
/// </summary>
|
||||
public interface ICveDeltaProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets CVEs from a baseline reference.
|
||||
/// </summary>
|
||||
/// <param name="baselineReference">Baseline reference (image digest, release ID, etc.).</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>CVE findings from the baseline.</returns>
|
||||
Task<IReadOnlyList<CveFinding>> GetBaselineCvesAsync(
|
||||
string baselineReference,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CveGateHelpers.cs
|
||||
// Sprint: SPRINT_20260118_027_Policy_cve_release_gates
|
||||
// Task: TASK-027-01 - Gate Infrastructure Extensions
|
||||
// Description: Helper classes and extension methods for CVE gates
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy.Gates.Cve;
|
||||
|
||||
/// <summary>
|
||||
/// Static helper methods for creating GateResult instances.
|
||||
/// Simplifies gate implementation with consistent result creation.
|
||||
/// </summary>
|
||||
public static class GateResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a passing gate result.
|
||||
/// </summary>
|
||||
public static Gates.GateResult Pass(string gateName, string reason, IEnumerable<string>? warnings = null)
|
||||
{
|
||||
var details = ImmutableDictionary<string, object>.Empty;
|
||||
if (warnings != null)
|
||||
{
|
||||
var warningList = warnings.ToList();
|
||||
if (warningList.Count > 0)
|
||||
{
|
||||
details = details.Add("warnings", warningList);
|
||||
}
|
||||
}
|
||||
|
||||
return new Gates.GateResult
|
||||
{
|
||||
GateName = gateName,
|
||||
Passed = true,
|
||||
Reason = reason,
|
||||
Details = details
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failing gate result.
|
||||
/// </summary>
|
||||
public static Gates.GateResult Fail(string gateName, string reason, ImmutableDictionary<string, object>? details = null)
|
||||
{
|
||||
return new Gates.GateResult
|
||||
{
|
||||
GateName = gateName,
|
||||
Passed = false,
|
||||
Reason = reason,
|
||||
Details = details ?? ImmutableDictionary<string, object>.Empty
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failing gate result with simple details.
|
||||
/// </summary>
|
||||
public static Gates.GateResult Fail(string gateName, string reason, IDictionary<string, object>? details)
|
||||
{
|
||||
return new Gates.GateResult
|
||||
{
|
||||
GateName = gateName,
|
||||
Passed = false,
|
||||
Reason = reason,
|
||||
Details = details?.ToImmutableDictionary() ?? ImmutableDictionary<string, object>.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for PolicyGateContext to support CVE gates.
|
||||
/// </summary>
|
||||
public static class PolicyGateContextExtensions
|
||||
{
|
||||
private const string CveFindingsKey = "CveFindings";
|
||||
private const string BaselineCvesKey = "BaselineCves";
|
||||
private const string BaselineReferenceKey = "BaselineReference";
|
||||
private const string CveFirstSeenDatesKey = "CveFirstSeenDates";
|
||||
|
||||
/// <summary>
|
||||
/// Gets CVE findings from the context.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<CveFinding>? GetCveFindings(this PolicyGateContext context)
|
||||
{
|
||||
if (context.Metadata?.TryGetValue(CveFindingsKey, out var findings) == true)
|
||||
{
|
||||
// If stored as JSON string, deserialize
|
||||
if (findings is string json)
|
||||
{
|
||||
return System.Text.Json.JsonSerializer.Deserialize<List<CveFinding>>(json);
|
||||
}
|
||||
}
|
||||
|
||||
// Check extension properties
|
||||
if (context is ExtendedPolicyGateContext extended)
|
||||
{
|
||||
return extended.CveFindings;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets baseline CVEs from the context.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<CveFinding>? GetBaselineCves(this PolicyGateContext context)
|
||||
{
|
||||
if (context is ExtendedPolicyGateContext extended)
|
||||
{
|
||||
return extended.BaselineCves;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets baseline reference from the context.
|
||||
/// </summary>
|
||||
public static string? GetBaselineReference(this PolicyGateContext context)
|
||||
{
|
||||
if (context is ExtendedPolicyGateContext extended)
|
||||
{
|
||||
return extended.BaselineReference;
|
||||
}
|
||||
|
||||
return context.Metadata?.TryGetValue(BaselineReferenceKey, out var reference) == true
|
||||
? reference
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets CVE first seen dates from the context.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, DateTimeOffset>? GetCveFirstSeenDates(this PolicyGateContext context)
|
||||
{
|
||||
if (context is ExtendedPolicyGateContext extended)
|
||||
{
|
||||
return extended.CveFirstSeenDates;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extended PolicyGateContext with CVE-specific properties.
|
||||
/// </summary>
|
||||
public sealed record ExtendedPolicyGateContext : PolicyGateContext
|
||||
{
|
||||
/// <summary>
|
||||
/// CVE findings for the current release.
|
||||
/// </summary>
|
||||
public IReadOnlyList<CveFinding>? CveFindings { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVE findings from the baseline release.
|
||||
/// </summary>
|
||||
public IReadOnlyList<CveFinding>? BaselineCves { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Baseline reference (image digest, release ID, etc.).
|
||||
/// </summary>
|
||||
public string? BaselineReference { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Map of CVE ID to first seen date for SLA tracking.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, DateTimeOffset>? CveFirstSeenDates { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IPolicyGate interface for CVE gates.
|
||||
/// Simplified interface without MergeResult for CVE-specific gates.
|
||||
/// </summary>
|
||||
public interface IPolicyGate
|
||||
{
|
||||
/// <summary>
|
||||
/// Gate identifier.
|
||||
/// </summary>
|
||||
string Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Display name for the gate.
|
||||
/// </summary>
|
||||
string DisplayName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Description of what the gate checks.
|
||||
/// </summary>
|
||||
string Description { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates the gate against the given context.
|
||||
/// </summary>
|
||||
Task<Gates.GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CveGatesServiceCollectionExtensions.cs
|
||||
// Sprint: SPRINT_20260118_027_Policy_cve_release_gates
|
||||
// Task: TASK-027-07 - Gate Registration and Documentation
|
||||
// Description: DI registration for CVE policy gates
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace StellaOps.Policy.Gates.Cve;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering CVE gates in the DI container.
|
||||
/// </summary>
|
||||
public static class CveGatesServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds all CVE policy gates to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configuration">Optional configuration for gate options.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddCvePolicyGates(
|
||||
this IServiceCollection services,
|
||||
IConfiguration? configuration = null)
|
||||
{
|
||||
// Register EPSS threshold gate
|
||||
services.AddEpssThresholdGate(configuration);
|
||||
|
||||
// Register KEV blocker gate
|
||||
services.AddKevBlockerGate(configuration);
|
||||
|
||||
// Register reachable CVE gate
|
||||
services.AddReachableCveGate(configuration);
|
||||
|
||||
// Register CVE delta gate
|
||||
services.AddCveDeltaGate(configuration);
|
||||
|
||||
// Register release aggregate CVE gate
|
||||
services.AddReleaseAggregateCveGate(configuration);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the EPSS threshold gate.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddEpssThresholdGate(
|
||||
this IServiceCollection services,
|
||||
IConfiguration? configuration = null)
|
||||
{
|
||||
if (configuration != null)
|
||||
{
|
||||
services.Configure<EpssThresholdGateOptions>(
|
||||
configuration.GetSection(EpssThresholdGateOptions.SectionName));
|
||||
}
|
||||
|
||||
services.AddSingleton<EpssThresholdGate>(sp =>
|
||||
{
|
||||
var options = configuration?.GetSection(EpssThresholdGateOptions.SectionName)
|
||||
.Get<EpssThresholdGateOptions>();
|
||||
var epssProvider = sp.GetService<IEpssDataProvider>();
|
||||
|
||||
return new EpssThresholdGate(
|
||||
epssProvider ?? new NullEpssDataProvider(),
|
||||
options);
|
||||
});
|
||||
|
||||
services.AddSingleton<IPolicyGate>(sp => sp.GetRequiredService<EpssThresholdGate>());
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the KEV blocker gate.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddKevBlockerGate(
|
||||
this IServiceCollection services,
|
||||
IConfiguration? configuration = null)
|
||||
{
|
||||
if (configuration != null)
|
||||
{
|
||||
services.Configure<KevBlockerGateOptions>(
|
||||
configuration.GetSection(KevBlockerGateOptions.SectionName));
|
||||
}
|
||||
|
||||
services.AddSingleton<KevBlockerGate>(sp =>
|
||||
{
|
||||
var options = configuration?.GetSection(KevBlockerGateOptions.SectionName)
|
||||
.Get<KevBlockerGateOptions>();
|
||||
var kevProvider = sp.GetService<IKevDataProvider>();
|
||||
|
||||
return new KevBlockerGate(
|
||||
kevProvider ?? new NullKevDataProvider(),
|
||||
options);
|
||||
});
|
||||
|
||||
services.AddSingleton<IPolicyGate>(sp => sp.GetRequiredService<KevBlockerGate>());
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the reachable CVE gate.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddReachableCveGate(
|
||||
this IServiceCollection services,
|
||||
IConfiguration? configuration = null)
|
||||
{
|
||||
if (configuration != null)
|
||||
{
|
||||
services.Configure<ReachableCveGateOptions>(
|
||||
configuration.GetSection(ReachableCveGateOptions.SectionName));
|
||||
}
|
||||
|
||||
services.AddSingleton<ReachableCveGate>(sp =>
|
||||
{
|
||||
var options = configuration?.GetSection(ReachableCveGateOptions.SectionName)
|
||||
.Get<ReachableCveGateOptions>();
|
||||
|
||||
return new ReachableCveGate(options);
|
||||
});
|
||||
|
||||
services.AddSingleton<IPolicyGate>(sp => sp.GetRequiredService<ReachableCveGate>());
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the CVE delta gate.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddCveDeltaGate(
|
||||
this IServiceCollection services,
|
||||
IConfiguration? configuration = null)
|
||||
{
|
||||
if (configuration != null)
|
||||
{
|
||||
services.Configure<CveDeltaGateOptions>(
|
||||
configuration.GetSection(CveDeltaGateOptions.SectionName));
|
||||
}
|
||||
|
||||
services.AddSingleton<CveDeltaGate>(sp =>
|
||||
{
|
||||
var options = configuration?.GetSection(CveDeltaGateOptions.SectionName)
|
||||
.Get<CveDeltaGateOptions>();
|
||||
var deltaProvider = sp.GetService<ICveDeltaProvider>();
|
||||
|
||||
return new CveDeltaGate(options, deltaProvider);
|
||||
});
|
||||
|
||||
services.AddSingleton<IPolicyGate>(sp => sp.GetRequiredService<CveDeltaGate>());
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the release aggregate CVE gate.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddReleaseAggregateCveGate(
|
||||
this IServiceCollection services,
|
||||
IConfiguration? configuration = null)
|
||||
{
|
||||
if (configuration != null)
|
||||
{
|
||||
services.Configure<ReleaseAggregateCveGateOptions>(
|
||||
configuration.GetSection(ReleaseAggregateCveGateOptions.SectionName));
|
||||
}
|
||||
|
||||
services.AddSingleton<ReleaseAggregateCveGate>(sp =>
|
||||
{
|
||||
var options = configuration?.GetSection(ReleaseAggregateCveGateOptions.SectionName)
|
||||
.Get<ReleaseAggregateCveGateOptions>();
|
||||
|
||||
return new ReleaseAggregateCveGate(options);
|
||||
});
|
||||
|
||||
services.AddSingleton<IPolicyGate>(sp => sp.GetRequiredService<ReleaseAggregateCveGate>());
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null EPSS data provider for when no provider is configured.
|
||||
/// </summary>
|
||||
internal sealed class NullEpssDataProvider : IEpssDataProvider
|
||||
{
|
||||
public Task<EpssScore?> GetScoreAsync(string cveId, CancellationToken ct = default)
|
||||
=> Task.FromResult<EpssScore?>(null);
|
||||
|
||||
public Task<IReadOnlyDictionary<string, EpssScore>> GetScoresBatchAsync(
|
||||
IEnumerable<string> cveIds,
|
||||
CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyDictionary<string, EpssScore>>(
|
||||
new Dictionary<string, EpssScore>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null KEV data provider for when no provider is configured.
|
||||
/// </summary>
|
||||
internal sealed class NullKevDataProvider : IKevDataProvider
|
||||
{
|
||||
public Task<KevEntry?> GetKevEntryAsync(string cveId, CancellationToken ct = default)
|
||||
=> Task.FromResult<KevEntry?>(null);
|
||||
|
||||
public Task<IReadOnlyDictionary<string, KevEntry>> GetKevEntriesBatchAsync(
|
||||
IEnumerable<string> cveIds,
|
||||
CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyDictionary<string, KevEntry>>(
|
||||
new Dictionary<string, KevEntry>());
|
||||
|
||||
public Task<bool> IsKevAsync(string cveId, CancellationToken ct = default)
|
||||
=> Task.FromResult(false);
|
||||
|
||||
public Task<DateTimeOffset?> GetCatalogUpdateTimeAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult<DateTimeOffset?>(null);
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// EpssThresholdGate.cs
|
||||
// Sprint: SPRINT_20260118_027_Policy_cve_release_gates
|
||||
// Task: TASK-027-02 - EPSS Threshold Gate
|
||||
// Description: Policy gate that blocks releases based on EPSS exploitation probability
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Policy.Gates.Cve;
|
||||
|
||||
/// <summary>
|
||||
/// Policy gate that blocks releases based on EPSS exploitation probability.
|
||||
/// EPSS + reachability enables accurate risk-based gating.
|
||||
/// </summary>
|
||||
public sealed class EpssThresholdGate : IPolicyGate
|
||||
{
|
||||
/// <summary>
|
||||
/// Gate identifier.
|
||||
/// </summary>
|
||||
public const string GateId = "epss-threshold";
|
||||
|
||||
private readonly IEpssDataProvider _epssProvider;
|
||||
private readonly EpssThresholdGateOptions _options;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Id => GateId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DisplayName => "EPSS Threshold";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Blocks releases based on EPSS exploitation probability thresholds";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new EPSS threshold gate.
|
||||
/// </summary>
|
||||
public EpssThresholdGate(
|
||||
IEpssDataProvider epssProvider,
|
||||
EpssThresholdGateOptions? options = null)
|
||||
{
|
||||
_epssProvider = epssProvider ?? throw new ArgumentNullException(nameof(epssProvider));
|
||||
_options = options ?? new EpssThresholdGateOptions();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
return GateResult.Pass(Id, "EPSS threshold gate disabled");
|
||||
}
|
||||
|
||||
var envOptions = GetEnvironmentOptions(context.Environment);
|
||||
var cves = context.GetCveFindings();
|
||||
|
||||
if (cves == null || cves.Count == 0)
|
||||
{
|
||||
return GateResult.Pass(Id, "No CVE findings to evaluate");
|
||||
}
|
||||
|
||||
// Batch fetch EPSS scores
|
||||
var cveIds = cves.Select(c => c.CveId).Distinct().ToList();
|
||||
var epssScores = await _epssProvider.GetScoresBatchAsync(cveIds, ct);
|
||||
|
||||
var violations = new List<EpssViolation>();
|
||||
var warnings = new List<string>();
|
||||
|
||||
foreach (var cve in cves)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
// Skip if reachability-aware and not reachable
|
||||
if (envOptions.OnlyReachable && !cve.IsReachable)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!epssScores.TryGetValue(cve.CveId, out var score))
|
||||
{
|
||||
// Handle missing EPSS
|
||||
HandleMissingEpss(cve, envOptions, warnings, violations);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check percentile threshold
|
||||
if (envOptions.PercentileThreshold.HasValue &&
|
||||
score.Percentile >= envOptions.PercentileThreshold.Value)
|
||||
{
|
||||
violations.Add(new EpssViolation
|
||||
{
|
||||
CveId = cve.CveId,
|
||||
Score = score.Score,
|
||||
Percentile = score.Percentile,
|
||||
Threshold = $"percentile >= {envOptions.PercentileThreshold.Value:P0}",
|
||||
IsReachable = cve.IsReachable
|
||||
});
|
||||
}
|
||||
// Check score threshold
|
||||
else if (envOptions.ScoreThreshold.HasValue &&
|
||||
score.Score >= envOptions.ScoreThreshold.Value)
|
||||
{
|
||||
violations.Add(new EpssViolation
|
||||
{
|
||||
CveId = cve.CveId,
|
||||
Score = score.Score,
|
||||
Percentile = score.Percentile,
|
||||
Threshold = $"score >= {envOptions.ScoreThreshold.Value:F2}",
|
||||
IsReachable = cve.IsReachable
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.Count > 0)
|
||||
{
|
||||
var message = $"EPSS threshold exceeded for {violations.Count} CVE(s): " +
|
||||
string.Join(", ", violations.Take(5).Select(v =>
|
||||
$"{v.CveId} (EPSS: {v.Score:F3}, {v.Percentile:P0})"));
|
||||
|
||||
if (violations.Count > 5)
|
||||
{
|
||||
message += $" and {violations.Count - 5} more";
|
||||
}
|
||||
|
||||
return GateResult.Fail(Id, message);
|
||||
}
|
||||
|
||||
var passMessage = $"EPSS check passed for {cves.Count} CVE(s)";
|
||||
if (warnings.Count > 0)
|
||||
{
|
||||
passMessage += $" ({warnings.Count} warnings)";
|
||||
}
|
||||
|
||||
return GateResult.Pass(Id, passMessage, warnings: warnings);
|
||||
}
|
||||
|
||||
private void HandleMissingEpss(
|
||||
CveFinding cve,
|
||||
EpssThresholdGateOptions options,
|
||||
List<string> warnings,
|
||||
List<EpssViolation> violations)
|
||||
{
|
||||
switch (options.MissingEpssAction)
|
||||
{
|
||||
case MissingEpssAction.Allow:
|
||||
// Silently allow
|
||||
break;
|
||||
|
||||
case MissingEpssAction.Warn:
|
||||
warnings.Add($"{cve.CveId}: no EPSS score available");
|
||||
break;
|
||||
|
||||
case MissingEpssAction.Fail:
|
||||
violations.Add(new EpssViolation
|
||||
{
|
||||
CveId = cve.CveId,
|
||||
Score = 0,
|
||||
Percentile = 0,
|
||||
Threshold = "EPSS score required but missing",
|
||||
IsReachable = cve.IsReachable
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private EpssThresholdGateOptions GetEnvironmentOptions(string? environment)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(environment))
|
||||
return _options;
|
||||
|
||||
if (_options.Environments.TryGetValue(environment, out var envOverride))
|
||||
{
|
||||
return _options with
|
||||
{
|
||||
Enabled = envOverride.Enabled ?? _options.Enabled,
|
||||
PercentileThreshold = envOverride.PercentileThreshold ?? _options.PercentileThreshold,
|
||||
ScoreThreshold = envOverride.ScoreThreshold ?? _options.ScoreThreshold,
|
||||
OnlyReachable = envOverride.OnlyReachable ?? _options.OnlyReachable,
|
||||
MissingEpssAction = envOverride.MissingEpssAction ?? _options.MissingEpssAction
|
||||
};
|
||||
}
|
||||
|
||||
return _options;
|
||||
}
|
||||
|
||||
private sealed record EpssViolation
|
||||
{
|
||||
public required string CveId { get; init; }
|
||||
public double Score { get; init; }
|
||||
public double Percentile { get; init; }
|
||||
public required string Threshold { get; init; }
|
||||
public bool IsReachable { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for EPSS threshold gate.
|
||||
/// </summary>
|
||||
public sealed record EpssThresholdGateOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Policy:Gates:EpssThreshold";
|
||||
|
||||
/// <summary>Whether the gate is enabled.</summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Percentile threshold (0.0-1.0). CVEs at or above this percentile are blocked.
|
||||
/// Example: 0.75 = block top 25% of exploitable CVEs.
|
||||
/// </summary>
|
||||
public double? PercentileThreshold { get; init; } = 0.75;
|
||||
|
||||
/// <summary>
|
||||
/// Score threshold (0.0-1.0). CVEs at or above this score are blocked.
|
||||
/// Alternative to percentile threshold.
|
||||
/// </summary>
|
||||
public double? ScoreThreshold { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to only apply to reachable CVEs.
|
||||
/// If true, unreachable CVEs are ignored regardless of EPSS score.
|
||||
/// </summary>
|
||||
public bool OnlyReachable { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Action when EPSS score is missing for a CVE.
|
||||
/// </summary>
|
||||
public MissingEpssAction MissingEpssAction { get; init; } = MissingEpssAction.Warn;
|
||||
|
||||
/// <summary>
|
||||
/// Per-environment configuration overrides.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, EpssThresholdGateEnvironmentOverride> Environments { get; init; }
|
||||
= new Dictionary<string, EpssThresholdGateEnvironmentOverride>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-environment overrides.
|
||||
/// </summary>
|
||||
public sealed record EpssThresholdGateEnvironmentOverride
|
||||
{
|
||||
/// <summary>Override for Enabled.</summary>
|
||||
public bool? Enabled { get; init; }
|
||||
|
||||
/// <summary>Override for PercentileThreshold.</summary>
|
||||
public double? PercentileThreshold { get; init; }
|
||||
|
||||
/// <summary>Override for ScoreThreshold.</summary>
|
||||
public double? ScoreThreshold { get; init; }
|
||||
|
||||
/// <summary>Override for OnlyReachable.</summary>
|
||||
public bool? OnlyReachable { get; init; }
|
||||
|
||||
/// <summary>Override for MissingEpssAction.</summary>
|
||||
public MissingEpssAction? MissingEpssAction { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Action when EPSS score is missing.
|
||||
/// </summary>
|
||||
public enum MissingEpssAction
|
||||
{
|
||||
/// <summary>Allow the CVE to pass.</summary>
|
||||
Allow,
|
||||
|
||||
/// <summary>Pass but log a warning.</summary>
|
||||
Warn,
|
||||
|
||||
/// <summary>Fail the gate.</summary>
|
||||
Fail
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provider for EPSS data.
|
||||
/// </summary>
|
||||
public interface IEpssDataProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets EPSS score for a single CVE.
|
||||
/// </summary>
|
||||
Task<EpssScore?> GetScoreAsync(string cveId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets EPSS scores for multiple CVEs.
|
||||
/// </summary>
|
||||
Task<IReadOnlyDictionary<string, EpssScore>> GetScoresBatchAsync(
|
||||
IEnumerable<string> cveIds,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EPSS score data.
|
||||
/// </summary>
|
||||
public sealed record EpssScore
|
||||
{
|
||||
/// <summary>CVE identifier.</summary>
|
||||
public required string CveId { get; init; }
|
||||
|
||||
/// <summary>EPSS score (0.0-1.0).</summary>
|
||||
public required double Score { get; init; }
|
||||
|
||||
/// <summary>EPSS percentile (0.0-1.0).</summary>
|
||||
public required double Percentile { get; init; }
|
||||
|
||||
/// <summary>Score date.</summary>
|
||||
public DateTimeOffset ScoreDate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CVE finding for gate evaluation.
|
||||
/// </summary>
|
||||
public record CveFinding
|
||||
{
|
||||
/// <summary>CVE identifier.</summary>
|
||||
public required string CveId { get; init; }
|
||||
|
||||
/// <summary>Whether the CVE is reachable.</summary>
|
||||
public bool IsReachable { get; init; }
|
||||
|
||||
/// <summary>CVSS score.</summary>
|
||||
public double? CvssScore { get; init; }
|
||||
|
||||
/// <summary>Affected component PURL.</summary>
|
||||
public string? ComponentPurl { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// KevBlockerGate.cs
|
||||
// Sprint: SPRINT_20260118_027_Policy_cve_release_gates
|
||||
// Task: TASK-027-03 - KEV Blocker Gate
|
||||
// Description: Policy gate that blocks releases containing CISA KEV CVEs
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Policy.Gates.Cve;
|
||||
|
||||
/// <summary>
|
||||
/// Policy gate that blocks releases containing CVEs in the CISA Known Exploited
|
||||
/// Vulnerabilities (KEV) catalog. KEV entries are actively exploited in the wild.
|
||||
/// </summary>
|
||||
public sealed class KevBlockerGate : IPolicyGate
|
||||
{
|
||||
/// <summary>
|
||||
/// Gate identifier.
|
||||
/// </summary>
|
||||
public const string GateId = "kev-blocker";
|
||||
|
||||
private readonly IKevDataProvider _kevProvider;
|
||||
private readonly KevBlockerGateOptions _options;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Id => GateId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DisplayName => "KEV Blocker";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Blocks releases containing CVEs in the CISA Known Exploited Vulnerabilities catalog";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new KEV blocker gate.
|
||||
/// </summary>
|
||||
public KevBlockerGate(
|
||||
IKevDataProvider kevProvider,
|
||||
KevBlockerGateOptions? options = null)
|
||||
{
|
||||
_kevProvider = kevProvider ?? throw new ArgumentNullException(nameof(kevProvider));
|
||||
_options = options ?? new KevBlockerGateOptions();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
return GateResult.Pass(Id, "KEV blocker gate disabled");
|
||||
}
|
||||
|
||||
var envOptions = GetEnvironmentOptions(context.Environment);
|
||||
var cves = context.GetCveFindings();
|
||||
|
||||
if (cves == null || cves.Count == 0)
|
||||
{
|
||||
return GateResult.Pass(Id, "No CVE findings to evaluate");
|
||||
}
|
||||
|
||||
// Batch check KEV membership
|
||||
var cveIds = cves.Select(c => c.CveId).Distinct().ToList();
|
||||
var kevEntries = await _kevProvider.GetKevEntriesBatchAsync(cveIds, ct);
|
||||
|
||||
var violations = new List<KevViolation>();
|
||||
|
||||
foreach (var cve in cves)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
// Skip if reachability-aware and not reachable
|
||||
if (envOptions.OnlyReachable && !cve.IsReachable)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (kevEntries.TryGetValue(cve.CveId, out var kevEntry))
|
||||
{
|
||||
// Check if past due date
|
||||
var isPastDue = kevEntry.DueDate.HasValue &&
|
||||
kevEntry.DueDate.Value < DateTimeOffset.UtcNow;
|
||||
|
||||
// Check severity filter
|
||||
if (envOptions.MinimumSeverity.HasValue &&
|
||||
cve.CvssScore.HasValue &&
|
||||
cve.CvssScore.Value < envOptions.MinimumSeverity.Value)
|
||||
{
|
||||
// Below minimum severity, skip
|
||||
continue;
|
||||
}
|
||||
|
||||
violations.Add(new KevViolation
|
||||
{
|
||||
CveId = cve.CveId,
|
||||
VendorProject = kevEntry.VendorProject,
|
||||
Product = kevEntry.Product,
|
||||
VulnerabilityName = kevEntry.VulnerabilityName,
|
||||
DueDate = kevEntry.DueDate,
|
||||
IsPastDue = isPastDue,
|
||||
IsReachable = cve.IsReachable
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.Count > 0)
|
||||
{
|
||||
var pastDueCount = violations.Count(v => v.IsPastDue);
|
||||
var message = $"Found {violations.Count} CVE(s) in CISA KEV catalog";
|
||||
|
||||
if (pastDueCount > 0)
|
||||
{
|
||||
message += $" ({pastDueCount} past remediation due date)";
|
||||
}
|
||||
|
||||
message += ": " + string.Join(", ", violations.Take(5).Select(v =>
|
||||
$"{v.CveId} ({v.VendorProject}/{v.Product})"));
|
||||
|
||||
if (violations.Count > 5)
|
||||
{
|
||||
message += $" and {violations.Count - 5} more";
|
||||
}
|
||||
|
||||
return GateResult.Fail(Id, message);
|
||||
}
|
||||
|
||||
return GateResult.Pass(Id, $"No KEV entries found among {cves.Count} CVE(s)");
|
||||
}
|
||||
|
||||
private KevBlockerGateOptions GetEnvironmentOptions(string? environment)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(environment))
|
||||
return _options;
|
||||
|
||||
if (_options.Environments.TryGetValue(environment, out var envOverride))
|
||||
{
|
||||
return _options with
|
||||
{
|
||||
Enabled = envOverride.Enabled ?? _options.Enabled,
|
||||
OnlyReachable = envOverride.OnlyReachable ?? _options.OnlyReachable,
|
||||
MinimumSeverity = envOverride.MinimumSeverity ?? _options.MinimumSeverity,
|
||||
BlockPastDueOnly = envOverride.BlockPastDueOnly ?? _options.BlockPastDueOnly
|
||||
};
|
||||
}
|
||||
|
||||
return _options;
|
||||
}
|
||||
|
||||
private sealed record KevViolation
|
||||
{
|
||||
public required string CveId { get; init; }
|
||||
public string? VendorProject { get; init; }
|
||||
public string? Product { get; init; }
|
||||
public string? VulnerabilityName { get; init; }
|
||||
public DateTimeOffset? DueDate { get; init; }
|
||||
public bool IsPastDue { get; init; }
|
||||
public bool IsReachable { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for KEV blocker gate.
|
||||
/// </summary>
|
||||
public sealed record KevBlockerGateOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Policy:Gates:KevBlocker";
|
||||
|
||||
/// <summary>Whether the gate is enabled.</summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to only apply to reachable CVEs.
|
||||
/// </summary>
|
||||
public bool OnlyReachable { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum CVSS severity to block.
|
||||
/// Set to 0 to block all KEV CVEs.
|
||||
/// </summary>
|
||||
public double? MinimumSeverity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to only block KEV entries past their due date.
|
||||
/// If true, upcoming KEV entries are allowed.
|
||||
/// </summary>
|
||||
public bool BlockPastDueOnly { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Per-environment configuration overrides.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, KevBlockerGateEnvironmentOverride> Environments { get; init; }
|
||||
= new Dictionary<string, KevBlockerGateEnvironmentOverride>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-environment overrides.
|
||||
/// </summary>
|
||||
public sealed record KevBlockerGateEnvironmentOverride
|
||||
{
|
||||
/// <summary>Override for Enabled.</summary>
|
||||
public bool? Enabled { get; init; }
|
||||
|
||||
/// <summary>Override for OnlyReachable.</summary>
|
||||
public bool? OnlyReachable { get; init; }
|
||||
|
||||
/// <summary>Override for MinimumSeverity.</summary>
|
||||
public double? MinimumSeverity { get; init; }
|
||||
|
||||
/// <summary>Override for BlockPastDueOnly.</summary>
|
||||
public bool? BlockPastDueOnly { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provider for KEV data.
|
||||
/// </summary>
|
||||
public interface IKevDataProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks if a CVE is in the KEV catalog.
|
||||
/// </summary>
|
||||
Task<KevEntry?> GetKevEntryAsync(string cveId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Batch check for KEV membership.
|
||||
/// </summary>
|
||||
Task<IReadOnlyDictionary<string, KevEntry>> GetKevEntriesBatchAsync(
|
||||
IEnumerable<string> cveIds,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the last catalog update timestamp.
|
||||
/// </summary>
|
||||
Task<DateTimeOffset?> GetCatalogUpdateTimeAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// KEV catalog entry.
|
||||
/// </summary>
|
||||
public sealed record KevEntry
|
||||
{
|
||||
/// <summary>CVE identifier.</summary>
|
||||
public required string CveId { get; init; }
|
||||
|
||||
/// <summary>Vendor/project name.</summary>
|
||||
public string? VendorProject { get; init; }
|
||||
|
||||
/// <summary>Product name.</summary>
|
||||
public string? Product { get; init; }
|
||||
|
||||
/// <summary>Vulnerability name.</summary>
|
||||
public string? VulnerabilityName { get; init; }
|
||||
|
||||
/// <summary>Date added to KEV catalog.</summary>
|
||||
public DateTimeOffset DateAdded { get; init; }
|
||||
|
||||
/// <summary>Short description.</summary>
|
||||
public string? ShortDescription { get; init; }
|
||||
|
||||
/// <summary>Required action.</summary>
|
||||
public string? RequiredAction { get; init; }
|
||||
|
||||
/// <summary>Remediation due date.</summary>
|
||||
public DateTimeOffset? DueDate { get; init; }
|
||||
|
||||
/// <summary>Notes.</summary>
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ReachableCveGate.cs
|
||||
// Sprint: SPRINT_20260118_027_Policy_cve_release_gates
|
||||
// Task: TASK-027-04 - Reachable CVE Gate
|
||||
// Description: Policy gate that blocks only reachable CVEs, reducing noise
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Policy.Gates.Cve;
|
||||
|
||||
/// <summary>
|
||||
/// Policy gate that only blocks CVEs that are confirmed reachable in the application.
|
||||
/// Reduces false positives by ignoring unreachable vulnerable code.
|
||||
/// </summary>
|
||||
public sealed class ReachableCveGate : IPolicyGate
|
||||
{
|
||||
/// <summary>
|
||||
/// Gate identifier.
|
||||
/// </summary>
|
||||
public const string GateId = "reachable-cve";
|
||||
|
||||
private readonly ReachableCveGateOptions _options;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Id => GateId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DisplayName => "Reachable CVE";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Blocks releases containing reachable CVEs above severity threshold";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new reachable CVE gate.
|
||||
/// </summary>
|
||||
public ReachableCveGate(ReachableCveGateOptions? options = null)
|
||||
{
|
||||
_options = options ?? new ReachableCveGateOptions();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
return Task.FromResult(GateResult.Pass(Id, "Reachable CVE gate disabled"));
|
||||
}
|
||||
|
||||
var envOptions = GetEnvironmentOptions(context.Environment);
|
||||
var cves = context.GetCveFindings();
|
||||
|
||||
if (cves == null || cves.Count == 0)
|
||||
{
|
||||
return Task.FromResult(GateResult.Pass(Id, "No CVE findings to evaluate"));
|
||||
}
|
||||
|
||||
var reachableCves = new List<CveFinding>();
|
||||
var unknownReachability = new List<CveFinding>();
|
||||
var unreachableCves = new List<CveFinding>();
|
||||
|
||||
foreach (var cve in cves)
|
||||
{
|
||||
// Classify by reachability
|
||||
if (cve.IsReachable)
|
||||
{
|
||||
reachableCves.Add(cve);
|
||||
}
|
||||
else if (cve.ReachabilityStatus == ReachabilityStatus.Unknown)
|
||||
{
|
||||
unknownReachability.Add(cve);
|
||||
}
|
||||
else
|
||||
{
|
||||
unreachableCves.Add(cve);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by severity threshold
|
||||
var blocking = reachableCves
|
||||
.Where(c => c.CvssScore.HasValue && c.CvssScore.Value >= envOptions.MinimumSeverity)
|
||||
.ToList();
|
||||
|
||||
// Handle unknown reachability
|
||||
if (envOptions.TreatUnknownAsReachable)
|
||||
{
|
||||
var unknownBlocking = unknownReachability
|
||||
.Where(c => c.CvssScore.HasValue && c.CvssScore.Value >= envOptions.MinimumSeverity)
|
||||
.ToList();
|
||||
blocking.AddRange(unknownBlocking);
|
||||
}
|
||||
|
||||
var warnings = new List<string>();
|
||||
|
||||
// Warn about unknown reachability if not treating as reachable
|
||||
if (!envOptions.TreatUnknownAsReachable && unknownReachability.Count > 0)
|
||||
{
|
||||
warnings.Add($"{unknownReachability.Count} CVE(s) have unknown reachability status");
|
||||
}
|
||||
|
||||
if (blocking.Count > 0)
|
||||
{
|
||||
var message = $"Found {blocking.Count} reachable CVE(s) at or above severity {envOptions.MinimumSeverity}: " +
|
||||
string.Join(", ", blocking.Take(5).Select(c =>
|
||||
$"{c.CveId} (CVSS: {c.CvssScore:F1})"));
|
||||
|
||||
if (blocking.Count > 5)
|
||||
{
|
||||
message += $" and {blocking.Count - 5} more";
|
||||
}
|
||||
|
||||
return Task.FromResult(GateResult.Fail(Id, message));
|
||||
}
|
||||
|
||||
var passMessage = $"No blocking reachable CVEs. " +
|
||||
$"Reachable: {reachableCves.Count}, " +
|
||||
$"Unreachable: {unreachableCves.Count}, " +
|
||||
$"Unknown: {unknownReachability.Count}";
|
||||
|
||||
return Task.FromResult(GateResult.Pass(Id, passMessage, warnings: warnings));
|
||||
}
|
||||
|
||||
private ReachableCveGateOptions GetEnvironmentOptions(string? environment)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(environment))
|
||||
return _options;
|
||||
|
||||
if (_options.Environments.TryGetValue(environment, out var envOverride))
|
||||
{
|
||||
return _options with
|
||||
{
|
||||
Enabled = envOverride.Enabled ?? _options.Enabled,
|
||||
MinimumSeverity = envOverride.MinimumSeverity ?? _options.MinimumSeverity,
|
||||
TreatUnknownAsReachable = envOverride.TreatUnknownAsReachable ?? _options.TreatUnknownAsReachable
|
||||
};
|
||||
}
|
||||
|
||||
return _options;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for reachable CVE gate.
|
||||
/// </summary>
|
||||
public sealed record ReachableCveGateOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Policy:Gates:ReachableCve";
|
||||
|
||||
/// <summary>Whether the gate is enabled.</summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum CVSS severity to block.
|
||||
/// Only reachable CVEs at or above this severity are blocked.
|
||||
/// Default: 7.0 (High).
|
||||
/// </summary>
|
||||
public double MinimumSeverity { get; init; } = 7.0;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to treat CVEs with unknown reachability as reachable.
|
||||
/// If true, unknown reachability is conservative (blocks).
|
||||
/// If false, unknown reachability is permissive (allows).
|
||||
/// Default: false (permissive).
|
||||
/// </summary>
|
||||
public bool TreatUnknownAsReachable { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Per-environment configuration overrides.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, ReachableCveGateEnvironmentOverride> Environments { get; init; }
|
||||
= new Dictionary<string, ReachableCveGateEnvironmentOverride>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-environment overrides.
|
||||
/// </summary>
|
||||
public sealed record ReachableCveGateEnvironmentOverride
|
||||
{
|
||||
/// <summary>Override for Enabled.</summary>
|
||||
public bool? Enabled { get; init; }
|
||||
|
||||
/// <summary>Override for MinimumSeverity.</summary>
|
||||
public double? MinimumSeverity { get; init; }
|
||||
|
||||
/// <summary>Override for TreatUnknownAsReachable.</summary>
|
||||
public bool? TreatUnknownAsReachable { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extended CVE finding with reachability status.
|
||||
/// </summary>
|
||||
public sealed record CveFindingWithReachability : CveFinding
|
||||
{
|
||||
/// <summary>Detailed reachability status.</summary>
|
||||
public ReachabilityStatus ReachabilityStatus { get; init; }
|
||||
|
||||
/// <summary>Reachability confidence score (0-1).</summary>
|
||||
public double? ReachabilityConfidence { get; init; }
|
||||
|
||||
/// <summary>Whether the CVE has been witnessed at runtime.</summary>
|
||||
public bool IsWitnessed { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reachability determination status.
|
||||
/// </summary>
|
||||
public enum ReachabilityStatus
|
||||
{
|
||||
/// <summary>Reachability not yet determined.</summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>Confirmed reachable via static analysis.</summary>
|
||||
ReachableStatic,
|
||||
|
||||
/// <summary>Confirmed reachable via runtime witness.</summary>
|
||||
ReachableWitnessed,
|
||||
|
||||
/// <summary>Confirmed not reachable.</summary>
|
||||
NotReachable,
|
||||
|
||||
/// <summary>Partially reachable (some paths blocked).</summary>
|
||||
PartiallyReachable
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ReleaseAggregateCveGate.cs
|
||||
// Sprint: SPRINT_20260118_027_Policy_cve_release_gates
|
||||
// Task: TASK-027-06 - Release Aggregate CVE Gate
|
||||
// Description: Policy gate that enforces aggregate CVE limits per release
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Policy.Gates.Cve;
|
||||
|
||||
/// <summary>
|
||||
/// Policy gate that enforces aggregate CVE limits per release.
|
||||
/// Unlike CvssThresholdGate which operates per-finding, this operates per-release.
|
||||
/// </summary>
|
||||
public sealed class ReleaseAggregateCveGate : IPolicyGate
|
||||
{
|
||||
/// <summary>
|
||||
/// Gate identifier.
|
||||
/// </summary>
|
||||
public const string GateId = "release-aggregate-cve";
|
||||
|
||||
private readonly ReleaseAggregateCveGateOptions _options;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Id => GateId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DisplayName => "Release Aggregate CVE";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Enforces aggregate CVE count limits per release by severity";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new release aggregate CVE gate.
|
||||
/// </summary>
|
||||
public ReleaseAggregateCveGate(ReleaseAggregateCveGateOptions? options = null)
|
||||
{
|
||||
_options = options ?? new ReleaseAggregateCveGateOptions();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
return Task.FromResult(GateResult.Pass(Id, "Release aggregate CVE gate disabled"));
|
||||
}
|
||||
|
||||
var envOptions = GetEnvironmentOptions(context.Environment);
|
||||
var cves = context.GetCveFindings();
|
||||
|
||||
if (cves == null || cves.Count == 0)
|
||||
{
|
||||
return Task.FromResult(GateResult.Pass(Id, "No CVE findings in release"));
|
||||
}
|
||||
|
||||
// Filter CVEs based on options
|
||||
var cvesToCount = FilterCves(cves, envOptions);
|
||||
|
||||
// Count by severity
|
||||
var counts = CountBySeverity(cvesToCount);
|
||||
|
||||
// Check limits
|
||||
var violations = CheckLimits(counts, envOptions);
|
||||
var warnings = new List<string>();
|
||||
|
||||
// Add warnings for near-limit counts
|
||||
AddNearLimitWarnings(counts, envOptions, warnings);
|
||||
|
||||
if (violations.Count > 0)
|
||||
{
|
||||
var message = "Release CVE aggregate limits exceeded: " +
|
||||
string.Join(", ", violations.Select(v =>
|
||||
$"{v.Severity}: {v.Count}/{v.Limit}"));
|
||||
|
||||
return Task.FromResult(GateResult.Fail(Id, message));
|
||||
}
|
||||
|
||||
var passMessage = $"Release CVE counts within limits. " +
|
||||
$"Critical: {counts.Critical}, High: {counts.High}, Medium: {counts.Medium}, Low: {counts.Low}";
|
||||
|
||||
return Task.FromResult(GateResult.Pass(Id, passMessage, warnings: warnings));
|
||||
}
|
||||
|
||||
private IReadOnlyList<CveFinding> FilterCves(
|
||||
IReadOnlyList<CveFinding> cves,
|
||||
ReleaseAggregateCveGateOptions options)
|
||||
{
|
||||
var filtered = cves.AsEnumerable();
|
||||
|
||||
// Filter by suppression status
|
||||
if (!options.CountSuppressed && cves is IReadOnlyList<CveFindingWithStatus> statusCves)
|
||||
{
|
||||
filtered = statusCves.Where(c => !c.IsSuppressed);
|
||||
}
|
||||
|
||||
// Filter by reachability
|
||||
if (options.OnlyCountReachable)
|
||||
{
|
||||
filtered = filtered.Where(c => c.IsReachable);
|
||||
}
|
||||
|
||||
return filtered.ToList();
|
||||
}
|
||||
|
||||
private static CveSeverityCounts CountBySeverity(IReadOnlyList<CveFinding> cves)
|
||||
{
|
||||
var critical = 0;
|
||||
var high = 0;
|
||||
var medium = 0;
|
||||
var low = 0;
|
||||
var unknown = 0;
|
||||
|
||||
foreach (var cve in cves)
|
||||
{
|
||||
var severity = ClassifySeverity(cve.CvssScore);
|
||||
switch (severity)
|
||||
{
|
||||
case CveSeverity.Critical:
|
||||
critical++;
|
||||
break;
|
||||
case CveSeverity.High:
|
||||
high++;
|
||||
break;
|
||||
case CveSeverity.Medium:
|
||||
medium++;
|
||||
break;
|
||||
case CveSeverity.Low:
|
||||
low++;
|
||||
break;
|
||||
default:
|
||||
unknown++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new CveSeverityCounts
|
||||
{
|
||||
Critical = critical,
|
||||
High = high,
|
||||
Medium = medium,
|
||||
Low = low,
|
||||
Unknown = unknown,
|
||||
Total = cves.Count
|
||||
};
|
||||
}
|
||||
|
||||
private static CveSeverity ClassifySeverity(double? cvssScore)
|
||||
{
|
||||
if (!cvssScore.HasValue)
|
||||
return CveSeverity.Unknown;
|
||||
|
||||
return cvssScore.Value switch
|
||||
{
|
||||
>= 9.0 => CveSeverity.Critical,
|
||||
>= 7.0 => CveSeverity.High,
|
||||
>= 4.0 => CveSeverity.Medium,
|
||||
>= 0.1 => CveSeverity.Low,
|
||||
_ => CveSeverity.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
private static List<LimitViolation> CheckLimits(
|
||||
CveSeverityCounts counts,
|
||||
ReleaseAggregateCveGateOptions options)
|
||||
{
|
||||
var violations = new List<LimitViolation>();
|
||||
|
||||
if (options.MaxCritical.HasValue && counts.Critical > options.MaxCritical.Value)
|
||||
{
|
||||
violations.Add(new LimitViolation
|
||||
{
|
||||
Severity = "Critical",
|
||||
Count = counts.Critical,
|
||||
Limit = options.MaxCritical.Value
|
||||
});
|
||||
}
|
||||
|
||||
if (options.MaxHigh.HasValue && counts.High > options.MaxHigh.Value)
|
||||
{
|
||||
violations.Add(new LimitViolation
|
||||
{
|
||||
Severity = "High",
|
||||
Count = counts.High,
|
||||
Limit = options.MaxHigh.Value
|
||||
});
|
||||
}
|
||||
|
||||
if (options.MaxMedium.HasValue && counts.Medium > options.MaxMedium.Value)
|
||||
{
|
||||
violations.Add(new LimitViolation
|
||||
{
|
||||
Severity = "Medium",
|
||||
Count = counts.Medium,
|
||||
Limit = options.MaxMedium.Value
|
||||
});
|
||||
}
|
||||
|
||||
if (options.MaxLow.HasValue && counts.Low > options.MaxLow.Value)
|
||||
{
|
||||
violations.Add(new LimitViolation
|
||||
{
|
||||
Severity = "Low",
|
||||
Count = counts.Low,
|
||||
Limit = options.MaxLow.Value
|
||||
});
|
||||
}
|
||||
|
||||
if (options.MaxTotal.HasValue && counts.Total > options.MaxTotal.Value)
|
||||
{
|
||||
violations.Add(new LimitViolation
|
||||
{
|
||||
Severity = "Total",
|
||||
Count = counts.Total,
|
||||
Limit = options.MaxTotal.Value
|
||||
});
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
private static void AddNearLimitWarnings(
|
||||
CveSeverityCounts counts,
|
||||
ReleaseAggregateCveGateOptions options,
|
||||
List<string> warnings)
|
||||
{
|
||||
const double WarningThreshold = 0.8; // Warn at 80% of limit
|
||||
|
||||
if (options.MaxCritical.HasValue && counts.Critical > 0)
|
||||
{
|
||||
var ratio = (double)counts.Critical / options.MaxCritical.Value;
|
||||
if (ratio >= WarningThreshold && ratio < 1.0)
|
||||
{
|
||||
warnings.Add($"Critical CVE count ({counts.Critical}) approaching limit ({options.MaxCritical.Value})");
|
||||
}
|
||||
}
|
||||
|
||||
if (options.MaxHigh.HasValue && counts.High > 0)
|
||||
{
|
||||
var ratio = (double)counts.High / options.MaxHigh.Value;
|
||||
if (ratio >= WarningThreshold && ratio < 1.0)
|
||||
{
|
||||
warnings.Add($"High CVE count ({counts.High}) approaching limit ({options.MaxHigh.Value})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ReleaseAggregateCveGateOptions GetEnvironmentOptions(string? environment)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(environment))
|
||||
return _options;
|
||||
|
||||
if (_options.Environments.TryGetValue(environment, out var envOverride))
|
||||
{
|
||||
return _options with
|
||||
{
|
||||
Enabled = envOverride.Enabled ?? _options.Enabled,
|
||||
MaxCritical = envOverride.MaxCritical ?? _options.MaxCritical,
|
||||
MaxHigh = envOverride.MaxHigh ?? _options.MaxHigh,
|
||||
MaxMedium = envOverride.MaxMedium ?? _options.MaxMedium,
|
||||
MaxLow = envOverride.MaxLow ?? _options.MaxLow,
|
||||
MaxTotal = envOverride.MaxTotal ?? _options.MaxTotal,
|
||||
CountSuppressed = envOverride.CountSuppressed ?? _options.CountSuppressed,
|
||||
OnlyCountReachable = envOverride.OnlyCountReachable ?? _options.OnlyCountReachable
|
||||
};
|
||||
}
|
||||
|
||||
return _options;
|
||||
}
|
||||
|
||||
private sealed record LimitViolation
|
||||
{
|
||||
public required string Severity { get; init; }
|
||||
public int Count { get; init; }
|
||||
public int Limit { get; init; }
|
||||
}
|
||||
|
||||
private sealed record CveSeverityCounts
|
||||
{
|
||||
public int Critical { get; init; }
|
||||
public int High { get; init; }
|
||||
public int Medium { get; init; }
|
||||
public int Low { get; init; }
|
||||
public int Unknown { get; init; }
|
||||
public int Total { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CVE severity classification.
|
||||
/// </summary>
|
||||
public enum CveSeverity
|
||||
{
|
||||
/// <summary>Unknown severity.</summary>
|
||||
Unknown,
|
||||
/// <summary>Low severity (CVSS 0.1-3.9).</summary>
|
||||
Low,
|
||||
/// <summary>Medium severity (CVSS 4.0-6.9).</summary>
|
||||
Medium,
|
||||
/// <summary>High severity (CVSS 7.0-8.9).</summary>
|
||||
High,
|
||||
/// <summary>Critical severity (CVSS 9.0-10.0).</summary>
|
||||
Critical
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for release aggregate CVE gate.
|
||||
/// </summary>
|
||||
public sealed record ReleaseAggregateCveGateOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Policy:Gates:ReleaseAggregateCve";
|
||||
|
||||
/// <summary>Whether the gate is enabled.</summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum allowed critical CVEs (CVSS 9.0+).
|
||||
/// Default: 0 (no critical CVEs allowed in production).
|
||||
/// </summary>
|
||||
public int? MaxCritical { get; init; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum allowed high CVEs (CVSS 7.0-8.9).
|
||||
/// Default: 3.
|
||||
/// </summary>
|
||||
public int? MaxHigh { get; init; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum allowed medium CVEs (CVSS 4.0-6.9).
|
||||
/// Default: 20.
|
||||
/// </summary>
|
||||
public int? MaxMedium { get; init; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum allowed low CVEs (CVSS 0.1-3.9).
|
||||
/// Null means unlimited.
|
||||
/// </summary>
|
||||
public int? MaxLow { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum total CVEs regardless of severity.
|
||||
/// Null means no total limit.
|
||||
/// </summary>
|
||||
public int? MaxTotal { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to count suppressed/excepted CVEs.
|
||||
/// If false, suppressed CVEs are excluded from counts.
|
||||
/// </summary>
|
||||
public bool CountSuppressed { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to only count reachable CVEs.
|
||||
/// If true, unreachable CVEs are excluded from counts.
|
||||
/// </summary>
|
||||
public bool OnlyCountReachable { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Per-environment configuration overrides.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, ReleaseAggregateCveGateEnvironmentOverride> Environments { get; init; }
|
||||
= new Dictionary<string, ReleaseAggregateCveGateEnvironmentOverride>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-environment overrides.
|
||||
/// </summary>
|
||||
public sealed record ReleaseAggregateCveGateEnvironmentOverride
|
||||
{
|
||||
/// <summary>Override for Enabled.</summary>
|
||||
public bool? Enabled { get; init; }
|
||||
|
||||
/// <summary>Override for MaxCritical.</summary>
|
||||
public int? MaxCritical { get; init; }
|
||||
|
||||
/// <summary>Override for MaxHigh.</summary>
|
||||
public int? MaxHigh { get; init; }
|
||||
|
||||
/// <summary>Override for MaxMedium.</summary>
|
||||
public int? MaxMedium { get; init; }
|
||||
|
||||
/// <summary>Override for MaxLow.</summary>
|
||||
public int? MaxLow { get; init; }
|
||||
|
||||
/// <summary>Override for MaxTotal.</summary>
|
||||
public int? MaxTotal { get; init; }
|
||||
|
||||
/// <summary>Override for CountSuppressed.</summary>
|
||||
public bool? CountSuppressed { get; init; }
|
||||
|
||||
/// <summary>Override for OnlyCountReachable.</summary>
|
||||
public bool? OnlyCountReachable { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CVE finding with suppression status.
|
||||
/// </summary>
|
||||
public sealed record CveFindingWithStatus : CveFinding
|
||||
{
|
||||
/// <summary>Whether the CVE is suppressed/excepted.</summary>
|
||||
public bool IsSuppressed { get; init; }
|
||||
|
||||
/// <summary>Exception ID if suppressed.</summary>
|
||||
public string? ExceptionId { get; init; }
|
||||
|
||||
/// <summary>Exception expiry date.</summary>
|
||||
public DateTimeOffset? ExceptionExpiry { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// HttpOpaClient.cs
|
||||
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
|
||||
// Task: TASK-017-007 - OPA Client Integration
|
||||
// Description: HTTP client implementation for Open Policy Agent (OPA)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Policy.Gates.Opa;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for interacting with an external OPA server.
|
||||
/// </summary>
|
||||
public sealed class HttpOpaClient : IOpaClient, IDisposable
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<HttpOpaClient> _logger;
|
||||
private readonly OpaClientOptions _options;
|
||||
private readonly bool _ownsHttpClient;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new HTTP OPA client with the specified options.
|
||||
/// </summary>
|
||||
public HttpOpaClient(
|
||||
IOptions<OpaClientOptions> options,
|
||||
ILogger<HttpOpaClient> logger,
|
||||
HttpClient? httpClient = null)
|
||||
{
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
if (httpClient is null)
|
||||
{
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(_options.BaseUrl),
|
||||
Timeout = TimeSpan.FromSeconds(_options.TimeoutSeconds)
|
||||
};
|
||||
_ownsHttpClient = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_ownsHttpClient = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<OpaEvaluationResult> EvaluateAsync(
|
||||
string policyPath,
|
||||
object input,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(policyPath);
|
||||
ArgumentNullException.ThrowIfNull(input);
|
||||
|
||||
try
|
||||
{
|
||||
var requestPath = BuildQueryPath(policyPath);
|
||||
var request = new OpaQueryRequest { Input = input };
|
||||
|
||||
_logger.LogDebug("Evaluating OPA policy at {Path}", requestPath);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(requestPath, request, JsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogWarning(
|
||||
"OPA evaluation failed: {StatusCode} - {Error}",
|
||||
response.StatusCode, errorContent);
|
||||
|
||||
return new OpaEvaluationResult
|
||||
{
|
||||
Success = false,
|
||||
Error = $"OPA returned {response.StatusCode}: {errorContent}"
|
||||
};
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<OpaQueryResponse>(JsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new OpaEvaluationResult
|
||||
{
|
||||
Success = true,
|
||||
DecisionId = result?.DecisionId,
|
||||
Result = result?.Result,
|
||||
Metrics = result?.Metrics is not null ? MapMetrics(result.Metrics) : null
|
||||
};
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "HTTP error connecting to OPA at {BaseUrl}", _options.BaseUrl);
|
||||
return new OpaEvaluationResult
|
||||
{
|
||||
Success = false,
|
||||
Error = $"HTTP error: {ex.Message}"
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
|
||||
{
|
||||
_logger.LogError(ex, "OPA request timed out after {Timeout}s", _options.TimeoutSeconds);
|
||||
return new OpaEvaluationResult
|
||||
{
|
||||
Success = false,
|
||||
Error = $"Request timed out after {_options.TimeoutSeconds} seconds"
|
||||
};
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse OPA response");
|
||||
return new OpaEvaluationResult
|
||||
{
|
||||
Success = false,
|
||||
Error = $"JSON parse error: {ex.Message}"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<OpaTypedResult<TResult>> EvaluateAsync<TResult>(
|
||||
string policyPath,
|
||||
object input,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await EvaluateAsync(policyPath, input, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return new OpaTypedResult<TResult>
|
||||
{
|
||||
Success = false,
|
||||
DecisionId = result.DecisionId,
|
||||
Error = result.Error,
|
||||
Metrics = result.Metrics
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var typedResult = default(TResult);
|
||||
|
||||
if (result.Result is JsonElement jsonElement)
|
||||
{
|
||||
typedResult = jsonElement.Deserialize<TResult>(JsonOptions);
|
||||
}
|
||||
else if (result.Result is TResult directResult)
|
||||
{
|
||||
typedResult = directResult;
|
||||
}
|
||||
else if (result.Result is not null)
|
||||
{
|
||||
// Try re-serializing and deserializing
|
||||
var json = JsonSerializer.Serialize(result.Result, JsonOptions);
|
||||
typedResult = JsonSerializer.Deserialize<TResult>(json, JsonOptions);
|
||||
}
|
||||
|
||||
return new OpaTypedResult<TResult>
|
||||
{
|
||||
Success = true,
|
||||
DecisionId = result.DecisionId,
|
||||
Result = typedResult,
|
||||
Metrics = result.Metrics
|
||||
};
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to deserialize OPA result to {Type}", typeof(TResult).Name);
|
||||
return new OpaTypedResult<TResult>
|
||||
{
|
||||
Success = false,
|
||||
DecisionId = result.DecisionId,
|
||||
Error = $"Failed to deserialize result: {ex.Message}",
|
||||
Metrics = result.Metrics
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> HealthCheckAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync("health", cancellationToken).ConfigureAwait(false);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "OPA health check failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UploadPolicyAsync(
|
||||
string policyId,
|
||||
string regoContent,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(policyId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(regoContent);
|
||||
|
||||
var requestPath = $"v1/policies/{Uri.EscapeDataString(policyId)}";
|
||||
|
||||
using var content = new StringContent(regoContent, System.Text.Encoding.UTF8, "text/plain");
|
||||
var response = await _httpClient.PutAsync(requestPath, content, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Failed to upload policy: {response.StatusCode} - {errorContent}");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Uploaded policy {PolicyId} to OPA", policyId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeletePolicyAsync(
|
||||
string policyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(policyId);
|
||||
|
||||
var requestPath = $"v1/policies/{Uri.EscapeDataString(policyId)}";
|
||||
var response = await _httpClient.DeleteAsync(requestPath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode && response.StatusCode != System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Failed to delete policy: {response.StatusCode} - {errorContent}");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Deleted policy {PolicyId} from OPA", policyId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the HTTP client if owned.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsHttpClient)
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private string BuildQueryPath(string policyPath)
|
||||
{
|
||||
// Normalize path: remove leading "data/" if present
|
||||
var normalizedPath = policyPath.TrimStart('/');
|
||||
if (normalizedPath.StartsWith("data/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
normalizedPath = normalizedPath[5..];
|
||||
}
|
||||
|
||||
// Use v1/data endpoint for queries
|
||||
return $"v1/data/{normalizedPath}?metrics={_options.IncludeMetrics.ToString().ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static OpaMetrics MapMetrics(Dictionary<string, long> metrics) => new()
|
||||
{
|
||||
TimerRegoQueryCompileNs = metrics.GetValueOrDefault("timer_rego_query_compile_ns"),
|
||||
TimerRegoQueryEvalNs = metrics.GetValueOrDefault("timer_rego_query_eval_ns"),
|
||||
TimerServerHandlerNs = metrics.GetValueOrDefault("timer_server_handler_ns")
|
||||
};
|
||||
|
||||
private sealed record OpaQueryRequest
|
||||
{
|
||||
public required object Input { get; init; }
|
||||
}
|
||||
|
||||
private sealed record OpaQueryResponse
|
||||
{
|
||||
[JsonPropertyName("decision_id")]
|
||||
public string? DecisionId { get; init; }
|
||||
|
||||
public object? Result { get; init; }
|
||||
|
||||
public Dictionary<string, long>? Metrics { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the OPA client.
|
||||
/// </summary>
|
||||
public sealed class OpaClientOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Section name in configuration.
|
||||
/// </summary>
|
||||
public const string SectionName = "Opa";
|
||||
|
||||
/// <summary>
|
||||
/// Base URL of the OPA server (e.g., "http://localhost:8181").
|
||||
/// </summary>
|
||||
public string BaseUrl { get; set; } = "http://localhost:8181";
|
||||
|
||||
/// <summary>
|
||||
/// Request timeout in seconds.
|
||||
/// </summary>
|
||||
public int TimeoutSeconds { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include metrics in responses.
|
||||
/// </summary>
|
||||
public bool IncludeMetrics { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Optional API key for authenticated OPA servers.
|
||||
/// </summary>
|
||||
public string? ApiKey { get; set; }
|
||||
}
|
||||
150
src/Policy/__Libraries/StellaOps.Policy/Gates/Opa/IOpaClient.cs
Normal file
150
src/Policy/__Libraries/StellaOps.Policy/Gates/Opa/IOpaClient.cs
Normal file
@@ -0,0 +1,150 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IOpaClient.cs
|
||||
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
|
||||
// Task: TASK-017-007 - OPA Client Integration
|
||||
// Description: Interface for Open Policy Agent (OPA) client
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Policy.Gates.Opa;
|
||||
|
||||
/// <summary>
|
||||
/// Client interface for interacting with Open Policy Agent (OPA).
|
||||
/// </summary>
|
||||
public interface IOpaClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluates a policy decision against OPA.
|
||||
/// </summary>
|
||||
/// <param name="policyPath">The policy path (e.g., "data/stella/attestation/allow").</param>
|
||||
/// <param name="input">The input data for policy evaluation.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The policy evaluation result.</returns>
|
||||
Task<OpaEvaluationResult> EvaluateAsync(
|
||||
string policyPath,
|
||||
object input,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates a policy and returns a typed result.
|
||||
/// </summary>
|
||||
/// <typeparam name="TResult">The expected result type.</typeparam>
|
||||
/// <param name="policyPath">The policy path.</param>
|
||||
/// <param name="input">The input data for policy evaluation.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The typed policy evaluation result.</returns>
|
||||
Task<OpaTypedResult<TResult>> EvaluateAsync<TResult>(
|
||||
string policyPath,
|
||||
object input,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks OPA server health.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if OPA is healthy.</returns>
|
||||
Task<bool> HealthCheckAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Uploads a policy to OPA.
|
||||
/// </summary>
|
||||
/// <param name="policyId">Unique policy identifier.</param>
|
||||
/// <param name="regoContent">The Rego policy content.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task UploadPolicyAsync(
|
||||
string policyId,
|
||||
string regoContent,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a policy from OPA.
|
||||
/// </summary>
|
||||
/// <param name="policyId">The policy identifier to delete.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task DeletePolicyAsync(
|
||||
string policyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of an OPA policy evaluation.
|
||||
/// </summary>
|
||||
public sealed record OpaEvaluationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the evaluation was successful.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The decision ID for tracing.
|
||||
/// </summary>
|
||||
public string? DecisionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The raw result object.
|
||||
/// </summary>
|
||||
public object? Result { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if evaluation failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Metrics from OPA (timing, etc.).
|
||||
/// </summary>
|
||||
public OpaMetrics? Metrics { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Typed result of an OPA policy evaluation.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The result type.</typeparam>
|
||||
public sealed record OpaTypedResult<T>
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the evaluation was successful.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The decision ID for tracing.
|
||||
/// </summary>
|
||||
public string? DecisionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The typed result.
|
||||
/// </summary>
|
||||
public T? Result { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if evaluation failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Metrics from OPA.
|
||||
/// </summary>
|
||||
public OpaMetrics? Metrics { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metrics from OPA policy evaluation.
|
||||
/// </summary>
|
||||
public sealed record OpaMetrics
|
||||
{
|
||||
/// <summary>
|
||||
/// Time taken to compile the query (nanoseconds).
|
||||
/// </summary>
|
||||
public long? TimerRegoQueryCompileNs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Time taken to evaluate the query (nanoseconds).
|
||||
/// </summary>
|
||||
public long? TimerRegoQueryEvalNs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total server handler time (nanoseconds).
|
||||
/// </summary>
|
||||
public long? TimerServerHandlerNs { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// OpaGateAdapter.cs
|
||||
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
|
||||
// Task: TASK-017-007 - OPA Client Integration
|
||||
// Description: Adapter that wraps OPA policy evaluation as an IPolicyGate
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.TrustLattice;
|
||||
|
||||
namespace StellaOps.Policy.Gates.Opa;
|
||||
|
||||
/// <summary>
|
||||
/// Adapter that wraps an OPA policy evaluation as an <see cref="IPolicyGate"/>.
|
||||
/// This enables Rego policies to be used alongside C# gates in the gate registry.
|
||||
/// </summary>
|
||||
public sealed class OpaGateAdapter : IPolicyGate
|
||||
{
|
||||
private readonly IOpaClient _opaClient;
|
||||
private readonly ILogger<OpaGateAdapter> _logger;
|
||||
private readonly OpaGateOptions _options;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
|
||||
public OpaGateAdapter(
|
||||
IOpaClient opaClient,
|
||||
IOptions<OpaGateOptions> options,
|
||||
ILogger<OpaGateAdapter> logger)
|
||||
{
|
||||
_opaClient = opaClient ?? throw new ArgumentNullException(nameof(opaClient));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<GateResult> EvaluateAsync(
|
||||
MergeResult mergeResult,
|
||||
PolicyGateContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var input = BuildOpaInput(mergeResult, context);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Evaluating OPA gate {GateName} at policy path {PolicyPath}",
|
||||
_options.GateName, _options.PolicyPath);
|
||||
|
||||
var result = await _opaClient.EvaluateAsync<OpaGateResult>(
|
||||
_options.PolicyPath,
|
||||
input,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"OPA gate {GateName} evaluation failed: {Error}",
|
||||
_options.GateName, result.Error);
|
||||
|
||||
return BuildFailureResult(
|
||||
_options.FailOnError ? false : true,
|
||||
$"OPA evaluation error: {result.Error}");
|
||||
}
|
||||
|
||||
var opaResult = result.Result;
|
||||
if (opaResult is null)
|
||||
{
|
||||
_logger.LogWarning("OPA gate {GateName} returned null result", _options.GateName);
|
||||
return BuildFailureResult(
|
||||
_options.FailOnError ? false : true,
|
||||
"OPA returned null result");
|
||||
}
|
||||
|
||||
var passed = opaResult.Allow ?? false;
|
||||
var reason = opaResult.Reason ?? (passed ? "Policy allowed" : "Policy denied");
|
||||
|
||||
_logger.LogDebug(
|
||||
"OPA gate {GateName} result: Passed={Passed}, Reason={Reason}",
|
||||
_options.GateName, passed, reason);
|
||||
|
||||
return new GateResult
|
||||
{
|
||||
GateName = _options.GateName,
|
||||
Passed = passed,
|
||||
Reason = reason,
|
||||
Details = BuildDetails(result.DecisionId, opaResult, result.Metrics)
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "OPA gate {GateName} threw exception", _options.GateName);
|
||||
|
||||
return BuildFailureResult(
|
||||
_options.FailOnError ? false : true,
|
||||
$"OPA gate exception: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private object BuildOpaInput(MergeResult mergeResult, PolicyGateContext context)
|
||||
{
|
||||
// Build a comprehensive input object for OPA evaluation
|
||||
return new
|
||||
{
|
||||
MergeResult = new
|
||||
{
|
||||
mergeResult.Findings,
|
||||
mergeResult.TotalFindings,
|
||||
mergeResult.CriticalCount,
|
||||
mergeResult.HighCount,
|
||||
mergeResult.MediumCount,
|
||||
mergeResult.LowCount,
|
||||
mergeResult.UnknownCount,
|
||||
mergeResult.NewFindings,
|
||||
mergeResult.RemovedFindings,
|
||||
mergeResult.UnchangedFindings
|
||||
},
|
||||
Context = new
|
||||
{
|
||||
context.Environment,
|
||||
context.UnknownCount,
|
||||
context.HasReachabilityProof,
|
||||
context.Severity,
|
||||
context.CveId,
|
||||
context.SubjectKey,
|
||||
ReasonCodes = context.ReasonCodes.ToArray()
|
||||
},
|
||||
Policy = new
|
||||
{
|
||||
_options.TrustedKeyIds,
|
||||
_options.IntegratedTimeCutoff,
|
||||
_options.AllowedPayloadTypes,
|
||||
_options.CustomData
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private GateResult BuildFailureResult(bool passed, string reason)
|
||||
{
|
||||
return new GateResult
|
||||
{
|
||||
GateName = _options.GateName,
|
||||
Passed = passed,
|
||||
Reason = reason,
|
||||
Details = ImmutableDictionary<string, object>.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private ImmutableDictionary<string, object> BuildDetails(
|
||||
string? decisionId,
|
||||
OpaGateResult opaResult,
|
||||
OpaMetrics? metrics)
|
||||
{
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, object>();
|
||||
|
||||
if (decisionId is not null)
|
||||
{
|
||||
builder.Add("opaDecisionId", decisionId);
|
||||
}
|
||||
|
||||
if (opaResult.Violations is not null && opaResult.Violations.Count > 0)
|
||||
{
|
||||
builder.Add("violations", opaResult.Violations);
|
||||
}
|
||||
|
||||
if (opaResult.Warnings is not null && opaResult.Warnings.Count > 0)
|
||||
{
|
||||
builder.Add("warnings", opaResult.Warnings);
|
||||
}
|
||||
|
||||
if (metrics is not null)
|
||||
{
|
||||
builder.Add("opaEvalTimeNs", metrics.TimerRegoQueryEvalNs ?? 0);
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expected structure of the OPA gate evaluation result.
|
||||
/// </summary>
|
||||
private sealed record OpaGateResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the policy allows the action.
|
||||
/// </summary>
|
||||
public bool? Allow { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable reason for the decision.
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// List of policy violations (if denied).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Violations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// List of policy warnings (even if allowed).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Warnings { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for an OPA gate adapter.
|
||||
/// </summary>
|
||||
public sealed class OpaGateOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the gate (used in results and logging).
|
||||
/// </summary>
|
||||
public string GateName { get; set; } = "OpaGate";
|
||||
|
||||
/// <summary>
|
||||
/// The OPA policy path to evaluate (e.g., "stella/attestation/allow").
|
||||
/// </summary>
|
||||
public string PolicyPath { get; set; } = "stella/policy/allow";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to fail the gate if OPA evaluation fails.
|
||||
/// If false, gate passes on OPA errors.
|
||||
/// </summary>
|
||||
public bool FailOnError { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// List of trusted key IDs to pass to the policy.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> TrustedKeyIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Integrated time cutoff for Rekor freshness checks.
|
||||
/// </summary>
|
||||
public DateTimeOffset? IntegratedTimeCutoff { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// List of allowed payload types.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> AllowedPayloadTypes { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Custom data to pass to the policy.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, object>? CustomData { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# attestation.rego
|
||||
# Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
|
||||
# Task: TASK-017-007 - OPA Client Integration
|
||||
# Description: Sample Rego policy for attestation verification
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
package stella.attestation
|
||||
|
||||
import future.keywords.if
|
||||
import future.keywords.in
|
||||
import future.keywords.contains
|
||||
|
||||
# Default deny
|
||||
default allow := false
|
||||
|
||||
# Allow if all attestation checks pass
|
||||
allow if {
|
||||
valid_payload_type
|
||||
trusted_key
|
||||
rekor_fresh_enough
|
||||
vex_status_acceptable
|
||||
}
|
||||
|
||||
# Build comprehensive response
|
||||
result := {
|
||||
"allow": allow,
|
||||
"reason": reason,
|
||||
"violations": violations,
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
# Determine reason for decision
|
||||
reason := "All attestation checks passed" if {
|
||||
allow
|
||||
}
|
||||
|
||||
reason := concat("; ", violations) if {
|
||||
not allow
|
||||
count(violations) > 0
|
||||
}
|
||||
|
||||
reason := "Unknown policy failure" if {
|
||||
not allow
|
||||
count(violations) == 0
|
||||
}
|
||||
|
||||
# Collect all violations
|
||||
violations contains msg if {
|
||||
not valid_payload_type
|
||||
msg := sprintf("Invalid payload type: got %v, expected one of %v",
|
||||
[input.attestation.payloadType, input.policy.allowedPayloadTypes])
|
||||
}
|
||||
|
||||
violations contains msg if {
|
||||
not trusted_key
|
||||
msg := sprintf("Untrusted signing key: %v not in trusted set",
|
||||
[input.attestation.keyId])
|
||||
}
|
||||
|
||||
violations contains msg if {
|
||||
not rekor_fresh_enough
|
||||
msg := sprintf("Rekor proof too old or too new: integratedTime %v outside valid range",
|
||||
[input.rekor.integratedTime])
|
||||
}
|
||||
|
||||
violations contains msg if {
|
||||
some vuln in input.vex.vulnerabilities
|
||||
vuln.status == "affected"
|
||||
vuln.reachable == true
|
||||
msg := sprintf("Reachable vulnerability with affected status: %v", [vuln.id])
|
||||
}
|
||||
|
||||
# Collect warnings
|
||||
warnings contains msg if {
|
||||
some vuln in input.vex.vulnerabilities
|
||||
vuln.status == "under_investigation"
|
||||
msg := sprintf("Vulnerability under investigation: %v", [vuln.id])
|
||||
}
|
||||
|
||||
warnings contains msg if {
|
||||
input.rekor.integratedTime
|
||||
time_since_integrated := time.now_ns() / 1000000000 - input.rekor.integratedTime
|
||||
time_since_integrated > 86400 * 7 # More than 7 days old
|
||||
msg := sprintf("Rekor proof is %v days old", [time_since_integrated / 86400])
|
||||
}
|
||||
|
||||
# Check payload type is in allowed list
|
||||
valid_payload_type if {
|
||||
input.attestation.payloadType in input.policy.allowedPayloadTypes
|
||||
}
|
||||
|
||||
valid_payload_type if {
|
||||
count(input.policy.allowedPayloadTypes) == 0 # No restrictions
|
||||
}
|
||||
|
||||
# Check if signing key is trusted
|
||||
trusted_key if {
|
||||
input.attestation.keyId in input.policy.trustedKeyIds
|
||||
}
|
||||
|
||||
trusted_key if {
|
||||
count(input.policy.trustedKeyIds) == 0 # No restrictions
|
||||
}
|
||||
|
||||
# Check if the key fingerprint matches
|
||||
trusted_key if {
|
||||
some key in input.policy.trustedKeys
|
||||
key.fingerprint == input.attestation.fingerprint
|
||||
key.active == true
|
||||
not key.revoked
|
||||
}
|
||||
|
||||
# Check Rekor freshness
|
||||
rekor_fresh_enough if {
|
||||
not input.policy.integratedTimeCutoff # No cutoff set
|
||||
}
|
||||
|
||||
rekor_fresh_enough if {
|
||||
input.rekor.integratedTime
|
||||
input.rekor.integratedTime <= input.policy.integratedTimeCutoff
|
||||
}
|
||||
|
||||
rekor_fresh_enough if {
|
||||
not input.rekor.integratedTime
|
||||
not input.policy.requireRekorProof
|
||||
}
|
||||
|
||||
# Check VEX status
|
||||
vex_status_acceptable if {
|
||||
not input.vex # No VEX data
|
||||
}
|
||||
|
||||
vex_status_acceptable if {
|
||||
not affected_and_reachable
|
||||
}
|
||||
|
||||
# Helper: check if any vulnerability is both affected and reachable
|
||||
affected_and_reachable if {
|
||||
some vuln in input.vex.vulnerabilities
|
||||
vuln.status == "affected"
|
||||
vuln.reachable == true
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Additional policy rules for composite checks
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Minimum confidence score check
|
||||
minimum_confidence_met if {
|
||||
input.context.confidenceScore >= input.policy.minimumConfidence
|
||||
}
|
||||
|
||||
minimum_confidence_met if {
|
||||
not input.policy.minimumConfidence
|
||||
}
|
||||
|
||||
# SBOM presence check
|
||||
sbom_present if {
|
||||
input.artifacts.sbom
|
||||
input.artifacts.sbom.present == true
|
||||
}
|
||||
|
||||
sbom_present if {
|
||||
not input.policy.requireSbom
|
||||
}
|
||||
|
||||
# Signature algorithm allowlist
|
||||
allowed_algorithm if {
|
||||
input.attestation.algorithm in input.policy.allowedAlgorithms
|
||||
}
|
||||
|
||||
allowed_algorithm if {
|
||||
count(input.policy.allowedAlgorithms) == 0
|
||||
}
|
||||
|
||||
# Environment-specific rules
|
||||
production_ready if {
|
||||
input.context.environment != "production"
|
||||
}
|
||||
|
||||
production_ready if {
|
||||
input.context.environment == "production"
|
||||
minimum_confidence_met
|
||||
sbom_present
|
||||
allowed_algorithm
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RuntimeWitnessGate.cs
|
||||
// Sprint: SPRINT_20260118_018_Policy_runtime_witness_gate
|
||||
// Tasks: TASK-018-001 through TASK-018-006
|
||||
// Description: Policy gate requiring runtime witness confirmation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Policy.Gates.RuntimeWitness;
|
||||
|
||||
/// <summary>
|
||||
/// Policy gate that requires runtime witness confirmation for reachability claims.
|
||||
/// Follows VexProofGate anchor-aware pattern.
|
||||
/// </summary>
|
||||
public sealed class RuntimeWitnessGate : IPolicyGate
|
||||
{
|
||||
/// <summary>
|
||||
/// Gate identifier.
|
||||
/// </summary>
|
||||
public const string GateId = "runtime-witness";
|
||||
|
||||
private readonly IWitnessVerifier? _verifier;
|
||||
private readonly RuntimeWitnessGateOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Id => GateId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DisplayName => "Runtime Witness";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Requires runtime witness confirmation for reachability claims";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new runtime witness gate.
|
||||
/// </summary>
|
||||
public RuntimeWitnessGate(
|
||||
IWitnessVerifier? verifier = null,
|
||||
RuntimeWitnessGateOptions? options = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_verifier = verifier;
|
||||
_options = options ?? new RuntimeWitnessGateOptions();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
return GateResult.Pass(Id, "Runtime witness gate disabled");
|
||||
}
|
||||
|
||||
// Get environment-specific options
|
||||
var envOptions = GetEnvironmentOptions(context.Environment);
|
||||
|
||||
// Get findings with reachability evidence
|
||||
var findings = context.GetReachabilityFindings();
|
||||
if (findings == null || findings.Count == 0)
|
||||
{
|
||||
if (envOptions.RequireRuntimeWitness)
|
||||
{
|
||||
return GateResult.Fail(Id, "No reachability findings to verify");
|
||||
}
|
||||
return GateResult.Pass(Id, "No reachability findings - skipping witness check");
|
||||
}
|
||||
|
||||
var witnessed = 0;
|
||||
var unwitnessed = 0;
|
||||
var warnings = new List<string>();
|
||||
var failures = new List<string>();
|
||||
|
||||
foreach (var finding in findings)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var result = await EvaluateFindingAsync(finding, envOptions, ct);
|
||||
|
||||
if (result.IsWitnessed)
|
||||
{
|
||||
witnessed++;
|
||||
|
||||
// Check freshness
|
||||
if (result.WitnessAge > TimeSpan.FromHours(envOptions.MaxWitnessAgeHours))
|
||||
{
|
||||
if (envOptions.AllowUnwitnessedAdvisory)
|
||||
{
|
||||
warnings.Add($"{finding.VulnerabilityId}: witness expired ({result.WitnessAge.TotalHours:F1}h)");
|
||||
}
|
||||
else
|
||||
{
|
||||
failures.Add($"{finding.VulnerabilityId}: witness expired ({result.WitnessAge.TotalHours:F1}h)");
|
||||
}
|
||||
}
|
||||
|
||||
// Check confidence
|
||||
if (result.MatchConfidence < envOptions.MinMatchConfidence)
|
||||
{
|
||||
warnings.Add($"{finding.VulnerabilityId}: low match confidence ({result.MatchConfidence:P0})");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
unwitnessed++;
|
||||
|
||||
if (envOptions.RequireRuntimeWitness && !envOptions.AllowUnwitnessedAdvisory)
|
||||
{
|
||||
failures.Add($"{finding.VulnerabilityId}: no runtime witness found");
|
||||
}
|
||||
else if (envOptions.RequireRuntimeWitness)
|
||||
{
|
||||
warnings.Add($"{finding.VulnerabilityId}: no runtime witness (advisory)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine result
|
||||
if (failures.Count > 0)
|
||||
{
|
||||
return GateResult.Fail(Id,
|
||||
$"Runtime witness check failed: {string.Join("; ", failures)}");
|
||||
}
|
||||
|
||||
var message = $"Witnessed: {witnessed}/{findings.Count}";
|
||||
if (warnings.Count > 0)
|
||||
{
|
||||
message += $" (warnings: {warnings.Count})";
|
||||
}
|
||||
|
||||
return GateResult.Pass(Id, message, warnings: warnings);
|
||||
}
|
||||
|
||||
private async Task<WitnessEvaluationResult> EvaluateFindingAsync(
|
||||
ReachabilityFinding finding,
|
||||
RuntimeWitnessGateOptions envOptions,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Check if finding has witness evidence
|
||||
if (finding.WitnessDigest == null)
|
||||
{
|
||||
return new WitnessEvaluationResult { IsWitnessed = false };
|
||||
}
|
||||
|
||||
// If verifier is available, do full verification
|
||||
if (_verifier != null && finding.ClaimId != null)
|
||||
{
|
||||
var verification = await _verifier.VerifyAsync(finding.ClaimId, ct);
|
||||
|
||||
if (verification.Status == WitnessVerificationStatus.Verified && verification.BestMatch != null)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var age = verification.BestMatch.IntegratedTime.HasValue
|
||||
? now - verification.BestMatch.IntegratedTime.Value
|
||||
: TimeSpan.Zero;
|
||||
|
||||
return new WitnessEvaluationResult
|
||||
{
|
||||
IsWitnessed = true,
|
||||
WitnessAge = age,
|
||||
MatchConfidence = verification.BestMatch.Confidence,
|
||||
ObservationCount = 1,
|
||||
IsRekorAnchored = verification.BestMatch.RekorVerified
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to metadata check
|
||||
return new WitnessEvaluationResult
|
||||
{
|
||||
IsWitnessed = finding.WitnessedAt.HasValue,
|
||||
WitnessAge = finding.WitnessedAt.HasValue
|
||||
? _timeProvider.GetUtcNow() - finding.WitnessedAt.Value
|
||||
: TimeSpan.Zero,
|
||||
MatchConfidence = 1.0, // Assume full confidence for metadata-only
|
||||
ObservationCount = 1
|
||||
};
|
||||
}
|
||||
|
||||
private RuntimeWitnessGateOptions GetEnvironmentOptions(string? environment)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(environment))
|
||||
{
|
||||
return _options;
|
||||
}
|
||||
|
||||
if (_options.Environments.TryGetValue(environment, out var envOverride))
|
||||
{
|
||||
return _options with
|
||||
{
|
||||
Enabled = envOverride.Enabled ?? _options.Enabled,
|
||||
RequireRuntimeWitness = envOverride.RequireRuntimeWitness ?? _options.RequireRuntimeWitness,
|
||||
MaxWitnessAgeHours = envOverride.MaxWitnessAgeHours ?? _options.MaxWitnessAgeHours,
|
||||
MinObservationCount = envOverride.MinObservationCount ?? _options.MinObservationCount,
|
||||
RequireRekorAnchoring = envOverride.RequireRekorAnchoring ?? _options.RequireRekorAnchoring,
|
||||
MinMatchConfidence = envOverride.MinMatchConfidence ?? _options.MinMatchConfidence,
|
||||
AllowUnwitnessedAdvisory = envOverride.AllowUnwitnessedAdvisory ?? _options.AllowUnwitnessedAdvisory
|
||||
};
|
||||
}
|
||||
|
||||
return _options;
|
||||
}
|
||||
|
||||
private sealed record WitnessEvaluationResult
|
||||
{
|
||||
public bool IsWitnessed { get; init; }
|
||||
public TimeSpan WitnessAge { get; init; }
|
||||
public double MatchConfidence { get; init; }
|
||||
public int ObservationCount { get; init; }
|
||||
public bool IsRekorAnchored { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for runtime witness gate.
|
||||
/// </summary>
|
||||
public sealed record RuntimeWitnessGateOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Policy:Gates:RuntimeWitness";
|
||||
|
||||
/// <summary>
|
||||
/// Whether the gate is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to require runtime witnesses (false = opt-in).
|
||||
/// </summary>
|
||||
public bool RequireRuntimeWitness { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum age for witnesses in hours.
|
||||
/// Default: 168 (7 days), following VexProofGate convention.
|
||||
/// </summary>
|
||||
public int MaxWitnessAgeHours { get; init; } = 168;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum number of observations required.
|
||||
/// </summary>
|
||||
public int MinObservationCount { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to require Rekor anchoring for witnesses.
|
||||
/// </summary>
|
||||
public bool RequireRekorAnchoring { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum match confidence threshold.
|
||||
/// </summary>
|
||||
public double MinMatchConfidence { get; init; } = 0.8;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to pass with advisory for unwitnessed paths.
|
||||
/// If false, unwitnessed paths cause gate failure.
|
||||
/// </summary>
|
||||
public bool AllowUnwitnessedAdvisory { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Per-environment configuration overrides.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, RuntimeWitnessGateEnvironmentOverride> Environments { get; init; }
|
||||
= new Dictionary<string, RuntimeWitnessGateEnvironmentOverride>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-environment overrides for runtime witness gate.
|
||||
/// </summary>
|
||||
public sealed record RuntimeWitnessGateEnvironmentOverride
|
||||
{
|
||||
/// <summary>Override for Enabled.</summary>
|
||||
public bool? Enabled { get; init; }
|
||||
|
||||
/// <summary>Override for RequireRuntimeWitness.</summary>
|
||||
public bool? RequireRuntimeWitness { get; init; }
|
||||
|
||||
/// <summary>Override for MaxWitnessAgeHours.</summary>
|
||||
public int? MaxWitnessAgeHours { get; init; }
|
||||
|
||||
/// <summary>Override for MinObservationCount.</summary>
|
||||
public int? MinObservationCount { get; init; }
|
||||
|
||||
/// <summary>Override for RequireRekorAnchoring.</summary>
|
||||
public bool? RequireRekorAnchoring { get; init; }
|
||||
|
||||
/// <summary>Override for MinMatchConfidence.</summary>
|
||||
public double? MinMatchConfidence { get; init; }
|
||||
|
||||
/// <summary>Override for AllowUnwitnessedAdvisory.</summary>
|
||||
public bool? AllowUnwitnessedAdvisory { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A reachability finding for gate evaluation.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityFinding
|
||||
{
|
||||
/// <summary>Vulnerability ID.</summary>
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>Claim ID for witness lookup.</summary>
|
||||
public string? ClaimId { get; init; }
|
||||
|
||||
/// <summary>Witness digest if witnessed.</summary>
|
||||
public string? WitnessDigest { get; init; }
|
||||
|
||||
/// <summary>When the finding was witnessed.</summary>
|
||||
public DateTimeOffset? WitnessedAt { get; init; }
|
||||
|
||||
/// <summary>Whether the path is reachable.</summary>
|
||||
public bool IsReachable { get; init; }
|
||||
|
||||
/// <summary>Component PURL.</summary>
|
||||
public string? ComponentPurl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for witness verification in gate context.
|
||||
/// </summary>
|
||||
public interface IWitnessVerifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies witnesses for a claim.
|
||||
/// </summary>
|
||||
Task<WitnessVerificationResult> VerifyAsync(string claimId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of witness verification.
|
||||
/// </summary>
|
||||
public sealed record WitnessVerificationResult
|
||||
{
|
||||
/// <summary>Verification status.</summary>
|
||||
public required WitnessVerificationStatus Status { get; init; }
|
||||
|
||||
/// <summary>Best matching witness.</summary>
|
||||
public WitnessMatchResult? BestMatch { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verification status.
|
||||
/// </summary>
|
||||
public enum WitnessVerificationStatus
|
||||
{
|
||||
/// <summary>Verified successfully.</summary>
|
||||
Verified,
|
||||
|
||||
/// <summary>No witness found.</summary>
|
||||
NoWitnessFound,
|
||||
|
||||
/// <summary>Verification failed.</summary>
|
||||
Failed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Match result for a witness.
|
||||
/// </summary>
|
||||
public sealed record WitnessMatchResult
|
||||
{
|
||||
/// <summary>Match confidence (0.0-1.0).</summary>
|
||||
public double Confidence { get; init; }
|
||||
|
||||
/// <summary>Integrated time from Rekor.</summary>
|
||||
public DateTimeOffset? IntegratedTime { get; init; }
|
||||
|
||||
/// <summary>Whether Rekor verification passed.</summary>
|
||||
public bool RekorVerified { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IUnknownsGateChecker.cs
|
||||
// Sprint: SPRINT_20260118_018_Unknowns_queue_enhancement
|
||||
// Task: UQ-003 - Implement fail-closed gate integration
|
||||
// Description: Interface and implementation for unknowns gate checking
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Policy.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Result of an unknowns gate check.
|
||||
/// </summary>
|
||||
public sealed record UnknownsGateCheckResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gate decision: pass, warn, or block.
|
||||
/// </summary>
|
||||
public required GateDecision Decision { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current state of unknowns for this component.
|
||||
/// </summary>
|
||||
public required string State { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// IDs of blocking unknowns.
|
||||
/// </summary>
|
||||
public ImmutableArray<Guid> BlockingUnknownIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable reason for the decision.
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether an exception was granted to bypass the block.
|
||||
/// </summary>
|
||||
public bool ExceptionGranted { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Exception reference if granted.
|
||||
/// </summary>
|
||||
public string? ExceptionRef { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gate decision types.
|
||||
/// </summary>
|
||||
public enum GateDecision
|
||||
{
|
||||
/// <summary>Gate passed, no blocking unknowns.</summary>
|
||||
Pass,
|
||||
|
||||
/// <summary>Warning: unknowns present but not blocking.</summary>
|
||||
Warn,
|
||||
|
||||
/// <summary>Blocked: HOT unknowns or SLA breached.</summary>
|
||||
Block
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unknown state for gate checks.
|
||||
/// </summary>
|
||||
public sealed record UnknownState
|
||||
{
|
||||
/// <summary>Unknown ID.</summary>
|
||||
public required Guid UnknownId { get; init; }
|
||||
|
||||
/// <summary>CVE ID if applicable.</summary>
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>Priority band.</summary>
|
||||
public required string Band { get; init; }
|
||||
|
||||
/// <summary>Current state (pending, under_review, escalated, resolved, rejected).</summary>
|
||||
public required string State { get; init; }
|
||||
|
||||
/// <summary>Hours remaining in SLA.</summary>
|
||||
public double? SlaRemainingHours { get; init; }
|
||||
|
||||
/// <summary>Whether SLA is breached.</summary>
|
||||
public bool SlaBreach { get; init; }
|
||||
|
||||
/// <summary>Whether in CISA KEV.</summary>
|
||||
public bool InKev { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for checking unknowns gate.
|
||||
/// </summary>
|
||||
public interface IUnknownsGateChecker
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks if a component can pass the unknowns gate.
|
||||
/// </summary>
|
||||
/// <param name="bomRef">BOM reference of the component.</param>
|
||||
/// <param name="proposedVerdict">Proposed VEX verdict (e.g., "not_affected").</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Gate check result.</returns>
|
||||
Task<UnknownsGateCheckResult> CheckAsync(
|
||||
string bomRef,
|
||||
string? proposedVerdict = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all unknowns for a component.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<UnknownState>> GetUnknownsAsync(
|
||||
string bomRef,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Requests an exception to bypass the gate.
|
||||
/// </summary>
|
||||
Task<ExceptionResult> RequestExceptionAsync(
|
||||
string bomRef,
|
||||
IEnumerable<Guid> unknownIds,
|
||||
string justification,
|
||||
string requestedBy,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception request result.
|
||||
/// </summary>
|
||||
public sealed record ExceptionResult
|
||||
{
|
||||
/// <summary>Whether exception was granted.</summary>
|
||||
public bool Granted { get; init; }
|
||||
|
||||
/// <summary>Exception reference.</summary>
|
||||
public string? ExceptionRef { get; init; }
|
||||
|
||||
/// <summary>Reason for denial if not granted.</summary>
|
||||
public string? DenialReason { get; init; }
|
||||
|
||||
/// <summary>When exception expires.</summary>
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for unknowns gate checker.
|
||||
/// </summary>
|
||||
public sealed record UnknownsGateOptions
|
||||
{
|
||||
/// <summary>Configuration section name.</summary>
|
||||
public const string SectionName = "Policy:UnknownsGate";
|
||||
|
||||
/// <summary>Whether to fail-closed (block on HOT unknowns).</summary>
|
||||
public bool FailClosed { get; init; } = true;
|
||||
|
||||
/// <summary>Whether to block "not_affected" verdicts when unknowns exist.</summary>
|
||||
public bool BlockNotAffectedWithUnknowns { get; init; } = true;
|
||||
|
||||
/// <summary>Whether to require exception approval for KEV items.</summary>
|
||||
public bool RequireKevException { get; init; } = true;
|
||||
|
||||
/// <summary>Whether to force manual review when SLA is breached.</summary>
|
||||
public bool ForceReviewOnSlaBreach { get; init; } = true;
|
||||
|
||||
/// <summary>Cache TTL for gate checks (seconds).</summary>
|
||||
public int CacheTtlSeconds { get; init; } = 30;
|
||||
|
||||
/// <summary>Base URL for Unknowns API.</summary>
|
||||
public string UnknownsApiUrl { get; init; } = "http://unknowns-api:8080";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of unknowns gate checker.
|
||||
/// </summary>
|
||||
public sealed class UnknownsGateChecker : IUnknownsGateChecker
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly UnknownsGateOptions _options;
|
||||
private readonly ILogger<UnknownsGateChecker> _logger;
|
||||
|
||||
public UnknownsGateChecker(
|
||||
HttpClient httpClient,
|
||||
IMemoryCache cache,
|
||||
IOptions<UnknownsGateOptions> options,
|
||||
ILogger<UnknownsGateChecker> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
_options = options?.Value ?? new UnknownsGateOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<UnknownsGateCheckResult> CheckAsync(
|
||||
string bomRef,
|
||||
string? proposedVerdict = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var cacheKey = $"unknowns-gate:{bomRef}:{proposedVerdict}";
|
||||
|
||||
if (_cache.TryGetValue<UnknownsGateCheckResult>(cacheKey, out var cached) && cached != null)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var unknowns = await GetUnknownsAsync(bomRef, ct);
|
||||
|
||||
// No unknowns = pass
|
||||
if (unknowns.Count == 0)
|
||||
{
|
||||
return CacheAndReturn(cacheKey, new UnknownsGateCheckResult
|
||||
{
|
||||
Decision = GateDecision.Pass,
|
||||
State = "resolved",
|
||||
Reason = "No pending unknowns"
|
||||
});
|
||||
}
|
||||
|
||||
// Check for blocking conditions
|
||||
var hotUnknowns = unknowns.Where(u => u.Band == "hot").ToList();
|
||||
var kevUnknowns = unknowns.Where(u => u.InKev).ToList();
|
||||
var slaBreached = unknowns.Where(u => u.SlaBreach).ToList();
|
||||
|
||||
// Block: HOT unknowns in fail-closed mode
|
||||
if (_options.FailClosed && hotUnknowns.Count > 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Blocking gate for {BomRef}: {Count} HOT unknowns",
|
||||
bomRef, hotUnknowns.Count);
|
||||
|
||||
return CacheAndReturn(cacheKey, new UnknownsGateCheckResult
|
||||
{
|
||||
Decision = GateDecision.Block,
|
||||
State = "blocked_by_unknowns",
|
||||
BlockingUnknownIds = [..hotUnknowns.Select(u => u.UnknownId)],
|
||||
Reason = $"{hotUnknowns.Count} HOT unknown(s) require resolution"
|
||||
});
|
||||
}
|
||||
|
||||
// Block: "not_affected" verdict with any unknowns
|
||||
if (_options.BlockNotAffectedWithUnknowns &&
|
||||
proposedVerdict?.Equals("not_affected", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Blocking not_affected verdict for {BomRef}: {Count} unknowns exist",
|
||||
bomRef, unknowns.Count);
|
||||
|
||||
return CacheAndReturn(cacheKey, new UnknownsGateCheckResult
|
||||
{
|
||||
Decision = GateDecision.Block,
|
||||
State = "blocked_by_unknowns",
|
||||
BlockingUnknownIds = [..unknowns.Select(u => u.UnknownId)],
|
||||
Reason = "Cannot claim 'not_affected' with unresolved unknowns"
|
||||
});
|
||||
}
|
||||
|
||||
// Block: KEV items require exception
|
||||
if (_options.RequireKevException && kevUnknowns.Count > 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Blocking gate for {BomRef}: {Count} KEV unknowns require exception",
|
||||
bomRef, kevUnknowns.Count);
|
||||
|
||||
return CacheAndReturn(cacheKey, new UnknownsGateCheckResult
|
||||
{
|
||||
Decision = GateDecision.Block,
|
||||
State = "blocked_by_kev",
|
||||
BlockingUnknownIds = [..kevUnknowns.Select(u => u.UnknownId)],
|
||||
Reason = $"{kevUnknowns.Count} KEV unknown(s) require exception approval"
|
||||
});
|
||||
}
|
||||
|
||||
// Block: SLA breached requires manual review
|
||||
if (_options.ForceReviewOnSlaBreach && slaBreached.Count > 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Blocking gate for {BomRef}: {Count} unknowns with breached SLA",
|
||||
bomRef, slaBreached.Count);
|
||||
|
||||
return CacheAndReturn(cacheKey, new UnknownsGateCheckResult
|
||||
{
|
||||
Decision = GateDecision.Block,
|
||||
State = "blocked_by_sla",
|
||||
BlockingUnknownIds = [..slaBreached.Select(u => u.UnknownId)],
|
||||
Reason = $"{slaBreached.Count} unknown(s) have breached SLA - manual review required"
|
||||
});
|
||||
}
|
||||
|
||||
// Warn: Non-HOT unknowns present
|
||||
var worstState = unknowns.Any(u => u.State == "escalated") ? "escalated" :
|
||||
unknowns.Any(u => u.State == "under_review") ? "under_review" : "pending";
|
||||
|
||||
return CacheAndReturn(cacheKey, new UnknownsGateCheckResult
|
||||
{
|
||||
Decision = GateDecision.Warn,
|
||||
State = worstState,
|
||||
Reason = $"{unknowns.Count} unknown(s) pending, but not blocking"
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UnknownState>> GetUnknownsAsync(
|
||||
string bomRef,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// In production, call Unknowns API
|
||||
// var response = await _httpClient.GetAsync($"{_options.UnknownsApiUrl}/api/v1/unknowns?bom_ref={Uri.EscapeDataString(bomRef)}", ct);
|
||||
|
||||
// Simulate lookup
|
||||
await Task.Delay(10, ct);
|
||||
|
||||
// Return simulated data
|
||||
return GenerateSimulatedUnknowns(bomRef);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fetch unknowns for {BomRef}", bomRef);
|
||||
|
||||
// Fail-closed: treat as if HOT unknowns exist
|
||||
if (_options.FailClosed)
|
||||
{
|
||||
return
|
||||
[
|
||||
new UnknownState
|
||||
{
|
||||
UnknownId = Guid.NewGuid(),
|
||||
Band = "hot",
|
||||
State = "pending",
|
||||
SlaRemainingHours = 0,
|
||||
SlaBreach = true
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ExceptionResult> RequestExceptionAsync(
|
||||
string bomRef,
|
||||
IEnumerable<Guid> unknownIds,
|
||||
string justification,
|
||||
string requestedBy,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Exception requested for {BomRef} by {RequestedBy}: {Justification}",
|
||||
bomRef, requestedBy, justification);
|
||||
|
||||
// In production, this would create an exception record
|
||||
await Task.Delay(10, ct);
|
||||
|
||||
return new ExceptionResult
|
||||
{
|
||||
Granted = false,
|
||||
DenialReason = "Automatic exceptions not enabled - requires manual approval",
|
||||
ExpiresAt = null
|
||||
};
|
||||
}
|
||||
|
||||
private UnknownsGateCheckResult CacheAndReturn(string key, UnknownsGateCheckResult result)
|
||||
{
|
||||
_cache.Set(key, result, TimeSpan.FromSeconds(_options.CacheTtlSeconds));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<UnknownState> GenerateSimulatedUnknowns(string bomRef)
|
||||
{
|
||||
// Deterministic simulation based on bomRef hash
|
||||
var hash = bomRef.GetHashCode();
|
||||
var random = new Random(hash);
|
||||
|
||||
if (random.NextDouble() > 0.3)
|
||||
{
|
||||
return []; // 70% have no unknowns
|
||||
}
|
||||
|
||||
var count = random.Next(1, 3);
|
||||
var unknowns = new List<UnknownState>();
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var band = random.NextDouble() switch
|
||||
{
|
||||
< 0.2 => "hot",
|
||||
< 0.5 => "warm",
|
||||
_ => "cold"
|
||||
};
|
||||
|
||||
unknowns.Add(new UnknownState
|
||||
{
|
||||
UnknownId = Guid.NewGuid(),
|
||||
CveId = $"CVE-2026-{random.Next(1000, 9999)}",
|
||||
Band = band,
|
||||
State = random.NextDouble() < 0.3 ? "under_review" : "pending",
|
||||
SlaRemainingHours = random.Next(1, 168),
|
||||
SlaBreach = random.NextDouble() < 0.1,
|
||||
InKev = random.NextDouble() < 0.05
|
||||
});
|
||||
}
|
||||
|
||||
return unknowns;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gate bypass audit entry for tracking unknown-related bypasses.
|
||||
/// </summary>
|
||||
public sealed record GateBypassAuditEntry
|
||||
{
|
||||
/// <summary>Audit entry ID.</summary>
|
||||
public required Guid AuditId { get; init; }
|
||||
|
||||
/// <summary>BOM reference that was bypassed.</summary>
|
||||
public required string BomRef { get; init; }
|
||||
|
||||
/// <summary>Unknown IDs that were bypassed.</summary>
|
||||
public ImmutableArray<Guid> BypassedUnknownIds { get; init; } = [];
|
||||
|
||||
/// <summary>Justification for bypass.</summary>
|
||||
public required string Justification { get; init; }
|
||||
|
||||
/// <summary>Who approved the bypass.</summary>
|
||||
public required string ApprovedBy { get; init; }
|
||||
|
||||
/// <summary>When bypass was approved.</summary>
|
||||
public DateTimeOffset ApprovedAt { get; init; }
|
||||
|
||||
/// <summary>When bypass expires.</summary>
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
}
|
||||
@@ -3,11 +3,16 @@
|
||||
* Sprint: SPRINT_3600_0001_0001 (Trust Algebra and Lattice Engine)
|
||||
* Task: TRUST-014
|
||||
* Update: SPRINT_4300_0002_0001 (BUDGET-002) - Added UnknownBudgets support.
|
||||
* Update: SPRINT_20260118_019 (GR-003) - Added content-addressable hashing.
|
||||
*
|
||||
* Defines trust roots, trust requirements, selection rule overrides, and unknown budgets.
|
||||
*/
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.TrustLattice;
|
||||
|
||||
@@ -287,4 +292,156 @@ public sealed record PolicyBundle
|
||||
Name = "Default Policy",
|
||||
Version = "1.0.0",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Computes a content-addressable hash of the policy bundle.
|
||||
/// Used for exact version identification during replay.
|
||||
/// Sprint: SPRINT_20260118_019 (GR-003)
|
||||
/// </summary>
|
||||
/// <returns>SHA-256 hash prefixed with "sha256:"</returns>
|
||||
public string ComputeHash()
|
||||
{
|
||||
var canonical = new PolicyBundleCanonicalForm
|
||||
{
|
||||
Id = Id,
|
||||
Name = Name,
|
||||
Version = Version,
|
||||
TrustRoots = TrustRoots
|
||||
.Where(r => r.IsActive)
|
||||
.OrderBy(r => r.Principal.Id)
|
||||
.Select(r => new TrustRootCanonicalForm
|
||||
{
|
||||
PrincipalId = r.Principal.Id,
|
||||
PrincipalType = r.Principal.Type.ToString(),
|
||||
ScopeType = r.Scope.Type.ToString(),
|
||||
ScopeConstraint = r.Scope.Constraint,
|
||||
MaxAssurance = r.MaxAssurance.ToString(),
|
||||
ExpiresAt = r.ExpiresAt?.ToUnixTimeSeconds()
|
||||
})
|
||||
.ToList(),
|
||||
TrustRequirements = new TrustRequirementsCanonicalForm
|
||||
{
|
||||
MinResolvedAssurance = TrustRequirements.MinResolvedAssurance.ToString(),
|
||||
MinPedigreeAssurance = TrustRequirements.MinPedigreeAssurance.ToString(),
|
||||
MinEvidenceClass = TrustRequirements.MinEvidenceClass.ToString(),
|
||||
MaxClaimAgeSeconds = TrustRequirements.MaxClaimAge?.TotalSeconds,
|
||||
RequireSignatures = TrustRequirements.RequireSignatures
|
||||
},
|
||||
CustomRules = CustomRules
|
||||
.OrderBy(r => r.Priority)
|
||||
.ThenBy(r => r.Name)
|
||||
.Select(r => new SelectionRuleCanonicalForm
|
||||
{
|
||||
Name = r.Name,
|
||||
Priority = r.Priority,
|
||||
Condition = r.Condition,
|
||||
Disposition = r.Disposition.ToString()
|
||||
})
|
||||
.ToList(),
|
||||
ConflictResolution = ConflictResolution.ToString(),
|
||||
AssumeReachableWhenUnknown = AssumeReachableWhenUnknown,
|
||||
AcceptedVexFormats = AcceptedVexFormats.Order().ToList(),
|
||||
UnknownBudgets = UnknownBudgets
|
||||
.OrderBy(b => b.Environment)
|
||||
.ThenBy(b => b.Name)
|
||||
.Select(b => new UnknownBudgetCanonicalForm
|
||||
{
|
||||
Name = b.Name,
|
||||
Environment = b.Environment,
|
||||
TierMax = b.TierMax,
|
||||
CountMax = b.CountMax,
|
||||
EntropyMax = b.EntropyMax,
|
||||
ReasonLimits = b.ReasonLimits.OrderBy(kv => kv.Key).ToDictionary(kv => kv.Key, kv => kv.Value),
|
||||
Action = b.Action,
|
||||
Message = b.Message
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(canonical, PolicyBundleHashJsonContext.Default.PolicyBundleCanonicalForm);
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cached content hash. Computed lazily and cached.
|
||||
/// </summary>
|
||||
private string? _cachedHash;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the content hash, computing it if necessary.
|
||||
/// </summary>
|
||||
public string ContentHash => _cachedHash ??= ComputeHash();
|
||||
}
|
||||
|
||||
#region Canonical Forms for Hashing
|
||||
|
||||
/// <summary>
|
||||
/// Canonical form of PolicyBundle for deterministic hashing.
|
||||
/// </summary>
|
||||
internal sealed record PolicyBundleCanonicalForm
|
||||
{
|
||||
public string? Id { get; init; }
|
||||
public string? Name { get; init; }
|
||||
public string Version { get; init; } = "";
|
||||
public List<TrustRootCanonicalForm> TrustRoots { get; init; } = [];
|
||||
public TrustRequirementsCanonicalForm TrustRequirements { get; init; } = new();
|
||||
public List<SelectionRuleCanonicalForm> CustomRules { get; init; } = [];
|
||||
public string ConflictResolution { get; init; } = "";
|
||||
public bool AssumeReachableWhenUnknown { get; init; }
|
||||
public List<string> AcceptedVexFormats { get; init; } = [];
|
||||
public List<UnknownBudgetCanonicalForm> UnknownBudgets { get; init; } = [];
|
||||
}
|
||||
|
||||
internal sealed record TrustRootCanonicalForm
|
||||
{
|
||||
public string PrincipalId { get; init; } = "";
|
||||
public string PrincipalType { get; init; } = "";
|
||||
public string ScopeType { get; init; } = "";
|
||||
public string? ScopeConstraint { get; init; }
|
||||
public string MaxAssurance { get; init; } = "";
|
||||
public long? ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record TrustRequirementsCanonicalForm
|
||||
{
|
||||
public string MinResolvedAssurance { get; init; } = "";
|
||||
public string MinPedigreeAssurance { get; init; } = "";
|
||||
public string MinEvidenceClass { get; init; } = "";
|
||||
public double? MaxClaimAgeSeconds { get; init; }
|
||||
public bool RequireSignatures { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record SelectionRuleCanonicalForm
|
||||
{
|
||||
public string Name { get; init; } = "";
|
||||
public int Priority { get; init; }
|
||||
public string Condition { get; init; } = "";
|
||||
public string Disposition { get; init; } = "";
|
||||
}
|
||||
|
||||
internal sealed record UnknownBudgetCanonicalForm
|
||||
{
|
||||
public string Name { get; init; } = "";
|
||||
public string Environment { get; init; } = "";
|
||||
public int? TierMax { get; init; }
|
||||
public int? CountMax { get; init; }
|
||||
public double? EntropyMax { get; init; }
|
||||
public Dictionary<string, int> ReasonLimits { get; init; } = [];
|
||||
public string Action { get; init; } = "";
|
||||
public string? Message { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source-generated JSON context for canonical forms (deterministic serialization).
|
||||
/// </summary>
|
||||
[JsonSourceGenerationOptions(
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
[JsonSerializable(typeof(PolicyBundleCanonicalForm))]
|
||||
internal partial class PolicyBundleHashJsonContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// GatesEndpointsIntegrationTests.cs
|
||||
// Sprint: SPRINT_20260118_018_Unknowns_queue_enhancement
|
||||
// Task: UQ-006 - Integration tests for gates endpoint
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Policy.Gateway.Endpoints;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Tests.Endpoints;
|
||||
|
||||
public sealed class GatesEndpointsIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public GatesEndpointsIntegrationTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_factory = factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Add test doubles
|
||||
services.AddMemoryCache();
|
||||
});
|
||||
});
|
||||
_client = _factory.CreateClient();
|
||||
}
|
||||
|
||||
#region GET /gates/{bom_ref} Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetGateStatus_ValidBomRef_ReturnsOk()
|
||||
{
|
||||
// Arrange
|
||||
var bomRef = Uri.EscapeDataString("pkg:npm/lodash@4.17.21");
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/v1/gates/{bomRef}");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var content = await response.Content.ReadFromJsonAsync<GateStatusResponse>();
|
||||
Assert.NotNull(content);
|
||||
Assert.Equal("pkg:npm/lodash@4.17.21", content.BomRef);
|
||||
Assert.Contains(content.GateDecision, new[] { "pass", "warn", "block" });
|
||||
Assert.NotEqual(default, content.CheckedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetGateStatus_EncodedBomRef_DecodesCorrectly()
|
||||
{
|
||||
// Arrange - Docker image with SHA
|
||||
var bomRef = Uri.EscapeDataString("pkg:docker/acme/api@sha256:abc123def456");
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/v1/gates/{bomRef}");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var content = await response.Content.ReadFromJsonAsync<GateStatusResponse>();
|
||||
Assert.Equal("pkg:docker/acme/api@sha256:abc123def456", content?.BomRef);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetGateStatus_ResolvedComponent_IncludesVerdictHash()
|
||||
{
|
||||
// Arrange - Use a known-clean component
|
||||
var bomRef = Uri.EscapeDataString("pkg:npm/clean-package@1.0.0");
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/v1/gates/{bomRef}");
|
||||
|
||||
// Assert
|
||||
var content = await response.Content.ReadFromJsonAsync<GateStatusResponse>();
|
||||
if (content?.State == "resolved" && content.GateDecision == "pass")
|
||||
{
|
||||
Assert.NotNull(content.VerdictHash);
|
||||
Assert.StartsWith("sha256:", content.VerdictHash);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetGateStatus_UnknownsExist_ReturnsUnknownsList()
|
||||
{
|
||||
// Arrange - Use component that may have unknowns
|
||||
var bomRef = Uri.EscapeDataString("pkg:npm/test-unknowns@1.0.0");
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/v1/gates/{bomRef}");
|
||||
|
||||
// Assert
|
||||
var content = await response.Content.ReadFromJsonAsync<GateStatusResponse>();
|
||||
Assert.NotNull(content);
|
||||
Assert.NotNull(content.Unknowns);
|
||||
|
||||
if (content.Unknowns.Count > 0)
|
||||
{
|
||||
var unknown = content.Unknowns[0];
|
||||
Assert.NotEqual(Guid.Empty, unknown.UnknownId);
|
||||
Assert.Contains(unknown.Band, new[] { "hot", "warm", "cold" });
|
||||
Assert.Contains(unknown.State, new[] { "pending", "under_review", "escalated", "resolved", "rejected" });
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetGateStatus_CachesResponse()
|
||||
{
|
||||
// Arrange
|
||||
var bomRef = Uri.EscapeDataString("pkg:npm/cached@1.0.0");
|
||||
|
||||
// Act - Make two requests
|
||||
var response1 = await _client.GetAsync($"/api/v1/gates/{bomRef}");
|
||||
var response2 = await _client.GetAsync($"/api/v1/gates/{bomRef}");
|
||||
|
||||
// Assert
|
||||
var content1 = await response1.Content.ReadFromJsonAsync<GateStatusResponse>();
|
||||
var content2 = await response2.Content.ReadFromJsonAsync<GateStatusResponse>();
|
||||
|
||||
// Same result should be returned (from cache)
|
||||
Assert.Equal(content1?.GateDecision, content2?.GateDecision);
|
||||
Assert.Equal(content1?.State, content2?.State);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region POST /gates/{bom_ref}/check Tests
|
||||
|
||||
[Fact]
|
||||
public async Task CheckGate_WithoutVerdict_ReturnsDecision()
|
||||
{
|
||||
// Arrange
|
||||
var bomRef = Uri.EscapeDataString("pkg:npm/test@1.0.0");
|
||||
var request = new GateCheckRequest { ProposedVerdict = null };
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync($"/api/v1/gates/{bomRef}/check", request);
|
||||
|
||||
// Assert
|
||||
Assert.True(response.IsSuccessStatusCode || response.StatusCode == HttpStatusCode.Forbidden);
|
||||
|
||||
var content = await response.Content.ReadFromJsonAsync<GateCheckResponse>();
|
||||
Assert.NotNull(content);
|
||||
Assert.Contains(content.Decision, new[] { "pass", "warn", "block" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckGate_NotAffectedVerdict_WithUnknowns_Blocks()
|
||||
{
|
||||
// Arrange - Component with unknowns
|
||||
var bomRef = Uri.EscapeDataString("pkg:npm/has-unknowns@1.0.0");
|
||||
var request = new GateCheckRequest { ProposedVerdict = "not_affected" };
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync($"/api/v1/gates/{bomRef}/check", request);
|
||||
|
||||
// Assert - May be blocked if unknowns exist
|
||||
var content = await response.Content.ReadFromJsonAsync<GateCheckResponse>();
|
||||
Assert.NotNull(content);
|
||||
|
||||
if (content.BlockingUnknownIds.Count > 0)
|
||||
{
|
||||
Assert.Equal("block", content.Decision);
|
||||
Assert.Contains("not_affected", content.Reason, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckGate_Blocked_Returns403()
|
||||
{
|
||||
// Arrange - Force a blocking scenario
|
||||
var bomRef = Uri.EscapeDataString("pkg:npm/blocked-test@1.0.0");
|
||||
var request = new GateCheckRequest { ProposedVerdict = "not_affected" };
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync($"/api/v1/gates/{bomRef}/check", request);
|
||||
|
||||
// Assert
|
||||
var content = await response.Content.ReadFromJsonAsync<GateCheckResponse>();
|
||||
if (content?.Decision == "block")
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region POST /gates/{bom_ref}/exception Tests
|
||||
|
||||
[Fact]
|
||||
public async Task RequestException_ReturnsResponse()
|
||||
{
|
||||
// Arrange
|
||||
var bomRef = Uri.EscapeDataString("pkg:npm/exception-test@1.0.0");
|
||||
var request = new ExceptionRequest
|
||||
{
|
||||
UnknownIds = [Guid.NewGuid()],
|
||||
Justification = "Critical business deadline - risk accepted by security team"
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync($"/api/v1/gates/{bomRef}/exception", request);
|
||||
|
||||
// Assert
|
||||
var content = await response.Content.ReadFromJsonAsync<ExceptionResponse>();
|
||||
Assert.NotNull(content);
|
||||
Assert.NotEqual(default, content.RequestedAt);
|
||||
|
||||
// By default, exceptions are not auto-granted
|
||||
if (!content.Granted)
|
||||
{
|
||||
Assert.NotNull(content.DenialReason);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestException_WithoutJustification_ReturnsBadRequest()
|
||||
{
|
||||
// Arrange
|
||||
var bomRef = Uri.EscapeDataString("pkg:npm/no-justification@1.0.0");
|
||||
var request = new { UnknownIds = new[] { Guid.NewGuid() } }; // Missing justification
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync($"/api/v1/gates/{bomRef}/exception", request);
|
||||
|
||||
// Assert - Should reject missing required field
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Response Format Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Response_UsesSnakeCaseJson()
|
||||
{
|
||||
// Arrange
|
||||
var bomRef = Uri.EscapeDataString("pkg:npm/json-test@1.0.0");
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/v1/gates/{bomRef}");
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// Assert - Check for snake_case property names
|
||||
Assert.Contains("\"bom_ref\"", json);
|
||||
Assert.Contains("\"gate_decision\"", json);
|
||||
Assert.Contains("\"checked_at\"", json);
|
||||
|
||||
// Should NOT contain camelCase
|
||||
Assert.DoesNotContain("\"bomRef\"", json);
|
||||
Assert.DoesNotContain("\"gateDecision\"", json);
|
||||
Assert.DoesNotContain("\"checkedAt\"", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Response_IncludesUnknownDetails()
|
||||
{
|
||||
// Arrange
|
||||
var bomRef = Uri.EscapeDataString("pkg:npm/detailed@1.0.0");
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/v1/gates/{bomRef}");
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// Assert - Check for expected fields in unknowns array
|
||||
if (json.Contains("\"unknowns\":"))
|
||||
{
|
||||
Assert.Contains("\"unknown_id\"", json);
|
||||
Assert.Contains("\"band\"", json);
|
||||
Assert.Contains("\"state\"", json);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
// Placeholder for test Program class if not available
|
||||
#if !INTEGRATION_TEST_HOST
|
||||
public class Program { }
|
||||
#endif
|
||||
@@ -0,0 +1,545 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) 2026 StellaOps
|
||||
// Sprint: SPRINT_20260118_030_LIB_verdict_rekor_gate_api
|
||||
// Task: TASK-030-006 - Integration tests for Score Gate API Endpoint
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using StellaOps.Policy.Gateway.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
using GatewayProgram = StellaOps.Policy.Gateway.Program;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for score-based gate evaluation endpoints.
|
||||
/// Sprint: SPRINT_20260118_030_LIB_verdict_rekor_gate_api
|
||||
/// </summary>
|
||||
public sealed class ScoreGateEndpointsTests : IClassFixture<WebApplicationFactory<GatewayProgram>>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly WebApplicationFactory<GatewayProgram> _factory;
|
||||
|
||||
public ScoreGateEndpointsTests(WebApplicationFactory<GatewayProgram> factory)
|
||||
{
|
||||
_factory = factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.UseSetting("Environment", "Testing");
|
||||
});
|
||||
|
||||
_client = _factory.CreateClient();
|
||||
_client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "test-tenant");
|
||||
}
|
||||
|
||||
#region Health Check Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Health_ReturnsHealthy()
|
||||
{
|
||||
// Act
|
||||
var response = await _client.GetAsync("/api/v1/gate/health", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: CancellationToken.None);
|
||||
result.GetProperty("status").GetString().Should().Be("healthy");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Gate Evaluation Tests
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Evaluate_WithHighScore_ReturnsBlock()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ScoreGateEvaluateRequest
|
||||
{
|
||||
FindingId = "CVE-2024-1234@pkg:npm/lodash@4.17.20",
|
||||
CvssBase = 9.0,
|
||||
Epss = 0.85,
|
||||
Reachability = "function_level",
|
||||
ExploitMaturity = "high",
|
||||
PatchProofConfidence = 0.0
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/gate/evaluate", request, cancellationToken: CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
|
||||
var result = await response.Content.ReadFromJsonAsync<ScoreGateEvaluateResponse>(cancellationToken: CancellationToken.None);
|
||||
result.Should().NotBeNull();
|
||||
result!.Action.Should().Be(ScoreGateActions.Block);
|
||||
result.Score.Should().BeGreaterOrEqualTo(0.65);
|
||||
result.ExitCode.Should().Be(ScoreGateExitCodes.Block);
|
||||
result.VerdictBundleId.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Evaluate_WithMediumScore_ReturnsWarn()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ScoreGateEvaluateRequest
|
||||
{
|
||||
FindingId = "CVE-2024-5678@pkg:npm/express@4.0.0",
|
||||
CvssBase = 5.5,
|
||||
Epss = 0.25,
|
||||
Reachability = "package_level",
|
||||
ExploitMaturity = "poc",
|
||||
PatchProofConfidence = 0.3
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/gate/evaluate", request, cancellationToken: CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var result = await response.Content.ReadFromJsonAsync<ScoreGateEvaluateResponse>(cancellationToken: CancellationToken.None);
|
||||
result.Should().NotBeNull();
|
||||
result!.Action.Should().BeOneOf(ScoreGateActions.Warn, ScoreGateActions.Pass);
|
||||
result.VerdictBundleId.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Evaluate_WithLowScore_ReturnsPass()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ScoreGateEvaluateRequest
|
||||
{
|
||||
FindingId = "CVE-2024-9999@pkg:npm/safe-package@1.0.0",
|
||||
CvssBase = 2.0,
|
||||
Epss = 0.01,
|
||||
Reachability = "none",
|
||||
ExploitMaturity = "none",
|
||||
PatchProofConfidence = 0.9
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/gate/evaluate", request, cancellationToken: CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var result = await response.Content.ReadFromJsonAsync<ScoreGateEvaluateResponse>(cancellationToken: CancellationToken.None);
|
||||
result.Should().NotBeNull();
|
||||
result!.Action.Should().Be(ScoreGateActions.Pass);
|
||||
result.Score.Should().BeLessThan(0.40);
|
||||
result.ExitCode.Should().Be(ScoreGateExitCodes.Pass);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Evaluate_WithNotAffectedVex_ReturnsPass()
|
||||
{
|
||||
// Arrange - authoritative VEX should auto-pass regardless of other scores
|
||||
var request = new ScoreGateEvaluateRequest
|
||||
{
|
||||
FindingId = "CVE-2024-7777@pkg:npm/risky-lib@2.0.0",
|
||||
CvssBase = 9.8,
|
||||
Epss = 0.95,
|
||||
Reachability = "caller",
|
||||
ExploitMaturity = "high",
|
||||
PatchProofConfidence = 0.0,
|
||||
VexStatus = "not_affected",
|
||||
VexSource = ".vex/internal-assessment"
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/gate/evaluate", request, cancellationToken: CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var result = await response.Content.ReadFromJsonAsync<ScoreGateEvaluateResponse>(cancellationToken: CancellationToken.None);
|
||||
result.Should().NotBeNull();
|
||||
result!.Action.Should().Be(ScoreGateActions.Pass);
|
||||
result.MatchedRules.Should().Contain("auto_pass_trusted_vex");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Evaluate_WithIncludeVerdict_ReturnsFullBundle()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ScoreGateEvaluateRequest
|
||||
{
|
||||
FindingId = "CVE-2024-1111@pkg:npm/test-lib@1.0.0",
|
||||
CvssBase = 5.0,
|
||||
Epss = 0.30,
|
||||
IncludeVerdict = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/gate/evaluate", request, cancellationToken: CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var result = await response.Content.ReadFromJsonAsync<ScoreGateEvaluateResponse>(cancellationToken: CancellationToken.None);
|
||||
result.Should().NotBeNull();
|
||||
result!.VerdictBundle.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Evaluate_IncludesBreakdown()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ScoreGateEvaluateRequest
|
||||
{
|
||||
FindingId = "CVE-2024-2222@pkg:npm/breakdown-test@1.0.0",
|
||||
CvssBase = 7.5,
|
||||
Epss = 0.42,
|
||||
Reachability = "function_level",
|
||||
ExploitMaturity = "poc",
|
||||
PatchProofConfidence = 0.3
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/gate/evaluate", request, cancellationToken: CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.Forbidden);
|
||||
var result = await response.Content.ReadFromJsonAsync<ScoreGateEvaluateResponse>(cancellationToken: CancellationToken.None);
|
||||
result.Should().NotBeNull();
|
||||
result!.Breakdown.Should().NotBeNull();
|
||||
result.Breakdown!.Count.Should().BeGreaterThan(0);
|
||||
result.Breakdown.Should().Contain(b => b.Dimension == "CVSS Base" || b.Dimension == "Reachability");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validation Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Evaluate_WithoutFindingId_ReturnsBadRequest()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ScoreGateEvaluateRequest
|
||||
{
|
||||
FindingId = "",
|
||||
CvssBase = 7.5,
|
||||
Epss = 0.42
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/gate/evaluate", request, cancellationToken: CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Evaluate_WithNullBody_ReturnsBadRequest()
|
||||
{
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync<ScoreGateEvaluateRequest?>("/api/v1/gate/evaluate", null, cancellationToken: CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Policy Profile Tests
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Evaluate_WithAdvisoryProfile_UsesAdvisoryFormula()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ScoreGateEvaluateRequest
|
||||
{
|
||||
FindingId = "CVE-2024-3333@pkg:npm/advisory-test@1.0.0",
|
||||
CvssBase = 7.0,
|
||||
Epss = 0.50,
|
||||
Reachability = "function_level",
|
||||
ExploitMaturity = "poc",
|
||||
PatchProofConfidence = 0.2,
|
||||
PolicyProfile = "advisory"
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/gate/evaluate", request, cancellationToken: CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.Forbidden);
|
||||
var result = await response.Content.ReadFromJsonAsync<ScoreGateEvaluateResponse>(cancellationToken: CancellationToken.None);
|
||||
result.Should().NotBeNull();
|
||||
result!.Breakdown.Should().NotBeNull();
|
||||
// Advisory formula should have CVSS, EPSS, Reachability, Exploit Maturity, Patch Proof dimensions
|
||||
result.Breakdown!.Should().Contain(b => b.Symbol == "CVS" || b.Symbol == "EPS" || b.Symbol == "PPF");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Evaluate_WithLegacyProfile_UsesLegacyFormula()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ScoreGateEvaluateRequest
|
||||
{
|
||||
FindingId = "CVE-2024-4444@pkg:npm/legacy-test@1.0.0",
|
||||
CvssBase = 7.0,
|
||||
Epss = 0.50,
|
||||
PolicyProfile = "legacy"
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/gate/evaluate", request, cancellationToken: CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.Forbidden);
|
||||
var result = await response.Content.ReadFromJsonAsync<ScoreGateEvaluateResponse>(cancellationToken: CancellationToken.None);
|
||||
result.Should().NotBeNull();
|
||||
result!.Breakdown.Should().NotBeNull();
|
||||
// Legacy formula should have RCH, RTS, BKP, XPL, SRC, MIT dimensions
|
||||
result.Breakdown!.Should().Contain(b => b.Symbol == "RCH" || b.Symbol == "RTS");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Batch Evaluation Tests
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task EvaluateBatch_WithMultipleFindings_ReturnsAggregatedResults()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ScoreGateBatchEvaluateRequest
|
||||
{
|
||||
Findings =
|
||||
[
|
||||
new ScoreGateEvaluateRequest
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/safe@1.0.0",
|
||||
CvssBase = 2.0,
|
||||
Epss = 0.01
|
||||
},
|
||||
new ScoreGateEvaluateRequest
|
||||
{
|
||||
FindingId = "CVE-2024-0002@pkg:npm/medium@1.0.0",
|
||||
CvssBase = 5.5,
|
||||
Epss = 0.35
|
||||
},
|
||||
new ScoreGateEvaluateRequest
|
||||
{
|
||||
FindingId = "CVE-2024-0003@pkg:npm/risky@1.0.0",
|
||||
CvssBase = 9.8,
|
||||
Epss = 0.90,
|
||||
ExploitMaturity = "high"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/gate/evaluate-batch", request, cancellationToken: CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.Forbidden);
|
||||
var result = await response.Content.ReadFromJsonAsync<ScoreGateBatchEvaluateResponse>(cancellationToken: CancellationToken.None);
|
||||
result.Should().NotBeNull();
|
||||
result!.Summary.Total.Should().Be(3);
|
||||
result.Decisions.Should().HaveCount(3);
|
||||
result.DurationMs.Should().BeGreaterOrEqualTo(0);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task EvaluateBatch_WithBlockedFinding_ReturnsBlockOverallAction()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ScoreGateBatchEvaluateRequest
|
||||
{
|
||||
Findings =
|
||||
[
|
||||
new ScoreGateEvaluateRequest
|
||||
{
|
||||
FindingId = "CVE-2024-SAFE@pkg:npm/safe@1.0.0",
|
||||
CvssBase = 2.0,
|
||||
Epss = 0.01
|
||||
},
|
||||
new ScoreGateEvaluateRequest
|
||||
{
|
||||
FindingId = "CVE-2024-BLOCKED@pkg:npm/risky@1.0.0",
|
||||
CvssBase = 9.8,
|
||||
Epss = 0.95,
|
||||
Reachability = "caller",
|
||||
ExploitMaturity = "high"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/gate/evaluate-batch", request, cancellationToken: CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
|
||||
var result = await response.Content.ReadFromJsonAsync<ScoreGateBatchEvaluateResponse>(cancellationToken: CancellationToken.None);
|
||||
result.Should().NotBeNull();
|
||||
result!.OverallAction.Should().Be(ScoreGateActions.Block);
|
||||
result.ExitCode.Should().Be(ScoreGateExitCodes.Block);
|
||||
result.Summary.Blocked.Should().BeGreaterOrEqualTo(1);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task EvaluateBatch_WithAllPassing_ReturnsPassOverallAction()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ScoreGateBatchEvaluateRequest
|
||||
{
|
||||
Findings =
|
||||
[
|
||||
new ScoreGateEvaluateRequest
|
||||
{
|
||||
FindingId = "CVE-2024-LOW1@pkg:npm/safe1@1.0.0",
|
||||
CvssBase = 2.0,
|
||||
Epss = 0.01,
|
||||
PatchProofConfidence = 0.9
|
||||
},
|
||||
new ScoreGateEvaluateRequest
|
||||
{
|
||||
FindingId = "CVE-2024-LOW2@pkg:npm/safe2@1.0.0",
|
||||
CvssBase = 3.0,
|
||||
Epss = 0.05,
|
||||
PatchProofConfidence = 0.8
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/gate/evaluate-batch", request, cancellationToken: CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var result = await response.Content.ReadFromJsonAsync<ScoreGateBatchEvaluateResponse>(cancellationToken: CancellationToken.None);
|
||||
result.Should().NotBeNull();
|
||||
result!.OverallAction.Should().Be(ScoreGateActions.Pass);
|
||||
result.ExitCode.Should().Be(ScoreGateExitCodes.Pass);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task EvaluateBatch_WithFailFast_StopsOnFirstBlock()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ScoreGateBatchEvaluateRequest
|
||||
{
|
||||
Findings =
|
||||
[
|
||||
new ScoreGateEvaluateRequest
|
||||
{
|
||||
FindingId = "CVE-2024-FIRST@pkg:npm/first@1.0.0",
|
||||
CvssBase = 9.8,
|
||||
Epss = 0.95,
|
||||
ExploitMaturity = "high"
|
||||
},
|
||||
new ScoreGateEvaluateRequest
|
||||
{
|
||||
FindingId = "CVE-2024-SECOND@pkg:npm/second@1.0.0",
|
||||
CvssBase = 9.8,
|
||||
Epss = 0.95,
|
||||
ExploitMaturity = "high"
|
||||
}
|
||||
],
|
||||
Options = new ScoreGateBatchOptions { FailFast = true }
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/gate/evaluate-batch", request, cancellationToken: CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
|
||||
var result = await response.Content.ReadFromJsonAsync<ScoreGateBatchEvaluateResponse>(cancellationToken: CancellationToken.None);
|
||||
result.Should().NotBeNull();
|
||||
result!.OverallAction.Should().Be(ScoreGateActions.Block);
|
||||
// With fail-fast, it may stop before processing all
|
||||
result.Summary.Blocked.Should().BeGreaterOrEqualTo(1);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task EvaluateBatch_WithIncludeVerdicts_ReturnsVerdictBundles()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ScoreGateBatchEvaluateRequest
|
||||
{
|
||||
Findings =
|
||||
[
|
||||
new ScoreGateEvaluateRequest
|
||||
{
|
||||
FindingId = "CVE-2024-VERBOSE@pkg:npm/verbose@1.0.0",
|
||||
CvssBase = 5.0,
|
||||
Epss = 0.30
|
||||
}
|
||||
],
|
||||
Options = new ScoreGateBatchOptions { IncludeVerdicts = true }
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/gate/evaluate-batch", request, cancellationToken: CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.Forbidden);
|
||||
var result = await response.Content.ReadFromJsonAsync<ScoreGateBatchEvaluateResponse>(cancellationToken: CancellationToken.None);
|
||||
result.Should().NotBeNull();
|
||||
result!.Decisions.Should().HaveCount(1);
|
||||
result.Decisions[0].VerdictBundle.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvaluateBatch_WithEmptyFindings_ReturnsBadRequest()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ScoreGateBatchEvaluateRequest
|
||||
{
|
||||
Findings = []
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/gate/evaluate-batch", request, cancellationToken: CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task EvaluateBatch_WithManyFindings_HandlesParallelEvaluation()
|
||||
{
|
||||
// Arrange - 20 findings for parallel test
|
||||
var findings = Enumerable.Range(1, 20).Select(i => new ScoreGateEvaluateRequest
|
||||
{
|
||||
FindingId = $"CVE-2024-{i:D4}@pkg:npm/test-{i}@1.0.0",
|
||||
CvssBase = (i % 10) + 1,
|
||||
Epss = (i % 100) / 100.0
|
||||
}).ToList();
|
||||
|
||||
var request = new ScoreGateBatchEvaluateRequest
|
||||
{
|
||||
Findings = findings,
|
||||
Options = new ScoreGateBatchOptions { MaxParallelism = 5 }
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/gate/evaluate-batch", request, cancellationToken: CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.Forbidden);
|
||||
var result = await response.Content.ReadFromJsonAsync<ScoreGateBatchEvaluateResponse>(cancellationToken: CancellationToken.None);
|
||||
result.Should().NotBeNull();
|
||||
result!.Summary.Total.Should().Be(20);
|
||||
result.Decisions.Should().HaveCount(20);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// HttpOpaClientTests.cs
|
||||
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
|
||||
// Task: TASK-017-007 - OPA Client Integration
|
||||
// Description: Unit tests for HTTP OPA client
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Gates.Opa;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.Gates;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HttpOpaClientTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_SuccessfulResponse_ReturnsResult()
|
||||
{
|
||||
var responseJson = """
|
||||
{
|
||||
"decision_id": "test-123",
|
||||
"result": {
|
||||
"allow": true,
|
||||
"reason": "All checks passed"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var mockHandler = new MockHttpMessageHandler(
|
||||
new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(responseJson, System.Text.Encoding.UTF8, "application/json")
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(mockHandler)
|
||||
{
|
||||
BaseAddress = new Uri("http://localhost:8181")
|
||||
};
|
||||
|
||||
var options = Options.Create(new OpaClientOptions
|
||||
{
|
||||
BaseUrl = "http://localhost:8181",
|
||||
IncludeMetrics = true
|
||||
});
|
||||
|
||||
using var client = new HttpOpaClient(options, NullLogger<HttpOpaClient>.Instance, httpClient);
|
||||
var result = await client.EvaluateAsync("stella/test/allow", new { input = "test" });
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("test-123", result.DecisionId);
|
||||
Assert.NotNull(result.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ServerError_ReturnsFailure()
|
||||
{
|
||||
var mockHandler = new MockHttpMessageHandler(
|
||||
new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
||||
{
|
||||
Content = new StringContent("Internal Server Error")
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(mockHandler)
|
||||
{
|
||||
BaseAddress = new Uri("http://localhost:8181")
|
||||
};
|
||||
|
||||
var options = Options.Create(new OpaClientOptions());
|
||||
|
||||
using var client = new HttpOpaClient(options, NullLogger<HttpOpaClient>.Instance, httpClient);
|
||||
var result = await client.EvaluateAsync("stella/test/allow", new { input = "test" });
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("500", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_TypedResult_DeserializesCorrectly()
|
||||
{
|
||||
var responseJson = """
|
||||
{
|
||||
"decision_id": "typed-123",
|
||||
"result": {
|
||||
"allow": false,
|
||||
"reason": "Key not trusted",
|
||||
"violations": ["Untrusted signing key"]
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var mockHandler = new MockHttpMessageHandler(
|
||||
new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(responseJson, System.Text.Encoding.UTF8, "application/json")
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(mockHandler)
|
||||
{
|
||||
BaseAddress = new Uri("http://localhost:8181")
|
||||
};
|
||||
|
||||
var options = Options.Create(new OpaClientOptions());
|
||||
|
||||
using var client = new HttpOpaClient(options, NullLogger<HttpOpaClient>.Instance, httpClient);
|
||||
var result = await client.EvaluateAsync<TestPolicyResult>("stella/test/allow", new { input = "test" });
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Result);
|
||||
Assert.False(result.Result.Allow);
|
||||
Assert.Equal("Key not trusted", result.Result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthCheckAsync_HealthyServer_ReturnsTrue()
|
||||
{
|
||||
var mockHandler = new MockHttpMessageHandler(
|
||||
new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
var httpClient = new HttpClient(mockHandler)
|
||||
{
|
||||
BaseAddress = new Uri("http://localhost:8181")
|
||||
};
|
||||
|
||||
var options = Options.Create(new OpaClientOptions());
|
||||
|
||||
using var client = new HttpOpaClient(options, NullLogger<HttpOpaClient>.Instance, httpClient);
|
||||
var result = await client.HealthCheckAsync();
|
||||
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthCheckAsync_UnhealthyServer_ReturnsFalse()
|
||||
{
|
||||
var mockHandler = new MockHttpMessageHandler(
|
||||
new HttpResponseMessage(HttpStatusCode.ServiceUnavailable));
|
||||
|
||||
var httpClient = new HttpClient(mockHandler)
|
||||
{
|
||||
BaseAddress = new Uri("http://localhost:8181")
|
||||
};
|
||||
|
||||
var options = Options.Create(new OpaClientOptions());
|
||||
|
||||
using var client = new HttpOpaClient(options, NullLogger<HttpOpaClient>.Instance, httpClient);
|
||||
var result = await client.HealthCheckAsync();
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WithMetrics_IncludesMetricsParameter()
|
||||
{
|
||||
var responseJson = """
|
||||
{
|
||||
"decision_id": "metrics-test",
|
||||
"result": { "allow": true },
|
||||
"metrics": {
|
||||
"timer_rego_query_compile_ns": 12345,
|
||||
"timer_rego_query_eval_ns": 67890,
|
||||
"timer_server_handler_ns": 100000
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var mockHandler = new MockHttpMessageHandler(
|
||||
new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(responseJson, System.Text.Encoding.UTF8, "application/json")
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(mockHandler)
|
||||
{
|
||||
BaseAddress = new Uri("http://localhost:8181")
|
||||
};
|
||||
|
||||
var options = Options.Create(new OpaClientOptions { IncludeMetrics = true });
|
||||
|
||||
using var client = new HttpOpaClient(options, NullLogger<HttpOpaClient>.Instance, httpClient);
|
||||
var result = await client.EvaluateAsync("stella/test/allow", new { });
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Metrics);
|
||||
Assert.Equal(12345, result.Metrics.TimerRegoQueryCompileNs);
|
||||
Assert.Equal(67890, result.Metrics.TimerRegoQueryEvalNs);
|
||||
Assert.Equal(100000, result.Metrics.TimerServerHandlerNs);
|
||||
}
|
||||
|
||||
private sealed record TestPolicyResult
|
||||
{
|
||||
public bool Allow { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
public IReadOnlyList<string>? Violations { get; init; }
|
||||
}
|
||||
|
||||
private sealed class MockHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpResponseMessage _response;
|
||||
|
||||
public MockHttpMessageHandler(HttpResponseMessage response)
|
||||
{
|
||||
_response = response;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(_response);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// OpaGateAdapterTests.cs
|
||||
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
|
||||
// Task: TASK-017-007 - OPA Client Integration
|
||||
// Description: Unit tests for OPA gate adapter
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Gates;
|
||||
using StellaOps.Policy.Gates.Opa;
|
||||
using StellaOps.Policy.TrustLattice;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.Gates;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpaGateAdapterTests
|
||||
{
|
||||
private static MergeResult CreateMergeResult() => new()
|
||||
{
|
||||
Status = VexStatus.Affected,
|
||||
Confidence = 0.8,
|
||||
HasConflicts = false,
|
||||
AllClaims = ImmutableArray<ScoredClaim>.Empty,
|
||||
WinningClaim = new ScoredClaim
|
||||
{
|
||||
SourceId = "test",
|
||||
Status = VexStatus.Affected,
|
||||
OriginalScore = 0.8,
|
||||
AdjustedScore = 0.8,
|
||||
ScopeSpecificity = 1,
|
||||
Accepted = true,
|
||||
Reason = "test"
|
||||
},
|
||||
Conflicts = ImmutableArray<ConflictRecord>.Empty
|
||||
};
|
||||
|
||||
private static PolicyGateContext CreateContext(string environment = "production") => new()
|
||||
{
|
||||
Environment = environment,
|
||||
HasReachabilityProof = true
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_OpaReturnsAllow_ReturnsPass()
|
||||
{
|
||||
var mockClient = new MockOpaClient(new OpaTypedResult<object>
|
||||
{
|
||||
Success = true,
|
||||
DecisionId = "test-decision-123",
|
||||
Result = new { allow = true, reason = "All checks passed" }
|
||||
});
|
||||
|
||||
var options = Options.Create(new OpaGateOptions
|
||||
{
|
||||
GateName = "TestOpaGate",
|
||||
PolicyPath = "stella/test/allow"
|
||||
});
|
||||
|
||||
var gate = new OpaGateAdapter(mockClient, options, NullLogger<OpaGateAdapter>.Instance);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Equal("TestOpaGate", result.GateName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_OpaReturnsDeny_ReturnsFail()
|
||||
{
|
||||
var mockClient = new MockOpaClient(new OpaTypedResult<object>
|
||||
{
|
||||
Success = true,
|
||||
DecisionId = "test-decision-456",
|
||||
Result = new { allow = false, reason = "Untrusted key", violations = new[] { "Key not in trusted set" } }
|
||||
});
|
||||
|
||||
var options = Options.Create(new OpaGateOptions
|
||||
{
|
||||
GateName = "TestOpaGate",
|
||||
PolicyPath = "stella/test/allow"
|
||||
});
|
||||
|
||||
var gate = new OpaGateAdapter(mockClient, options, NullLogger<OpaGateAdapter>.Instance);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.False(result.Passed);
|
||||
Assert.Contains("Untrusted key", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_OpaError_FailOnErrorTrue_ReturnsFail()
|
||||
{
|
||||
var mockClient = new MockOpaClient(new OpaTypedResult<object>
|
||||
{
|
||||
Success = false,
|
||||
Error = "Connection refused"
|
||||
});
|
||||
|
||||
var options = Options.Create(new OpaGateOptions
|
||||
{
|
||||
GateName = "TestOpaGate",
|
||||
PolicyPath = "stella/test/allow",
|
||||
FailOnError = true
|
||||
});
|
||||
|
||||
var gate = new OpaGateAdapter(mockClient, options, NullLogger<OpaGateAdapter>.Instance);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.False(result.Passed);
|
||||
Assert.Contains("OPA evaluation error", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_OpaError_FailOnErrorFalse_ReturnsPass()
|
||||
{
|
||||
var mockClient = new MockOpaClient(new OpaTypedResult<object>
|
||||
{
|
||||
Success = false,
|
||||
Error = "Connection refused"
|
||||
});
|
||||
|
||||
var options = Options.Create(new OpaGateOptions
|
||||
{
|
||||
GateName = "TestOpaGate",
|
||||
PolicyPath = "stella/test/allow",
|
||||
FailOnError = false
|
||||
});
|
||||
|
||||
var gate = new OpaGateAdapter(mockClient, options, NullLogger<OpaGateAdapter>.Instance);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.True(result.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_IncludesDecisionIdInDetails()
|
||||
{
|
||||
var mockClient = new MockOpaClient(new OpaTypedResult<object>
|
||||
{
|
||||
Success = true,
|
||||
DecisionId = "decision-abc-123",
|
||||
Result = new { allow = true }
|
||||
});
|
||||
|
||||
var options = Options.Create(new OpaGateOptions
|
||||
{
|
||||
GateName = "TestOpaGate",
|
||||
PolicyPath = "stella/test/allow"
|
||||
});
|
||||
|
||||
var gate = new OpaGateAdapter(mockClient, options, NullLogger<OpaGateAdapter>.Instance);
|
||||
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
|
||||
|
||||
Assert.True(result.Details.ContainsKey("opaDecisionId"));
|
||||
Assert.Equal("decision-abc-123", result.Details["opaDecisionId"]);
|
||||
}
|
||||
|
||||
private sealed class MockOpaClient : IOpaClient
|
||||
{
|
||||
private readonly OpaTypedResult<object> _result;
|
||||
|
||||
public MockOpaClient(OpaTypedResult<object> result)
|
||||
{
|
||||
_result = result;
|
||||
}
|
||||
|
||||
public Task<OpaEvaluationResult> EvaluateAsync(string policyPath, object input, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new OpaEvaluationResult
|
||||
{
|
||||
Success = _result.Success,
|
||||
DecisionId = _result.DecisionId,
|
||||
Result = _result.Result,
|
||||
Error = _result.Error
|
||||
});
|
||||
}
|
||||
|
||||
public Task<OpaTypedResult<TResult>> EvaluateAsync<TResult>(string policyPath, object input, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// For the mock, we just return what we have
|
||||
return Task.FromResult(new OpaTypedResult<TResult>
|
||||
{
|
||||
Success = _result.Success,
|
||||
DecisionId = _result.DecisionId,
|
||||
Result = _result.Result is TResult typed ? typed : default,
|
||||
Error = _result.Error
|
||||
});
|
||||
}
|
||||
|
||||
public Task<bool> HealthCheckAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
public Task UploadPolicyAsync(string policyId, string regoContent, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task DeletePolicyAsync(string policyId, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TrustedKeyRegistryTests.cs
|
||||
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
|
||||
// Task: TASK-017-005 - Trusted Key Registry
|
||||
// Description: Unit tests for trusted key registry implementations
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Policy.Gates.Attestation;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.Gates;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TrustedKeyRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task InMemoryRegistry_IsTrustedAsync_UnknownKey_ReturnsFalse()
|
||||
{
|
||||
var registry = new InMemoryTrustedKeyRegistry();
|
||||
|
||||
var result = await registry.IsTrustedAsync("unknown-key-id");
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InMemoryRegistry_AddAsync_ThenIsTrusted_ReturnsTrue()
|
||||
{
|
||||
var registry = new InMemoryTrustedKeyRegistry();
|
||||
var key = new TrustedKey
|
||||
{
|
||||
KeyId = "key-001",
|
||||
Fingerprint = "sha256:abc123def456",
|
||||
Algorithm = "ECDSA_P256",
|
||||
TrustedAt = DateTimeOffset.UtcNow,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
await registry.AddAsync(key);
|
||||
var result = await registry.IsTrustedAsync("key-001");
|
||||
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InMemoryRegistry_GetKeyAsync_ReturnsKey()
|
||||
{
|
||||
var registry = new InMemoryTrustedKeyRegistry();
|
||||
var key = new TrustedKey
|
||||
{
|
||||
KeyId = "key-002",
|
||||
Fingerprint = "sha256:xyz789",
|
||||
Algorithm = "Ed25519",
|
||||
Owner = "test@example.com",
|
||||
TrustedAt = DateTimeOffset.UtcNow,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
await registry.AddAsync(key);
|
||||
var retrieved = await registry.GetKeyAsync("key-002");
|
||||
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal("key-002", retrieved.KeyId);
|
||||
Assert.Equal("Ed25519", retrieved.Algorithm);
|
||||
Assert.Equal("test@example.com", retrieved.Owner);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InMemoryRegistry_GetByFingerprintAsync_ReturnsKey()
|
||||
{
|
||||
var registry = new InMemoryTrustedKeyRegistry();
|
||||
var key = new TrustedKey
|
||||
{
|
||||
KeyId = "key-003",
|
||||
Fingerprint = "sha256:fingerprint123",
|
||||
Algorithm = "RSA_2048",
|
||||
TrustedAt = DateTimeOffset.UtcNow,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
await registry.AddAsync(key);
|
||||
var retrieved = await registry.GetByFingerprintAsync("sha256:fingerprint123");
|
||||
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal("key-003", retrieved.KeyId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InMemoryRegistry_RevokeAsync_KeyNoLongerTrusted()
|
||||
{
|
||||
var registry = new InMemoryTrustedKeyRegistry();
|
||||
var key = new TrustedKey
|
||||
{
|
||||
KeyId = "key-004",
|
||||
Fingerprint = "sha256:revokeme",
|
||||
Algorithm = "ECDSA_P384",
|
||||
TrustedAt = DateTimeOffset.UtcNow,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
await registry.AddAsync(key);
|
||||
Assert.True(await registry.IsTrustedAsync("key-004"));
|
||||
|
||||
await registry.RevokeAsync("key-004", "Compromised key");
|
||||
|
||||
Assert.False(await registry.IsTrustedAsync("key-004"));
|
||||
|
||||
var revokedKey = await registry.GetKeyAsync("key-004");
|
||||
Assert.NotNull(revokedKey);
|
||||
Assert.False(revokedKey.IsActive);
|
||||
Assert.Equal("Compromised key", revokedKey.RevokedReason);
|
||||
Assert.NotNull(revokedKey.RevokedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InMemoryRegistry_ExpiredKey_NotTrusted()
|
||||
{
|
||||
var registry = new InMemoryTrustedKeyRegistry();
|
||||
var key = new TrustedKey
|
||||
{
|
||||
KeyId = "key-005",
|
||||
Fingerprint = "sha256:expired",
|
||||
Algorithm = "ECDSA_P256",
|
||||
TrustedAt = DateTimeOffset.UtcNow.AddDays(-30),
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(-1), // Expired yesterday
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
await registry.AddAsync(key);
|
||||
|
||||
Assert.False(await registry.IsTrustedAsync("key-005"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InMemoryRegistry_ListAsync_ReturnsAllKeys()
|
||||
{
|
||||
var registry = new InMemoryTrustedKeyRegistry();
|
||||
|
||||
await registry.AddAsync(new TrustedKey
|
||||
{
|
||||
KeyId = "list-key-1",
|
||||
Fingerprint = "sha256:list1",
|
||||
Algorithm = "ECDSA_P256",
|
||||
TrustedAt = DateTimeOffset.UtcNow,
|
||||
IsActive = true
|
||||
});
|
||||
|
||||
await registry.AddAsync(new TrustedKey
|
||||
{
|
||||
KeyId = "list-key-2",
|
||||
Fingerprint = "sha256:list2",
|
||||
Algorithm = "Ed25519",
|
||||
TrustedAt = DateTimeOffset.UtcNow,
|
||||
IsActive = true
|
||||
});
|
||||
|
||||
var keys = new List<TrustedKey>();
|
||||
await foreach (var key in registry.ListAsync())
|
||||
{
|
||||
keys.Add(key);
|
||||
}
|
||||
|
||||
Assert.Equal(2, keys.Count);
|
||||
Assert.Contains(keys, k => k.KeyId == "list-key-1");
|
||||
Assert.Contains(keys, k => k.KeyId == "list-key-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InMemoryRegistry_InactiveKey_NotTrusted()
|
||||
{
|
||||
var registry = new InMemoryTrustedKeyRegistry();
|
||||
var key = new TrustedKey
|
||||
{
|
||||
KeyId = "key-006",
|
||||
Fingerprint = "sha256:inactive",
|
||||
Algorithm = "ECDSA_P256",
|
||||
TrustedAt = DateTimeOffset.UtcNow,
|
||||
IsActive = false // Explicitly inactive
|
||||
};
|
||||
|
||||
await registry.AddAsync(key);
|
||||
|
||||
Assert.False(await registry.IsTrustedAsync("key-006"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InMemoryRegistry_KeyWithPurposes_StoresPurposes()
|
||||
{
|
||||
var registry = new InMemoryTrustedKeyRegistry();
|
||||
var key = new TrustedKey
|
||||
{
|
||||
KeyId = "key-007",
|
||||
Fingerprint = "sha256:purposes",
|
||||
Algorithm = "ECDSA_P256",
|
||||
Purposes = new[] { "sbom-signing", "vex-signing" },
|
||||
TrustedAt = DateTimeOffset.UtcNow,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
await registry.AddAsync(key);
|
||||
var retrieved = await registry.GetKeyAsync("key-007");
|
||||
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal(2, retrieved.Purposes.Count);
|
||||
Assert.Contains("sbom-signing", retrieved.Purposes);
|
||||
Assert.Contains("vex-signing", retrieved.Purposes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// UnknownsGateCheckerIntegrationTests.cs
|
||||
// Sprint: SPRINT_20260118_018_Unknowns_queue_enhancement
|
||||
// Task: UQ-003 - Integration tests with mocked unknowns
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using StellaOps.Policy.Gates;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.Gates;
|
||||
|
||||
public sealed class UnknownsGateCheckerIntegrationTests
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly UnknownsGateOptions _options;
|
||||
private readonly ILogger<UnknownsGateChecker> _logger;
|
||||
|
||||
public UnknownsGateCheckerIntegrationTests()
|
||||
{
|
||||
_httpClient = new HttpClient();
|
||||
_cache = new MemoryCache(new MemoryCacheOptions());
|
||||
_options = new UnknownsGateOptions
|
||||
{
|
||||
FailClosed = true,
|
||||
BlockNotAffectedWithUnknowns = true,
|
||||
RequireKevException = true,
|
||||
ForceReviewOnSlaBreach = true,
|
||||
CacheTtlSeconds = 30
|
||||
};
|
||||
_logger = Substitute.For<ILogger<UnknownsGateChecker>>();
|
||||
}
|
||||
|
||||
#region Gate Decision Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Check_NoUnknowns_ReturnsPass()
|
||||
{
|
||||
// Arrange
|
||||
var checker = CreateCheckerWithMockedUnknowns([]);
|
||||
|
||||
// Act
|
||||
var result = await checker.CheckAsync("pkg:npm/clean@1.0.0");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(GateDecision.Pass, result.Decision);
|
||||
Assert.Equal("resolved", result.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_HotUnknowns_FailClosed_ReturnsBlock()
|
||||
{
|
||||
// Arrange
|
||||
var unknowns = new List<UnknownState>
|
||||
{
|
||||
new()
|
||||
{
|
||||
UnknownId = Guid.NewGuid(),
|
||||
CveId = "CVE-2026-1234",
|
||||
Band = "hot",
|
||||
State = "pending",
|
||||
SlaRemainingHours = 12
|
||||
}
|
||||
};
|
||||
var checker = CreateCheckerWithMockedUnknowns(unknowns);
|
||||
|
||||
// Act
|
||||
var result = await checker.CheckAsync("pkg:npm/vulnerable@1.0.0");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(GateDecision.Block, result.Decision);
|
||||
Assert.Equal("blocked_by_unknowns", result.State);
|
||||
Assert.Single(result.BlockingUnknownIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_WarmUnknowns_ReturnsWarn()
|
||||
{
|
||||
// Arrange
|
||||
var unknowns = new List<UnknownState>
|
||||
{
|
||||
new()
|
||||
{
|
||||
UnknownId = Guid.NewGuid(),
|
||||
CveId = "CVE-2026-5678",
|
||||
Band = "warm",
|
||||
State = "pending",
|
||||
SlaRemainingHours = 120
|
||||
}
|
||||
};
|
||||
var checker = CreateCheckerWithMockedUnknowns(unknowns);
|
||||
|
||||
// Act
|
||||
var result = await checker.CheckAsync("pkg:npm/warning@1.0.0");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(GateDecision.Warn, result.Decision);
|
||||
Assert.Equal("pending", result.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_ColdUnknowns_ReturnsWarn()
|
||||
{
|
||||
// Arrange
|
||||
var unknowns = new List<UnknownState>
|
||||
{
|
||||
new()
|
||||
{
|
||||
UnknownId = Guid.NewGuid(),
|
||||
CveId = "CVE-2026-9999",
|
||||
Band = "cold",
|
||||
State = "pending",
|
||||
SlaRemainingHours = 500
|
||||
}
|
||||
};
|
||||
var checker = CreateCheckerWithMockedUnknowns(unknowns);
|
||||
|
||||
// Act
|
||||
var result = await checker.CheckAsync("pkg:npm/cold@1.0.0");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(GateDecision.Warn, result.Decision);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Not Affected Verdict Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Check_NotAffectedVerdict_WithUnknowns_Blocks()
|
||||
{
|
||||
// Arrange
|
||||
var unknowns = new List<UnknownState>
|
||||
{
|
||||
new()
|
||||
{
|
||||
UnknownId = Guid.NewGuid(),
|
||||
Band = "warm",
|
||||
State = "pending",
|
||||
SlaRemainingHours = 100
|
||||
}
|
||||
};
|
||||
var checker = CreateCheckerWithMockedUnknowns(unknowns);
|
||||
|
||||
// Act
|
||||
var result = await checker.CheckAsync("pkg:npm/test@1.0.0", "not_affected");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(GateDecision.Block, result.Decision);
|
||||
Assert.Contains("not_affected", result.Reason, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_NotAffectedVerdict_NoUnknowns_Passes()
|
||||
{
|
||||
// Arrange
|
||||
var checker = CreateCheckerWithMockedUnknowns([]);
|
||||
|
||||
// Act
|
||||
var result = await checker.CheckAsync("pkg:npm/clean@1.0.0", "not_affected");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(GateDecision.Pass, result.Decision);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region KEV Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Check_KevUnknown_RequiresException()
|
||||
{
|
||||
// Arrange
|
||||
var unknowns = new List<UnknownState>
|
||||
{
|
||||
new()
|
||||
{
|
||||
UnknownId = Guid.NewGuid(),
|
||||
CveId = "CVE-2026-KEV1",
|
||||
Band = "warm",
|
||||
State = "pending",
|
||||
InKev = true
|
||||
}
|
||||
};
|
||||
var checker = CreateCheckerWithMockedUnknowns(unknowns);
|
||||
|
||||
// Act
|
||||
var result = await checker.CheckAsync("pkg:npm/kev@1.0.0");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(GateDecision.Block, result.Decision);
|
||||
Assert.Contains("KEV", result.Reason, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SLA Breach Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Check_SlaBreached_ForcesReview()
|
||||
{
|
||||
// Arrange
|
||||
var unknowns = new List<UnknownState>
|
||||
{
|
||||
new()
|
||||
{
|
||||
UnknownId = Guid.NewGuid(),
|
||||
Band = "cold",
|
||||
State = "pending",
|
||||
SlaBreach = true
|
||||
}
|
||||
};
|
||||
var checker = CreateCheckerWithMockedUnknowns(unknowns);
|
||||
|
||||
// Act
|
||||
var result = await checker.CheckAsync("pkg:npm/overdue@1.0.0");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(GateDecision.Block, result.Decision);
|
||||
Assert.Contains("SLA", result.Reason, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Caching Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Check_CachesResult()
|
||||
{
|
||||
// Arrange
|
||||
var callCount = 0;
|
||||
var unknowns = new List<UnknownState>();
|
||||
var checker = CreateCheckerWithMockedUnknowns(unknowns, () => callCount++);
|
||||
|
||||
// Act
|
||||
await checker.CheckAsync("pkg:npm/cached@1.0.0");
|
||||
await checker.CheckAsync("pkg:npm/cached@1.0.0");
|
||||
await checker.CheckAsync("pkg:npm/cached@1.0.0");
|
||||
|
||||
// Assert - Should only call underlying once due to caching
|
||||
Assert.Equal(1, callCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_DifferentBomRefs_NotCached()
|
||||
{
|
||||
// Arrange
|
||||
var callCount = 0;
|
||||
var unknowns = new List<UnknownState>();
|
||||
var checker = CreateCheckerWithMockedUnknowns(unknowns, () => callCount++);
|
||||
|
||||
// Act
|
||||
await checker.CheckAsync("pkg:npm/one@1.0.0");
|
||||
await checker.CheckAsync("pkg:npm/two@1.0.0");
|
||||
|
||||
// Assert - Should call twice (different keys)
|
||||
Assert.Equal(2, callCount);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Aggregate State Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Check_MultipleUnknowns_ReturnsWorstState()
|
||||
{
|
||||
// Arrange
|
||||
var unknowns = new List<UnknownState>
|
||||
{
|
||||
new() { UnknownId = Guid.NewGuid(), Band = "cold", State = "pending" },
|
||||
new() { UnknownId = Guid.NewGuid(), Band = "warm", State = "under_review" },
|
||||
new() { UnknownId = Guid.NewGuid(), Band = "warm", State = "escalated" }
|
||||
};
|
||||
var checker = CreateCheckerWithMockedUnknowns(unknowns);
|
||||
|
||||
// Act
|
||||
var result = await checker.CheckAsync("pkg:npm/multi@1.0.0");
|
||||
|
||||
// Assert - Escalated is worst
|
||||
Assert.Equal("escalated", result.State);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Exception Workflow Tests
|
||||
|
||||
[Fact]
|
||||
public async Task RequestException_ReturnsNotGrantedByDefault()
|
||||
{
|
||||
// Arrange
|
||||
var checker = CreateCheckerWithMockedUnknowns([]);
|
||||
|
||||
// Act
|
||||
var result = await checker.RequestExceptionAsync(
|
||||
"pkg:npm/test@1.0.0",
|
||||
[Guid.NewGuid()],
|
||||
"Critical business need",
|
||||
"user@example.com");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Granted);
|
||||
Assert.NotNull(result.DenialReason);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private UnknownsGateChecker CreateCheckerWithMockedUnknowns(
|
||||
List<UnknownState> unknowns,
|
||||
Action? onGetUnknowns = null)
|
||||
{
|
||||
return new MockedUnknownsGateChecker(
|
||||
_httpClient,
|
||||
_cache,
|
||||
Options.Create(_options),
|
||||
_logger,
|
||||
unknowns,
|
||||
onGetUnknowns);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mocked gate checker for testing.
|
||||
/// </summary>
|
||||
public sealed class MockedUnknownsGateChecker : UnknownsGateChecker
|
||||
{
|
||||
private readonly List<UnknownState> _unknowns;
|
||||
private readonly Action? _onGetUnknowns;
|
||||
|
||||
public MockedUnknownsGateChecker(
|
||||
HttpClient httpClient,
|
||||
IMemoryCache cache,
|
||||
IOptions<UnknownsGateOptions> options,
|
||||
ILogger<UnknownsGateChecker> logger,
|
||||
List<UnknownState> unknowns,
|
||||
Action? onGetUnknowns = null)
|
||||
: base(httpClient, cache, options, logger)
|
||||
{
|
||||
_unknowns = unknowns;
|
||||
_onGetUnknowns = onGetUnknowns;
|
||||
}
|
||||
|
||||
public override Task<IReadOnlyList<UnknownState>> GetUnknownsAsync(
|
||||
string bomRef,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_onGetUnknowns?.Invoke();
|
||||
return Task.FromResult<IReadOnlyList<UnknownState>>(_unknowns);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user