save checkpoint

This commit is contained in:
master
2026-02-11 01:32:14 +02:00
parent 5593212b41
commit cf5b72974f
2316 changed files with 68799 additions and 3808 deletions

View File

@@ -434,7 +434,7 @@ public sealed class DataPrefetcher : BackgroundService
// Clean up completed jobs after a delay
_ = Task.Delay(TimeSpan.FromMinutes(5), ct)
.ContinueWith(_ => _activeJobs.TryRemove(request.Id.ToString(), out _), ct);
.ContinueWith(__ => _activeJobs.TryRemove(request.Id.ToString(), out _), ct);
}
}

View File

@@ -61,11 +61,12 @@ public sealed class DecisionEngine : IDecisionEngine
var promotion = await _promotionStore.GetAsync(promotionId, ct)
?? throw new PromotionNotFoundException(promotionId);
var gateConfig = await GetGateConfigAsync(promotion.TargetEnvironmentId, ct);
var enabledGates = await GetEnabledGatesAsync(promotion.TargetEnvironmentId, ct);
var gateConfig = await GetGateConfigAsync(promotion.TargetEnvironmentId, enabledGates, ct);
// Evaluate all required gates
var gateContext = BuildGateContext(promotion);
var gateResults = await EvaluateGatesAsync(gateConfig.RequiredGates, gateContext, ct);
var gateResults = await EvaluateGatesAsync(enabledGates, gateContext, ct);
// Get approval status
var approvalStatus = await _approvalGateway.GetStatusAsync(promotionId, ct);
@@ -148,10 +149,10 @@ public sealed class DecisionEngine : IDecisionEngine
var promotion = await _promotionStore.GetAsync(promotionId, ct)
?? throw new PromotionNotFoundException(promotionId);
var gateConfig = await GetGateConfigAsync(promotion.TargetEnvironmentId, ct);
var enabledGates = await GetEnabledGatesAsync(promotion.TargetEnvironmentId, ct);
var context = BuildGateContext(promotion);
return await _gateEvaluator.EvaluateAllAsync(gateConfig.RequiredGates.ToList(), context, ct);
return await EvaluateGatesAsync(enabledGates, context, ct);
}
/// <inheritdoc />
@@ -172,35 +173,47 @@ public sealed class DecisionEngine : IDecisionEngine
}
private async Task<ImmutableArray<GateResult>> EvaluateGatesAsync(
ImmutableArray<string> gateNames,
IReadOnlyList<EnvironmentGate> gates,
GateContext context,
CancellationToken ct)
{
if (gateNames.Length == 0)
if (gates.Count == 0)
{
return [];
}
var results = await _gateEvaluator.EvaluateAllAsync(gateNames.ToList(), context, ct);
var hasGateSpecificConfig = gates.Any(static gate => gate.Config is { Count: > 0 });
if (!hasGateSpecificConfig)
{
var sharedResults = await _gateEvaluator.EvaluateAllAsync(
gates.Select(static gate => gate.GateName).ToList(),
context,
ct);
return sharedResults.ToImmutableArray();
}
var tasks = gates.Select(gate =>
{
var gateContext = context with { Config = ToImmutableConfig(gate.Config) };
return _gateEvaluator.EvaluateAsync(gate.GateName, gateContext, ct);
});
var results = await Task.WhenAll(tasks);
return results.ToImmutableArray();
}
private async Task<EnvironmentGateConfig> GetGateConfigAsync(
Guid environmentId,
IReadOnlyList<EnvironmentGate> enabledGates,
CancellationToken ct)
{
var environment = await _environmentService.GetAsync(environmentId, ct)
?? throw new EnvironmentNotFoundException(environmentId);
// Get configured gates for this environment
var configuredGates = await _environmentService.GetGatesAsync(environmentId, ct);
return new EnvironmentGateConfig
{
EnvironmentId = environmentId,
RequiredGates = configuredGates
.Where(g => g.IsEnabled)
.OrderBy(g => g.Order)
RequiredGates = enabledGates
.Select(g => g.GateName)
.ToImmutableArray(),
RequiredApprovals = environment.RequiredApprovals,
@@ -209,6 +222,16 @@ public sealed class DecisionEngine : IDecisionEngine
};
}
private async Task<IReadOnlyList<EnvironmentGate>> GetEnabledGatesAsync(Guid environmentId, CancellationToken ct)
{
var configuredGates = await _environmentService.GetGatesAsync(environmentId, ct)
?? [];
return configuredGates
.Where(static gate => gate.IsEnabled)
.OrderBy(static gate => gate.Order)
.ToList();
}
private static GateContext BuildGateContext(Models.Promotion promotion) =>
new()
{
@@ -223,6 +246,18 @@ public sealed class DecisionEngine : IDecisionEngine
RequestedBy = promotion.RequestedBy,
RequestedAt = promotion.RequestedAt
};
private static ImmutableDictionary<string, object> ToImmutableConfig(IReadOnlyDictionary<string, object>? config)
{
if (config is null || config.Count == 0)
{
return ImmutableDictionary<string, object>.Empty;
}
return config.ToImmutableDictionary(
static pair => pair.Key,
static pair => pair.Value);
}
}
/// <summary>

View File

@@ -46,6 +46,8 @@ public sealed class DecisionNotifier
DecisionOutcome.Deny => BuildDenyNotification(promotion, result),
DecisionOutcome.PendingApproval => BuildPendingApprovalNotification(promotion, result),
DecisionOutcome.PendingGate => BuildPendingGateNotification(promotion, result),
DecisionOutcome.HoldAsync => BuildHoldAsyncNotification(promotion, result),
DecisionOutcome.Escalate => BuildEscalationNotification(promotion, result),
_ => null
};
@@ -119,4 +121,34 @@ public sealed class DecisionNotifier
["outcome"] = "pending_gate"
}
};
private static NotificationRequest BuildHoldAsyncNotification(
Models.Promotion promotion,
DecisionResult result) =>
new()
{
Channel = "slack",
Title = $"Async Hold: {promotion.ReleaseName}",
Message = $"Release '{promotion.ReleaseName}' is on asynchronous evidence hold for {promotion.TargetEnvironmentName}.\n\n{result.BlockingReason}",
Metadata = new Dictionary<string, string>
{
["promotionId"] = promotion.Id.ToString(),
["outcome"] = "hold_async"
}
};
private static NotificationRequest BuildEscalationNotification(
Models.Promotion promotion,
DecisionResult result) =>
new()
{
Channel = "slack",
Title = $"Escalation Required: {promotion.ReleaseName}",
Message = $"Release '{promotion.ReleaseName}' requires escalation for promotion to {promotion.TargetEnvironmentName}.\n\n{result.BlockingReason}",
Metadata = new Dictionary<string, string>
{
["promotionId"] = promotion.Id.ToString(),
["outcome"] = "escalate"
}
};
}

View File

@@ -58,7 +58,13 @@ public sealed class DecisionRecorder
GateConfig = config,
EvaluatedAt = _timeProvider.GetUtcNow(),
EvaluatedBy = Guid.Empty, // System evaluation
EvidenceDigest = ComputeEvidenceDigest(result)
EvidenceDigest = ComputeEvidenceDigest(result),
SloP50Ms = ExtractSloValue(result, "p50Ms"),
SloP90Ms = ExtractSloValue(result, "p90Ms"),
SloP99Ms = ExtractSloValue(result, "p99Ms"),
AsyncHoldSlaHours = ExtractSloValue(result, "asyncHoldSlaHours"),
EvidenceTtlHours = ExtractSloValue(result, "evidenceTtlHours"),
HumanDecisionDsseRef = ExtractHumanDecisionDsseRef(result)
};
await _store.SaveAsync(record, ct);
@@ -136,4 +142,77 @@ public sealed class DecisionRecorder
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
private static int? ExtractSloValue(DecisionResult result, string key)
{
foreach (var gate in result.GateResults)
{
if (!gate.Details.TryGetValue("slo", out var sloRaw) || sloRaw is null)
{
continue;
}
if (sloRaw is IReadOnlyDictionary<string, object> readonlyDictionary &&
readonlyDictionary.TryGetValue(key, out var value) &&
TryReadInt(value, out var parsed))
{
return parsed >= 0 ? parsed : null;
}
if (sloRaw is IDictionary<string, object> dictionary &&
dictionary.TryGetValue(key, out var dictionaryValue) &&
TryReadInt(dictionaryValue, out var dictionaryParsed))
{
return dictionaryParsed >= 0 ? dictionaryParsed : null;
}
}
return null;
}
private static string? ExtractHumanDecisionDsseRef(DecisionResult result)
{
foreach (var gate in result.GateResults)
{
foreach (var value in gate.Details.Values)
{
if (value is not IReadOnlyDictionary<string, object> nested)
{
continue;
}
if (!nested.TryGetValue("humanDecisionDsseRef", out var raw) || raw is null)
{
continue;
}
var text = raw.ToString();
if (!string.IsNullOrWhiteSpace(text))
{
return text.Trim();
}
}
}
return null;
}
private static bool TryReadInt(object? value, out int parsed)
{
parsed = default;
switch (value)
{
case int intValue:
parsed = intValue;
return true;
case long longValue when longValue is <= int.MaxValue and >= int.MinValue:
parsed = (int)longValue;
return true;
case string text when int.TryParse(text, out var fromString):
parsed = fromString;
return true;
default:
return false;
}
}
}

View File

@@ -22,6 +22,33 @@ public sealed class DecisionRules
ApprovalStatus approvalStatus,
EnvironmentGateConfig config)
{
var blockingEscalations = gateResults
.Where(static gate => HasOutcomeHint(gate, "escalate"))
.Where(static gate => GetBooleanDetail(gate, "escalationBlocking", defaultValue: true))
.ToList();
if (blockingEscalations.Count > 0)
{
return new DecisionOutcomeResult(
Decision: DecisionOutcome.Escalate,
CanProceed: false,
BlockingReason: $"Escalation required by gates: {string.Join(", ", blockingEscalations.Select(g => g.GateName))}"
);
}
var holdAsyncGates = gateResults
.Where(static gate => HasOutcomeHint(gate, "hold_async") || GetBooleanDetail(gate, "waitingForConfirmation", defaultValue: false))
.ToList();
if (holdAsyncGates.Count > 0)
{
return new DecisionOutcomeResult(
Decision: DecisionOutcome.HoldAsync,
CanProceed: false,
BlockingReason: $"Waiting for async evidence: {string.Join(", ", holdAsyncGates.Select(g => g.GateName))}"
);
}
// Check for blocking gate failures first
var blockingFailures = gateResults.Where(g => !g.Passed && g.Blocking).ToList();
if (blockingFailures.Count > 0)
@@ -33,20 +60,6 @@ public sealed class DecisionRules
);
}
// Check for async gates waiting for callback
var pendingGates = gateResults
.Where(g => !g.Passed && g.Details.ContainsKey("waitingForConfirmation"))
.ToList();
if (pendingGates.Count > 0)
{
return new DecisionOutcomeResult(
Decision: DecisionOutcome.PendingGate,
CanProceed: false,
BlockingReason: $"Waiting for: {string.Join(", ", pendingGates.Select(g => g.GateName))}"
);
}
// Check if all gates must pass
if (config.AllGatesMustPass)
{
@@ -87,6 +100,31 @@ public sealed class DecisionRules
BlockingReason: null
);
}
private static bool HasOutcomeHint(GateResult gate, string expectedHint)
{
if (!gate.Details.TryGetValue("gateOutcomeHint", out var rawHint) || rawHint is null)
{
return false;
}
return string.Equals(rawHint.ToString(), expectedHint, StringComparison.OrdinalIgnoreCase);
}
private static bool GetBooleanDetail(GateResult gate, string key, bool defaultValue)
{
if (!gate.Details.TryGetValue(key, out var rawValue) || rawValue is null)
{
return defaultValue;
}
return rawValue switch
{
bool boolValue => boolValue,
string text when bool.TryParse(text, out var parsed) => parsed,
_ => defaultValue
};
}
}
/// <summary>

View File

@@ -55,6 +55,9 @@ public sealed record ScanResult
/// </summary>
public sealed record ReproducibilityEvidenceStatus
{
/// <summary>Aggregated evidence score used for policy threshold checks (0-100).</summary>
public int? EvidenceScoreValue { get; init; }
/// <summary>Whether DSSE-signed SLSA provenance exists and verified.</summary>
public bool HasDsseProvenance { get; init; }
@@ -70,9 +73,33 @@ public sealed record ReproducibilityEvidenceStatus
/// <summary>Whether Rekor inclusion proof is verified (online or offline profile).</summary>
public bool RekorVerified { get; init; }
/// <summary>When Rekor verification was last checked for freshness policies.</summary>
public DateTimeOffset? RekorCheckedAt { get; init; }
/// <summary>Whether verification was done in explicit break-glass mode.</summary>
public bool UsedBreakGlassVerification { get; init; }
/// <summary>Whether an in-toto build link exists.</summary>
public bool BuildLinkExists { get; init; }
/// <summary>Build link product digest using SHA-256.</summary>
public string? BuildProductDigestSha256 { get; init; }
/// <summary>Build link product digest using SHA-512.</summary>
public string? BuildProductDigestSha512 { get; init; }
/// <summary>Artifact digest using SHA-256 when available.</summary>
public string? ArtifactDigestSha256 { get; init; }
/// <summary>Artifact digest using SHA-512 when available.</summary>
public string? ArtifactDigestSha512 { get; init; }
/// <summary>DSSE signatures attached to evidence inputs.</summary>
public IReadOnlyList<DsseSignatureEvidence> DsseSignatures { get; init; } = [];
/// <summary>Reference to DSSE-signed human escalation decision evidence.</summary>
public string? HumanDecisionDsseRef { get; init; }
/// <summary>Stable policy violation codes produced upstream by attestor/policy checks.</summary>
public IReadOnlyList<string> ViolationCodes { get; init; } = [];
@@ -89,6 +116,21 @@ public sealed record ReproducibilityEvidenceStatus
public IReadOnlyList<string> AttestationRefs { get; init; } = [];
}
/// <summary>
/// DSSE signature evidence entry used for k-of-n signer policy checks.
/// </summary>
public sealed record DsseSignatureEvidence
{
/// <summary>Whether the signature cryptographically verifies.</summary>
public bool Valid { get; init; }
/// <summary>Signature algorithm (for example ed25519, ecdsa, rsa).</summary>
public string? Algorithm { get; init; }
/// <summary>Signer key identifier (key ID or cert SKI).</summary>
public string? KeyId { get; init; }
}
/// <summary>
/// A vulnerability found in a scan.
/// </summary>

View File

@@ -5,6 +5,9 @@ namespace StellaOps.ReleaseOrchestrator.Promotion.Gate.Security;
/// </summary>
public sealed record SecurityGateConfig
{
/// <summary>Minimum required evidence score (0-100). Null disables threshold check.</summary>
public int? MinEvidenceScore { get; init; }
/// <summary>Maximum critical vulnerabilities allowed (default: 0).</summary>
public int MaxCritical { get; init; }
@@ -49,4 +52,58 @@ public sealed record SecurityGateConfig
/// <summary>Require Evidence Locker evidence_score match against local recomputation (default: false).</summary>
public bool RequireEvidenceScoreMatch { get; init; }
/// <summary>Require in-toto build link evidence and digest binding (default: false).</summary>
public bool RequireBuildLinkDigestBinding { get; init; }
/// <summary>Digest algorithm for product/artifact binding (sha256|sha512).</summary>
public string ProductDigestAlgorithm { get; init; } = "sha256";
/// <summary>Minimum distinct valid DSSE signers required. Zero disables threshold check.</summary>
public int DsseSignerThresholdK { get; init; }
/// <summary>Total signer set size for policy contract validation.</summary>
public int DsseSignerThresholdN { get; init; }
/// <summary>Allowed DSSE signer key IDs.</summary>
public IReadOnlyList<string> AllowedSignerKeys { get; init; } = [];
/// <summary>Allowed DSSE signature algorithms.</summary>
public IReadOnlyList<string> AllowedDsseAlgorithms { get; init; } = [];
/// <summary>Maximum Rekor freshness age in seconds. Null disables freshness check.</summary>
public int? RekorMaxFreshSeconds { get; init; }
/// <summary>Retry backoff initial delay in milliseconds.</summary>
public int RetryBackoffInitialMs { get; init; } = 500;
/// <summary>Retry backoff multiplier.</summary>
public double RetryBackoffFactor { get; init; } = 2.0;
/// <summary>Maximum retries for transient evidence freshness failures.</summary>
public int MaxRetries { get; init; } = 0;
/// <summary>Escalation behavior when retries are exhausted (fail_closed|fail_open_with_alert).</summary>
public string EscalationMode { get; init; } = "fail_closed";
/// <summary>Escalation queue identifier for human review.</summary>
public string HumanQueue { get; init; } = "security-approvals";
/// <summary>Whether escalations require DSSE-signed human disposition evidence.</summary>
public bool RequireSignedHumanDecision { get; init; }
/// <summary>SLO target for p50 gate latency in milliseconds.</summary>
public int? SloP50Ms { get; init; }
/// <summary>SLO target for p90 gate latency in milliseconds.</summary>
public int? SloP90Ms { get; init; }
/// <summary>SLO target for p99 gate latency in milliseconds.</summary>
public int? SloP99Ms { get; init; }
/// <summary>SLA for asynchronous hold resolution in hours.</summary>
public int? AsyncHoldSlaHours { get; init; }
/// <summary>TTL of evidence freshness metadata in hours.</summary>
public int? EvidenceTtlHours { get; init; }
}

View File

@@ -39,4 +39,22 @@ public sealed record DecisionRecord
/// <summary>SHA-256 digest of the evidence used for the decision.</summary>
public string? EvidenceDigest { get; init; }
/// <summary>SLO target p50 latency in milliseconds, if present in gate evidence.</summary>
public int? SloP50Ms { get; init; }
/// <summary>SLO target p90 latency in milliseconds, if present in gate evidence.</summary>
public int? SloP90Ms { get; init; }
/// <summary>SLO target p99 latency in milliseconds, if present in gate evidence.</summary>
public int? SloP99Ms { get; init; }
/// <summary>Async hold SLA in hours, if configured for decision evidence.</summary>
public int? AsyncHoldSlaHours { get; init; }
/// <summary>Evidence TTL in hours, if configured for decision evidence.</summary>
public int? EvidenceTtlHours { get; init; }
/// <summary>DSSE reference for signed human escalation decision, when required.</summary>
public string? HumanDecisionDsseRef { get; init; }
}

View File

@@ -60,9 +60,15 @@ public enum DecisionOutcome
/// <summary>Gates passed but awaiting required approvals.</summary>
PendingApproval,
/// <summary>An async gate is awaiting callback.</summary>
/// <summary>An async gate is awaiting callback (legacy alias for hold_async semantics).</summary>
PendingGate,
/// <summary>Promotion is placed on asynchronous hold pending additional evidence.</summary>
HoldAsync,
/// <summary>Promotion requires escalation and human disposition.</summary>
Escalate,
/// <summary>An error occurred during evaluation.</summary>
Error
}

View File

@@ -367,6 +367,41 @@ public sealed class DecisionEngineTests
Times.Once);
}
[Fact]
public async Task EvaluateAsync_GateSpecificConfig_PassesConfigToGateContext()
{
var ct = TestContext.Current.CancellationToken;
SetupStandardMocks();
_environmentService.Setup(s => s.GetGatesAsync(_targetEnvId, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<EnvironmentGate>
{
new()
{
GateName = "security-gate",
IsEnabled = true,
Order = 1,
Config = new Dictionary<string, object>
{
["minEvidenceScore"] = 85
}
}
});
_gateEvaluator.Setup(e => e.EvaluateAsync(
"security-gate",
It.Is<GateContext>(c => c.Config.ContainsKey("minEvidenceScore") && (int)c.Config["minEvidenceScore"] == 85),
It.IsAny<CancellationToken>()))
.ReturnsAsync(GateResult.Success("security-gate", "security", _timeProvider, "Passed"));
var result = await _engine.EvaluateAsync(_promotionId, ct);
result.Outcome.Should().Be(DecisionOutcome.Allow);
_gateEvaluator.Verify(
e => e.EvaluateAllAsync(It.IsAny<IReadOnlyList<string>>(), It.IsAny<GateContext>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task EvaluateGateAsync_EvaluatesSingleGate()
{

View File

@@ -170,6 +170,50 @@ public sealed class DecisionNotifierTests
sentRequest.Metadata["outcome"].Should().Be("pending_gate");
}
[Fact]
public async Task NotifyDecisionAsync_HoldAsync_SendsHoldNotification()
{
var ct = TestContext.Current.CancellationToken;
var promotion = CreatePromotion();
var result = CreateDecisionResult(DecisionOutcome.HoldAsync, "Awaiting async evidence");
_promotionStore.Setup(s => s.GetAsync(_promotionId, It.IsAny<CancellationToken>()))
.ReturnsAsync(promotion);
NotificationRequest? sentRequest = null;
_notificationService.Setup(s => s.SendAsync(It.IsAny<NotificationRequest>(), It.IsAny<CancellationToken>()))
.Callback<NotificationRequest, CancellationToken>((r, _) => sentRequest = r)
.Returns(Task.CompletedTask);
await _notifier.NotifyDecisionAsync(result, ct);
sentRequest.Should().NotBeNull();
sentRequest!.Title.Should().Contain("Async Hold");
sentRequest.Metadata["outcome"].Should().Be("hold_async");
}
[Fact]
public async Task NotifyDecisionAsync_Escalate_SendsEscalationNotification()
{
var ct = TestContext.Current.CancellationToken;
var promotion = CreatePromotion();
var result = CreateDecisionResult(DecisionOutcome.Escalate, "Security escalation required");
_promotionStore.Setup(s => s.GetAsync(_promotionId, It.IsAny<CancellationToken>()))
.ReturnsAsync(promotion);
NotificationRequest? sentRequest = null;
_notificationService.Setup(s => s.SendAsync(It.IsAny<NotificationRequest>(), It.IsAny<CancellationToken>()))
.Callback<NotificationRequest, CancellationToken>((r, _) => sentRequest = r)
.Returns(Task.CompletedTask);
await _notifier.NotifyDecisionAsync(result, ct);
sentRequest.Should().NotBeNull();
sentRequest!.Title.Should().Contain("Escalation Required");
sentRequest.Metadata["outcome"].Should().Be("escalate");
}
[Fact]
public async Task NotifyDecisionAsync_Error_DoesNotSendNotification()
{

View File

@@ -203,6 +203,52 @@ public sealed class DecisionRecorderTests
records[0].EvidenceDigest.Should().Be(records[1].EvidenceDigest);
}
[Fact]
public async Task RecordAsync_ExtractsSloAndHumanDecisionMetadata()
{
var ct = TestContext.Current.CancellationToken;
_guidGenerator.Setup(g => g.NewGuid()).Returns(Guid.NewGuid());
var promotion = CreatePromotion();
var result = CreateDecisionResult() with
{
GateResults =
[
GateResult.Success(
"security-gate",
"security",
details: ImmutableDictionary<string, object>.Empty
.Add("slo", new Dictionary<string, object>
{
["p50Ms"] = 200,
["p90Ms"] = 2000,
["p99Ms"] = 15000,
["asyncHoldSlaHours"] = 12,
["evidenceTtlHours"] = 24
})
.Add("component_my-app", new Dictionary<string, object>
{
["humanDecisionDsseRef"] = "cas://evidence/human-decision.dsse.json"
}))
]
};
DecisionRecord? savedRecord = null;
_store.Setup(s => s.SaveAsync(It.IsAny<DecisionRecord>(), It.IsAny<CancellationToken>()))
.Callback<DecisionRecord, CancellationToken>((r, _) => savedRecord = r)
.Returns(Task.CompletedTask);
await _recorder.RecordAsync(promotion, result, CreateConfig(), ct);
savedRecord.Should().NotBeNull();
savedRecord!.SloP50Ms.Should().Be(200);
savedRecord.SloP90Ms.Should().Be(2000);
savedRecord.SloP99Ms.Should().Be(15000);
savedRecord.AsyncHoldSlaHours.Should().Be(12);
savedRecord.EvidenceTtlHours.Should().Be(24);
savedRecord.HumanDecisionDsseRef.Should().Be("cas://evidence/human-decision.dsse.json");
}
[Fact]
public async Task GetLatestAsync_ReturnsFromStore()
{

View File

@@ -170,7 +170,7 @@ public sealed class DecisionRulesTests
}
[Fact]
public void Evaluate_PendingGate_ReturnsPendingGate()
public void Evaluate_PendingGate_ReturnsHoldAsync()
{
// Arrange
var gateResults = ImmutableArray.Create(
@@ -183,11 +183,32 @@ public sealed class DecisionRulesTests
var result = _rules.Evaluate(gateResults, approvalStatus, config);
// Assert
result.Decision.Should().Be(DecisionOutcome.PendingGate);
result.Decision.Should().Be(DecisionOutcome.HoldAsync);
result.CanProceed.Should().BeFalse();
result.BlockingReason.Should().Contain("manual-gate");
}
[Fact]
public void Evaluate_BlockingEscalationHint_ReturnsEscalate()
{
var gateResults = ImmutableArray.Create(
GateResult.Failure(
"security-gate",
"security",
"Escalation required",
blocking: true,
details: ImmutableDictionary<string, object>.Empty
.Add("gateOutcomeHint", "escalate")
.Add("escalationBlocking", true)));
var approvalStatus = CreateApprovalStatus(approved: true);
var config = CreateConfig();
var result = _rules.Evaluate(gateResults, approvalStatus, config);
result.Decision.Should().Be(DecisionOutcome.Escalate);
result.CanProceed.Should().BeFalse();
}
[Fact]
public void Evaluate_NoGates_NoApprovals_Allows()
{

View File

@@ -1177,6 +1177,230 @@ public sealed class SecurityGateTests
((string[])result.Details["policyViolationCodes"]).Should().Contain("SEC_REPRO_EVIDENCE_SCORE_MISSING");
}
[Fact]
public async Task EvaluateAsync_MinEvidenceScoreBelowThreshold_Fails()
{
var ct = TestContext.Current.CancellationToken;
var config = new Dictionary<string, object>
{
["minEvidenceScore"] = 85
}.ToImmutableDictionary();
var context = CreateContext(config);
var component = new ReleaseComponent { Name = "my-app", Digest = _componentDigest, ImageReference = "registry.example.com/my-app:1.0" };
var release = CreateRelease(component);
var scan = CreateScan(
reproducibilityEvidence: new ReproducibilityEvidenceStatus
{
EvidenceScoreValue = 72,
HasDsseProvenance = true
});
_releaseService.Setup(s => s.GetAsync(_releaseId, It.IsAny<CancellationToken>())).ReturnsAsync(release);
_sbomService.Setup(s => s.GetByDigestAsync(_componentDigest, It.IsAny<CancellationToken>()))
.ReturnsAsync(new SbomDocument { Id = Guid.NewGuid(), Digest = _componentDigest, Format = "CycloneDX", GeneratedAt = _timeProvider.GetUtcNow().AddDays(-1), Components = [] });
_scannerService.Setup(s => s.GetLatestScanAsync(_componentDigest, It.IsAny<CancellationToken>())).ReturnsAsync(scan);
_kevService.Setup(s => s.GetKevVulnerabilitiesAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>())).ReturnsAsync(new HashSet<string>());
_vexService.Setup(s => s.GetVexForDigestAsync(_componentDigest, It.IsAny<CancellationToken>())).ReturnsAsync([]);
var result = await _gate.EvaluateAsync(context, ct);
result.Passed.Should().BeFalse();
result.Message.Should().Contain("below threshold");
((string[])result.Details["policyViolationCodes"]).Should().Contain("SEC_REPRO_EVIDENCE_SCORE_THRESHOLD");
}
[Fact]
public async Task EvaluateAsync_RequireBuildLinkDigestBinding_DigestMismatch_Fails()
{
var ct = TestContext.Current.CancellationToken;
var config = new Dictionary<string, object>
{
["requireBuildLinkDigestBinding"] = true,
["productDigestAlgorithm"] = "sha256"
}.ToImmutableDictionary();
var context = CreateContext(config);
var component = new ReleaseComponent { Name = "my-app", Digest = _componentDigest, ImageReference = "registry.example.com/my-app:1.0" };
var release = CreateRelease(component);
var scan = CreateScan(
reproducibilityEvidence: new ReproducibilityEvidenceStatus
{
BuildLinkExists = true,
BuildProductDigestSha256 = new string('a', 64),
ArtifactDigestSha256 = new string('b', 64)
});
_releaseService.Setup(s => s.GetAsync(_releaseId, It.IsAny<CancellationToken>())).ReturnsAsync(release);
_sbomService.Setup(s => s.GetByDigestAsync(_componentDigest, It.IsAny<CancellationToken>()))
.ReturnsAsync(new SbomDocument { Id = Guid.NewGuid(), Digest = _componentDigest, Format = "CycloneDX", GeneratedAt = _timeProvider.GetUtcNow().AddDays(-1), Components = [] });
_scannerService.Setup(s => s.GetLatestScanAsync(_componentDigest, It.IsAny<CancellationToken>())).ReturnsAsync(scan);
_kevService.Setup(s => s.GetKevVulnerabilitiesAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>())).ReturnsAsync(new HashSet<string>());
_vexService.Setup(s => s.GetVexForDigestAsync(_componentDigest, It.IsAny<CancellationToken>())).ReturnsAsync([]);
var result = await _gate.EvaluateAsync(context, ct);
result.Passed.Should().BeFalse();
((string[])result.Details["policyViolationCodes"]).Should().Contain("SEC_REPRO_BUILD_DIGEST_MISMATCH");
}
[Fact]
public async Task EvaluateAsync_DsseSignerThreshold_NotMet_Fails()
{
var ct = TestContext.Current.CancellationToken;
var config = new Dictionary<string, object>
{
["dsseSignerThresholdK"] = 2,
["dsseSignerThresholdN"] = 4,
["allowedSignerKeys"] = new[] { "kid:builder", "kid:relmgr", "kid:secops", "kid:prov" },
["allowedDsseAlgorithms"] = new[] { "ed25519", "ecdsa" }
}.ToImmutableDictionary<string, object>();
var context = CreateContext(config);
var component = new ReleaseComponent { Name = "my-app", Digest = _componentDigest, ImageReference = "registry.example.com/my-app:1.0" };
var release = CreateRelease(component);
var scan = CreateScan(
reproducibilityEvidence: new ReproducibilityEvidenceStatus
{
DsseSignatures =
[
new DsseSignatureEvidence { Valid = true, KeyId = "kid:builder", Algorithm = "ed25519" },
new DsseSignatureEvidence { Valid = false, KeyId = "kid:relmgr", Algorithm = "ed25519" },
new DsseSignatureEvidence { Valid = true, KeyId = "kid:unknown", Algorithm = "ed25519" }
]
});
_releaseService.Setup(s => s.GetAsync(_releaseId, It.IsAny<CancellationToken>())).ReturnsAsync(release);
_sbomService.Setup(s => s.GetByDigestAsync(_componentDigest, It.IsAny<CancellationToken>()))
.ReturnsAsync(new SbomDocument { Id = Guid.NewGuid(), Digest = _componentDigest, Format = "CycloneDX", GeneratedAt = _timeProvider.GetUtcNow().AddDays(-1), Components = [] });
_scannerService.Setup(s => s.GetLatestScanAsync(_componentDigest, It.IsAny<CancellationToken>())).ReturnsAsync(scan);
_kevService.Setup(s => s.GetKevVulnerabilitiesAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>())).ReturnsAsync(new HashSet<string>());
_vexService.Setup(s => s.GetVexForDigestAsync(_componentDigest, It.IsAny<CancellationToken>())).ReturnsAsync([]);
var result = await _gate.EvaluateAsync(context, ct);
result.Passed.Should().BeFalse();
((string[])result.Details["policyViolationCodes"]).Should().Contain("SEC_REPRO_DSSE_THRESHOLD");
}
[Fact]
public async Task EvaluateAsync_RekorFreshnessExceeded_FailClosed_EscalatesAndBlocks()
{
var ct = TestContext.Current.CancellationToken;
var config = new Dictionary<string, object>
{
["requireRekorVerification"] = true,
["rekorMaxFreshSeconds"] = 30,
["maxRetries"] = 1,
["escalationMode"] = "fail_closed"
}.ToImmutableDictionary();
var context = CreateContext(config);
var component = new ReleaseComponent { Name = "my-app", Digest = _componentDigest, ImageReference = "registry.example.com/my-app:1.0" };
var release = CreateRelease(component);
var scan = CreateScan(
reproducibilityEvidence: new ReproducibilityEvidenceStatus
{
RekorVerified = true,
RekorCheckedAt = _timeProvider.GetUtcNow().AddHours(-4)
});
_releaseService.Setup(s => s.GetAsync(_releaseId, It.IsAny<CancellationToken>())).ReturnsAsync(release);
_sbomService.Setup(s => s.GetByDigestAsync(_componentDigest, It.IsAny<CancellationToken>()))
.ReturnsAsync(new SbomDocument { Id = Guid.NewGuid(), Digest = _componentDigest, Format = "CycloneDX", GeneratedAt = _timeProvider.GetUtcNow().AddDays(-1), Components = [] });
_scannerService.Setup(s => s.GetLatestScanAsync(_componentDigest, It.IsAny<CancellationToken>())).ReturnsAsync(scan);
_kevService.Setup(s => s.GetKevVulnerabilitiesAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>())).ReturnsAsync(new HashSet<string>());
_vexService.Setup(s => s.GetVexForDigestAsync(_componentDigest, It.IsAny<CancellationToken>())).ReturnsAsync([]);
var result = await _gate.EvaluateAsync(context, ct);
result.Passed.Should().BeFalse();
result.Blocking.Should().BeTrue();
result.Details["gateOutcomeHint"].Should().Be("escalate");
((string[])result.Details["policyViolationCodes"]).Should().Contain("SEC_REPRO_REKOR_FRESHNESS_EXCEEDED");
}
[Fact]
public async Task EvaluateAsync_RekorFreshnessExceeded_FailOpenWithSignedHumanDecision_PassesWithAlert()
{
var ct = TestContext.Current.CancellationToken;
var config = new Dictionary<string, object>
{
["requireRekorVerification"] = true,
["rekorMaxFreshSeconds"] = 30,
["maxRetries"] = 0,
["escalationMode"] = "fail_open_with_alert",
["requireSignedHumanDecision"] = true
}.ToImmutableDictionary();
var context = CreateContext(config);
var component = new ReleaseComponent { Name = "my-app", Digest = _componentDigest, ImageReference = "registry.example.com/my-app:1.0" };
var release = CreateRelease(component);
var scan = CreateScan(
reproducibilityEvidence: new ReproducibilityEvidenceStatus
{
RekorVerified = true,
RekorCheckedAt = _timeProvider.GetUtcNow().AddHours(-4),
HumanDecisionDsseRef = "cas://evidence/human-decision.dsse.json"
});
_releaseService.Setup(s => s.GetAsync(_releaseId, It.IsAny<CancellationToken>())).ReturnsAsync(release);
_sbomService.Setup(s => s.GetByDigestAsync(_componentDigest, It.IsAny<CancellationToken>()))
.ReturnsAsync(new SbomDocument { Id = Guid.NewGuid(), Digest = _componentDigest, Format = "CycloneDX", GeneratedAt = _timeProvider.GetUtcNow().AddDays(-1), Components = [] });
_scannerService.Setup(s => s.GetLatestScanAsync(_componentDigest, It.IsAny<CancellationToken>())).ReturnsAsync(scan);
_kevService.Setup(s => s.GetKevVulnerabilitiesAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>())).ReturnsAsync(new HashSet<string>());
_vexService.Setup(s => s.GetVexForDigestAsync(_componentDigest, It.IsAny<CancellationToken>())).ReturnsAsync([]);
var result = await _gate.EvaluateAsync(context, ct);
result.Passed.Should().BeTrue();
result.Details["gateOutcomeHint"].Should().Be("escalate");
((string[])result.Details["policyViolationCodes"]).Should().Contain("SEC_ESCALATION_FAIL_OPEN_ALERT");
}
[Fact]
public async Task EvaluateAsync_EvidenceScoreNotReady_WithAsyncHold_ReturnsNonBlockingHold()
{
var ct = TestContext.Current.CancellationToken;
var config = new Dictionary<string, object>
{
["requireEvidenceScoreMatch"] = true,
["asyncHoldSlaHours"] = 12
}.ToImmutableDictionary();
var context = CreateContext(config);
var component = new ReleaseComponent { Name = "my-app", Digest = _componentDigest, ImageReference = "registry.example.com/my-app:1.0" };
var release = CreateRelease(component);
var scan = CreateScan(
reproducibilityEvidence: new ReproducibilityEvidenceStatus
{
HasDsseProvenance = true,
HasDsseInTotoLink = true,
CanonicalizationPassed = true,
ToolchainDigestPinned = true,
RekorVerified = true,
EvidenceArtifactId = "stella://svc/my-app@sha256:abc",
CanonicalBomSha256 = new string('1', 64),
PayloadDigest = new string('2', 64),
AttestationRefs = ["sha256://attestation-a"]
});
_releaseService.Setup(s => s.GetAsync(_releaseId, It.IsAny<CancellationToken>())).ReturnsAsync(release);
_sbomService.Setup(s => s.GetByDigestAsync(_componentDigest, It.IsAny<CancellationToken>()))
.ReturnsAsync(new SbomDocument { Id = Guid.NewGuid(), Digest = _componentDigest, Format = "CycloneDX", GeneratedAt = _timeProvider.GetUtcNow().AddDays(-1), Components = [] });
_scannerService.Setup(s => s.GetLatestScanAsync(_componentDigest, It.IsAny<CancellationToken>())).ReturnsAsync(scan);
_evidenceScoreService.Setup(s => s.GetScoreAsync(_tenantId, "stella://svc/my-app@sha256:abc", It.IsAny<CancellationToken>()))
.ReturnsAsync(new EvidenceScoreLookupResult
{
ArtifactId = "stella://svc/my-app@sha256:abc",
EvidenceScore = new string('9', 64),
Status = "pending"
});
_kevService.Setup(s => s.GetKevVulnerabilitiesAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>())).ReturnsAsync(new HashSet<string>());
_vexService.Setup(s => s.GetVexForDigestAsync(_componentDigest, It.IsAny<CancellationToken>())).ReturnsAsync([]);
var result = await _gate.EvaluateAsync(context, ct);
result.Passed.Should().BeFalse();
result.Blocking.Should().BeFalse();
result.Details["gateOutcomeHint"].Should().Be("hold_async");
((string[])result.Details["policyViolationCodes"]).Should().Contain("SEC_HOLD_ASYNC_EVIDENCE_NOT_READY");
}
[Fact]
public async Task ValidateConfigAsync_BreakGlassWithoutRekor_Fails()
{