359 lines
9.8 KiB
C#
359 lines
9.8 KiB
C#
|
|
using StellaOps.Zastava.Core.Contracts;
|
|
using StellaOps.Zastava.Observer.Configuration;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
|
|
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)
|
|
{
|
|
}
|
|
}
|