save checkpoint. addition features and their state. check some ofthem

This commit is contained in:
master
2026-02-10 07:54:44 +02:00
parent 4bdc298ec1
commit 5593212b41
211 changed files with 10248 additions and 1208 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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