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:
master
2026-01-19 09:02:59 +02:00
parent 8c4bf54aed
commit 17419ba7c4
809 changed files with 170738 additions and 12244 deletions

View File

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

View 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

View File

@@ -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 { }

View File

@@ -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();