up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
console-runner-image / build-runner-image (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
console-runner-image / build-runner-image (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,6 +15,7 @@ using StellaOps.Zastava.Observer.ContainerRuntime;
|
||||
using StellaOps.Zastava.Observer.ContainerRuntime.Cri;
|
||||
using StellaOps.Zastava.Observer.Posture;
|
||||
using StellaOps.Zastava.Observer.Runtime;
|
||||
using StellaOps.Zastava.Observer.Runtime.ProcSnapshot;
|
||||
using StellaOps.Zastava.Observer.Secrets;
|
||||
using StellaOps.Zastava.Observer.Surface;
|
||||
using StellaOps.Zastava.Observer.Worker;
|
||||
@@ -67,6 +68,7 @@ public static class ObserverServiceCollectionExtensions
|
||||
services.TryAddSingleton<ICriRuntimeClientFactory, CriRuntimeClientFactory>();
|
||||
services.TryAddSingleton<IRuntimeEventBuffer, RuntimeEventBuffer>();
|
||||
services.TryAddSingleton<IRuntimeProcessCollector, RuntimeProcessCollector>();
|
||||
services.TryAddSingleton<IProcSnapshotCollector, ProcSnapshotCollector>();
|
||||
services.TryAddSingleton<IRuntimePostureCache, RuntimePostureCache>();
|
||||
services.TryAddSingleton<IRuntimePostureEvaluator, RuntimePostureEvaluator>();
|
||||
services.TryAddSingleton<ContainerStateTrackerFactory>();
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Signals.Models;
|
||||
|
||||
namespace StellaOps.Zastava.Observer.Runtime.ProcSnapshot;
|
||||
|
||||
/// <summary>
|
||||
/// Collects loaded .NET assembly information from a running process.
|
||||
/// Parses /proc/<pid>/maps for loaded DLLs and correlates with deps.json for NuGet metadata.
|
||||
/// </summary>
|
||||
internal sealed partial class DotNetAssemblyCollector
|
||||
{
|
||||
private static readonly Regex DotNetProcessRegex = GenerateDotNetRegex();
|
||||
private static readonly Regex MapsLineRegex = GenerateMapsRegex();
|
||||
private const int MaxAssemblies = 512;
|
||||
private const long MaxFileSize = 50 * 1024 * 1024; // 50 MiB
|
||||
private const long MaxTotalHashBytes = 100_000_000; // ~95 MiB
|
||||
|
||||
private readonly string _procRoot;
|
||||
private readonly ILogger<DotNetAssemblyCollector> _logger;
|
||||
|
||||
public DotNetAssemblyCollector(string procRoot, ILogger<DotNetAssemblyCollector> logger)
|
||||
{
|
||||
_procRoot = procRoot?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||||
?? throw new ArgumentNullException(nameof(procRoot));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if a process appears to be a .NET process.
|
||||
/// </summary>
|
||||
public async Task<bool> IsDotNetProcessAsync(int pid, CancellationToken cancellationToken)
|
||||
{
|
||||
var cmdline = await ReadCmdlineAsync(pid, cancellationToken).ConfigureAwait(false);
|
||||
if (cmdline.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if it's dotnet or a .dll being executed
|
||||
var exe = cmdline[0];
|
||||
if (DotNetProcessRegex.IsMatch(exe))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Also check if cmdline contains a .dll argument (dotnet MyApp.dll)
|
||||
return cmdline.Any(arg => arg.EndsWith(".dll", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collect loaded assemblies from a .NET process.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<LoadedAssemblyEntry>> CollectAsync(int pid, CancellationToken cancellationToken)
|
||||
{
|
||||
var cmdline = await ReadCmdlineAsync(pid, cancellationToken).ConfigureAwait(false);
|
||||
if (cmdline.Count == 0)
|
||||
{
|
||||
return Array.Empty<LoadedAssemblyEntry>();
|
||||
}
|
||||
|
||||
if (!await IsDotNetProcessAsync(pid, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
_logger.LogDebug("Process {Pid} is not a .NET process", pid);
|
||||
return Array.Empty<LoadedAssemblyEntry>();
|
||||
}
|
||||
|
||||
// Find deps.json for NuGet metadata correlation
|
||||
var depsJson = await FindDepsJsonAsync(pid, cmdline, cancellationToken).ConfigureAwait(false);
|
||||
var depsMetadata = depsJson != null
|
||||
? await ParseDepsJsonAsync(depsJson, cancellationToken).ConfigureAwait(false)
|
||||
: new DepsJsonMetadata();
|
||||
|
||||
// Parse /proc/<pid>/maps for loaded assemblies
|
||||
var loadedPaths = await GetLoadedAssemblyPathsAsync(pid, cancellationToken).ConfigureAwait(false);
|
||||
if (loadedPaths.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No loaded assemblies found for .NET process {Pid}", pid);
|
||||
return Array.Empty<LoadedAssemblyEntry>();
|
||||
}
|
||||
|
||||
var entries = new List<LoadedAssemblyEntry>();
|
||||
var totalBytesHashed = 0L;
|
||||
|
||||
foreach (var path in loadedPaths.Take(MaxAssemblies))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var (entry, bytesHashed) = await ProcessAssemblyAsync(
|
||||
path,
|
||||
depsMetadata,
|
||||
totalBytesHashed,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (entry != null)
|
||||
{
|
||||
entries.Add(entry);
|
||||
}
|
||||
totalBytesHashed += bytesHashed;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Collected {Count} assembly entries for .NET process {Pid}", entries.Count, pid);
|
||||
return entries;
|
||||
}
|
||||
|
||||
private async Task<List<string>> ReadCmdlineAsync(int pid, CancellationToken cancellationToken)
|
||||
{
|
||||
var cmdlinePath = Path.Combine(_procRoot, pid.ToString(CultureInfo.InvariantCulture), "cmdline");
|
||||
if (!File.Exists(cmdlinePath))
|
||||
{
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllBytesAsync(cmdlinePath, cancellationToken).ConfigureAwait(false);
|
||||
if (content.Length == 0)
|
||||
{
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
return System.Text.Encoding.UTF8.GetString(content)
|
||||
.Split('\0', StringSplitOptions.RemoveEmptyEntries)
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to read cmdline for PID {Pid}", pid);
|
||||
return new List<string>();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<HashSet<string>> GetLoadedAssemblyPathsAsync(int pid, CancellationToken cancellationToken)
|
||||
{
|
||||
var mapsPath = Path.Combine(_procRoot, pid.ToString(CultureInfo.InvariantCulture), "maps");
|
||||
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!File.Exists(mapsPath))
|
||||
{
|
||||
return paths;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var lines = await File.ReadAllLinesAsync(mapsPath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var match = MapsLineRegex.Match(line);
|
||||
if (!match.Success)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var path = match.Groups["path"].Value;
|
||||
if (string.IsNullOrWhiteSpace(path) || path.StartsWith('['))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Include .dll files and .NET native libraries (.so)
|
||||
if (path.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) ||
|
||||
(path.Contains("/dotnet/", StringComparison.OrdinalIgnoreCase) &&
|
||||
path.EndsWith(".so", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
paths.Add(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to read maps for PID {Pid}", pid);
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
private async Task<string?> FindDepsJsonAsync(int pid, IReadOnlyList<string> cmdline, CancellationToken cancellationToken)
|
||||
{
|
||||
// Try to find deps.json from:
|
||||
// 1. The directory of the main assembly (from cmdline)
|
||||
// 2. Working directory of the process
|
||||
|
||||
// Look for the main .dll in cmdline
|
||||
string? mainDll = null;
|
||||
for (var i = 0; i < cmdline.Count; i++)
|
||||
{
|
||||
if (cmdline[i].EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
mainDll = cmdline[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(mainDll) && File.Exists(mainDll))
|
||||
{
|
||||
var depsPath = Path.ChangeExtension(mainDll, ".deps.json");
|
||||
if (File.Exists(depsPath))
|
||||
{
|
||||
return depsPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Try working directory
|
||||
var cwdPath = Path.Combine(_procRoot, pid.ToString(CultureInfo.InvariantCulture), "cwd");
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(cwdPath))
|
||||
{
|
||||
var cwd = Path.GetFullPath(cwdPath);
|
||||
var depsFiles = Directory.GetFiles(cwd, "*.deps.json", SearchOption.TopDirectoryOnly);
|
||||
if (depsFiles.Length > 0)
|
||||
{
|
||||
return depsFiles[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to search for deps.json in process working directory");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<DepsJsonMetadata> ParseDepsJsonAsync(string depsJsonPath, CancellationToken cancellationToken)
|
||||
{
|
||||
var metadata = new DepsJsonMetadata();
|
||||
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(depsJsonPath);
|
||||
if (fileInfo.Length > 10 * 1024 * 1024) // 10 MiB max for deps.json
|
||||
{
|
||||
_logger.LogDebug("deps.json too large: {Path} ({Size} bytes)", depsJsonPath, fileInfo.Length);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
await using var stream = new FileStream(depsJsonPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Extract RID from runtimeTarget
|
||||
if (root.TryGetProperty("runtimeTarget", out var runtimeTarget) &&
|
||||
runtimeTarget.TryGetProperty("name", out var targetName))
|
||||
{
|
||||
var target = targetName.GetString() ?? string.Empty;
|
||||
// Format: ".NETCoreApp,Version=v8.0/linux-x64"
|
||||
var slashIndex = target.LastIndexOf('/');
|
||||
if (slashIndex > 0)
|
||||
{
|
||||
metadata.RuntimeIdentifier = target[(slashIndex + 1)..];
|
||||
}
|
||||
}
|
||||
|
||||
// Parse libraries for NuGet package info
|
||||
if (root.TryGetProperty("libraries", out var libraries))
|
||||
{
|
||||
foreach (var library in libraries.EnumerateObject())
|
||||
{
|
||||
var nameVersion = library.Name;
|
||||
var slashIndex = nameVersion.IndexOf('/');
|
||||
if (slashIndex <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var packageName = nameVersion[..slashIndex];
|
||||
var packageVersion = nameVersion[(slashIndex + 1)..];
|
||||
|
||||
var libInfo = new LibraryInfo
|
||||
{
|
||||
PackageName = packageName,
|
||||
PackageVersion = packageVersion
|
||||
};
|
||||
|
||||
if (library.Value.TryGetProperty("type", out var typeElement))
|
||||
{
|
||||
libInfo.Type = typeElement.GetString();
|
||||
}
|
||||
|
||||
if (library.Value.TryGetProperty("sha512", out var sha512Element))
|
||||
{
|
||||
libInfo.Sha512 = sha512Element.GetString();
|
||||
}
|
||||
|
||||
metadata.Libraries[packageName.ToLowerInvariant()] = libInfo;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse targets for assembly-to-package mapping
|
||||
if (root.TryGetProperty("targets", out var targets))
|
||||
{
|
||||
foreach (var target in targets.EnumerateObject())
|
||||
{
|
||||
foreach (var package in target.Value.EnumerateObject())
|
||||
{
|
||||
var nameVersion = package.Name;
|
||||
var slashIndex = nameVersion.IndexOf('/');
|
||||
if (slashIndex <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var packageName = nameVersion[..slashIndex];
|
||||
var packageVersion = nameVersion[(slashIndex + 1)..];
|
||||
|
||||
// Map assemblies to packages
|
||||
foreach (var section in new[] { "runtime", "compile", "native" })
|
||||
{
|
||||
if (!package.Value.TryGetProperty(section, out var assemblies))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var assembly in assemblies.EnumerateObject())
|
||||
{
|
||||
var assemblyPath = assembly.Name;
|
||||
var assemblyName = Path.GetFileNameWithoutExtension(assemblyPath);
|
||||
|
||||
metadata.AssemblyPackages[assemblyName.ToLowerInvariant()] = new AssemblyPackageInfo
|
||||
{
|
||||
PackageName = packageName,
|
||||
PackageVersion = packageVersion,
|
||||
DepsSource = section
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or IOException)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to parse deps.json: {Path}", depsJsonPath);
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private async Task<(LoadedAssemblyEntry? Entry, long BytesHashed)> ProcessAssemblyAsync(
|
||||
string path,
|
||||
DepsJsonMetadata depsMetadata,
|
||||
long currentTotalBytesHashed,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return (new LoadedAssemblyEntry
|
||||
{
|
||||
Name = Path.GetFileNameWithoutExtension(path),
|
||||
Path = path
|
||||
}, 0);
|
||||
}
|
||||
|
||||
var name = Path.GetFileNameWithoutExtension(path);
|
||||
var entry = new LoadedAssemblyEntry
|
||||
{
|
||||
Name = name,
|
||||
Path = path,
|
||||
Rid = depsMetadata.RuntimeIdentifier
|
||||
};
|
||||
|
||||
// Determine if framework assembly
|
||||
entry = entry with
|
||||
{
|
||||
IsFrameworkAssembly = IsFrameworkPath(path)
|
||||
};
|
||||
|
||||
// Look up NuGet package info from deps.json
|
||||
var nameLower = name.ToLowerInvariant();
|
||||
if (depsMetadata.AssemblyPackages.TryGetValue(nameLower, out var packageInfo))
|
||||
{
|
||||
entry = entry with
|
||||
{
|
||||
NuGetPackage = packageInfo.PackageName,
|
||||
NuGetVersion = packageInfo.PackageVersion,
|
||||
DepsSource = packageInfo.DepsSource,
|
||||
Purl = $"pkg:nuget/{packageInfo.PackageName}@{packageInfo.PackageVersion}"
|
||||
};
|
||||
}
|
||||
|
||||
// Hash the file if within limits
|
||||
long bytesHashed = 0;
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(path);
|
||||
if (fileInfo.Length <= MaxFileSize && currentTotalBytesHashed + fileInfo.Length <= MaxTotalHashBytes)
|
||||
{
|
||||
var hash = await ComputeFileHashAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
entry = entry with { Sha256 = hash };
|
||||
bytesHashed = fileInfo.Length;
|
||||
}
|
||||
|
||||
// Try to extract version from assembly
|
||||
var version = await TryGetAssemblyVersionAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
entry = entry with { Version = version };
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to process assembly: {Path}", path);
|
||||
}
|
||||
|
||||
return (entry, bytesHashed);
|
||||
}
|
||||
|
||||
private static bool IsFrameworkPath(string path)
|
||||
{
|
||||
// Framework assemblies are typically in shared framework paths
|
||||
var pathLower = path.ToLowerInvariant();
|
||||
return pathLower.Contains("/dotnet/shared/", StringComparison.Ordinal) ||
|
||||
pathLower.Contains("/usr/share/dotnet/", StringComparison.Ordinal) ||
|
||||
pathLower.Contains("\\dotnet\\shared\\", StringComparison.Ordinal) ||
|
||||
pathLower.Contains("\\program files\\dotnet\\", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeFileHashAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static async Task<string?> TryGetAssemblyVersionAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
// For .dll files, try to read assembly version from PE header
|
||||
// This is a simplified version - full implementation would use System.Reflection.Metadata
|
||||
try
|
||||
{
|
||||
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
using var reader = new BinaryReader(stream);
|
||||
|
||||
// Check DOS header magic number
|
||||
if (reader.ReadUInt16() != 0x5A4D) // "MZ"
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Seek to PE header offset
|
||||
stream.Seek(0x3C, SeekOrigin.Begin);
|
||||
var peOffset = reader.ReadUInt32();
|
||||
|
||||
// Verify PE signature
|
||||
stream.Seek(peOffset, SeekOrigin.Begin);
|
||||
if (reader.ReadUInt32() != 0x00004550) // "PE\0\0"
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// For managed assemblies, we'd need to parse the metadata tables
|
||||
// This would require System.Reflection.Metadata for proper implementation
|
||||
// For now, return null and rely on deps.json for version info
|
||||
return null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"(^|/)(dotnet)(\.(exe))?$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex GenerateDotNetRegex();
|
||||
|
||||
[GeneratedRegex(@"^[0-9a-f]+-[0-9a-f]+\s+[r-][w-][x-][ps-]\s+[0-9a-f]+\s+[0-9a-f]+:[0-9a-f]+\s+\d+\s+(?<path>.+)$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex GenerateMapsRegex();
|
||||
|
||||
private sealed class DepsJsonMetadata
|
||||
{
|
||||
public string? RuntimeIdentifier { get; set; }
|
||||
public Dictionary<string, LibraryInfo> Libraries { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public Dictionary<string, AssemblyPackageInfo> AssemblyPackages { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private sealed class LibraryInfo
|
||||
{
|
||||
public string PackageName { get; init; } = string.Empty;
|
||||
public string PackageVersion { get; init; } = string.Empty;
|
||||
public string? Type { get; set; }
|
||||
public string? Sha512 { get; set; }
|
||||
}
|
||||
|
||||
private sealed class AssemblyPackageInfo
|
||||
{
|
||||
public string PackageName { get; init; } = string.Empty;
|
||||
public string PackageVersion { get; init; } = string.Empty;
|
||||
public string? DepsSource { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Signals.Models;
|
||||
|
||||
namespace StellaOps.Zastava.Observer.Runtime.ProcSnapshot;
|
||||
|
||||
/// <summary>
|
||||
/// Collects PHP autoload information from Composer-based applications.
|
||||
/// Parses composer.json, composer.lock, and vendor/autoload.php for package metadata.
|
||||
/// </summary>
|
||||
internal sealed partial class PhpAutoloadCollector
|
||||
{
|
||||
private static readonly Regex PhpProcessRegex = GeneratePhpRegex();
|
||||
private const int MaxAutoloadEntries = 1024;
|
||||
private const long MaxComposerLockSize = 10 * 1024 * 1024; // 10 MiB
|
||||
|
||||
private readonly string _procRoot;
|
||||
private readonly ILogger<PhpAutoloadCollector> _logger;
|
||||
|
||||
public PhpAutoloadCollector(string procRoot, ILogger<PhpAutoloadCollector> logger)
|
||||
{
|
||||
_procRoot = procRoot?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||||
?? throw new ArgumentNullException(nameof(procRoot));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if a process appears to be a PHP process.
|
||||
/// </summary>
|
||||
public async Task<bool> IsPhpProcessAsync(int pid, CancellationToken cancellationToken)
|
||||
{
|
||||
var cmdline = await ReadCmdlineAsync(pid, cancellationToken).ConfigureAwait(false);
|
||||
if (cmdline.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return PhpProcessRegex.IsMatch(cmdline[0]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collect autoload entries from a PHP process.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<AutoloadPathEntry>> CollectAsync(int pid, CancellationToken cancellationToken)
|
||||
{
|
||||
var cmdline = await ReadCmdlineAsync(pid, cancellationToken).ConfigureAwait(false);
|
||||
if (cmdline.Count == 0)
|
||||
{
|
||||
return Array.Empty<AutoloadPathEntry>();
|
||||
}
|
||||
|
||||
if (!PhpProcessRegex.IsMatch(cmdline[0]))
|
||||
{
|
||||
_logger.LogDebug("Process {Pid} is not a PHP process", pid);
|
||||
return Array.Empty<AutoloadPathEntry>();
|
||||
}
|
||||
|
||||
// Find the application root by looking for composer.json
|
||||
var appRoot = await FindApplicationRootAsync(pid, cmdline, cancellationToken).ConfigureAwait(false);
|
||||
if (string.IsNullOrEmpty(appRoot))
|
||||
{
|
||||
_logger.LogDebug("No composer.json found for PHP process {Pid}", pid);
|
||||
return Array.Empty<AutoloadPathEntry>();
|
||||
}
|
||||
|
||||
// Parse composer.lock for installed packages
|
||||
var installedPackages = await ParseComposerLockAsync(appRoot, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Collect autoload entries
|
||||
var entries = new List<AutoloadPathEntry>();
|
||||
|
||||
// Parse composer.json for autoload configuration
|
||||
await CollectAutoloadFromComposerJsonAsync(appRoot, installedPackages, entries, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Scan vendor directory for additional package autoloads
|
||||
await CollectVendorPackagesAsync(appRoot, installedPackages, entries, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug("Collected {Count} autoload entries for PHP process {Pid}", entries.Count, pid);
|
||||
return entries.Take(MaxAutoloadEntries).ToList();
|
||||
}
|
||||
|
||||
private async Task<List<string>> ReadCmdlineAsync(int pid, CancellationToken cancellationToken)
|
||||
{
|
||||
var cmdlinePath = Path.Combine(_procRoot, pid.ToString(CultureInfo.InvariantCulture), "cmdline");
|
||||
if (!File.Exists(cmdlinePath))
|
||||
{
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllBytesAsync(cmdlinePath, cancellationToken).ConfigureAwait(false);
|
||||
if (content.Length == 0)
|
||||
{
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
return System.Text.Encoding.UTF8.GetString(content)
|
||||
.Split('\0', StringSplitOptions.RemoveEmptyEntries)
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to read cmdline for PID {Pid}", pid);
|
||||
return new List<string>();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> FindApplicationRootAsync(
|
||||
int pid,
|
||||
IReadOnlyList<string> cmdline,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var candidateDirs = new List<string>();
|
||||
|
||||
// Check script path from cmdline
|
||||
foreach (var arg in cmdline.Skip(1))
|
||||
{
|
||||
if (arg.EndsWith(".php", StringComparison.OrdinalIgnoreCase) && File.Exists(arg))
|
||||
{
|
||||
var scriptDir = Path.GetDirectoryName(arg);
|
||||
if (!string.IsNullOrEmpty(scriptDir))
|
||||
{
|
||||
candidateDirs.Add(scriptDir);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check process working directory
|
||||
var cwdLink = Path.Combine(_procRoot, pid.ToString(CultureInfo.InvariantCulture), "cwd");
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(cwdLink))
|
||||
{
|
||||
var cwd = Path.GetFullPath(cwdLink);
|
||||
candidateDirs.Add(cwd);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to read cwd for PID {Pid}", pid);
|
||||
}
|
||||
|
||||
// Search up the directory tree for composer.json
|
||||
foreach (var startDir in candidateDirs)
|
||||
{
|
||||
var dir = startDir;
|
||||
for (var i = 0; i < 10 && !string.IsNullOrEmpty(dir); i++) // Max 10 levels up
|
||||
{
|
||||
var composerPath = Path.Combine(dir, "composer.json");
|
||||
if (File.Exists(composerPath))
|
||||
{
|
||||
return dir;
|
||||
}
|
||||
dir = Path.GetDirectoryName(dir);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, ComposerPackage>> ParseComposerLockAsync(
|
||||
string appRoot,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var packages = new Dictionary<string, ComposerPackage>(StringComparer.OrdinalIgnoreCase);
|
||||
var lockPath = Path.Combine(appRoot, "composer.lock");
|
||||
|
||||
if (!File.Exists(lockPath))
|
||||
{
|
||||
return packages;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(lockPath);
|
||||
if (fileInfo.Length > MaxComposerLockSize)
|
||||
{
|
||||
_logger.LogDebug("composer.lock too large: {Path} ({Size} bytes)", lockPath, fileInfo.Length);
|
||||
return packages;
|
||||
}
|
||||
|
||||
await using var stream = new FileStream(lockPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Parse packages array
|
||||
foreach (var section in new[] { "packages", "packages-dev" })
|
||||
{
|
||||
if (!root.TryGetProperty(section, out var packagesArray))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var package in packagesArray.EnumerateArray())
|
||||
{
|
||||
if (!package.TryGetProperty("name", out var nameElement))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = nameElement.GetString();
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var pkg = new ComposerPackage { Name = name };
|
||||
|
||||
if (package.TryGetProperty("version", out var versionElement))
|
||||
{
|
||||
pkg.Version = versionElement.GetString();
|
||||
}
|
||||
|
||||
if (package.TryGetProperty("autoload", out var autoload))
|
||||
{
|
||||
pkg.Autoload = ParseAutoloadSection(autoload);
|
||||
}
|
||||
|
||||
packages[name] = pkg;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or IOException)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to parse composer.lock: {Path}", lockPath);
|
||||
}
|
||||
|
||||
return packages;
|
||||
}
|
||||
|
||||
private async Task CollectAutoloadFromComposerJsonAsync(
|
||||
string appRoot,
|
||||
Dictionary<string, ComposerPackage> installedPackages,
|
||||
List<AutoloadPathEntry> entries,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var composerPath = Path.Combine(appRoot, "composer.json");
|
||||
if (!File.Exists(composerPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = new FileStream(composerPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Get project name for PURL
|
||||
string? projectName = null;
|
||||
if (root.TryGetProperty("name", out var nameElement))
|
||||
{
|
||||
projectName = nameElement.GetString();
|
||||
}
|
||||
|
||||
// Parse autoload sections
|
||||
foreach (var section in new[] { "autoload", "autoload-dev" })
|
||||
{
|
||||
if (!root.TryGetProperty(section, out var autoload))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var autoloadConfig = ParseAutoloadSection(autoload);
|
||||
AddAutoloadEntries(appRoot, autoloadConfig, projectName, null, entries);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or IOException)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to parse composer.json: {Path}", composerPath);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CollectVendorPackagesAsync(
|
||||
string appRoot,
|
||||
Dictionary<string, ComposerPackage> installedPackages,
|
||||
List<AutoloadPathEntry> entries,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var vendorDir = Path.Combine(appRoot, "vendor");
|
||||
if (!Directory.Exists(vendorDir))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var package in installedPackages.Values)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (package.Autoload == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var packageDir = Path.Combine(vendorDir, package.Name.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (!Directory.Exists(packageDir))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
AddAutoloadEntries(packageDir, package.Autoload, package.Name, package.Version, entries);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddAutoloadEntries(
|
||||
string baseDir,
|
||||
AutoloadConfig autoload,
|
||||
string? packageName,
|
||||
string? packageVersion,
|
||||
List<AutoloadPathEntry> entries)
|
||||
{
|
||||
// PSR-4
|
||||
foreach (var (ns, paths) in autoload.Psr4)
|
||||
{
|
||||
foreach (var path in paths)
|
||||
{
|
||||
var fullPath = Path.Combine(baseDir, path.TrimStart('/', '\\'));
|
||||
entries.Add(CreateEntry("psr-4", ns, fullPath, packageName, packageVersion));
|
||||
}
|
||||
}
|
||||
|
||||
// PSR-0
|
||||
foreach (var (ns, paths) in autoload.Psr0)
|
||||
{
|
||||
foreach (var path in paths)
|
||||
{
|
||||
var fullPath = Path.Combine(baseDir, path.TrimStart('/', '\\'));
|
||||
entries.Add(CreateEntry("psr-0", ns, fullPath, packageName, packageVersion));
|
||||
}
|
||||
}
|
||||
|
||||
// Classmap
|
||||
foreach (var path in autoload.Classmap)
|
||||
{
|
||||
var fullPath = Path.Combine(baseDir, path.TrimStart('/', '\\'));
|
||||
entries.Add(CreateEntry("classmap", null, fullPath, packageName, packageVersion));
|
||||
}
|
||||
|
||||
// Files
|
||||
foreach (var path in autoload.Files)
|
||||
{
|
||||
var fullPath = Path.Combine(baseDir, path.TrimStart('/', '\\'));
|
||||
entries.Add(CreateEntry("files", null, fullPath, packageName, packageVersion));
|
||||
}
|
||||
}
|
||||
|
||||
private static AutoloadPathEntry CreateEntry(
|
||||
string type,
|
||||
string? ns,
|
||||
string path,
|
||||
string? packageName,
|
||||
string? packageVersion)
|
||||
{
|
||||
var entry = new AutoloadPathEntry
|
||||
{
|
||||
Type = type,
|
||||
Namespace = ns,
|
||||
Path = path,
|
||||
ComposerPackage = packageName,
|
||||
ComposerVersion = packageVersion
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(packageName))
|
||||
{
|
||||
// Composer package names are vendor/package format
|
||||
var purl = $"pkg:composer/{packageName}";
|
||||
if (!string.IsNullOrWhiteSpace(packageVersion))
|
||||
{
|
||||
// Normalize version (remove 'v' prefix if present)
|
||||
var version = packageVersion.TrimStart('v', 'V');
|
||||
purl += $"@{version}";
|
||||
}
|
||||
entry = new AutoloadPathEntry
|
||||
{
|
||||
Type = entry.Type,
|
||||
Namespace = entry.Namespace,
|
||||
Path = entry.Path,
|
||||
ComposerPackage = entry.ComposerPackage,
|
||||
ComposerVersion = entry.ComposerVersion,
|
||||
Purl = purl
|
||||
};
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
private static AutoloadConfig ParseAutoloadSection(JsonElement autoload)
|
||||
{
|
||||
var config = new AutoloadConfig();
|
||||
|
||||
// PSR-4
|
||||
if (autoload.TryGetProperty("psr-4", out var psr4))
|
||||
{
|
||||
foreach (var item in psr4.EnumerateObject())
|
||||
{
|
||||
var ns = item.Name;
|
||||
var paths = new List<string>();
|
||||
|
||||
if (item.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var path = item.Value.GetString();
|
||||
if (!string.IsNullOrEmpty(path))
|
||||
{
|
||||
paths.Add(path);
|
||||
}
|
||||
}
|
||||
else if (item.Value.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var pathElement in item.Value.EnumerateArray())
|
||||
{
|
||||
var path = pathElement.GetString();
|
||||
if (!string.IsNullOrEmpty(path))
|
||||
{
|
||||
paths.Add(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (paths.Count > 0)
|
||||
{
|
||||
config.Psr4[ns] = paths;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PSR-0
|
||||
if (autoload.TryGetProperty("psr-0", out var psr0))
|
||||
{
|
||||
foreach (var item in psr0.EnumerateObject())
|
||||
{
|
||||
var ns = item.Name;
|
||||
var paths = new List<string>();
|
||||
|
||||
if (item.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var path = item.Value.GetString();
|
||||
if (!string.IsNullOrEmpty(path))
|
||||
{
|
||||
paths.Add(path);
|
||||
}
|
||||
}
|
||||
else if (item.Value.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var pathElement in item.Value.EnumerateArray())
|
||||
{
|
||||
var path = pathElement.GetString();
|
||||
if (!string.IsNullOrEmpty(path))
|
||||
{
|
||||
paths.Add(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (paths.Count > 0)
|
||||
{
|
||||
config.Psr0[ns] = paths;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Classmap
|
||||
if (autoload.TryGetProperty("classmap", out var classmap) && classmap.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in classmap.EnumerateArray())
|
||||
{
|
||||
var path = item.GetString();
|
||||
if (!string.IsNullOrEmpty(path))
|
||||
{
|
||||
config.Classmap.Add(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Files
|
||||
if (autoload.TryGetProperty("files", out var files) && files.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in files.EnumerateArray())
|
||||
{
|
||||
var path = item.GetString();
|
||||
if (!string.IsNullOrEmpty(path))
|
||||
{
|
||||
config.Files.Add(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"(^|/)(php|php-fpm|php-cgi|php\d+(\.\d+)?)(\.exe)?$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex GeneratePhpRegex();
|
||||
|
||||
private sealed class ComposerPackage
|
||||
{
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string? Version { get; set; }
|
||||
public AutoloadConfig? Autoload { get; set; }
|
||||
}
|
||||
|
||||
private sealed class AutoloadConfig
|
||||
{
|
||||
public Dictionary<string, List<string>> Psr4 { get; } = new(StringComparer.Ordinal);
|
||||
public Dictionary<string, List<string>> Psr0 { get; } = new(StringComparer.Ordinal);
|
||||
public List<string> Classmap { get; } = new();
|
||||
public List<string> Files { get; } = new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Signals.Models;
|
||||
using StellaOps.Zastava.Observer.Configuration;
|
||||
using StellaOps.Zastava.Observer.ContainerRuntime.Cri;
|
||||
|
||||
namespace StellaOps.Zastava.Observer.Runtime.ProcSnapshot;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for collecting proc snapshot data from running processes.
|
||||
/// </summary>
|
||||
internal interface IProcSnapshotCollector
|
||||
{
|
||||
/// <summary>
|
||||
/// Collect proc snapshot data for a container's main process.
|
||||
/// </summary>
|
||||
Task<ProcSnapshotDocument?> CollectAsync(
|
||||
CriContainerInfo container,
|
||||
string imageDigest,
|
||||
string tenant,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates language-specific proc snapshot collectors (Java, .NET, PHP).
|
||||
/// </summary>
|
||||
internal sealed class ProcSnapshotCollector : IProcSnapshotCollector
|
||||
{
|
||||
private readonly JavaClasspathCollector _javaCollector;
|
||||
private readonly DotNetAssemblyCollector _dotnetCollector;
|
||||
private readonly PhpAutoloadCollector _phpCollector;
|
||||
private readonly ILogger<ProcSnapshotCollector> _logger;
|
||||
private readonly string _procRoot;
|
||||
|
||||
public ProcSnapshotCollector(
|
||||
IOptions<ZastavaObserverOptions> options,
|
||||
ILoggerFactory loggerFactory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(loggerFactory);
|
||||
|
||||
_procRoot = options.Value.ProcRootPath;
|
||||
_logger = loggerFactory.CreateLogger<ProcSnapshotCollector>();
|
||||
|
||||
_javaCollector = new JavaClasspathCollector(
|
||||
_procRoot,
|
||||
loggerFactory.CreateLogger<JavaClasspathCollector>());
|
||||
|
||||
_dotnetCollector = new DotNetAssemblyCollector(
|
||||
_procRoot,
|
||||
loggerFactory.CreateLogger<DotNetAssemblyCollector>());
|
||||
|
||||
_phpCollector = new PhpAutoloadCollector(
|
||||
_procRoot,
|
||||
loggerFactory.CreateLogger<PhpAutoloadCollector>());
|
||||
}
|
||||
|
||||
public async Task<ProcSnapshotDocument?> CollectAsync(
|
||||
CriContainerInfo container,
|
||||
string imageDigest,
|
||||
string tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(container);
|
||||
|
||||
if (container.Pid is null or <= 0)
|
||||
{
|
||||
_logger.LogDebug("Container {ContainerId} lacks PID information; skipping proc snapshot.", container.Id);
|
||||
return null;
|
||||
}
|
||||
|
||||
var pid = container.Pid.Value;
|
||||
|
||||
// Detect runtime type and collect accordingly
|
||||
var (runtimeType, snapshot) = await DetectAndCollectAsync(pid, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (runtimeType == null || snapshot == null)
|
||||
{
|
||||
_logger.LogDebug("No supported runtime detected for container {ContainerId} (PID {Pid})", container.Id, pid);
|
||||
return null;
|
||||
}
|
||||
|
||||
var document = new ProcSnapshotDocument
|
||||
{
|
||||
Id = $"{tenant}:{imageDigest}:{pid}:{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}",
|
||||
Tenant = tenant,
|
||||
ImageDigest = imageDigest,
|
||||
ContainerId = container.Id,
|
||||
Pid = pid,
|
||||
RuntimeType = runtimeType,
|
||||
Classpath = snapshot.Classpath,
|
||||
LoadedAssemblies = snapshot.LoadedAssemblies,
|
||||
AutoloadPaths = snapshot.AutoloadPaths,
|
||||
CapturedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_logger.LogDebug(
|
||||
"Collected proc snapshot for container {ContainerId}: runtime={RuntimeType}, classpath={ClasspathCount}, assemblies={AssemblyCount}, autoload={AutoloadCount}",
|
||||
container.Id,
|
||||
runtimeType,
|
||||
snapshot.Classpath.Count,
|
||||
snapshot.LoadedAssemblies.Count,
|
||||
snapshot.AutoloadPaths.Count);
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
private async Task<(string? RuntimeType, CollectedSnapshot? Snapshot)> DetectAndCollectAsync(
|
||||
int pid,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Check Java first (JVM processes)
|
||||
if (await _javaCollector.IsJavaProcessAsync(pid, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var classpath = await _javaCollector.CollectAsync(pid, cancellationToken).ConfigureAwait(false);
|
||||
return (ProcSnapshotRuntimeTypes.Java, new CollectedSnapshot { Classpath = classpath });
|
||||
}
|
||||
|
||||
// Check .NET (dotnet processes)
|
||||
if (await _dotnetCollector.IsDotNetProcessAsync(pid, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var assemblies = await _dotnetCollector.CollectAsync(pid, cancellationToken).ConfigureAwait(false);
|
||||
return (ProcSnapshotRuntimeTypes.DotNet, new CollectedSnapshot { LoadedAssemblies = assemblies });
|
||||
}
|
||||
|
||||
// Check PHP (php/php-fpm processes)
|
||||
if (await _phpCollector.IsPhpProcessAsync(pid, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var autoload = await _phpCollector.CollectAsync(pid, cancellationToken).ConfigureAwait(false);
|
||||
return (ProcSnapshotRuntimeTypes.Php, new CollectedSnapshot { AutoloadPaths = autoload });
|
||||
}
|
||||
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
private sealed class CollectedSnapshot
|
||||
{
|
||||
public IReadOnlyList<ClasspathEntry> Classpath { get; init; } = Array.Empty<ClasspathEntry>();
|
||||
public IReadOnlyList<LoadedAssemblyEntry> LoadedAssemblies { get; init; } = Array.Empty<LoadedAssemblyEntry>();
|
||||
public IReadOnlyList<AutoloadPathEntry> AutoloadPaths { get; init; } = Array.Empty<AutoloadPathEntry>();
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Zastava.Core/StellaOps.Zastava.Core.csproj" />
|
||||
<ProjectReference Include="../../Signals/StellaOps.Signals/StellaOps.Signals.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Surface.Env/StellaOps.Scanner.Surface.Env.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Surface.FS/StellaOps.Scanner.Surface.FS.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Surface.Secrets/StellaOps.Scanner.Surface.Secrets.csproj" />
|
||||
|
||||
@@ -10,6 +10,7 @@ using StellaOps.Zastava.Observer.Configuration;
|
||||
using StellaOps.Zastava.Observer.ContainerRuntime;
|
||||
using StellaOps.Zastava.Observer.ContainerRuntime.Cri;
|
||||
using StellaOps.Zastava.Observer.Runtime;
|
||||
using StellaOps.Zastava.Observer.Runtime.ProcSnapshot;
|
||||
|
||||
namespace StellaOps.Zastava.Observer.Worker;
|
||||
|
||||
@@ -24,6 +25,7 @@ internal sealed class ContainerLifecycleHostedService : BackgroundService
|
||||
private readonly ContainerStateTrackerFactory trackerFactory;
|
||||
private readonly ContainerRuntimePoller poller;
|
||||
private readonly IRuntimeProcessCollector processCollector;
|
||||
private readonly IProcSnapshotCollector procSnapshotCollector;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<ContainerLifecycleHostedService> logger;
|
||||
private readonly Random jitterRandom = new();
|
||||
@@ -38,6 +40,7 @@ internal sealed class ContainerLifecycleHostedService : BackgroundService
|
||||
ContainerStateTrackerFactory trackerFactory,
|
||||
ContainerRuntimePoller poller,
|
||||
IRuntimeProcessCollector processCollector,
|
||||
IProcSnapshotCollector procSnapshotCollector,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<ContainerLifecycleHostedService> logger)
|
||||
{
|
||||
@@ -50,6 +53,7 @@ internal sealed class ContainerLifecycleHostedService : BackgroundService
|
||||
this.trackerFactory = trackerFactory ?? throw new ArgumentNullException(nameof(trackerFactory));
|
||||
this.poller = poller ?? throw new ArgumentNullException(nameof(poller));
|
||||
this.processCollector = processCollector ?? throw new ArgumentNullException(nameof(processCollector));
|
||||
this.procSnapshotCollector = procSnapshotCollector ?? throw new ArgumentNullException(nameof(procSnapshotCollector));
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
@@ -112,6 +116,7 @@ internal sealed class ContainerLifecycleHostedService : BackgroundService
|
||||
nodeName,
|
||||
timeProvider,
|
||||
processCollector,
|
||||
procSnapshotCollector,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (envelopes.Count > 0)
|
||||
|
||||
@@ -6,6 +6,7 @@ using StellaOps.Zastava.Observer.ContainerRuntime.Cri;
|
||||
using StellaOps.Zastava.Observer.Cri;
|
||||
using StellaOps.Zastava.Observer.Posture;
|
||||
using StellaOps.Zastava.Observer.Runtime;
|
||||
using StellaOps.Zastava.Observer.Runtime.ProcSnapshot;
|
||||
|
||||
namespace StellaOps.Zastava.Observer.Worker;
|
||||
|
||||
@@ -29,6 +30,7 @@ internal sealed class ContainerRuntimePoller
|
||||
string nodeName,
|
||||
TimeProvider timeProvider,
|
||||
IRuntimeProcessCollector? processCollector,
|
||||
IProcSnapshotCollector? procSnapshotCollector,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tracker);
|
||||
@@ -61,9 +63,21 @@ internal sealed class ContainerRuntimePoller
|
||||
}
|
||||
|
||||
RuntimeProcessCapture? capture = null;
|
||||
if (processCollector is not null && lifecycleEvent.Kind == ContainerLifecycleEventKind.Start)
|
||||
StellaOps.Signals.Models.ProcSnapshotDocument? procSnapshot = null;
|
||||
|
||||
if (lifecycleEvent.Kind == ContainerLifecycleEventKind.Start)
|
||||
{
|
||||
capture = await processCollector.CollectAsync(enriched, cancellationToken).ConfigureAwait(false);
|
||||
if (processCollector is not null)
|
||||
{
|
||||
capture = await processCollector.CollectAsync(enriched, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Collect proc snapshot for language-specific runtime info (Java/PHP/.NET)
|
||||
if (procSnapshotCollector is not null)
|
||||
{
|
||||
var imageDigest = enriched.ImageRef ?? enriched.Image ?? string.Empty;
|
||||
procSnapshot = await procSnapshotCollector.CollectAsync(enriched, imageDigest, tenant, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
RuntimePostureEvaluationResult? posture = null;
|
||||
@@ -80,7 +94,8 @@ internal sealed class ContainerRuntimePoller
|
||||
nodeName,
|
||||
capture,
|
||||
posture?.Posture,
|
||||
posture?.Evidence));
|
||||
posture?.Evidence,
|
||||
procSnapshot));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Signals.Models;
|
||||
using StellaOps.Zastava.Core.Contracts;
|
||||
using StellaOps.Zastava.Observer.Configuration;
|
||||
using StellaOps.Zastava.Observer.ContainerRuntime;
|
||||
@@ -20,7 +21,8 @@ internal static class RuntimeEventFactory
|
||||
string nodeName,
|
||||
RuntimeProcessCapture? capture = null,
|
||||
RuntimePosture? posture = null,
|
||||
IReadOnlyList<RuntimeEvidence>? additionalEvidence = null)
|
||||
IReadOnlyList<RuntimeEvidence>? additionalEvidence = null,
|
||||
ProcSnapshotDocument? procSnapshot = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(lifecycleEvent);
|
||||
ArgumentNullException.ThrowIfNull(endpoint);
|
||||
@@ -62,6 +64,7 @@ internal static class RuntimeEventFactory
|
||||
Process = capture?.Process,
|
||||
LoadedLibraries = capture?.Libraries ?? Array.Empty<RuntimeLoadedLibrary>(),
|
||||
Posture = posture,
|
||||
ProcSnapshot = procSnapshot,
|
||||
Evidence = MergeEvidence(capture?.Evidence, additionalEvidence),
|
||||
Annotations = annotations.Count == 0 ? null : new SortedDictionary<string, string>(annotations, StringComparer.Ordinal)
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user