Add property-based tests for SBOM/VEX document ordering and Unicode normalization determinism

- Implement `SbomVexOrderingDeterminismProperties` for testing component list and vulnerability metadata hash consistency.
- Create `UnicodeNormalizationDeterminismProperties` to validate NFC normalization and Unicode string handling.
- Add project file for `StellaOps.Testing.Determinism.Properties` with necessary dependencies.
- Introduce CI/CD template validation tests including YAML syntax checks and documentation content verification.
- Create validation script for CI/CD templates ensuring all required files and structures are present.
This commit is contained in:
StellaOps Bot
2025-12-26 15:17:15 +02:00
parent 7792749bb4
commit 907783f625
354 changed files with 79727 additions and 1346 deletions

View File

@@ -0,0 +1,243 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Sprint: SPRINT_20251226_001_BE_cicd_gate_integration
// Task: CICD-GATE-01 - Create gate/evaluate endpoint contracts
namespace StellaOps.Policy.Gateway.Contracts;
/// <summary>
/// Request to evaluate a CI/CD gate for an image.
/// </summary>
public sealed record GateEvaluateRequest
{
/// <summary>
/// The image digest to evaluate (e.g., sha256:abc123...).
/// </summary>
public required string ImageDigest { get; init; }
/// <summary>
/// The container repository name.
/// </summary>
public string? Repository { get; init; }
/// <summary>
/// The image tag, if any.
/// </summary>
public string? Tag { get; init; }
/// <summary>
/// The baseline reference for comparison.
/// Can be a snapshot ID, image digest, or strategy name (e.g., "last-approved", "production").
/// </summary>
public string? BaselineRef { get; init; }
/// <summary>
/// Optional policy ID to use for evaluation.
/// </summary>
public string? PolicyId { get; init; }
/// <summary>
/// Whether to allow override of blocking gates.
/// </summary>
public bool AllowOverride { get; init; }
/// <summary>
/// Justification for override (required if AllowOverride is true and gate would block).
/// </summary>
public string? OverrideJustification { get; init; }
/// <summary>
/// Source of the request (e.g., "cli", "api", "webhook").
/// </summary>
public string? Source { get; init; }
/// <summary>
/// CI/CD context identifier (e.g., "github-actions", "gitlab-ci").
/// </summary>
public string? CiContext { get; init; }
/// <summary>
/// Additional context for the gate evaluation.
/// </summary>
public GateEvaluationContext? Context { get; init; }
}
/// <summary>
/// Additional context for gate evaluation.
/// </summary>
public sealed record GateEvaluationContext
{
/// <summary>
/// Git branch name.
/// </summary>
public string? Branch { get; init; }
/// <summary>
/// Git commit SHA.
/// </summary>
public string? CommitSha { get; init; }
/// <summary>
/// CI/CD pipeline ID or job ID.
/// </summary>
public string? PipelineId { get; init; }
/// <summary>
/// Environment being deployed to (e.g., "production", "staging").
/// </summary>
public string? Environment { get; init; }
/// <summary>
/// Actor triggering the gate (e.g., user or service identity).
/// </summary>
public string? Actor { get; init; }
}
/// <summary>
/// Response from gate evaluation.
/// </summary>
public sealed record GateEvaluateResponse
{
/// <summary>
/// Unique decision ID for audit and tracking.
/// </summary>
public required string DecisionId { get; init; }
/// <summary>
/// The gate decision status.
/// </summary>
public required GateStatus Status { get; init; }
/// <summary>
/// Suggested CI exit code.
/// 0 = Pass, 1 = Warn (configurable pass-through), 2 = Fail/Block
/// </summary>
public required int ExitCode { get; init; }
/// <summary>
/// The image digest that was evaluated.
/// </summary>
public required string ImageDigest { get; init; }
/// <summary>
/// The baseline reference used for comparison.
/// </summary>
public string? BaselineRef { get; init; }
/// <summary>
/// When the decision was made (UTC).
/// </summary>
public required DateTimeOffset DecidedAt { get; init; }
/// <summary>
/// Summary message for the decision.
/// </summary>
public string? Summary { get; init; }
/// <summary>
/// Advisory or suggestion for the developer.
/// </summary>
public string? Advisory { get; init; }
/// <summary>
/// List of gate results.
/// </summary>
public IReadOnlyList<GateResultDto>? Gates { get; init; }
/// <summary>
/// Gate that caused the block (if blocked).
/// </summary>
public string? BlockedBy { get; init; }
/// <summary>
/// Detailed reason for the block.
/// </summary>
public string? BlockReason { get; init; }
/// <summary>
/// Suggestion for resolving the block.
/// </summary>
public string? Suggestion { get; init; }
/// <summary>
/// Whether an override was applied.
/// </summary>
public bool OverrideApplied { get; init; }
/// <summary>
/// Delta summary if available.
/// </summary>
public DeltaSummaryDto? DeltaSummary { get; init; }
}
/// <summary>
/// Result of a single gate evaluation.
/// </summary>
public sealed record GateResultDto
{
/// <summary>
/// Gate name/ID.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Gate result type.
/// </summary>
public required string Result { get; init; }
/// <summary>
/// Reason for the result.
/// </summary>
public required string Reason { get; init; }
/// <summary>
/// Additional note.
/// </summary>
public string? Note { get; init; }
/// <summary>
/// Condition expression that was evaluated.
/// </summary>
public string? Condition { get; init; }
}
/// <summary>
/// Gate evaluation status.
/// </summary>
public enum GateStatus
{
/// <summary>
/// Gate passed - proceed with deployment.
/// </summary>
Pass = 0,
/// <summary>
/// Gate produced warnings - proceed with caution.
/// </summary>
Warn = 1,
/// <summary>
/// Gate blocked - do not proceed.
/// </summary>
Fail = 2
}
/// <summary>
/// CI exit codes for gate evaluation.
/// </summary>
public static class GateExitCodes
{
/// <summary>
/// Gate passed - proceed with deployment.
/// </summary>
public const int Pass = 0;
/// <summary>
/// Gate produced warnings - configurable pass-through.
/// </summary>
public const int Warn = 1;
/// <summary>
/// Gate blocked - do not proceed.
/// </summary>
public const int Fail = 2;
}

View File

@@ -0,0 +1,398 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Sprint: SPRINT_20251226_001_BE_cicd_gate_integration
// Task: CICD-GATE-01 - Create POST /api/v1/policy/gate/evaluate endpoint
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Audit;
using StellaOps.Policy.Deltas;
using StellaOps.Policy.Engine.Gates;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Gateway.Contracts;
namespace StellaOps.Policy.Gateway.Endpoints;
/// <summary>
/// Gate API endpoints for CI/CD release gating.
/// </summary>
public static class GateEndpoints
{
private const string DeltaCachePrefix = "delta:";
private static readonly TimeSpan DeltaCacheDuration = TimeSpan.FromMinutes(30);
/// <summary>
/// Maps gate endpoints to the application.
/// </summary>
public static void MapGateEndpoints(this WebApplication app)
{
var gates = app.MapGroup("/api/v1/policy/gate")
.WithTags("Gates");
// POST /api/v1/policy/gate/evaluate - Evaluate gate for image
gates.MapPost("/evaluate", async Task<IResult>(
HttpContext httpContext,
GateEvaluateRequest request,
IDriftGateEvaluator gateEvaluator,
IDeltaComputer deltaComputer,
IBaselineSelector baselineSelector,
IGateBypassAuditor bypassAuditor,
IMemoryCache cache,
ILogger<DriftGateEvaluator> logger,
CancellationToken cancellationToken) =>
{
if (request is null)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Request body required",
Status = 400
});
}
if (string.IsNullOrWhiteSpace(request.ImageDigest))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Image digest is required",
Status = 400,
Detail = "Provide a valid container image digest (e.g., sha256:abc123...)"
});
}
try
{
// Step 1: Resolve baseline snapshot
var baselineResult = await ResolveBaselineAsync(
request.ImageDigest,
request.BaselineRef,
baselineSelector,
cancellationToken);
if (!baselineResult.IsFound)
{
// If no baseline, allow with a note (first build scenario)
logger.LogInformation(
"No baseline found for {ImageDigest}, allowing first build",
request.ImageDigest);
return Results.Ok(new GateEvaluateResponse
{
DecisionId = $"gate:{DateTimeOffset.UtcNow:yyyyMMddHHmmss}:{Guid.NewGuid():N}",
Status = GateStatus.Pass,
ExitCode = GateExitCodes.Pass,
ImageDigest = request.ImageDigest,
BaselineRef = request.BaselineRef,
DecidedAt = DateTimeOffset.UtcNow,
Summary = "First build - no baseline for comparison",
Advisory = "This appears to be a first build. Future builds will be compared against this baseline."
});
}
// Step 2: Compute delta between baseline and current
var delta = await deltaComputer.ComputeDeltaAsync(
baselineResult.Snapshot!.SnapshotId,
request.ImageDigest, // Use image digest as target snapshot ID
new ArtifactRef(request.ImageDigest, null, null),
cancellationToken);
// Cache the delta for audit
cache.Set(
DeltaCachePrefix + delta.DeltaId,
delta,
DeltaCacheDuration);
// Step 3: Build gate context from delta
var gateContext = BuildGateContext(delta);
// Step 4: Evaluate gates
var gateRequest = new DriftGateRequest
{
Context = gateContext,
PolicyId = request.PolicyId,
AllowOverride = request.AllowOverride,
OverrideJustification = request.OverrideJustification
};
var gateDecision = await gateEvaluator.EvaluateAsync(gateRequest, cancellationToken);
logger.LogInformation(
"Gate evaluated for {ImageDigest}: decision={Decision}, decisionId={DecisionId}",
request.ImageDigest,
gateDecision.Decision,
gateDecision.DecisionId);
// Step 5: Record bypass audit if override was applied
if (request.AllowOverride &&
!string.IsNullOrWhiteSpace(request.OverrideJustification) &&
gateDecision.Decision != DriftGateDecisionType.Allow)
{
var actor = httpContext.User.Identity?.Name ?? "unknown";
var actorSubject = httpContext.User.Claims
.FirstOrDefault(c => c.Type == "sub")?.Value;
var actorEmail = httpContext.User.Claims
.FirstOrDefault(c => c.Type == "email")?.Value;
var actorIp = httpContext.Connection.RemoteIpAddress?.ToString();
var bypassContext = new GateBypassContext
{
Decision = gateDecision,
Request = gateRequest,
ImageDigest = request.ImageDigest,
Repository = request.Repository,
Tag = request.Tag,
BaselineRef = request.BaselineRef,
Actor = actor,
ActorSubject = actorSubject,
ActorEmail = actorEmail,
ActorIpAddress = actorIp,
Justification = request.OverrideJustification,
Source = request.Source ?? "api",
CiContext = request.CiContext
};
await bypassAuditor.RecordBypassAsync(bypassContext, cancellationToken);
}
// Step 6: Build response
var response = BuildResponse(request, gateDecision, delta);
// Return appropriate status code based on decision
return gateDecision.Decision switch
{
DriftGateDecisionType.Block => Results.Json(response, statusCode: 403),
DriftGateDecisionType.Warn => Results.Ok(response),
_ => Results.Ok(response)
};
}
catch (InvalidOperationException ex) when (ex.Message.Contains("not found"))
{
return Results.NotFound(new ProblemDetails
{
Title = "Resource not found",
Status = 404,
Detail = ex.Message
});
}
catch (Exception ex)
{
logger.LogError(ex, "Gate evaluation failed for {ImageDigest}", request.ImageDigest);
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("EvaluateGate")
.WithDescription("Evaluate CI/CD gate for an image digest and baseline reference");
// GET /api/v1/policy/gate/decision/{decisionId} - Get a previous decision
gates.MapGet("/decision/{decisionId}", async Task<IResult>(
string decisionId,
IMemoryCache cache,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(decisionId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Decision ID required",
Status = 400
});
}
// Try to retrieve cached decision
var cacheKey = $"gate:decision:{decisionId}";
if (!cache.TryGetValue(cacheKey, out GateEvaluateResponse? response) || response is null)
{
return Results.NotFound(new ProblemDetails
{
Title = "Decision not found",
Status = 404,
Detail = $"No gate decision found with ID: {decisionId}"
});
}
return Results.Ok(response);
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.WithName("GetGateDecision")
.WithDescription("Retrieve a previous gate evaluation decision by ID");
// GET /api/v1/policy/gate/health - Health check for gate service
gates.MapGet("/health", () => Results.Ok(new { status = "healthy", timestamp = DateTimeOffset.UtcNow }))
.WithName("GateHealth")
.WithDescription("Health check for the gate evaluation service");
}
private static async Task<BaselineSelectionResult> ResolveBaselineAsync(
string imageDigest,
string? baselineRef,
IBaselineSelector baselineSelector,
CancellationToken cancellationToken)
{
if (!string.IsNullOrWhiteSpace(baselineRef))
{
// Check if it's an explicit snapshot ID
if (baselineRef.StartsWith("snapshot:") || Guid.TryParse(baselineRef, out _))
{
return await baselineSelector.SelectExplicitAsync(
baselineRef.Replace("snapshot:", ""),
cancellationToken);
}
// Parse as strategy name
var strategy = baselineRef.ToLowerInvariant() switch
{
"last-approved" or "lastapproved" => BaselineSelectionStrategy.LastApproved,
"previous-build" or "previousbuild" => BaselineSelectionStrategy.PreviousBuild,
"production" or "production-deployed" => BaselineSelectionStrategy.ProductionDeployed,
"branch-base" or "branchbase" => BaselineSelectionStrategy.BranchBase,
_ => BaselineSelectionStrategy.LastApproved
};
return await baselineSelector.SelectBaselineAsync(imageDigest, strategy, cancellationToken);
}
// Default to LastApproved strategy
return await baselineSelector.SelectBaselineAsync(
imageDigest,
BaselineSelectionStrategy.LastApproved,
cancellationToken);
}
private static DriftGateContext BuildGateContext(SecurityStateDelta delta)
{
var newlyReachableVexStatuses = new List<string>();
var newlyReachableSinkIds = new List<string>();
var newlyUnreachableSinkIds = new List<string>();
double? maxCvss = null;
double? maxEpss = null;
var hasKev = false;
var deltaReachable = 0;
var deltaUnreachable = 0;
// Extract metrics from delta drivers
foreach (var driver in delta.Drivers)
{
if (driver.Type is "new-reachable-cve" or "new-reachable-path")
{
deltaReachable++;
if (driver.CveId is not null)
{
newlyReachableSinkIds.Add(driver.CveId);
}
// Extract optional details from the Details dictionary
if (driver.Details.TryGetValue("vex_status", out var vexStatus))
{
newlyReachableVexStatuses.Add(vexStatus);
}
if (driver.Details.TryGetValue("cvss", out var cvssStr) &&
double.TryParse(cvssStr, out var cvss))
{
if (!maxCvss.HasValue || cvss > maxCvss.Value)
{
maxCvss = cvss;
}
}
if (driver.Details.TryGetValue("epss", out var epssStr) &&
double.TryParse(epssStr, out var epss))
{
if (!maxEpss.HasValue || epss > maxEpss.Value)
{
maxEpss = epss;
}
}
if (driver.Details.TryGetValue("is_kev", out var kevStr) &&
bool.TryParse(kevStr, out var isKev) && isKev)
{
hasKev = true;
}
}
else if (driver.Type is "removed-reachable-cve" or "removed-reachable-path")
{
deltaUnreachable++;
if (driver.CveId is not null)
{
newlyUnreachableSinkIds.Add(driver.CveId);
}
}
}
return new DriftGateContext
{
DeltaReachable = deltaReachable,
DeltaUnreachable = deltaUnreachable,
HasKevReachable = hasKev,
NewlyReachableVexStatuses = newlyReachableVexStatuses,
MaxCvss = maxCvss,
MaxEpss = maxEpss,
BaseScanId = delta.BaselineSnapshotId,
HeadScanId = delta.TargetSnapshotId,
NewlyReachableSinkIds = newlyReachableSinkIds,
NewlyUnreachableSinkIds = newlyUnreachableSinkIds
};
}
private static GateEvaluateResponse BuildResponse(
GateEvaluateRequest request,
DriftGateDecision decision,
SecurityStateDelta delta)
{
var status = decision.Decision switch
{
DriftGateDecisionType.Allow => GateStatus.Pass,
DriftGateDecisionType.Warn => GateStatus.Warn,
DriftGateDecisionType.Block => GateStatus.Fail,
_ => GateStatus.Pass
};
var exitCode = decision.Decision switch
{
DriftGateDecisionType.Allow => GateExitCodes.Pass,
DriftGateDecisionType.Warn => GateExitCodes.Warn,
DriftGateDecisionType.Block => GateExitCodes.Fail,
_ => GateExitCodes.Pass
};
return new GateEvaluateResponse
{
DecisionId = decision.DecisionId,
Status = status,
ExitCode = exitCode,
ImageDigest = request.ImageDigest,
BaselineRef = request.BaselineRef,
DecidedAt = decision.DecidedAt,
Summary = BuildSummary(decision),
Advisory = decision.Advisory,
Gates = decision.Gates.Select(g => new GateResultDto
{
Name = g.Name,
Result = g.Result.ToString(),
Reason = g.Reason,
Note = g.Note,
Condition = g.Condition
}).ToList(),
BlockedBy = decision.BlockedBy,
BlockReason = decision.BlockReason,
Suggestion = decision.Suggestion,
OverrideApplied = request.AllowOverride && decision.Decision == DriftGateDecisionType.Warn && !string.IsNullOrWhiteSpace(request.OverrideJustification),
DeltaSummary = DeltaSummaryDto.FromModel(delta.Summary)
};
}
private static string BuildSummary(DriftGateDecision decision)
{
return decision.Decision switch
{
DriftGateDecisionType.Allow => "Gate passed - release may proceed",
DriftGateDecisionType.Warn => $"Gate passed with warnings - review recommended{(decision.Advisory is not null ? $": {decision.Advisory}" : "")}",
DriftGateDecisionType.Block => $"Gate blocked - {decision.BlockReason ?? "release cannot proceed"}",
_ => "Gate evaluation complete"
};
}
}

View File

@@ -0,0 +1,403 @@
// -----------------------------------------------------------------------------
// RegistryWebhookEndpoints.cs
// Sprint: SPRINT_20251226_001_BE_cicd_gate_integration
// Task: CICD-GATE-02 - Webhook handler for registry image-push events
// Description: Receives webhooks from container registries and triggers gate evaluation
// -----------------------------------------------------------------------------
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Policy.Engine.Gates;
namespace StellaOps.Policy.Gateway.Endpoints;
/// <summary>
/// Endpoints for receiving registry webhook events and triggering gate evaluations.
/// </summary>
internal static class RegistryWebhookEndpoints
{
public static IEndpointRouteBuilder MapRegistryWebhooks(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/v1/webhooks/registry")
.WithTags("Registry Webhooks");
group.MapPost("/docker", HandleDockerRegistryWebhook)
.WithName("DockerRegistryWebhook")
.WithSummary("Handle Docker Registry v2 webhook events")
.Produces<WebhookAcceptedResponse>(StatusCodes.Status202Accepted)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
group.MapPost("/harbor", HandleHarborWebhook)
.WithName("HarborWebhook")
.WithSummary("Handle Harbor registry webhook events")
.Produces<WebhookAcceptedResponse>(StatusCodes.Status202Accepted)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
group.MapPost("/generic", HandleGenericWebhook)
.WithName("GenericRegistryWebhook")
.WithSummary("Handle generic registry webhook events with image digest")
.Produces<WebhookAcceptedResponse>(StatusCodes.Status202Accepted)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
return endpoints;
}
/// <summary>
/// Handles Docker Registry v2 notification webhooks.
/// </summary>
private static async Task<Results<Accepted<WebhookAcceptedResponse>, ProblemHttpResult>> HandleDockerRegistryWebhook(
[FromBody] DockerRegistryNotification notification,
IGateEvaluationQueue evaluationQueue,
ILogger<RegistryWebhookEndpointMarker> logger,
CancellationToken ct)
{
if (notification.Events is null || notification.Events.Count == 0)
{
return TypedResults.Problem(
"No events in notification",
statusCode: StatusCodes.Status400BadRequest);
}
var jobs = new List<string>();
foreach (var evt in notification.Events.Where(e => e.Action == "push"))
{
if (string.IsNullOrEmpty(evt.Target?.Digest))
{
logger.LogWarning("Skipping push event without digest: {Repository}", evt.Target?.Repository);
continue;
}
var jobId = await evaluationQueue.EnqueueAsync(new GateEvaluationRequest
{
ImageDigest = evt.Target.Digest,
Repository = evt.Target.Repository ?? "unknown",
Tag = evt.Target.Tag,
RegistryUrl = evt.Request?.Host,
Source = "docker-registry",
Timestamp = evt.Timestamp ?? DateTimeOffset.UtcNow
}, ct);
jobs.Add(jobId);
logger.LogInformation(
"Queued gate evaluation for {Repository}@{Digest}, job: {JobId}",
evt.Target.Repository,
evt.Target.Digest,
jobId);
}
return TypedResults.Accepted(
$"/api/v1/policy/gate/jobs/{jobs.FirstOrDefault()}",
new WebhookAcceptedResponse(jobs.Count, jobs));
}
/// <summary>
/// Handles Harbor registry webhook events.
/// </summary>
private static async Task<Results<Accepted<WebhookAcceptedResponse>, ProblemHttpResult>> HandleHarborWebhook(
[FromBody] HarborWebhookEvent notification,
IGateEvaluationQueue evaluationQueue,
ILogger<RegistryWebhookEndpointMarker> logger,
CancellationToken ct)
{
// Only process push events
if (notification.Type != "PUSH_ARTIFACT" && notification.Type != "pushImage")
{
logger.LogDebug("Ignoring Harbor event type: {Type}", notification.Type);
return TypedResults.Accepted(
"/api/v1/policy/gate/jobs",
new WebhookAcceptedResponse(0, []));
}
if (notification.EventData?.Resources is null || notification.EventData.Resources.Count == 0)
{
return TypedResults.Problem(
"No resources in Harbor notification",
statusCode: StatusCodes.Status400BadRequest);
}
var jobs = new List<string>();
foreach (var resource in notification.EventData.Resources)
{
if (string.IsNullOrEmpty(resource.Digest))
{
logger.LogWarning("Skipping resource without digest: {ResourceUrl}", resource.ResourceUrl);
continue;
}
var jobId = await evaluationQueue.EnqueueAsync(new GateEvaluationRequest
{
ImageDigest = resource.Digest,
Repository = notification.EventData.Repository?.Name ?? "unknown",
Tag = resource.Tag,
RegistryUrl = notification.EventData.Repository?.RepoFullName,
Source = "harbor",
Timestamp = notification.OccurAt ?? DateTimeOffset.UtcNow
}, ct);
jobs.Add(jobId);
logger.LogInformation(
"Queued gate evaluation for {Repository}@{Digest}, job: {JobId}",
notification.EventData.Repository?.Name,
resource.Digest,
jobId);
}
return TypedResults.Accepted(
$"/api/v1/policy/gate/jobs/{jobs.FirstOrDefault()}",
new WebhookAcceptedResponse(jobs.Count, jobs));
}
/// <summary>
/// Handles generic webhook events with image digest.
/// </summary>
private static async Task<Results<Accepted<WebhookAcceptedResponse>, ProblemHttpResult>> HandleGenericWebhook(
[FromBody] GenericRegistryWebhook notification,
IGateEvaluationQueue evaluationQueue,
ILogger<RegistryWebhookEndpointMarker> logger,
CancellationToken ct)
{
if (string.IsNullOrEmpty(notification.ImageDigest))
{
return TypedResults.Problem(
"imageDigest is required",
statusCode: StatusCodes.Status400BadRequest);
}
var jobId = await evaluationQueue.EnqueueAsync(new GateEvaluationRequest
{
ImageDigest = notification.ImageDigest,
Repository = notification.Repository ?? "unknown",
Tag = notification.Tag,
RegistryUrl = notification.RegistryUrl,
BaselineRef = notification.BaselineRef,
Source = notification.Source ?? "generic",
Timestamp = DateTimeOffset.UtcNow
}, ct);
logger.LogInformation(
"Queued gate evaluation for {Repository}@{Digest}, job: {JobId}",
notification.Repository,
notification.ImageDigest,
jobId);
return TypedResults.Accepted(
$"/api/v1/policy/gate/jobs/{jobId}",
new WebhookAcceptedResponse(1, [jobId]));
}
}
/// <summary>
/// Marker type for endpoint logging.
/// </summary>
internal sealed class RegistryWebhookEndpointMarker;
// ============================================================================
// Docker Registry Notification Models
// ============================================================================
/// <summary>
/// Docker Registry v2 notification envelope.
/// </summary>
public sealed record DockerRegistryNotification
{
[JsonPropertyName("events")]
public List<DockerRegistryEvent>? Events { get; init; }
}
/// <summary>
/// Docker Registry v2 event.
/// </summary>
public sealed record DockerRegistryEvent
{
[JsonPropertyName("id")]
public string? Id { get; init; }
[JsonPropertyName("timestamp")]
public DateTimeOffset? Timestamp { get; init; }
[JsonPropertyName("action")]
public string? Action { get; init; }
[JsonPropertyName("target")]
public DockerRegistryTarget? Target { get; init; }
[JsonPropertyName("request")]
public DockerRegistryRequest? Request { get; init; }
}
/// <summary>
/// Docker Registry event target (the image).
/// </summary>
public sealed record DockerRegistryTarget
{
[JsonPropertyName("mediaType")]
public string? MediaType { get; init; }
[JsonPropertyName("size")]
public long? Size { get; init; }
[JsonPropertyName("digest")]
public string? Digest { get; init; }
[JsonPropertyName("repository")]
public string? Repository { get; init; }
[JsonPropertyName("tag")]
public string? Tag { get; init; }
}
/// <summary>
/// Docker Registry request metadata.
/// </summary>
public sealed record DockerRegistryRequest
{
[JsonPropertyName("id")]
public string? Id { get; init; }
[JsonPropertyName("host")]
public string? Host { get; init; }
[JsonPropertyName("method")]
public string? Method { get; init; }
}
// ============================================================================
// Harbor Webhook Models
// ============================================================================
/// <summary>
/// Harbor webhook event.
/// </summary>
public sealed record HarborWebhookEvent
{
[JsonPropertyName("type")]
public string? Type { get; init; }
[JsonPropertyName("occur_at")]
public DateTimeOffset? OccurAt { get; init; }
[JsonPropertyName("operator")]
public string? Operator { get; init; }
[JsonPropertyName("event_data")]
public HarborEventData? EventData { get; init; }
}
/// <summary>
/// Harbor event data.
/// </summary>
public sealed record HarborEventData
{
[JsonPropertyName("resources")]
public List<HarborResource>? Resources { get; init; }
[JsonPropertyName("repository")]
public HarborRepository? Repository { get; init; }
}
/// <summary>
/// Harbor resource (artifact).
/// </summary>
public sealed record HarborResource
{
[JsonPropertyName("digest")]
public string? Digest { get; init; }
[JsonPropertyName("tag")]
public string? Tag { get; init; }
[JsonPropertyName("resource_url")]
public string? ResourceUrl { get; init; }
}
/// <summary>
/// Harbor repository info.
/// </summary>
public sealed record HarborRepository
{
[JsonPropertyName("name")]
public string? Name { get; init; }
[JsonPropertyName("namespace")]
public string? Namespace { get; init; }
[JsonPropertyName("repo_full_name")]
public string? RepoFullName { get; init; }
}
// ============================================================================
// Generic Webhook Models
// ============================================================================
/// <summary>
/// Generic registry webhook payload.
/// </summary>
public sealed record GenericRegistryWebhook
{
[JsonPropertyName("imageDigest")]
public string? ImageDigest { get; init; }
[JsonPropertyName("repository")]
public string? Repository { get; init; }
[JsonPropertyName("tag")]
public string? Tag { get; init; }
[JsonPropertyName("registryUrl")]
public string? RegistryUrl { get; init; }
[JsonPropertyName("baselineRef")]
public string? BaselineRef { get; init; }
[JsonPropertyName("source")]
public string? Source { get; init; }
}
// ============================================================================
// Response Models
// ============================================================================
/// <summary>
/// Response indicating webhook was accepted.
/// </summary>
public sealed record WebhookAcceptedResponse(
int JobsQueued,
IReadOnlyList<string> JobIds);
// ============================================================================
// Gate Evaluation Queue Interface
// ============================================================================
/// <summary>
/// Interface for queuing gate evaluation jobs.
/// </summary>
public interface IGateEvaluationQueue
{
/// <summary>
/// Enqueues a gate evaluation request.
/// </summary>
/// <param name="request">The evaluation request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The job ID for tracking.</returns>
Task<string> EnqueueAsync(GateEvaluationRequest request, CancellationToken cancellationToken = default);
}
/// <summary>
/// Request to evaluate a gate for an image.
/// </summary>
public sealed record GateEvaluationRequest
{
public required string ImageDigest { get; init; }
public required string Repository { get; init; }
public string? Tag { get; init; }
public string? RegistryUrl { get; init; }
public string? BaselineRef { get; init; }
public required string Source { get; init; }
public required DateTimeOffset Timestamp { get; init; }
}

View File

@@ -20,6 +20,7 @@ using StellaOps.Policy.Gateway.Infrastructure;
using StellaOps.Policy.Gateway.Options;
using StellaOps.Policy.Gateway.Services;
using StellaOps.Policy.Deltas;
using StellaOps.Policy.Engine.Gates;
using StellaOps.Policy.Snapshots;
using StellaOps.Policy.Storage.Postgres;
using Polly;
@@ -127,6 +128,21 @@ builder.Services.AddScoped<IBaselineSelector, BaselineSelector>();
builder.Services.AddScoped<ISnapshotStore, InMemorySnapshotStore>();
builder.Services.AddScoped<StellaOps.Policy.Deltas.ISnapshotService, DeltaSnapshotServiceAdapter>();
// Gate services (Sprint: SPRINT_20251226_001_BE_cicd_gate_integration)
builder.Services.Configure<DriftGateOptions>(
builder.Configuration.GetSection(DriftGateOptions.SectionName));
builder.Services.AddScoped<IDriftGateEvaluator, DriftGateEvaluator>();
builder.Services.AddSingleton<InMemoryGateEvaluationQueue>();
builder.Services.AddSingleton<IGateEvaluationQueue>(sp => sp.GetRequiredService<InMemoryGateEvaluationQueue>());
builder.Services.AddHostedService<GateEvaluationWorker>();
// Gate bypass audit services (Sprint: SPRINT_20251226_001_BE_cicd_gate_integration, Task: CICD-GATE-06)
builder.Services.AddSingleton<StellaOps.Policy.Audit.IGateBypassAuditRepository,
StellaOps.Policy.Audit.InMemoryGateBypassAuditRepository>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.GateBypassAuditOptions>();
builder.Services.AddScoped<StellaOps.Policy.Engine.Services.IGateBypassAuditor,
StellaOps.Policy.Engine.Services.GateBypassAuditor>();
builder.Services.AddStellaOpsResourceServerAuthentication(
builder.Configuration,
configurationSection: $"{PolicyGatewayOptions.SectionName}:ResourceServer");
@@ -497,6 +513,12 @@ app.MapExceptionEndpoints();
// Delta management endpoints
app.MapDeltasEndpoints();
// Gate evaluation endpoints (Sprint: SPRINT_20251226_001_BE_cicd_gate_integration)
app.MapGateEndpoints();
// Registry webhook endpoints (Sprint: SPRINT_20251226_001_BE_cicd_gate_integration)
app.MapRegistryWebhooks();
app.Run();
static IAsyncPolicy<HttpResponseMessage> CreateAuthorityRetryPolicy(IServiceProvider provider)

View File

@@ -0,0 +1,180 @@
// -----------------------------------------------------------------------------
// InMemoryGateEvaluationQueue.cs
// Sprint: SPRINT_20251226_001_BE_cicd_gate_integration
// Task: CICD-GATE-02 - Gate evaluation queue implementation
// Description: In-memory queue for gate evaluation jobs with background processing
// -----------------------------------------------------------------------------
using System.Threading.Channels;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Engine.Gates;
using StellaOps.Policy.Gateway.Endpoints;
namespace StellaOps.Policy.Gateway.Services;
/// <summary>
/// In-memory implementation of the gate evaluation queue.
/// Uses System.Threading.Channels for async producer-consumer pattern.
/// </summary>
public sealed class InMemoryGateEvaluationQueue : IGateEvaluationQueue
{
private readonly Channel<GateEvaluationJob> _channel;
private readonly ILogger<InMemoryGateEvaluationQueue> _logger;
public InMemoryGateEvaluationQueue(ILogger<InMemoryGateEvaluationQueue> logger)
{
ArgumentNullException.ThrowIfNull(logger);
_logger = logger;
// Bounded channel to prevent unbounded memory growth
_channel = Channel.CreateBounded<GateEvaluationJob>(new BoundedChannelOptions(1000)
{
FullMode = BoundedChannelFullMode.Wait,
SingleReader = false,
SingleWriter = false
});
}
/// <inheritdoc />
public async Task<string> EnqueueAsync(GateEvaluationRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var jobId = GenerateJobId();
var job = new GateEvaluationJob
{
JobId = jobId,
Request = request,
QueuedAt = DateTimeOffset.UtcNow
};
await _channel.Writer.WriteAsync(job, cancellationToken).ConfigureAwait(false);
_logger.LogDebug(
"Enqueued gate evaluation job {JobId} for {Repository}@{Digest}",
jobId,
request.Repository,
request.ImageDigest);
return jobId;
}
/// <summary>
/// Gets the channel reader for consuming jobs.
/// </summary>
public ChannelReader<GateEvaluationJob> Reader => _channel.Reader;
private static string GenerateJobId()
{
// Format: gate-{timestamp}-{random}
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var random = Guid.NewGuid().ToString("N")[..8];
return $"gate-{timestamp}-{random}";
}
}
/// <summary>
/// A gate evaluation job in the queue.
/// </summary>
public sealed record GateEvaluationJob
{
public required string JobId { get; init; }
public required GateEvaluationRequest Request { get; init; }
public required DateTimeOffset QueuedAt { get; init; }
}
/// <summary>
/// Background service that processes gate evaluation jobs from the queue.
/// Orchestrates: image analysis → drift delta computation → gate evaluation.
/// </summary>
public sealed class GateEvaluationWorker : BackgroundService
{
private readonly InMemoryGateEvaluationQueue _queue;
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<GateEvaluationWorker> _logger;
public GateEvaluationWorker(
InMemoryGateEvaluationQueue queue,
IServiceScopeFactory scopeFactory,
ILogger<GateEvaluationWorker> logger)
{
ArgumentNullException.ThrowIfNull(queue);
ArgumentNullException.ThrowIfNull(scopeFactory);
ArgumentNullException.ThrowIfNull(logger);
_queue = queue;
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Gate evaluation worker starting");
await foreach (var job in _queue.Reader.ReadAllAsync(stoppingToken))
{
try
{
await ProcessJobAsync(job, stoppingToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex,
"Error processing gate evaluation job {JobId} for {Repository}@{Digest}",
job.JobId,
job.Request.Repository,
job.Request.ImageDigest);
}
}
_logger.LogInformation("Gate evaluation worker stopping");
}
private async Task ProcessJobAsync(GateEvaluationJob job, CancellationToken cancellationToken)
{
_logger.LogInformation(
"Processing gate evaluation job {JobId} for {Repository}@{Digest}",
job.JobId,
job.Request.Repository,
job.Request.ImageDigest);
using var scope = _scopeFactory.CreateScope();
var evaluator = scope.ServiceProvider.GetRequiredService<IDriftGateEvaluator>();
// Build a minimal context for the gate evaluation.
// In production, this would involve:
// 1. Fetching or triggering a scan of the image
// 2. Computing the reachability delta against the baseline
// 3. Building the DriftGateContext with actual metrics
//
// For now, we create a placeholder context that represents "no drift detected"
// which allows the gate to pass. The full implementation requires Scanner integration.
var driftContext = new DriftGateContext
{
DeltaReachable = 0,
DeltaUnreachable = 0,
HasKevReachable = false,
BaseScanId = job.Request.BaselineRef,
HeadScanId = job.Request.ImageDigest
};
var evalRequest = new DriftGateRequest
{
Context = driftContext,
PolicyId = null, // Use default policy
AllowOverride = false
};
var result = await evaluator.EvaluateAsync(evalRequest, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Gate evaluation {JobId} completed: Decision={Decision}, GateCount={GateCount}",
job.JobId,
result.Decision,
result.Gates.Length);
// TODO: Store result and notify via webhook/event
// This will be implemented in CICD-GATE-03
}
}

View File

@@ -17,6 +17,7 @@
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.csproj" />
<ProjectReference Include="../StellaOps.Policy.Scoring/StellaOps.Policy.Scoring.csproj" />
<ProjectReference Include="../StellaOps.Policy.Engine/StellaOps.Policy.Engine.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Policy.Exceptions/StellaOps.Policy.Exceptions.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Policy.Storage.Postgres/StellaOps.Policy.Storage.Postgres.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />