save checkpoint
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user