save checkpoint. addition features and their state. check some ofthem
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
namespace StellaOps.ReleaseOrchestrator.Promotion.Gate.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves authoritative evidence scores from Evidence Locker.
|
||||
/// </summary>
|
||||
public interface IEvidenceScoreService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets evidence score details for the specified artifact id.
|
||||
/// </summary>
|
||||
Task<EvidenceScoreLookupResult?> GetScoreAsync(Guid tenantId, string artifactId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence score response from Evidence Locker.
|
||||
/// </summary>
|
||||
public sealed record EvidenceScoreLookupResult
|
||||
{
|
||||
/// <summary>Artifact identifier queried.</summary>
|
||||
public required string ArtifactId { get; init; }
|
||||
|
||||
/// <summary>Deterministic evidence score.</summary>
|
||||
public required string EvidenceScore { get; init; }
|
||||
|
||||
/// <summary>Current readiness status for gate checks.</summary>
|
||||
public required string Status { get; init; }
|
||||
}
|
||||
@@ -42,6 +42,51 @@ public sealed record ScanResult
|
||||
|
||||
/// <summary>List of vulnerabilities found.</summary>
|
||||
public IReadOnlyList<Vulnerability> Vulnerabilities { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Reproducibility evidence status for this artifact/build.
|
||||
/// Fail-closed gates rely on this payload when enabled.
|
||||
/// </summary>
|
||||
public ReproducibilityEvidenceStatus? ReproducibilityEvidence { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reproducibility evidence status attached to a scan result.
|
||||
/// </summary>
|
||||
public sealed record ReproducibilityEvidenceStatus
|
||||
{
|
||||
/// <summary>Whether DSSE-signed SLSA provenance exists and verified.</summary>
|
||||
public bool HasDsseProvenance { get; init; }
|
||||
|
||||
/// <summary>Whether DSSE-signed in-toto link evidence exists and verified.</summary>
|
||||
public bool HasDsseInTotoLink { get; init; }
|
||||
|
||||
/// <summary>Whether canonicalization checks passed for artifact + metadata.</summary>
|
||||
public bool CanonicalizationPassed { get; init; }
|
||||
|
||||
/// <summary>Whether toolchain reference is pinned to a digest.</summary>
|
||||
public bool ToolchainDigestPinned { get; init; }
|
||||
|
||||
/// <summary>Whether Rekor inclusion proof is verified (online or offline profile).</summary>
|
||||
public bool RekorVerified { get; init; }
|
||||
|
||||
/// <summary>Whether verification was done in explicit break-glass mode.</summary>
|
||||
public bool UsedBreakGlassVerification { get; init; }
|
||||
|
||||
/// <summary>Stable policy violation codes produced upstream by attestor/policy checks.</summary>
|
||||
public IReadOnlyList<string> ViolationCodes { get; init; } = [];
|
||||
|
||||
/// <summary>Artifact identifier used by Evidence Locker score lookup.</summary>
|
||||
public string? EvidenceArtifactId { get; init; }
|
||||
|
||||
/// <summary>Canonical BOM SHA-256 digest used for evidence score recomputation.</summary>
|
||||
public string? CanonicalBomSha256 { get; init; }
|
||||
|
||||
/// <summary>DSSE payload digest used for evidence score recomputation.</summary>
|
||||
public string? PayloadDigest { get; init; }
|
||||
|
||||
/// <summary>Attestation references folded into evidence score recomputation.</summary>
|
||||
public IReadOnlyList<string> AttestationRefs { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.ReleaseOrchestrator.Promotion.Gate.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Default fail-closed implementation when Evidence Locker integration is unavailable.
|
||||
/// </summary>
|
||||
public sealed class NullEvidenceScoreService : IEvidenceScoreService
|
||||
{
|
||||
public Task<EvidenceScoreLookupResult?> GetScoreAsync(Guid tenantId, string artifactId, CancellationToken ct = default)
|
||||
=> Task.FromResult<EvidenceScoreLookupResult?>(null);
|
||||
}
|
||||
@@ -2,7 +2,10 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.ReleaseOrchestrator.Promotion.Manager;
|
||||
using StellaOps.ReleaseOrchestrator.Promotion.Models;
|
||||
using System.Security.Cryptography;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.Promotion.Gate.Security;
|
||||
|
||||
@@ -16,6 +19,7 @@ public sealed class SecurityGate : IGateProvider
|
||||
private readonly IScannerService _scannerService;
|
||||
private readonly VulnerabilityCounter _vulnCounter;
|
||||
private readonly SbomRequirementChecker _sbomChecker;
|
||||
private readonly IEvidenceScoreService _evidenceScoreService;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<SecurityGate> _logger;
|
||||
|
||||
@@ -26,11 +30,31 @@ public sealed class SecurityGate : IGateProvider
|
||||
SbomRequirementChecker sbomChecker,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<SecurityGate> logger)
|
||||
: this(
|
||||
releaseService,
|
||||
scannerService,
|
||||
vulnCounter,
|
||||
sbomChecker,
|
||||
new NullEvidenceScoreService(),
|
||||
timeProvider,
|
||||
logger)
|
||||
{
|
||||
}
|
||||
|
||||
public SecurityGate(
|
||||
IReleaseService releaseService,
|
||||
IScannerService scannerService,
|
||||
VulnerabilityCounter vulnCounter,
|
||||
SbomRequirementChecker sbomChecker,
|
||||
IEvidenceScoreService evidenceScoreService,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<SecurityGate> logger)
|
||||
{
|
||||
_releaseService = releaseService;
|
||||
_scannerService = scannerService;
|
||||
_vulnCounter = vulnCounter;
|
||||
_sbomChecker = sbomChecker;
|
||||
_evidenceScoreService = evidenceScoreService ?? new NullEvidenceScoreService();
|
||||
_timeProvider = timeProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -89,7 +113,42 @@ public sealed class SecurityGate : IGateProvider
|
||||
"blockOnKnownExploited",
|
||||
GatePropertyType.Boolean,
|
||||
"Block on KEV vulnerabilities",
|
||||
Default: true)
|
||||
Default: true),
|
||||
new GateConfigProperty(
|
||||
"requireDsseProvenance",
|
||||
GatePropertyType.Boolean,
|
||||
"Require DSSE signed SLSA provenance evidence",
|
||||
Default: false),
|
||||
new GateConfigProperty(
|
||||
"requireDsseInTotoLink",
|
||||
GatePropertyType.Boolean,
|
||||
"Require DSSE signed in-toto link evidence",
|
||||
Default: false),
|
||||
new GateConfigProperty(
|
||||
"requireCanonicalizationPass",
|
||||
GatePropertyType.Boolean,
|
||||
"Require canonicalization policy pass",
|
||||
Default: false),
|
||||
new GateConfigProperty(
|
||||
"requirePinnedToolchainDigest",
|
||||
GatePropertyType.Boolean,
|
||||
"Require pinned toolchain digest evidence",
|
||||
Default: false),
|
||||
new GateConfigProperty(
|
||||
"requireRekorVerification",
|
||||
GatePropertyType.Boolean,
|
||||
"Require Rekor inclusion verification evidence",
|
||||
Default: false),
|
||||
new GateConfigProperty(
|
||||
"allowBreakGlassVerification",
|
||||
GatePropertyType.Boolean,
|
||||
"Allow break-glass verification mode",
|
||||
Default: false),
|
||||
new GateConfigProperty(
|
||||
"requireEvidenceScoreMatch",
|
||||
GatePropertyType.Boolean,
|
||||
"Require Evidence Locker evidence_score recomputation and match",
|
||||
Default: false)
|
||||
]
|
||||
};
|
||||
|
||||
@@ -110,6 +169,7 @@ public sealed class SecurityGate : IGateProvider
|
||||
}
|
||||
|
||||
var violations = new List<string>();
|
||||
var violationCodes = new List<string>();
|
||||
var details = new Dictionary<string, object>();
|
||||
var totalVulns = new VulnerabilityCounts();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
@@ -126,7 +186,11 @@ public sealed class SecurityGate : IGateProvider
|
||||
componentDetails["hasSbom"] = hasSbom;
|
||||
if (!hasSbom)
|
||||
{
|
||||
violations.Add($"Component {component.Name} has no SBOM");
|
||||
AddViolation(
|
||||
violations,
|
||||
violationCodes,
|
||||
"SEC_SBOM_MISSING",
|
||||
$"Component {component.Name} has no SBOM");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +201,11 @@ public sealed class SecurityGate : IGateProvider
|
||||
componentDetails["hasScan"] = false;
|
||||
if (config.RequireSbom)
|
||||
{
|
||||
violations.Add($"Component {component.Name} has no security scan");
|
||||
AddViolation(
|
||||
violations,
|
||||
violationCodes,
|
||||
"SEC_SCAN_MISSING",
|
||||
$"Component {component.Name} has no security scan");
|
||||
}
|
||||
details[componentKey] = componentDetails;
|
||||
continue;
|
||||
@@ -150,7 +218,11 @@ public sealed class SecurityGate : IGateProvider
|
||||
componentDetails["scanAgeHours"] = scanAge.TotalHours;
|
||||
if (scanAge.TotalHours > config.MaxScanAgeHours)
|
||||
{
|
||||
violations.Add($"Component {component.Name} scan is too old ({scanAge.TotalHours:F1}h > {config.MaxScanAgeHours}h)");
|
||||
AddViolation(
|
||||
violations,
|
||||
violationCodes,
|
||||
"SEC_SCAN_TOO_OLD",
|
||||
$"Component {component.Name} scan is too old ({scanAge.TotalHours:F1}h > {config.MaxScanAgeHours}h)");
|
||||
}
|
||||
|
||||
// Count vulnerabilities
|
||||
@@ -170,32 +242,61 @@ public sealed class SecurityGate : IGateProvider
|
||||
// Check for known exploited vulnerabilities
|
||||
if (config.BlockOnKnownExploited && vulnCounts.KnownExploitedCount > 0)
|
||||
{
|
||||
violations.Add(
|
||||
AddViolation(
|
||||
violations,
|
||||
violationCodes,
|
||||
"SEC_KEV_PRESENT",
|
||||
$"Component {component.Name} has {vulnCounts.KnownExploitedCount} known exploited vulnerabilities");
|
||||
}
|
||||
|
||||
await EvaluateReproducibilityEvidenceAsync(
|
||||
component.Name,
|
||||
config,
|
||||
context.TenantId,
|
||||
scan.ReproducibilityEvidence,
|
||||
componentDetails,
|
||||
violations,
|
||||
violationCodes,
|
||||
ct);
|
||||
|
||||
details[componentKey] = componentDetails;
|
||||
}
|
||||
|
||||
// Check aggregate thresholds
|
||||
if (totalVulns.Critical > config.MaxCritical)
|
||||
{
|
||||
violations.Add($"Critical vulnerabilities ({totalVulns.Critical}) exceed threshold ({config.MaxCritical})");
|
||||
AddViolation(
|
||||
violations,
|
||||
violationCodes,
|
||||
"SEC_THRESHOLD_CRITICAL",
|
||||
$"Critical vulnerabilities ({totalVulns.Critical}) exceed threshold ({config.MaxCritical})");
|
||||
}
|
||||
|
||||
if (totalVulns.High > config.MaxHigh)
|
||||
{
|
||||
violations.Add($"High vulnerabilities ({totalVulns.High}) exceed threshold ({config.MaxHigh})");
|
||||
AddViolation(
|
||||
violations,
|
||||
violationCodes,
|
||||
"SEC_THRESHOLD_HIGH",
|
||||
$"High vulnerabilities ({totalVulns.High}) exceed threshold ({config.MaxHigh})");
|
||||
}
|
||||
|
||||
if (config.MaxMedium.HasValue && totalVulns.Medium > config.MaxMedium.Value)
|
||||
{
|
||||
violations.Add($"Medium vulnerabilities ({totalVulns.Medium}) exceed threshold ({config.MaxMedium})");
|
||||
AddViolation(
|
||||
violations,
|
||||
violationCodes,
|
||||
"SEC_THRESHOLD_MEDIUM",
|
||||
$"Medium vulnerabilities ({totalVulns.Medium}) exceed threshold ({config.MaxMedium})");
|
||||
}
|
||||
|
||||
if (config.MaxLow.HasValue && totalVulns.Low > config.MaxLow.Value)
|
||||
{
|
||||
violations.Add($"Low vulnerabilities ({totalVulns.Low}) exceed threshold ({config.MaxLow})");
|
||||
AddViolation(
|
||||
violations,
|
||||
violationCodes,
|
||||
"SEC_THRESHOLD_LOW",
|
||||
$"Low vulnerabilities ({totalVulns.Low}) exceed threshold ({config.MaxLow})");
|
||||
}
|
||||
|
||||
// Add summary details
|
||||
@@ -216,8 +317,16 @@ public sealed class SecurityGate : IGateProvider
|
||||
["maxHigh"] = config.MaxHigh,
|
||||
["maxMedium"] = config.MaxMedium ?? -1,
|
||||
["maxLow"] = config.MaxLow ?? -1,
|
||||
["maxScanAgeHours"] = config.MaxScanAgeHours
|
||||
["maxScanAgeHours"] = config.MaxScanAgeHours,
|
||||
["requireDsseProvenance"] = config.RequireDsseProvenance,
|
||||
["requireDsseInTotoLink"] = config.RequireDsseInTotoLink,
|
||||
["requireCanonicalizationPass"] = config.RequireCanonicalizationPass,
|
||||
["requirePinnedToolchainDigest"] = config.RequirePinnedToolchainDigest,
|
||||
["requireRekorVerification"] = config.RequireRekorVerification,
|
||||
["allowBreakGlassVerification"] = config.AllowBreakGlassVerification,
|
||||
["requireEvidenceScoreMatch"] = config.RequireEvidenceScoreMatch
|
||||
};
|
||||
details["policyViolationCodes"] = violationCodes.Distinct(StringComparer.Ordinal).OrderBy(x => x, StringComparer.Ordinal).ToArray();
|
||||
|
||||
if (violations.Count > 0)
|
||||
{
|
||||
@@ -289,6 +398,14 @@ public sealed class SecurityGate : IGateProvider
|
||||
errors.Add("maxScanAge must be at least 1 hour");
|
||||
}
|
||||
|
||||
if (config.TryGetValue("allowBreakGlassVerification", out var allowBreakGlassVerification) &&
|
||||
allowBreakGlassVerification is true &&
|
||||
config.TryGetValue("requireRekorVerification", out var requireRekorVerification) &&
|
||||
requireRekorVerification is false)
|
||||
{
|
||||
errors.Add("allowBreakGlassVerification=true requires requireRekorVerification=true");
|
||||
}
|
||||
|
||||
return Task.FromResult(errors.Count == 0
|
||||
? ValidationResult.Success()
|
||||
: ValidationResult.Failure(errors));
|
||||
@@ -304,9 +421,208 @@ public sealed class SecurityGate : IGateProvider
|
||||
RequireSbom = GetConfigValue(config, "requireSbom", true),
|
||||
MaxScanAgeHours = GetConfigValue(config, "maxScanAge", 24),
|
||||
ApplyVexExceptions = GetConfigValue(config, "applyVexExceptions", true),
|
||||
BlockOnKnownExploited = GetConfigValue(config, "blockOnKnownExploited", true)
|
||||
BlockOnKnownExploited = GetConfigValue(config, "blockOnKnownExploited", true),
|
||||
RequireDsseProvenance = GetConfigValue(config, "requireDsseProvenance", false),
|
||||
RequireDsseInTotoLink = GetConfigValue(config, "requireDsseInTotoLink", false),
|
||||
RequireCanonicalizationPass = GetConfigValue(config, "requireCanonicalizationPass", false),
|
||||
RequirePinnedToolchainDigest = GetConfigValue(config, "requirePinnedToolchainDigest", false),
|
||||
RequireRekorVerification = GetConfigValue(config, "requireRekorVerification", false),
|
||||
AllowBreakGlassVerification = GetConfigValue(config, "allowBreakGlassVerification", false),
|
||||
RequireEvidenceScoreMatch = GetConfigValue(config, "requireEvidenceScoreMatch", false)
|
||||
};
|
||||
|
||||
private async Task EvaluateReproducibilityEvidenceAsync(
|
||||
string componentName,
|
||||
SecurityGateConfig config,
|
||||
Guid tenantId,
|
||||
ReproducibilityEvidenceStatus? evidence,
|
||||
Dictionary<string, object> componentDetails,
|
||||
List<string> violations,
|
||||
List<string> violationCodes,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var requireReproEvidence =
|
||||
config.RequireDsseProvenance ||
|
||||
config.RequireDsseInTotoLink ||
|
||||
config.RequireCanonicalizationPass ||
|
||||
config.RequirePinnedToolchainDigest ||
|
||||
config.RequireRekorVerification ||
|
||||
config.RequireEvidenceScoreMatch;
|
||||
|
||||
if (!requireReproEvidence)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
componentDetails["hasReproducibilityEvidence"] = evidence is not null;
|
||||
|
||||
if (evidence is null)
|
||||
{
|
||||
AddViolation(
|
||||
violations,
|
||||
violationCodes,
|
||||
"SEC_REPRO_EVIDENCE_MISSING",
|
||||
$"Component {componentName} has no reproducibility evidence");
|
||||
return;
|
||||
}
|
||||
|
||||
componentDetails["reproDsseProvenance"] = evidence.HasDsseProvenance;
|
||||
componentDetails["reproDsseInTotoLink"] = evidence.HasDsseInTotoLink;
|
||||
componentDetails["reproCanonicalizationPassed"] = evidence.CanonicalizationPassed;
|
||||
componentDetails["reproToolchainDigestPinned"] = evidence.ToolchainDigestPinned;
|
||||
componentDetails["reproRekorVerified"] = evidence.RekorVerified;
|
||||
componentDetails["reproUsedBreakGlassVerification"] = evidence.UsedBreakGlassVerification;
|
||||
componentDetails["reproViolationCodes"] = evidence.ViolationCodes
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(x => x, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
componentDetails["reproEvidenceArtifactId"] = evidence.EvidenceArtifactId ?? string.Empty;
|
||||
|
||||
if (config.RequireDsseProvenance && !evidence.HasDsseProvenance)
|
||||
{
|
||||
AddViolation(
|
||||
violations,
|
||||
violationCodes,
|
||||
"SEC_REPRO_DSSE_PROVENANCE_MISSING",
|
||||
$"Component {componentName} missing DSSE provenance evidence");
|
||||
}
|
||||
|
||||
if (config.RequireDsseInTotoLink && !evidence.HasDsseInTotoLink)
|
||||
{
|
||||
AddViolation(
|
||||
violations,
|
||||
violationCodes,
|
||||
"SEC_REPRO_DSSE_INTOTO_MISSING",
|
||||
$"Component {componentName} missing DSSE in-toto link evidence");
|
||||
}
|
||||
|
||||
if (config.RequireCanonicalizationPass && !evidence.CanonicalizationPassed)
|
||||
{
|
||||
AddViolation(
|
||||
violations,
|
||||
violationCodes,
|
||||
"SEC_REPRO_CANONICALIZATION_FAILED",
|
||||
$"Component {componentName} failed canonicalization policy");
|
||||
}
|
||||
|
||||
if (config.RequirePinnedToolchainDigest && !evidence.ToolchainDigestPinned)
|
||||
{
|
||||
AddViolation(
|
||||
violations,
|
||||
violationCodes,
|
||||
"SEC_REPRO_TOOLCHAIN_UNPINNED",
|
||||
$"Component {componentName} toolchain is not digest pinned");
|
||||
}
|
||||
|
||||
if (config.RequireRekorVerification && !evidence.RekorVerified)
|
||||
{
|
||||
AddViolation(
|
||||
violations,
|
||||
violationCodes,
|
||||
"SEC_REPRO_REKOR_UNVERIFIED",
|
||||
$"Component {componentName} missing verified Rekor evidence");
|
||||
}
|
||||
|
||||
if (!config.AllowBreakGlassVerification && evidence.UsedBreakGlassVerification)
|
||||
{
|
||||
AddViolation(
|
||||
violations,
|
||||
violationCodes,
|
||||
"SEC_REPRO_BREAK_GLASS_FORBIDDEN",
|
||||
$"Component {componentName} used break-glass verification mode");
|
||||
}
|
||||
|
||||
if (!config.RequireEvidenceScoreMatch)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(evidence.EvidenceArtifactId))
|
||||
{
|
||||
AddViolation(
|
||||
violations,
|
||||
violationCodes,
|
||||
"SEC_REPRO_EVIDENCE_ARTIFACT_MISSING",
|
||||
$"Component {componentName} is missing evidence artifact id");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsSha256Hex(evidence.CanonicalBomSha256) || !IsSha256Hex(evidence.PayloadDigest))
|
||||
{
|
||||
AddViolation(
|
||||
violations,
|
||||
violationCodes,
|
||||
"SEC_REPRO_EVIDENCE_SCORE_INPUT_INVALID",
|
||||
$"Component {componentName} has invalid evidence score digests");
|
||||
return;
|
||||
}
|
||||
|
||||
var sortedAttestationRefs = evidence.AttestationRefs
|
||||
.Where(static reference => !string.IsNullOrWhiteSpace(reference))
|
||||
.Select(static reference => reference.Trim())
|
||||
.OrderBy(static reference => reference, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
if (sortedAttestationRefs.Length != evidence.AttestationRefs.Count)
|
||||
{
|
||||
AddViolation(
|
||||
violations,
|
||||
violationCodes,
|
||||
"SEC_REPRO_EVIDENCE_SCORE_REFS_INVALID",
|
||||
$"Component {componentName} has invalid attestation refs for evidence score");
|
||||
return;
|
||||
}
|
||||
|
||||
var expectedScore = ComputeEvidenceScore(
|
||||
evidence.CanonicalBomSha256!,
|
||||
evidence.PayloadDigest!,
|
||||
sortedAttestationRefs);
|
||||
componentDetails["reproExpectedEvidenceScore"] = expectedScore;
|
||||
|
||||
var scoreLookup = await _evidenceScoreService.GetScoreAsync(tenantId, evidence.EvidenceArtifactId, ct);
|
||||
if (scoreLookup is null)
|
||||
{
|
||||
AddViolation(
|
||||
violations,
|
||||
violationCodes,
|
||||
"SEC_REPRO_EVIDENCE_SCORE_MISSING",
|
||||
$"Component {componentName} has no evidence score in Evidence Locker");
|
||||
return;
|
||||
}
|
||||
|
||||
componentDetails["reproEvidenceScore"] = scoreLookup.EvidenceScore;
|
||||
componentDetails["reproEvidenceStatus"] = scoreLookup.Status;
|
||||
|
||||
if (!string.Equals(scoreLookup.Status, "ready", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
AddViolation(
|
||||
violations,
|
||||
violationCodes,
|
||||
"SEC_REPRO_EVIDENCE_SCORE_NOT_READY",
|
||||
$"Component {componentName} evidence score status is '{scoreLookup.Status}'");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.Equals(scoreLookup.EvidenceScore, expectedScore, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
AddViolation(
|
||||
violations,
|
||||
violationCodes,
|
||||
"SEC_REPRO_EVIDENCE_SCORE_MISMATCH",
|
||||
$"Component {componentName} evidence score mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddViolation(
|
||||
List<string> violations,
|
||||
List<string> violationCodes,
|
||||
string code,
|
||||
string message)
|
||||
{
|
||||
violations.Add(message);
|
||||
violationCodes.Add(code);
|
||||
}
|
||||
|
||||
private static T GetConfigValue<T>(ImmutableDictionary<string, object> config, string key, T defaultValue)
|
||||
{
|
||||
if (config.TryGetValue(key, out var value) && value is T typedValue)
|
||||
@@ -324,4 +640,38 @@ public sealed class SecurityGate : IGateProvider
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ComputeEvidenceScore(string canonicalBomSha256, string payloadDigest, IReadOnlyList<string> attestationRefs)
|
||||
{
|
||||
const char separator = '\u001f';
|
||||
var pieces = new List<string>(2 + attestationRefs.Count)
|
||||
{
|
||||
canonicalBomSha256.ToLowerInvariant(),
|
||||
payloadDigest.ToLowerInvariant()
|
||||
};
|
||||
pieces.AddRange(attestationRefs);
|
||||
var serialized = string.Join(separator, pieces);
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(serialized));
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static bool IsSha256Hex(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) || value.Length != 64)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var i = 0; i < value.Length; i++)
|
||||
{
|
||||
var ch = value[i];
|
||||
var isHex = ch is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F';
|
||||
if (!isHex)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,4 +28,25 @@ public sealed record SecurityGateConfig
|
||||
|
||||
/// <summary>Block on known exploited vulnerabilities (default: true).</summary>
|
||||
public bool BlockOnKnownExploited { get; init; } = true;
|
||||
|
||||
/// <summary>Require DSSE signed provenance evidence (default: false).</summary>
|
||||
public bool RequireDsseProvenance { get; init; }
|
||||
|
||||
/// <summary>Require DSSE signed in-toto link evidence (default: false).</summary>
|
||||
public bool RequireDsseInTotoLink { get; init; }
|
||||
|
||||
/// <summary>Require canonicalization pass evidence (default: false).</summary>
|
||||
public bool RequireCanonicalizationPass { get; init; }
|
||||
|
||||
/// <summary>Require pinned toolchain digest evidence (default: false).</summary>
|
||||
public bool RequirePinnedToolchainDigest { get; init; }
|
||||
|
||||
/// <summary>Require verified Rekor inclusion evidence (default: false).</summary>
|
||||
public bool RequireRekorVerification { get; init; }
|
||||
|
||||
/// <summary>Allow break-glass offline verification evidence in promotion flow (default: false).</summary>
|
||||
public bool AllowBreakGlassVerification { get; init; }
|
||||
|
||||
/// <summary>Require Evidence Locker evidence_score match against local recomputation (default: false).</summary>
|
||||
public bool RequireEvidenceScoreMatch { get; init; }
|
||||
}
|
||||
|
||||
@@ -6,3 +6,5 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
| --- | --- | --- |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Promotion/StellaOps.ReleaseOrchestrator.Promotion.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| RB-005-REPRO-GATE-20260209 | DONE | Added fail-closed reproducibility gate policy checks, stable violation codes, and replay determinism assertion in `SecurityGateTests`. |
|
||||
| EL-GATE-003 | DONE | Added `requireEvidenceScoreMatch` fail-closed Evidence Locker score enforcement in `SecurityGate` with mismatch/missing tests (2026-02-09). |
|
||||
|
||||
@@ -21,6 +21,7 @@ public sealed class SecurityGateTests
|
||||
private readonly Mock<IVexService> _vexService = new();
|
||||
private readonly Mock<IKevService> _kevService = new();
|
||||
private readonly Mock<ISbomService> _sbomService = new();
|
||||
private readonly Mock<IEvidenceScoreService> _evidenceScoreService = new();
|
||||
private readonly Mock<ILogger<SecurityGate>> _logger = new();
|
||||
private readonly FakeTimeProvider _timeProvider = new();
|
||||
|
||||
@@ -44,6 +45,7 @@ public sealed class SecurityGateTests
|
||||
_scannerService.Object,
|
||||
_vulnCounter,
|
||||
_sbomChecker,
|
||||
_evidenceScoreService.Object,
|
||||
_timeProvider,
|
||||
_logger.Object);
|
||||
}
|
||||
@@ -84,7 +86,8 @@ public sealed class SecurityGateTests
|
||||
int medium = 0,
|
||||
int low = 0,
|
||||
DateTimeOffset? completedAt = null,
|
||||
List<Vulnerability>? vulnerabilities = null)
|
||||
List<Vulnerability>? vulnerabilities = null,
|
||||
ReproducibilityEvidenceStatus? reproducibilityEvidence = null)
|
||||
{
|
||||
var vulns = vulnerabilities ?? [];
|
||||
|
||||
@@ -97,7 +100,8 @@ public sealed class SecurityGateTests
|
||||
HighCount = high,
|
||||
MediumCount = medium,
|
||||
LowCount = low,
|
||||
Vulnerabilities = vulns
|
||||
Vulnerabilities = vulns,
|
||||
ReproducibilityEvidence = reproducibilityEvidence
|
||||
};
|
||||
}
|
||||
|
||||
@@ -636,4 +640,572 @@ public sealed class SecurityGateTests
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("maxScanAge"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ReproEvidenceRequired_MissingEvidence_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var config = new Dictionary<string, object>
|
||||
{
|
||||
["requireDsseProvenance"] = 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();
|
||||
|
||||
_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([]);
|
||||
|
||||
// Act
|
||||
var result = await _gate.EvaluateAsync(context, ct);
|
||||
|
||||
// Assert
|
||||
result.Passed.Should().BeFalse();
|
||||
result.Message.Should().Contain("reproducibility evidence");
|
||||
result.Details.Should().ContainKey("policyViolationCodes");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ReproEvidenceRequired_ValidEvidence_Passes()
|
||||
{
|
||||
// Arrange
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var config = new Dictionary<string, object>
|
||||
{
|
||||
["requireDsseProvenance"] = true,
|
||||
["requireDsseInTotoLink"] = true,
|
||||
["requireCanonicalizationPass"] = true,
|
||||
["requirePinnedToolchainDigest"] = true,
|
||||
["requireRekorVerification"] = 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
|
||||
{
|
||||
HasDsseProvenance = true,
|
||||
HasDsseInTotoLink = true,
|
||||
CanonicalizationPassed = true,
|
||||
ToolchainDigestPinned = true,
|
||||
RekorVerified = true,
|
||||
UsedBreakGlassVerification = false,
|
||||
ViolationCodes = []
|
||||
});
|
||||
|
||||
_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([]);
|
||||
|
||||
// Act
|
||||
var result = await _gate.EvaluateAsync(context, ct);
|
||||
|
||||
// Assert
|
||||
result.Passed.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ReproEvidenceReplay_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var config = new Dictionary<string, object>
|
||||
{
|
||||
["requireRekorVerification"] = true,
|
||||
["allowBreakGlassVerification"] = false
|
||||
}.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 = false,
|
||||
UsedBreakGlassVerification = true,
|
||||
ViolationCodes = ["z-last", "a-first", "a-first"]
|
||||
});
|
||||
|
||||
_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([]);
|
||||
|
||||
// Act
|
||||
var first = await _gate.EvaluateAsync(context, ct);
|
||||
var second = await _gate.EvaluateAsync(context, ct);
|
||||
|
||||
// Assert
|
||||
first.Passed.Should().BeFalse();
|
||||
second.Passed.Should().BeFalse();
|
||||
first.Message.Should().Be(second.Message);
|
||||
first.Details["policyViolationCodes"].Should().BeEquivalentTo(second.Details["policyViolationCodes"]);
|
||||
|
||||
var firstCodes = ((string[])first.Details["policyViolationCodes"]).ToArray();
|
||||
var secondCodes = ((string[])second.Details["policyViolationCodes"]).ToArray();
|
||||
firstCodes.Should().Equal(secondCodes);
|
||||
firstCodes.Should().Equal(firstCodes.OrderBy(static x => x, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RequireEvidenceScoreMatch_MissingEvidenceScore_FailsClosed()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var config = new Dictionary<string, object>
|
||||
{
|
||||
["requireEvidenceScoreMatch"] = 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
|
||||
{
|
||||
HasDsseProvenance = true,
|
||||
HasDsseInTotoLink = true,
|
||||
CanonicalizationPassed = true,
|
||||
ToolchainDigestPinned = true,
|
||||
RekorVerified = true,
|
||||
EvidenceArtifactId = "stella://svc/my-app@sha256:abc",
|
||||
CanonicalBomSha256 = new string('a', 64),
|
||||
PayloadDigest = new string('b', 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((EvidenceScoreLookupResult?)null);
|
||||
_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("no evidence score");
|
||||
((string[])result.Details["policyViolationCodes"]).Should().Contain("SEC_REPRO_EVIDENCE_SCORE_MISSING");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RequireEvidenceScoreMatch_Mismatch_FailsClosed()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var config = new Dictionary<string, object>
|
||||
{
|
||||
["requireEvidenceScoreMatch"] = 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
|
||||
{
|
||||
HasDsseProvenance = true,
|
||||
HasDsseInTotoLink = true,
|
||||
CanonicalizationPassed = true,
|
||||
ToolchainDigestPinned = true,
|
||||
RekorVerified = true,
|
||||
EvidenceArtifactId = "stella://svc/my-app@sha256:abc",
|
||||
CanonicalBomSha256 = new string('c', 64),
|
||||
PayloadDigest = new string('d', 64),
|
||||
AttestationRefs =
|
||||
[
|
||||
"sha256://attestation-b",
|
||||
"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('f', 64),
|
||||
Status = "ready"
|
||||
});
|
||||
_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("evidence score mismatch");
|
||||
((string[])result.Details["policyViolationCodes"]).Should().Contain("SEC_REPRO_EVIDENCE_SCORE_MISMATCH");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RequireEvidenceScoreMatch_Match_Passes()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var config = new Dictionary<string, object>
|
||||
{
|
||||
["requireEvidenceScoreMatch"] = 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 attestationRefs = new[] { "sha256://attestation-z", "sha256://attestation-a" };
|
||||
var expectedScore = ComputeExpectedEvidenceScore(new string('c', 64), new string('d', 64), attestationRefs);
|
||||
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('c', 64),
|
||||
PayloadDigest = new string('d', 64),
|
||||
AttestationRefs = attestationRefs
|
||||
});
|
||||
|
||||
_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 = expectedScore,
|
||||
Status = "ready"
|
||||
});
|
||||
_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.Should().ContainKey("component_my-app");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RequireEvidenceScoreMatch_NotReadyStatus_FailsClosed()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var config = new Dictionary<string, object>
|
||||
{
|
||||
["requireEvidenceScoreMatch"] = 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
|
||||
{
|
||||
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.Message.Should().Contain("evidence score status");
|
||||
((string[])result.Details["policyViolationCodes"]).Should().Contain("SEC_REPRO_EVIDENCE_SCORE_NOT_READY");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RequireEvidenceScoreMatch_BlankAttestationRef_FailsClosed()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var config = new Dictionary<string, object>
|
||||
{
|
||||
["requireEvidenceScoreMatch"] = 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
|
||||
{
|
||||
HasDsseProvenance = true,
|
||||
HasDsseInTotoLink = true,
|
||||
CanonicalizationPassed = true,
|
||||
ToolchainDigestPinned = true,
|
||||
RekorVerified = true,
|
||||
EvidenceArtifactId = "stella://svc/my-app@sha256:abc",
|
||||
CanonicalBomSha256 = new string('3', 64),
|
||||
PayloadDigest = new string('4', 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);
|
||||
_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("attestation refs");
|
||||
((string[])result.Details["policyViolationCodes"]).Should().Contain("SEC_REPRO_EVIDENCE_SCORE_REFS_INVALID");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RequireEvidenceScoreMatch_LegacyConstructor_FailsClosedWithoutService()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var legacyGate = new SecurityGate(
|
||||
_releaseService.Object,
|
||||
_scannerService.Object,
|
||||
_vulnCounter,
|
||||
_sbomChecker,
|
||||
_timeProvider,
|
||||
_logger.Object);
|
||||
var config = new Dictionary<string, object>
|
||||
{
|
||||
["requireEvidenceScoreMatch"] = 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
|
||||
{
|
||||
HasDsseProvenance = true,
|
||||
HasDsseInTotoLink = true,
|
||||
CanonicalizationPassed = true,
|
||||
ToolchainDigestPinned = true,
|
||||
RekorVerified = true,
|
||||
EvidenceArtifactId = "stella://svc/my-app@sha256:abc",
|
||||
CanonicalBomSha256 = new string('5', 64),
|
||||
PayloadDigest = new string('6', 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);
|
||||
_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 legacyGate.EvaluateAsync(context, ct);
|
||||
|
||||
result.Passed.Should().BeFalse();
|
||||
((string[])result.Details["policyViolationCodes"]).Should().Contain("SEC_REPRO_EVIDENCE_SCORE_MISSING");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateConfigAsync_BreakGlassWithoutRekor_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var config = new Dictionary<string, object>
|
||||
{
|
||||
["allowBreakGlassVerification"] = true,
|
||||
["requireRekorVerification"] = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _gate.ValidateConfigAsync(config, ct);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("allowBreakGlassVerification", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static string ComputeExpectedEvidenceScore(
|
||||
string canonicalBomSha256,
|
||||
string payloadDigest,
|
||||
IReadOnlyList<string> attestationRefs)
|
||||
{
|
||||
var sortedRefs = attestationRefs
|
||||
.OrderBy(static value => value, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
var serialized = string.Join('\u001f', new[] { canonicalBomSha256, payloadDigest }.Concat(sortedRefs));
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(serialized));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,3 +6,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
| --- | --- | --- |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/ReleaseOrchestrator/__Tests/StellaOps.ReleaseOrchestrator.Promotion.Tests/StellaOps.ReleaseOrchestrator.Promotion.Tests.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| EL-GATE-003 | DONE | Added security gate evidence score match/missing/mismatch unit coverage (2026-02-09). |
|
||||
|
||||
Reference in New Issue
Block a user