up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
This commit is contained in:
@@ -15,27 +15,27 @@ using StellaOps.Scanner.Storage.Repositories;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
using StellaOps.Zastava.Core.Contracts;
|
||||
using RuntimePolicyVerdict = StellaOps.Zastava.Core.Contracts.PolicyVerdict;
|
||||
using CanonicalPolicyVerdict = StellaOps.Policy.PolicyVerdict;
|
||||
using CanonicalPolicyVerdictStatus = StellaOps.Policy.PolicyVerdictStatus;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
internal interface IRuntimePolicyService
|
||||
{
|
||||
Task<RuntimePolicyEvaluationResult> EvaluateAsync(RuntimePolicyEvaluationRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
{
|
||||
private const int MaxBuildIdsPerImage = 3;
|
||||
|
||||
private static readonly Meter PolicyMeter = new("StellaOps.Scanner.RuntimePolicy", "1.0.0");
|
||||
private static readonly Counter<long> PolicyEvaluations = PolicyMeter.CreateCounter<long>("scanner.runtime.policy.requests", unit: "1", description: "Total runtime policy evaluation requests processed.");
|
||||
private static readonly Histogram<double> PolicyEvaluationLatencyMs = PolicyMeter.CreateHistogram<double>("scanner.runtime.policy.latency.ms", unit: "ms", description: "Latency for runtime policy evaluations.");
|
||||
|
||||
private readonly LinkRepository _linkRepository;
|
||||
private readonly ArtifactRepository _artifactRepository;
|
||||
using RuntimePolicyVerdict = StellaOps.Zastava.Core.Contracts.PolicyVerdict;
|
||||
using CanonicalPolicyVerdict = StellaOps.Policy.PolicyVerdict;
|
||||
using CanonicalPolicyVerdictStatus = StellaOps.Policy.PolicyVerdictStatus;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
internal interface IRuntimePolicyService
|
||||
{
|
||||
Task<RuntimePolicyEvaluationResult> EvaluateAsync(RuntimePolicyEvaluationRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
{
|
||||
private const int MaxBuildIdsPerImage = 3;
|
||||
|
||||
private static readonly Meter PolicyMeter = new("StellaOps.Scanner.RuntimePolicy", "1.0.0");
|
||||
private static readonly Counter<long> PolicyEvaluations = PolicyMeter.CreateCounter<long>("scanner.runtime.policy.requests", unit: "1", description: "Total runtime policy evaluation requests processed.");
|
||||
private static readonly Histogram<double> PolicyEvaluationLatencyMs = PolicyMeter.CreateHistogram<double>("scanner.runtime.policy.latency.ms", unit: "ms", description: "Latency for runtime policy evaluations.");
|
||||
|
||||
private readonly LinkRepository _linkRepository;
|
||||
private readonly ArtifactRepository _artifactRepository;
|
||||
private readonly RuntimeEventRepository _runtimeEventRepository;
|
||||
private readonly PolicySnapshotStore _policySnapshotStore;
|
||||
private readonly PolicyPreviewService _policyPreviewService;
|
||||
@@ -44,8 +44,8 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IRuntimeAttestationVerifier _attestationVerifier;
|
||||
private readonly ILogger<RuntimePolicyService> _logger;
|
||||
|
||||
public RuntimePolicyService(
|
||||
|
||||
public RuntimePolicyService(
|
||||
LinkRepository linkRepository,
|
||||
ArtifactRepository artifactRepository,
|
||||
RuntimeEventRepository runtimeEventRepository,
|
||||
@@ -68,17 +68,17 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
_attestationVerifier = attestationVerifier ?? throw new ArgumentNullException(nameof(attestationVerifier));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<RuntimePolicyEvaluationResult> EvaluateAsync(RuntimePolicyEvaluationRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var runtimeOptions = _optionsMonitor.CurrentValue.Runtime ?? new ScannerWebServiceOptions.RuntimeOptions();
|
||||
var ttlSeconds = Math.Max(1, runtimeOptions.PolicyCacheTtlSeconds);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = now.AddSeconds(ttlSeconds);
|
||||
|
||||
|
||||
public async Task<RuntimePolicyEvaluationResult> EvaluateAsync(RuntimePolicyEvaluationRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var runtimeOptions = _optionsMonitor.CurrentValue.Runtime ?? new ScannerWebServiceOptions.RuntimeOptions();
|
||||
var ttlSeconds = Math.Max(1, runtimeOptions.PolicyCacheTtlSeconds);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = now.AddSeconds(ttlSeconds);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var snapshot = await _policySnapshotStore.GetLatestAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -93,35 +93,35 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
|
||||
var policyRevision = snapshot?.RevisionId;
|
||||
var policyDigest = snapshot?.Digest;
|
||||
|
||||
var results = new Dictionary<string, RuntimePolicyImageDecision>(StringComparer.Ordinal);
|
||||
var evaluationTags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("policy_revision", policyRevision ?? "none"),
|
||||
new("namespace", request.Namespace ?? "unspecified")
|
||||
};
|
||||
|
||||
var buildIdObservations = await _runtimeEventRepository
|
||||
.GetRecentBuildIdsAsync(request.Images, MaxBuildIdsPerImage, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var evaluated = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var image in request.Images)
|
||||
{
|
||||
if (!evaluated.Add(image))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var metadata = await ResolveImageMetadataAsync(image, cancellationToken).ConfigureAwait(false);
|
||||
var (findings, heuristicReasons) = BuildFindings(image, metadata, request.Namespace);
|
||||
if (snapshot is null)
|
||||
{
|
||||
heuristicReasons.Add("policy.snapshot.missing");
|
||||
}
|
||||
|
||||
|
||||
var results = new Dictionary<string, RuntimePolicyImageDecision>(StringComparer.Ordinal);
|
||||
var evaluationTags = new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("policy_revision", policyRevision ?? "none"),
|
||||
new("namespace", request.Namespace ?? "unspecified")
|
||||
};
|
||||
|
||||
var buildIdObservations = await _runtimeEventRepository
|
||||
.GetRecentBuildIdsAsync(request.Images, MaxBuildIdsPerImage, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var evaluated = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var image in request.Images)
|
||||
{
|
||||
if (!evaluated.Add(image))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var metadata = await ResolveImageMetadataAsync(image, cancellationToken).ConfigureAwait(false);
|
||||
var (findings, heuristicReasons) = BuildFindings(image, metadata, request.Namespace);
|
||||
if (snapshot is null)
|
||||
{
|
||||
heuristicReasons.Add("policy.snapshot.missing");
|
||||
}
|
||||
|
||||
ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts = ImmutableArray<CanonicalPolicyVerdict>.Empty;
|
||||
ImmutableArray<PolicyIssue> issues = ImmutableArray<PolicyIssue>.Empty;
|
||||
IReadOnlyList<LinksetSummaryDto> linksets = Array.Empty<LinksetSummaryDto>();
|
||||
@@ -130,14 +130,14 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
{
|
||||
if (!findings.IsDefaultOrEmpty && findings.Length > 0)
|
||||
{
|
||||
var previewRequest = new PolicyPreviewRequest(
|
||||
image,
|
||||
findings,
|
||||
ImmutableArray<CanonicalPolicyVerdict>.Empty,
|
||||
snapshot,
|
||||
ProposedPolicy: null);
|
||||
|
||||
var preview = await _policyPreviewService.PreviewAsync(previewRequest, cancellationToken).ConfigureAwait(false);
|
||||
var previewRequest = new PolicyPreviewRequest(
|
||||
image,
|
||||
findings,
|
||||
ImmutableArray<CanonicalPolicyVerdict>.Empty,
|
||||
snapshot,
|
||||
ProposedPolicy: null);
|
||||
|
||||
var preview = await _policyPreviewService.PreviewAsync(previewRequest, cancellationToken).ConfigureAwait(false);
|
||||
issues = preview.Issues;
|
||||
if (!preview.Diffs.IsDefaultOrEmpty)
|
||||
{
|
||||
@@ -147,16 +147,16 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning(ex, "Runtime policy preview failed for image {ImageDigest}; falling back to heuristic evaluation.", image);
|
||||
}
|
||||
|
||||
var normalizedImage = image.Trim().ToLowerInvariant();
|
||||
buildIdObservations.TryGetValue(normalizedImage, out var buildIdObservation);
|
||||
|
||||
var decision = await BuildDecisionAsync(
|
||||
image,
|
||||
metadata,
|
||||
{
|
||||
_logger.LogWarning(ex, "Runtime policy preview failed for image {ImageDigest}; falling back to heuristic evaluation.", image);
|
||||
}
|
||||
|
||||
var normalizedImage = image.Trim().ToLowerInvariant();
|
||||
buildIdObservations.TryGetValue(normalizedImage, out var buildIdObservation);
|
||||
|
||||
var decision = await BuildDecisionAsync(
|
||||
image,
|
||||
metadata,
|
||||
heuristicReasons,
|
||||
projectedVerdicts,
|
||||
issues,
|
||||
@@ -164,128 +164,128 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
linksets,
|
||||
buildIdObservation?.BuildIds,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
results[image] = decision;
|
||||
|
||||
_logger.LogInformation("Runtime policy evaluated image {ImageDigest} with verdict {Verdict} (Signed: {Signed}, HasSbom: {HasSbom}, Reasons: {ReasonsCount})",
|
||||
image,
|
||||
decision.PolicyVerdict,
|
||||
decision.Signed,
|
||||
decision.HasSbomReferrers,
|
||||
decision.Reasons.Count);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
stopwatch.Stop();
|
||||
PolicyEvaluationLatencyMs.Record(stopwatch.Elapsed.TotalMilliseconds, evaluationTags);
|
||||
}
|
||||
|
||||
PolicyEvaluations.Add(results.Count, evaluationTags);
|
||||
|
||||
var evaluationResult = new RuntimePolicyEvaluationResult(
|
||||
ttlSeconds,
|
||||
expiresAt,
|
||||
policyRevision,
|
||||
new ReadOnlyDictionary<string, RuntimePolicyImageDecision>(results));
|
||||
|
||||
return evaluationResult;
|
||||
}
|
||||
|
||||
private async Task<RuntimeImageMetadata> ResolveImageMetadataAsync(string imageDigest, CancellationToken cancellationToken)
|
||||
{
|
||||
var links = await _linkRepository.ListBySourceAsync(LinkSourceType.Image, imageDigest, cancellationToken).ConfigureAwait(false);
|
||||
if (links.Count == 0)
|
||||
{
|
||||
return new RuntimeImageMetadata(imageDigest, false, false, null, MissingMetadata: true);
|
||||
}
|
||||
|
||||
var hasSbom = false;
|
||||
var signed = false;
|
||||
RuntimePolicyRekorReference? rekor = null;
|
||||
|
||||
foreach (var link in links)
|
||||
{
|
||||
var artifact = await _artifactRepository.GetAsync(link.ArtifactId, cancellationToken).ConfigureAwait(false);
|
||||
if (artifact is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (artifact.Type)
|
||||
{
|
||||
case ArtifactDocumentType.ImageBom:
|
||||
hasSbom = true;
|
||||
break;
|
||||
case ArtifactDocumentType.Attestation:
|
||||
signed = true;
|
||||
if (artifact.Rekor is { } rekorReference)
|
||||
{
|
||||
rekor = new RuntimePolicyRekorReference(
|
||||
Normalize(rekorReference.Uuid),
|
||||
Normalize(rekorReference.Url),
|
||||
rekorReference.Index.HasValue);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new RuntimeImageMetadata(imageDigest, signed, hasSbom, rekor, MissingMetadata: false);
|
||||
}
|
||||
|
||||
private (ImmutableArray<PolicyFinding> Findings, List<string> HeuristicReasons) BuildFindings(string imageDigest, RuntimeImageMetadata metadata, string? @namespace)
|
||||
{
|
||||
var findings = ImmutableArray.CreateBuilder<PolicyFinding>();
|
||||
var heuristics = new List<string>();
|
||||
|
||||
findings.Add(PolicyFinding.Create(
|
||||
$"{imageDigest}#baseline",
|
||||
PolicySeverity.None,
|
||||
environment: @namespace,
|
||||
source: "scanner.runtime"));
|
||||
|
||||
if (metadata.MissingMetadata)
|
||||
{
|
||||
const string reason = "image.metadata.missing";
|
||||
heuristics.Add(reason);
|
||||
findings.Add(PolicyFinding.Create(
|
||||
$"{imageDigest}#metadata",
|
||||
PolicySeverity.Critical,
|
||||
environment: @namespace,
|
||||
source: "scanner.runtime",
|
||||
tags: ImmutableArray.Create(reason)));
|
||||
}
|
||||
|
||||
if (!metadata.Signed)
|
||||
{
|
||||
const string reason = "unsigned";
|
||||
heuristics.Add(reason);
|
||||
findings.Add(PolicyFinding.Create(
|
||||
$"{imageDigest}#signature",
|
||||
PolicySeverity.High,
|
||||
environment: @namespace,
|
||||
source: "scanner.runtime",
|
||||
tags: ImmutableArray.Create(reason)));
|
||||
}
|
||||
|
||||
if (!metadata.HasSbomReferrers)
|
||||
{
|
||||
const string reason = "missing SBOM";
|
||||
heuristics.Add(reason);
|
||||
findings.Add(PolicyFinding.Create(
|
||||
$"{imageDigest}#sbom",
|
||||
PolicySeverity.High,
|
||||
environment: @namespace,
|
||||
source: "scanner.runtime",
|
||||
tags: ImmutableArray.Create(reason)));
|
||||
}
|
||||
|
||||
return (findings.ToImmutable(), heuristics);
|
||||
}
|
||||
|
||||
private async Task<RuntimePolicyImageDecision> BuildDecisionAsync(
|
||||
string imageDigest,
|
||||
RuntimeImageMetadata metadata,
|
||||
|
||||
results[image] = decision;
|
||||
|
||||
_logger.LogInformation("Runtime policy evaluated image {ImageDigest} with verdict {Verdict} (Signed: {Signed}, HasSbom: {HasSbom}, Reasons: {ReasonsCount})",
|
||||
image,
|
||||
decision.PolicyVerdict,
|
||||
decision.Signed,
|
||||
decision.HasSbomReferrers,
|
||||
decision.Reasons.Count);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
stopwatch.Stop();
|
||||
PolicyEvaluationLatencyMs.Record(stopwatch.Elapsed.TotalMilliseconds, evaluationTags);
|
||||
}
|
||||
|
||||
PolicyEvaluations.Add(results.Count, evaluationTags);
|
||||
|
||||
var evaluationResult = new RuntimePolicyEvaluationResult(
|
||||
ttlSeconds,
|
||||
expiresAt,
|
||||
policyRevision,
|
||||
new ReadOnlyDictionary<string, RuntimePolicyImageDecision>(results));
|
||||
|
||||
return evaluationResult;
|
||||
}
|
||||
|
||||
private async Task<RuntimeImageMetadata> ResolveImageMetadataAsync(string imageDigest, CancellationToken cancellationToken)
|
||||
{
|
||||
var links = await _linkRepository.ListBySourceAsync(LinkSourceType.Image, imageDigest, cancellationToken).ConfigureAwait(false);
|
||||
if (links.Count == 0)
|
||||
{
|
||||
return new RuntimeImageMetadata(imageDigest, false, false, null, MissingMetadata: true);
|
||||
}
|
||||
|
||||
var hasSbom = false;
|
||||
var signed = false;
|
||||
RuntimePolicyRekorReference? rekor = null;
|
||||
|
||||
foreach (var link in links)
|
||||
{
|
||||
var artifact = await _artifactRepository.GetAsync(link.ArtifactId, cancellationToken).ConfigureAwait(false);
|
||||
if (artifact is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (artifact.Type)
|
||||
{
|
||||
case ArtifactDocumentType.ImageBom:
|
||||
hasSbom = true;
|
||||
break;
|
||||
case ArtifactDocumentType.Attestation:
|
||||
signed = true;
|
||||
if (artifact.Rekor is { } rekorReference)
|
||||
{
|
||||
rekor = new RuntimePolicyRekorReference(
|
||||
Normalize(rekorReference.Uuid),
|
||||
Normalize(rekorReference.Url),
|
||||
rekorReference.Index.HasValue);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new RuntimeImageMetadata(imageDigest, signed, hasSbom, rekor, MissingMetadata: false);
|
||||
}
|
||||
|
||||
private (ImmutableArray<PolicyFinding> Findings, List<string> HeuristicReasons) BuildFindings(string imageDigest, RuntimeImageMetadata metadata, string? @namespace)
|
||||
{
|
||||
var findings = ImmutableArray.CreateBuilder<PolicyFinding>();
|
||||
var heuristics = new List<string>();
|
||||
|
||||
findings.Add(PolicyFinding.Create(
|
||||
$"{imageDigest}#baseline",
|
||||
PolicySeverity.None,
|
||||
environment: @namespace,
|
||||
source: "scanner.runtime"));
|
||||
|
||||
if (metadata.MissingMetadata)
|
||||
{
|
||||
const string reason = "image.metadata.missing";
|
||||
heuristics.Add(reason);
|
||||
findings.Add(PolicyFinding.Create(
|
||||
$"{imageDigest}#metadata",
|
||||
PolicySeverity.Critical,
|
||||
environment: @namespace,
|
||||
source: "scanner.runtime",
|
||||
tags: ImmutableArray.Create(reason)));
|
||||
}
|
||||
|
||||
if (!metadata.Signed)
|
||||
{
|
||||
const string reason = "unsigned";
|
||||
heuristics.Add(reason);
|
||||
findings.Add(PolicyFinding.Create(
|
||||
$"{imageDigest}#signature",
|
||||
PolicySeverity.High,
|
||||
environment: @namespace,
|
||||
source: "scanner.runtime",
|
||||
tags: ImmutableArray.Create(reason)));
|
||||
}
|
||||
|
||||
if (!metadata.HasSbomReferrers)
|
||||
{
|
||||
const string reason = "missing SBOM";
|
||||
heuristics.Add(reason);
|
||||
findings.Add(PolicyFinding.Create(
|
||||
$"{imageDigest}#sbom",
|
||||
PolicySeverity.High,
|
||||
environment: @namespace,
|
||||
source: "scanner.runtime",
|
||||
tags: ImmutableArray.Create(reason)));
|
||||
}
|
||||
|
||||
return (findings.ToImmutable(), heuristics);
|
||||
}
|
||||
|
||||
private async Task<RuntimePolicyImageDecision> BuildDecisionAsync(
|
||||
string imageDigest,
|
||||
RuntimeImageMetadata metadata,
|
||||
List<string> heuristicReasons,
|
||||
ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts,
|
||||
ImmutableArray<PolicyIssue> issues,
|
||||
@@ -293,51 +293,51 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
IReadOnlyList<LinksetSummaryDto> linksets,
|
||||
IReadOnlyList<string>? buildIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var reasons = new List<string>(heuristicReasons);
|
||||
|
||||
var overallVerdict = MapVerdict(projectedVerdicts, heuristicReasons);
|
||||
|
||||
if (!projectedVerdicts.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var verdict in projectedVerdicts)
|
||||
{
|
||||
if (verdict.Status == CanonicalPolicyVerdictStatus.Pass)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(verdict.RuleName))
|
||||
{
|
||||
reasons.Add($"policy.rule.{verdict.RuleName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
reasons.Add($"policy.status.{verdict.Status.ToString().ToLowerInvariant()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var confidence = ComputeConfidence(projectedVerdicts, overallVerdict);
|
||||
var quieted = !projectedVerdicts.IsDefaultOrEmpty && projectedVerdicts.Any(v => v.Quiet);
|
||||
var quietedBy = !projectedVerdicts.IsDefaultOrEmpty
|
||||
? projectedVerdicts.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v.QuietedBy))?.QuietedBy
|
||||
: null;
|
||||
|
||||
var metadataPayload = BuildMetadataPayload(heuristicReasons, projectedVerdicts, issues, policyDigest);
|
||||
|
||||
var rekor = metadata.Rekor;
|
||||
var verified = await _attestationVerifier.VerifyAsync(imageDigest, metadata.Rekor, cancellationToken).ConfigureAwait(false);
|
||||
if (rekor is not null && verified.HasValue)
|
||||
{
|
||||
rekor = rekor with { Verified = verified.Value };
|
||||
}
|
||||
|
||||
var normalizedReasons = reasons
|
||||
.Where(reason => !string.IsNullOrWhiteSpace(reason))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
{
|
||||
var reasons = new List<string>(heuristicReasons);
|
||||
|
||||
var overallVerdict = MapVerdict(projectedVerdicts, heuristicReasons);
|
||||
|
||||
if (!projectedVerdicts.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var verdict in projectedVerdicts)
|
||||
{
|
||||
if (verdict.Status == CanonicalPolicyVerdictStatus.Pass)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(verdict.RuleName))
|
||||
{
|
||||
reasons.Add($"policy.rule.{verdict.RuleName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
reasons.Add($"policy.status.{verdict.Status.ToString().ToLowerInvariant()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var confidence = ComputeConfidence(projectedVerdicts, overallVerdict);
|
||||
var quieted = !projectedVerdicts.IsDefaultOrEmpty && projectedVerdicts.Any(v => v.Quiet);
|
||||
var quietedBy = !projectedVerdicts.IsDefaultOrEmpty
|
||||
? projectedVerdicts.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v.QuietedBy))?.QuietedBy
|
||||
: null;
|
||||
|
||||
var metadataPayload = BuildMetadataPayload(heuristicReasons, projectedVerdicts, issues, policyDigest);
|
||||
|
||||
var rekor = metadata.Rekor;
|
||||
var verified = await _attestationVerifier.VerifyAsync(imageDigest, metadata.Rekor, cancellationToken).ConfigureAwait(false);
|
||||
if (rekor is not null && verified.HasValue)
|
||||
{
|
||||
rekor = rekor with { Verified = verified.Value };
|
||||
}
|
||||
|
||||
var normalizedReasons = reasons
|
||||
.Where(reason => !string.IsNullOrWhiteSpace(reason))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return new RuntimePolicyImageDecision(
|
||||
overallVerdict,
|
||||
metadata.Signed,
|
||||
@@ -351,165 +351,165 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
buildIds,
|
||||
linksets);
|
||||
}
|
||||
|
||||
private RuntimePolicyVerdict MapVerdict(ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts, IReadOnlyList<string> heuristicReasons)
|
||||
{
|
||||
if (!projectedVerdicts.IsDefaultOrEmpty && projectedVerdicts.Length > 0)
|
||||
{
|
||||
var statuses = projectedVerdicts.Select(v => v.Status).ToArray();
|
||||
if (statuses.Any(status => status == CanonicalPolicyVerdictStatus.Blocked))
|
||||
{
|
||||
return RuntimePolicyVerdict.Fail;
|
||||
}
|
||||
|
||||
if (statuses.Any(status =>
|
||||
status is CanonicalPolicyVerdictStatus.Warned
|
||||
or CanonicalPolicyVerdictStatus.Deferred
|
||||
or CanonicalPolicyVerdictStatus.Escalated
|
||||
or CanonicalPolicyVerdictStatus.RequiresVex))
|
||||
{
|
||||
return RuntimePolicyVerdict.Warn;
|
||||
}
|
||||
|
||||
return RuntimePolicyVerdict.Pass;
|
||||
}
|
||||
|
||||
if (heuristicReasons.Contains("image.metadata.missing", StringComparer.Ordinal) ||
|
||||
heuristicReasons.Contains("unsigned", StringComparer.Ordinal) ||
|
||||
heuristicReasons.Contains("missing SBOM", StringComparer.Ordinal))
|
||||
{
|
||||
return RuntimePolicyVerdict.Fail;
|
||||
}
|
||||
|
||||
if (heuristicReasons.Contains("policy.snapshot.missing", StringComparer.Ordinal))
|
||||
{
|
||||
return RuntimePolicyVerdict.Warn;
|
||||
}
|
||||
|
||||
return RuntimePolicyVerdict.Pass;
|
||||
}
|
||||
|
||||
private IDictionary<string, object?>? BuildMetadataPayload(
|
||||
IReadOnlyList<string> heuristics,
|
||||
ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts,
|
||||
ImmutableArray<PolicyIssue> issues,
|
||||
string? policyDigest)
|
||||
{
|
||||
var payload = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["heuristics"] = heuristics,
|
||||
["evaluatedAt"] = _timeProvider.GetUtcNow().UtcDateTime
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(policyDigest))
|
||||
{
|
||||
payload["policyDigest"] = policyDigest;
|
||||
}
|
||||
|
||||
if (!issues.IsDefaultOrEmpty && issues.Length > 0)
|
||||
{
|
||||
payload["issues"] = issues.Select(issue => new
|
||||
{
|
||||
code = issue.Code,
|
||||
severity = issue.Severity.ToString(),
|
||||
message = issue.Message,
|
||||
path = issue.Path
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
if (!projectedVerdicts.IsDefaultOrEmpty && projectedVerdicts.Length > 0)
|
||||
{
|
||||
payload["findings"] = projectedVerdicts.Select(verdict => new
|
||||
{
|
||||
id = verdict.FindingId,
|
||||
status = verdict.Status.ToString().ToLowerInvariant(),
|
||||
rule = verdict.RuleName,
|
||||
action = verdict.RuleAction,
|
||||
score = verdict.Score,
|
||||
quiet = verdict.Quiet,
|
||||
quietedBy = verdict.QuietedBy,
|
||||
inputs = verdict.GetInputs(),
|
||||
confidence = verdict.UnknownConfidence,
|
||||
confidenceBand = verdict.ConfidenceBand,
|
||||
sourceTrust = verdict.SourceTrust,
|
||||
reachability = verdict.Reachability
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
return payload.Count == 0 ? null : payload;
|
||||
}
|
||||
|
||||
private static double ComputeConfidence(ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts, RuntimePolicyVerdict overall)
|
||||
{
|
||||
if (!projectedVerdicts.IsDefaultOrEmpty && projectedVerdicts.Length > 0)
|
||||
{
|
||||
var confidences = projectedVerdicts
|
||||
.Select(v => v.UnknownConfidence)
|
||||
.Where(value => value.HasValue)
|
||||
.Select(value => value!.Value)
|
||||
.ToArray();
|
||||
|
||||
if (confidences.Length > 0)
|
||||
{
|
||||
return Math.Clamp(confidences.Average(), 0.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
return overall switch
|
||||
{
|
||||
RuntimePolicyVerdict.Pass => 0.95,
|
||||
RuntimePolicyVerdict.Warn => 0.5,
|
||||
RuntimePolicyVerdict.Fail => 0.1,
|
||||
_ => 0.25
|
||||
};
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
internal interface IRuntimeAttestationVerifier
|
||||
{
|
||||
ValueTask<bool?> VerifyAsync(string imageDigest, RuntimePolicyRekorReference? rekor, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class RuntimeAttestationVerifier : IRuntimeAttestationVerifier
|
||||
{
|
||||
private readonly ILogger<RuntimeAttestationVerifier> _logger;
|
||||
|
||||
public RuntimeAttestationVerifier(ILogger<RuntimeAttestationVerifier> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public ValueTask<bool?> VerifyAsync(string imageDigest, RuntimePolicyRekorReference? rekor, CancellationToken cancellationToken)
|
||||
{
|
||||
if (rekor is null)
|
||||
{
|
||||
return ValueTask.FromResult<bool?>(null);
|
||||
}
|
||||
|
||||
if (rekor.Verified.HasValue)
|
||||
{
|
||||
return ValueTask.FromResult(rekor.Verified);
|
||||
}
|
||||
|
||||
_logger.LogDebug("No attestation verification metadata available for image {ImageDigest}.", imageDigest);
|
||||
return ValueTask.FromResult<bool?>(null);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record RuntimePolicyEvaluationRequest(
|
||||
string? Namespace,
|
||||
IReadOnlyDictionary<string, string> Labels,
|
||||
IReadOnlyList<string> Images);
|
||||
|
||||
internal sealed record RuntimePolicyEvaluationResult(
|
||||
int TtlSeconds,
|
||||
DateTimeOffset ExpiresAtUtc,
|
||||
string? PolicyRevision,
|
||||
IReadOnlyDictionary<string, RuntimePolicyImageDecision> Results);
|
||||
|
||||
|
||||
private RuntimePolicyVerdict MapVerdict(ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts, IReadOnlyList<string> heuristicReasons)
|
||||
{
|
||||
if (!projectedVerdicts.IsDefaultOrEmpty && projectedVerdicts.Length > 0)
|
||||
{
|
||||
var statuses = projectedVerdicts.Select(v => v.Status).ToArray();
|
||||
if (statuses.Any(status => status == CanonicalPolicyVerdictStatus.Blocked))
|
||||
{
|
||||
return RuntimePolicyVerdict.Fail;
|
||||
}
|
||||
|
||||
if (statuses.Any(status =>
|
||||
status is CanonicalPolicyVerdictStatus.Warned
|
||||
or CanonicalPolicyVerdictStatus.Deferred
|
||||
or CanonicalPolicyVerdictStatus.Escalated
|
||||
or CanonicalPolicyVerdictStatus.RequiresVex))
|
||||
{
|
||||
return RuntimePolicyVerdict.Warn;
|
||||
}
|
||||
|
||||
return RuntimePolicyVerdict.Pass;
|
||||
}
|
||||
|
||||
if (heuristicReasons.Contains("image.metadata.missing", StringComparer.Ordinal) ||
|
||||
heuristicReasons.Contains("unsigned", StringComparer.Ordinal) ||
|
||||
heuristicReasons.Contains("missing SBOM", StringComparer.Ordinal))
|
||||
{
|
||||
return RuntimePolicyVerdict.Fail;
|
||||
}
|
||||
|
||||
if (heuristicReasons.Contains("policy.snapshot.missing", StringComparer.Ordinal))
|
||||
{
|
||||
return RuntimePolicyVerdict.Warn;
|
||||
}
|
||||
|
||||
return RuntimePolicyVerdict.Pass;
|
||||
}
|
||||
|
||||
private IDictionary<string, object?>? BuildMetadataPayload(
|
||||
IReadOnlyList<string> heuristics,
|
||||
ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts,
|
||||
ImmutableArray<PolicyIssue> issues,
|
||||
string? policyDigest)
|
||||
{
|
||||
var payload = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["heuristics"] = heuristics,
|
||||
["evaluatedAt"] = _timeProvider.GetUtcNow().UtcDateTime
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(policyDigest))
|
||||
{
|
||||
payload["policyDigest"] = policyDigest;
|
||||
}
|
||||
|
||||
if (!issues.IsDefaultOrEmpty && issues.Length > 0)
|
||||
{
|
||||
payload["issues"] = issues.Select(issue => new
|
||||
{
|
||||
code = issue.Code,
|
||||
severity = issue.Severity.ToString(),
|
||||
message = issue.Message,
|
||||
path = issue.Path
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
if (!projectedVerdicts.IsDefaultOrEmpty && projectedVerdicts.Length > 0)
|
||||
{
|
||||
payload["findings"] = projectedVerdicts.Select(verdict => new
|
||||
{
|
||||
id = verdict.FindingId,
|
||||
status = verdict.Status.ToString().ToLowerInvariant(),
|
||||
rule = verdict.RuleName,
|
||||
action = verdict.RuleAction,
|
||||
score = verdict.Score,
|
||||
quiet = verdict.Quiet,
|
||||
quietedBy = verdict.QuietedBy,
|
||||
inputs = verdict.GetInputs(),
|
||||
confidence = verdict.UnknownConfidence,
|
||||
confidenceBand = verdict.ConfidenceBand,
|
||||
sourceTrust = verdict.SourceTrust,
|
||||
reachability = verdict.Reachability
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
return payload.Count == 0 ? null : payload;
|
||||
}
|
||||
|
||||
private static double ComputeConfidence(ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts, RuntimePolicyVerdict overall)
|
||||
{
|
||||
if (!projectedVerdicts.IsDefaultOrEmpty && projectedVerdicts.Length > 0)
|
||||
{
|
||||
var confidences = projectedVerdicts
|
||||
.Select(v => v.UnknownConfidence)
|
||||
.Where(value => value.HasValue)
|
||||
.Select(value => value!.Value)
|
||||
.ToArray();
|
||||
|
||||
if (confidences.Length > 0)
|
||||
{
|
||||
return Math.Clamp(confidences.Average(), 0.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
return overall switch
|
||||
{
|
||||
RuntimePolicyVerdict.Pass => 0.95,
|
||||
RuntimePolicyVerdict.Warn => 0.5,
|
||||
RuntimePolicyVerdict.Fail => 0.1,
|
||||
_ => 0.25
|
||||
};
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
internal interface IRuntimeAttestationVerifier
|
||||
{
|
||||
ValueTask<bool?> VerifyAsync(string imageDigest, RuntimePolicyRekorReference? rekor, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class RuntimeAttestationVerifier : IRuntimeAttestationVerifier
|
||||
{
|
||||
private readonly ILogger<RuntimeAttestationVerifier> _logger;
|
||||
|
||||
public RuntimeAttestationVerifier(ILogger<RuntimeAttestationVerifier> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public ValueTask<bool?> VerifyAsync(string imageDigest, RuntimePolicyRekorReference? rekor, CancellationToken cancellationToken)
|
||||
{
|
||||
if (rekor is null)
|
||||
{
|
||||
return ValueTask.FromResult<bool?>(null);
|
||||
}
|
||||
|
||||
if (rekor.Verified.HasValue)
|
||||
{
|
||||
return ValueTask.FromResult(rekor.Verified);
|
||||
}
|
||||
|
||||
_logger.LogDebug("No attestation verification metadata available for image {ImageDigest}.", imageDigest);
|
||||
return ValueTask.FromResult<bool?>(null);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record RuntimePolicyEvaluationRequest(
|
||||
string? Namespace,
|
||||
IReadOnlyDictionary<string, string> Labels,
|
||||
IReadOnlyList<string> Images);
|
||||
|
||||
internal sealed record RuntimePolicyEvaluationResult(
|
||||
int TtlSeconds,
|
||||
DateTimeOffset ExpiresAtUtc,
|
||||
string? PolicyRevision,
|
||||
IReadOnlyDictionary<string, RuntimePolicyImageDecision> Results);
|
||||
|
||||
internal sealed record RuntimePolicyImageDecision(
|
||||
RuntimePolicyVerdict PolicyVerdict,
|
||||
bool Signed,
|
||||
@@ -522,12 +522,12 @@ internal sealed record RuntimePolicyImageDecision(
|
||||
string? QuietedBy,
|
||||
IReadOnlyList<string>? BuildIds,
|
||||
IReadOnlyList<LinksetSummaryDto> Linksets);
|
||||
|
||||
internal sealed record RuntimePolicyRekorReference(string? Uuid, string? Url, bool? Verified);
|
||||
|
||||
internal sealed record RuntimeImageMetadata(
|
||||
string ImageDigest,
|
||||
bool Signed,
|
||||
bool HasSbomReferrers,
|
||||
RuntimePolicyRekorReference? Rekor,
|
||||
bool MissingMetadata);
|
||||
|
||||
internal sealed record RuntimePolicyRekorReference(string? Uuid, string? Url, bool? Verified);
|
||||
|
||||
internal sealed record RuntimeImageMetadata(
|
||||
string ImageDigest,
|
||||
bool Signed,
|
||||
bool HasSbomReferrers,
|
||||
RuntimePolicyRekorReference? Rekor,
|
||||
bool MissingMetadata);
|
||||
|
||||
Reference in New Issue
Block a user