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:
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user