up
Some checks failed
api-governance / spectral-lint (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-26 09:28:16 +02:00
parent 1c782897f7
commit 4831c7fcb0
43 changed files with 1347 additions and 97 deletions

View File

@@ -0,0 +1,71 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Zastava.Observer.Configuration;
using StellaOps.Zastava.Observer.Runtime;
namespace StellaOps.Zastava.Observer.Backend;
internal interface IRuntimeFactsClient
{
Task PublishAsync(RuntimeFactsPublishRequest request, CancellationToken cancellationToken);
}
internal sealed class RuntimeFactsClient : IRuntimeFactsClient
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
private readonly HttpClient httpClient;
private readonly IOptionsMonitor<ZastavaObserverOptions> observerOptions;
private readonly ILogger<RuntimeFactsClient> logger;
public RuntimeFactsClient(
HttpClient httpClient,
IOptionsMonitor<ZastavaObserverOptions> observerOptions,
ILogger<RuntimeFactsClient> logger)
{
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
this.observerOptions = observerOptions ?? throw new ArgumentNullException(nameof(observerOptions));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task PublishAsync(RuntimeFactsPublishRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var reachability = observerOptions.CurrentValue.Reachability ?? new ReachabilityRuntimeOptions();
var endpoint = reachability.Endpoint;
if (string.IsNullOrWhiteSpace(endpoint))
{
throw new RuntimeFactsException("Reachability endpoint is not configured.");
}
using var message = new HttpRequestMessage(HttpMethod.Post, endpoint);
if (!string.IsNullOrWhiteSpace(reachability.AnalysisId))
{
message.Headers.TryAddWithoutValidation("X-Analysis-Id", reachability.AnalysisId);
}
var json = JsonSerializer.Serialize(request, SerializerOptions);
message.Content = new StringContent(json, Encoding.UTF8, "application/json");
message.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
using var response = await httpClient.SendAsync(message, cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
return;
}
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogWarning("Runtime facts publish failed with status {Status}: {Body}", (int)response.StatusCode, body);
throw new RuntimeFactsException($"Runtime facts publish failed with status {(int)response.StatusCode}.");
}
}

View File

@@ -0,0 +1,55 @@
using System.ComponentModel.DataAnnotations;
namespace StellaOps.Zastava.Observer.Configuration;
/// <summary>
/// Configuration for emitting runtime reachability facts to Signals.
/// </summary>
public sealed class ReachabilityRuntimeOptions
{
/// <summary>
/// Enables runtime reachability fact publishing when true.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Signals endpoint that accepts runtime facts (JSON or NDJSON).
/// </summary>
[Required(AllowEmptyStrings = false)]
public string Endpoint { get; set; } = "https://signals.internal/signals/runtime-facts";
/// <summary>
/// Required callgraph identifier used to correlate runtime facts with static graphs.
/// </summary>
[Required(AllowEmptyStrings = false)]
public string CallgraphId { get; set; } = string.Empty;
/// <summary>
/// Optional analysis identifier forwarded as X-Analysis-Id.
/// </summary>
public string? AnalysisId { get; set; }
/// <summary>
/// Maximum number of facts sent in a single publish attempt.
/// </summary>
[Range(1, 5000)]
public int BatchSize { get; set; } = 1000;
/// <summary>
/// Maximum delay (seconds) before flushing a partially filled batch.
/// </summary>
[Range(typeof(double), "0.1", "30")]
public double FlushIntervalSeconds { get; set; } = 2;
/// <summary>
/// Optional subject fallback when image data is missing.
/// </summary>
public string? SubjectScanId { get; set; }
public string? SubjectImageDigest { get; set; }
public string? SubjectComponent { get; set; }
public string? SubjectVersion { get; set; }
}

View File

@@ -7,8 +7,8 @@ namespace StellaOps.Zastava.Observer.Configuration;
/// <summary>
/// Observer-specific configuration applied on top of the shared runtime options.
/// </summary>
public sealed class ZastavaObserverOptions
{
public sealed class ZastavaObserverOptions
{
public const string SectionName = "zastava:observer";
private const string DefaultContainerdSocket = "unix:///run/containerd/containerd.sock";
@@ -126,6 +126,12 @@ public sealed class ZastavaObserverOptions
/// </summary>
[Range(1, 128)]
public int MaxEntrypointArguments { get; set; } = 32;
/// <summary>
/// Runtime reachability fact publishing configuration.
/// </summary>
[Required]
public ReachabilityRuntimeOptions Reachability { get; init; } = new();
}
public sealed class ZastavaObserverBackendOptions

View File

@@ -87,6 +87,14 @@ public static class ObserverServiceCollectionExtensions
client.Timeout = TimeSpan.FromSeconds(Math.Clamp(backend.RequestTimeoutSeconds, 1, 120));
});
services.AddHttpClient<IRuntimeFactsClient, RuntimeFactsClient>()
.ConfigureHttpClient((provider, client) =>
{
var optionsMonitor = provider.GetRequiredService<IOptionsMonitor<ZastavaObserverOptions>>();
var observer = optionsMonitor.CurrentValue;
client.Timeout = TimeSpan.FromSeconds(Math.Clamp(observer.Backend.RequestTimeoutSeconds, 1, 120));
});
services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<ZastavaRuntimeOptions>, ObserverRuntimeOptionsPostConfigure>());
// Surface environment + cache/manifest/secrets wiring

View File

@@ -0,0 +1,357 @@
using System;
using System.Security.Cryptography;
using System.Text;
using System.Linq;
using System.Collections.Generic;
using StellaOps.Zastava.Core.Contracts;
using StellaOps.Zastava.Observer.Configuration;
namespace StellaOps.Zastava.Observer.Runtime;
internal static class RuntimeFactsBuilder
{
public static RuntimeFactsPublishRequest? Build(
IReadOnlyCollection<RuntimeEventEnvelope> envelopes,
ReachabilityRuntimeOptions options)
{
ArgumentNullException.ThrowIfNull(envelopes);
ArgumentNullException.ThrowIfNull(options);
if (!options.Enabled || envelopes.Count == 0)
{
return null;
}
if (string.IsNullOrWhiteSpace(options.CallgraphId))
{
return null;
}
var facts = new List<RuntimeFactEventPayload>(envelopes.Count);
ReachabilitySubjectPayload? subject = null;
foreach (var envelope in envelopes)
{
var fact = TryBuildFact(envelope);
if (fact is null)
{
continue;
}
facts.Add(fact);
subject ??= BuildSubject(envelope, options);
}
if (facts.Count == 0)
{
return null;
}
subject ??= BuildFallbackSubject(options);
if (subject is null)
{
return null;
}
facts.Sort(CompareFacts);
return new RuntimeFactsPublishRequest
{
CallgraphId = options.CallgraphId.Trim(),
Subject = subject,
Events = facts
};
}
private static RuntimeFactEventPayload? TryBuildFact(RuntimeEventEnvelope envelope)
{
ArgumentNullException.ThrowIfNull(envelope);
var evt = envelope.Event;
if (evt.Kind != RuntimeEventKind.ContainerStart || evt.Process is null)
{
return null;
}
var symbolId = BuildSymbolId(evt.Process);
if (string.IsNullOrWhiteSpace(symbolId))
{
return null;
}
var fact = new RuntimeFactEventPayload
{
SymbolId = symbolId,
CodeId = Normalize(evt.Process.BuildId),
BuildId = Normalize(evt.Process.BuildId),
LoaderBase = null,
Purl = null,
SymbolDigest = null,
HitCount = 1,
ObservedAt = evt.When,
ProcessId = evt.Process.Pid,
ProcessName = ResolveProcessName(evt.Process.Entrypoint),
ContainerId = evt.Workload.ContainerId,
Metadata = BuildMetadata(evt.Process)
};
return fact;
}
private static string? BuildSymbolId(RuntimeProcess process)
{
if (!string.IsNullOrWhiteSpace(process.BuildId))
{
return $"sym:binary:{process.BuildId.Trim().ToLowerInvariant()}";
}
var trace = process.EntryTrace?.FirstOrDefault(t =>
!string.IsNullOrWhiteSpace(t.Target) || !string.IsNullOrWhiteSpace(t.File));
var seed = trace?.Target ?? trace?.File ?? process.Entrypoint.FirstOrDefault();
if (string.IsNullOrWhiteSpace(seed))
{
return null;
}
var stable = ComputeStableFragment(seed);
return $"sym:shell:{stable}";
}
private static ReachabilitySubjectPayload? BuildSubject(RuntimeEventEnvelope envelope, ReachabilityRuntimeOptions options)
{
var workload = envelope.Event.Workload;
var imageRef = workload.ImageRef;
var digest = ExtractImageDigest(imageRef);
var (component, version) = ExtractComponentAndVersion(imageRef);
digest ??= Normalize(options.SubjectImageDigest);
component ??= Normalize(options.SubjectComponent);
version ??= Normalize(options.SubjectVersion);
var scanId = Normalize(options.SubjectScanId);
if (string.IsNullOrWhiteSpace(digest) && string.IsNullOrWhiteSpace(scanId)
&& (string.IsNullOrWhiteSpace(component) || string.IsNullOrWhiteSpace(version)))
{
return null;
}
return new ReachabilitySubjectPayload
{
ScanId = scanId,
ImageDigest = digest,
Component = component,
Version = version
};
}
private static ReachabilitySubjectPayload? BuildFallbackSubject(ReachabilityRuntimeOptions options)
{
var digest = Normalize(options.SubjectImageDigest);
var component = Normalize(options.SubjectComponent);
var version = Normalize(options.SubjectVersion);
var scanId = Normalize(options.SubjectScanId);
if (string.IsNullOrWhiteSpace(digest) && string.IsNullOrWhiteSpace(scanId)
&& (string.IsNullOrWhiteSpace(component) || string.IsNullOrWhiteSpace(version)))
{
return null;
}
return new ReachabilitySubjectPayload
{
ScanId = scanId,
ImageDigest = digest,
Component = component,
Version = version
};
}
private static Dictionary<string, string?>? BuildMetadata(RuntimeProcess process)
{
if (process.EntryTrace is null || process.EntryTrace.Count == 0)
{
return null;
}
var sb = new StringBuilder();
foreach (var trace in process.EntryTrace)
{
if (trace is null)
{
continue;
}
var op = string.IsNullOrWhiteSpace(trace.Op) ? "exec" : trace.Op;
var target = trace.Target ?? trace.File;
if (string.IsNullOrWhiteSpace(target))
{
continue;
}
if (sb.Length > 0)
{
sb.Append(" | ");
}
sb.Append(op).Append(':').Append(target);
}
if (sb.Length == 0)
{
return null;
}
return new Dictionary<string, string?>(StringComparer.Ordinal)
{
["entryTrace"] = sb.ToString()
};
}
private static string? ExtractImageDigest(string? imageRef)
{
if (string.IsNullOrWhiteSpace(imageRef))
{
return null;
}
var digestStart = imageRef.IndexOf("sha256:", StringComparison.OrdinalIgnoreCase);
if (digestStart >= 0)
{
return imageRef[digestStart..].Trim();
}
var atIndex = imageRef.IndexOf('@');
if (atIndex > 0 && atIndex + 1 < imageRef.Length)
{
return imageRef[(atIndex + 1)..].Trim();
}
return null;
}
private static (string? Component, string? Version) ExtractComponentAndVersion(string? imageRef)
{
if (string.IsNullOrWhiteSpace(imageRef))
{
return (null, null);
}
var lastSlash = imageRef.LastIndexOf('/');
var lastColon = imageRef.LastIndexOf(':');
if (lastColon < 0 || lastColon < lastSlash)
{
return (null, null);
}
var component = imageRef[(lastSlash + 1)..lastColon];
var version = imageRef[(lastColon + 1)..];
if (string.IsNullOrWhiteSpace(component) || string.IsNullOrWhiteSpace(version))
{
return (null, null);
}
return (component.Trim(), version.Trim());
}
private static string? ResolveProcessName(IReadOnlyList<string>? entrypoint)
{
if (entrypoint is null || entrypoint.Count == 0)
{
return null;
}
var first = entrypoint[0];
if (string.IsNullOrWhiteSpace(first))
{
return null;
}
var lastSlash = first.LastIndexOf('/');
return lastSlash >= 0 ? first[(lastSlash + 1)..] : first;
}
private static string ComputeStableFragment(string seed)
{
var normalized = seed.Trim().ToLowerInvariant();
var bytes = Encoding.UTF8.GetBytes(normalized);
Span<byte> hash = stackalloc byte[32];
_ = SHA256.TryHashData(bytes, hash, out _);
return Convert.ToHexString(hash[..16]).ToLowerInvariant();
}
private static int CompareFacts(RuntimeFactEventPayload left, RuntimeFactEventPayload right)
{
var timeComparison = Nullable.Compare(left.ObservedAt, right.ObservedAt);
if (timeComparison != 0)
{
return timeComparison;
}
return string.Compare(left.SymbolId, right.SymbolId, StringComparison.Ordinal);
}
private static string? Normalize(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
internal sealed class RuntimeFactsPublishRequest
{
public ReachabilitySubjectPayload Subject { get; set; } = new();
public string CallgraphId { get; set; } = string.Empty;
public List<RuntimeFactEventPayload> Events { get; set; } = new();
}
internal sealed class ReachabilitySubjectPayload
{
public string? ScanId { get; set; }
public string? ImageDigest { get; set; }
public string? Component { get; set; }
public string? Version { get; set; }
}
internal sealed class RuntimeFactEventPayload
{
public string SymbolId { get; set; } = string.Empty;
public string? CodeId { get; set; }
public string? SymbolDigest { get; set; }
public string? Purl { get; set; }
public string? BuildId { get; set; }
public string? LoaderBase { get; set; }
public int? ProcessId { get; set; }
public string? ProcessName { get; set; }
public string? SocketAddress { get; set; }
public string? ContainerId { get; set; }
public string? EvidenceUri { get; set; }
public int HitCount { get; set; } = 1;
public DateTimeOffset? ObservedAt { get; set; }
public Dictionary<string, string?>? Metadata { get; set; }
}
internal sealed class RuntimeFactsException : Exception
{
public RuntimeFactsException(string message, Exception? innerException = null) : base(message, innerException)
{
}
}

View File

@@ -13,6 +13,7 @@ internal sealed class RuntimeEventDispatchService : BackgroundService
{
private readonly IRuntimeEventBuffer buffer;
private readonly IRuntimeEventsClient eventsClient;
private readonly IRuntimeFactsClient runtimeFactsClient;
private readonly IOptionsMonitor<ZastavaObserverOptions> observerOptions;
private readonly TimeProvider timeProvider;
private readonly ILogger<RuntimeEventDispatchService> logger;
@@ -20,12 +21,14 @@ internal sealed class RuntimeEventDispatchService : BackgroundService
public RuntimeEventDispatchService(
IRuntimeEventBuffer buffer,
IRuntimeEventsClient eventsClient,
IRuntimeFactsClient runtimeFactsClient,
IOptionsMonitor<ZastavaObserverOptions> observerOptions,
TimeProvider timeProvider,
ILogger<RuntimeEventDispatchService> logger)
{
this.buffer = buffer ?? throw new ArgumentNullException(nameof(buffer));
this.eventsClient = eventsClient ?? throw new ArgumentNullException(nameof(eventsClient));
this.runtimeFactsClient = runtimeFactsClient ?? throw new ArgumentNullException(nameof(runtimeFactsClient));
this.observerOptions = observerOptions ?? throw new ArgumentNullException(nameof(observerOptions));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
@@ -116,14 +119,17 @@ internal sealed class RuntimeEventDispatchService : BackgroundService
return;
}
var request = new RuntimeEventsIngestRequest
{
BatchId = $"obs-{timeProvider.GetUtcNow():yyyyMMddTHHmmssfff}-{Guid.NewGuid():N}",
Events = batch.Select(item => item.Envelope).ToArray()
};
try
{
var envelopes = batch.Select(item => item.Envelope).ToArray();
var factsPublished = await TryPublishRuntimeFactsAsync(envelopes, cancellationToken).ConfigureAwait(false);
var request = new RuntimeEventsIngestRequest
{
BatchId = $"obs-{timeProvider.GetUtcNow():yyyyMMddTHHmmssfff}-{Guid.NewGuid():N}",
Events = envelopes
};
var result = await eventsClient.PublishAsync(request, cancellationToken).ConfigureAwait(false);
if (result.Success)
{
@@ -132,10 +138,11 @@ internal sealed class RuntimeEventDispatchService : BackgroundService
await item.CompleteAsync().ConfigureAwait(false);
}
logger.LogInformation("Runtime events batch published (batchId={BatchId}, accepted={Accepted}, duplicates={Duplicates}).",
logger.LogInformation("Runtime events batch published (batchId={BatchId}, accepted={Accepted}, duplicates={Duplicates}, runtimeFacts={FactsPublished}).",
request.BatchId,
result.Accepted,
result.Duplicates);
result.Duplicates,
factsPublished);
}
else if (result.RateLimited)
{
@@ -166,6 +173,38 @@ internal sealed class RuntimeEventDispatchService : BackgroundService
}
}
private async Task<bool> TryPublishRuntimeFactsAsync(RuntimeEventEnvelope[] envelopes, CancellationToken cancellationToken)
{
if (envelopes.Length == 0)
{
return false;
}
var options = observerOptions.CurrentValue.Reachability;
var request = RuntimeFactsBuilder.Build(envelopes, options);
if (request is null)
{
return false;
}
try
{
await runtimeFactsClient.PublishAsync(request, cancellationToken).ConfigureAwait(false);
logger.LogDebug("Published {Count} runtime facts (callgraphId={CallgraphId}).", request.Events.Count, request.CallgraphId);
return true;
}
catch (RuntimeFactsException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogWarning(ex, "Runtime facts publish failed; batch will be retried.");
throw;
}
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogWarning(ex, "Runtime facts publish encountered an unexpected error; batch will be retried.");
throw;
}
}
private async Task RequeueBatchAsync(IEnumerable<RuntimeEventBufferItem> batch, CancellationToken cancellationToken)
{
foreach (var item in batch)