Some checks failed
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
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
283 lines
10 KiB
C#
283 lines
10 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics.Metrics;
|
|
using System.Globalization;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using StellaOps.Zastava.Core.Contracts;
|
|
using StellaOps.Zastava.Observer.Backend;
|
|
using StellaOps.Zastava.Observer.Configuration;
|
|
using StellaOps.Zastava.Observer.ContainerRuntime.Cri;
|
|
using StellaOps.Zastava.Observer.Surface;
|
|
|
|
namespace StellaOps.Zastava.Observer.Posture;
|
|
|
|
internal sealed class RuntimePostureEvaluator : IRuntimePostureEvaluator
|
|
{
|
|
private static readonly Meter Meter = new("StellaOps.Zastava.Observer", "1.0.0");
|
|
private static readonly Counter<long> ManifestFailuresCounter = Meter.CreateCounter<long>(
|
|
"zastava_surface_manifest_failures_total",
|
|
description: "Count of Surface manifest fetch failures");
|
|
|
|
private readonly IRuntimePolicyClient policyClient;
|
|
private readonly IRuntimePostureCache cache;
|
|
private readonly IRuntimeSurfaceFsClient surfaceFsClient;
|
|
private readonly IOptionsMonitor<ZastavaObserverOptions> optionsMonitor;
|
|
private readonly TimeProvider timeProvider;
|
|
private readonly ILogger<RuntimePostureEvaluator> logger;
|
|
|
|
public RuntimePostureEvaluator(
|
|
IRuntimePolicyClient policyClient,
|
|
IRuntimePostureCache cache,
|
|
IRuntimeSurfaceFsClient surfaceFsClient,
|
|
IOptionsMonitor<ZastavaObserverOptions> optionsMonitor,
|
|
TimeProvider timeProvider,
|
|
ILogger<RuntimePostureEvaluator> logger)
|
|
{
|
|
this.policyClient = policyClient ?? throw new ArgumentNullException(nameof(policyClient));
|
|
this.cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
|
this.surfaceFsClient = surfaceFsClient ?? throw new ArgumentNullException(nameof(surfaceFsClient));
|
|
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
|
this.timeProvider = timeProvider ?? TimeProvider.System;
|
|
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
}
|
|
|
|
public async Task<RuntimePostureEvaluationResult> EvaluateAsync(CriContainerInfo container, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(container);
|
|
|
|
var evidence = new List<RuntimeEvidence>();
|
|
var now = timeProvider.GetUtcNow();
|
|
var cacheOptions = optionsMonitor.CurrentValue.Posture;
|
|
var fallbackTtl = TimeSpan.FromSeconds(Math.Clamp(cacheOptions.FallbackTtlSeconds, 30, 86400));
|
|
var staleThreshold = TimeSpan.FromSeconds(Math.Clamp(cacheOptions.StaleWarningThresholdSeconds, 30, 86400));
|
|
|
|
var imageKey = ResolveImageKey(container);
|
|
if (string.IsNullOrWhiteSpace(imageKey))
|
|
{
|
|
evidence.Add(new RuntimeEvidence
|
|
{
|
|
Signal = "runtime.posture.skipped",
|
|
Value = "no-image-ref"
|
|
});
|
|
return new RuntimePostureEvaluationResult(null, evidence);
|
|
}
|
|
|
|
var cached = cache.Get(imageKey);
|
|
if (cached is not null && !cached.IsExpired(now))
|
|
{
|
|
evidence.Add(new RuntimeEvidence
|
|
{
|
|
Signal = "runtime.posture.cache",
|
|
Value = "hit"
|
|
});
|
|
return new RuntimePostureEvaluationResult(cached.Posture, evidence);
|
|
}
|
|
|
|
try
|
|
{
|
|
var request = BuildRequest(container, imageKey);
|
|
var response = await policyClient.EvaluateAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (!response.Results.TryGetValue(imageKey, out var imageResult))
|
|
{
|
|
evidence.Add(new RuntimeEvidence
|
|
{
|
|
Signal = "runtime.posture.missing",
|
|
Value = "policy-empty"
|
|
});
|
|
return new RuntimePostureEvaluationResult(null, evidence);
|
|
}
|
|
|
|
var posture = MapPosture(imageResult);
|
|
var expiresAt = response.ExpiresAtUtc != default
|
|
? response.ExpiresAtUtc
|
|
: now.AddSeconds(response.TtlSeconds > 0 ? response.TtlSeconds : fallbackTtl.TotalSeconds);
|
|
|
|
cache.Set(imageKey, posture, expiresAt, now);
|
|
|
|
evidence.Add(new RuntimeEvidence
|
|
{
|
|
Signal = "runtime.posture.source",
|
|
Value = "backend"
|
|
});
|
|
evidence.Add(new RuntimeEvidence
|
|
{
|
|
Signal = "runtime.posture.ttl",
|
|
Value = expiresAt.ToString("O", CultureInfo.InvariantCulture)
|
|
});
|
|
|
|
await EnrichWithManifestAsync(imageResult.ManifestDigest, evidence, cancellationToken).ConfigureAwait(false);
|
|
|
|
return new RuntimePostureEvaluationResult(posture, evidence);
|
|
}
|
|
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
logger.LogWarning(ex, "Runtime posture evaluation failed for image {ImageRef}.", imageKey);
|
|
|
|
if (cached is not null)
|
|
{
|
|
var cacheSignal = cached.IsExpired(now)
|
|
? cached.IsStale(now, staleThreshold) ? "stale-warning" : "stale"
|
|
: "hit";
|
|
|
|
evidence.Add(new RuntimeEvidence
|
|
{
|
|
Signal = "runtime.posture.cache",
|
|
Value = cacheSignal
|
|
});
|
|
|
|
evidence.Add(new RuntimeEvidence
|
|
{
|
|
Signal = "runtime.posture.error",
|
|
Value = ex.GetType().Name
|
|
});
|
|
|
|
return new RuntimePostureEvaluationResult(cached.Posture, evidence);
|
|
}
|
|
|
|
evidence.Add(new RuntimeEvidence
|
|
{
|
|
Signal = "runtime.posture.error",
|
|
Value = ex.GetType().Name
|
|
});
|
|
|
|
return new RuntimePostureEvaluationResult(null, evidence);
|
|
}
|
|
}
|
|
|
|
private static string? ResolveImageKey(CriContainerInfo container)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(container.ImageRef))
|
|
{
|
|
return container.ImageRef;
|
|
}
|
|
|
|
return string.IsNullOrWhiteSpace(container.Image) ? null : container.Image;
|
|
}
|
|
|
|
private static RuntimePolicyRequest BuildRequest(CriContainerInfo container, string imageKey)
|
|
{
|
|
var labels = container.Labels.Count == 0
|
|
? null
|
|
: new Dictionary<string, string>(container.Labels, StringComparer.Ordinal);
|
|
|
|
labels?.Remove(CriLabelKeys.PodUid);
|
|
|
|
return new RuntimePolicyRequest
|
|
{
|
|
Namespace = container.Labels.TryGetValue(CriLabelKeys.PodNamespace, out var ns) ? ns : null,
|
|
Labels = labels,
|
|
Images = new[] { imageKey }
|
|
};
|
|
}
|
|
|
|
private static RuntimePosture MapPosture(RuntimePolicyImageResult result)
|
|
{
|
|
var posture = new RuntimePosture
|
|
{
|
|
ImageSigned = result.Signed,
|
|
SbomReferrer = result.HasSbomReferrers ? "present" : "missing"
|
|
};
|
|
|
|
if (result.Rekor is not null)
|
|
{
|
|
posture = posture with
|
|
{
|
|
Attestation = new RuntimeAttestation
|
|
{
|
|
Uuid = result.Rekor.Uuid,
|
|
Verified = result.Rekor.Verified
|
|
}
|
|
};
|
|
}
|
|
|
|
return posture;
|
|
}
|
|
|
|
private async Task EnrichWithManifestAsync(string? manifestDigest, List<RuntimeEvidence> evidence, CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(manifestDigest))
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
var manifest = await surfaceFsClient.TryGetManifestAsync(manifestDigest, cancellationToken).ConfigureAwait(false);
|
|
if (manifest is null)
|
|
{
|
|
ManifestFailuresCounter.Add(1, new KeyValuePair<string, object?>("reason", "not_found"));
|
|
evidence.Add(new RuntimeEvidence
|
|
{
|
|
Signal = "runtime.surface.manifest",
|
|
Value = "not_found"
|
|
});
|
|
logger.LogDebug("Surface manifest {ManifestDigest} not found in local cache.", manifestDigest);
|
|
return;
|
|
}
|
|
|
|
evidence.Add(new RuntimeEvidence
|
|
{
|
|
Signal = "runtime.surface.manifest",
|
|
Value = "resolved"
|
|
});
|
|
|
|
evidence.Add(new RuntimeEvidence
|
|
{
|
|
Signal = "runtime.surface.manifestDigest",
|
|
Value = manifestDigest
|
|
});
|
|
|
|
if (!string.IsNullOrWhiteSpace(manifest.ImageDigest))
|
|
{
|
|
evidence.Add(new RuntimeEvidence
|
|
{
|
|
Signal = "runtime.surface.imageDigest",
|
|
Value = manifest.ImageDigest
|
|
});
|
|
}
|
|
|
|
foreach (var artifact in manifest.Artifacts)
|
|
{
|
|
var artifactKind = artifact.Kind;
|
|
if (string.IsNullOrWhiteSpace(artifactKind))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
evidence.Add(new RuntimeEvidence
|
|
{
|
|
Signal = $"runtime.surface.artifact.{artifactKind}",
|
|
Value = artifact.Digest
|
|
});
|
|
|
|
if (artifact.Metadata is not null && artifact.Metadata.Count > 0)
|
|
{
|
|
foreach (var kvp in artifact.Metadata.Take(5))
|
|
{
|
|
evidence.Add(new RuntimeEvidence
|
|
{
|
|
Signal = $"runtime.surface.artifact.{artifactKind}.{kvp.Key}",
|
|
Value = kvp.Value
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
ManifestFailuresCounter.Add(1, new KeyValuePair<string, object?>("reason", "fetch_error"));
|
|
evidence.Add(new RuntimeEvidence
|
|
{
|
|
Signal = "runtime.surface.manifest",
|
|
Value = "fetch_error"
|
|
});
|
|
logger.LogWarning(ex, "Failed to fetch Surface manifest {ManifestDigest}.", manifestDigest);
|
|
}
|
|
}
|
|
}
|