up
Some checks failed
Docs CI / lint-and-preview (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
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (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

This commit is contained in:
StellaOps Bot
2025-12-14 15:50:38 +02:00
parent f1a39c4ce3
commit 233873f620
249 changed files with 29746 additions and 154 deletions

View File

@@ -0,0 +1,396 @@
using System.IO.Pipes;
using System.Net.Http;
using System.Runtime.CompilerServices;
using System.Runtime.Versioning;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace StellaOps.Zastava.Observer.ContainerRuntime.Windows;
/// <summary>
/// Windows container runtime client using Docker Engine API over named pipe.
/// </summary>
[SupportedOSPlatform("windows")]
internal sealed class DockerWindowsRuntimeClient : IWindowsContainerRuntimeClient
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
private const string DefaultPipeName = "docker_engine";
private readonly string _pipeName;
private readonly TimeSpan _connectTimeout;
private readonly HttpClient _httpClient;
private readonly ILogger<DockerWindowsRuntimeClient> _logger;
private bool _disposed;
public DockerWindowsRuntimeClient(
ILogger<DockerWindowsRuntimeClient> logger,
string? pipeName = null,
TimeSpan? connectTimeout = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_pipeName = pipeName ?? DefaultPipeName;
_connectTimeout = connectTimeout ?? TimeSpan.FromSeconds(5);
_httpClient = CreateHttpClient(_pipeName, _connectTimeout);
}
private static HttpClient CreateHttpClient(string pipeName, TimeSpan connectTimeout)
{
var handler = new SocketsHttpHandler
{
ConnectCallback = async (context, cancellationToken) =>
{
var pipe = new NamedPipeClientStream(
".",
pipeName,
PipeDirection.InOut,
PipeOptions.Asynchronous);
await pipe.ConnectAsync((int)connectTimeout.TotalMilliseconds, cancellationToken).ConfigureAwait(false);
return pipe;
},
ConnectTimeout = connectTimeout
};
return new HttpClient(handler)
{
BaseAddress = new Uri("http://localhost/"),
Timeout = Timeout.InfiniteTimeSpan
};
}
public async Task<bool> IsAvailableAsync(CancellationToken cancellationToken)
{
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(_connectTimeout);
var response = await _httpClient.GetAsync("/_ping", cts.Token).ConfigureAwait(false);
return response.IsSuccessStatusCode;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogDebug(ex, "Docker Windows ping failed");
return false;
}
}
public async Task<WindowsRuntimeIdentity> GetIdentityAsync(CancellationToken cancellationToken)
{
var response = await _httpClient.GetAsync("/version", cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var version = JsonSerializer.Deserialize<DockerVersionResponse>(content, JsonOptions);
return new WindowsRuntimeIdentity
{
RuntimeName = "docker",
RuntimeVersion = version?.Version,
OsVersion = version?.Os,
OsBuild = TryParseOsBuild(version?.KernelVersion),
HyperVAvailable = true // Assume available on Windows Server
};
}
public async Task<IReadOnlyList<WindowsContainerInfo>> ListContainersAsync(
WindowsContainerState? stateFilter,
CancellationToken cancellationToken)
{
var url = stateFilter == null || stateFilter == WindowsContainerState.Running
? "/containers/json"
: "/containers/json?all=true";
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var containers = JsonSerializer.Deserialize<List<DockerContainerListItem>>(content, JsonOptions)
?? new List<DockerContainerListItem>();
var result = new List<WindowsContainerInfo>();
foreach (var container in containers)
{
var state = MapDockerState(container.State);
if (stateFilter.HasValue && state != stateFilter.Value)
{
continue;
}
result.Add(new WindowsContainerInfo
{
Id = container.Id,
Name = container.Names?.FirstOrDefault()?.TrimStart('/') ?? container.Id[..12],
ImageRef = container.Image,
ImageId = container.ImageID,
State = state,
ProcessId = 0, // Not available from list
CreatedAt = DateTimeOffset.FromUnixTimeSeconds(container.Created),
Command = container.Command?.Split(' ', StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>(),
Labels = container.Labels ?? new Dictionary<string, string>(),
RuntimeType = "windows"
});
}
return result;
}
public async Task<WindowsContainerInfo?> GetContainerAsync(string containerId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(containerId);
try
{
var response = await _httpClient.GetAsync($"/containers/{containerId}/json", cancellationToken).ConfigureAwait(false);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var inspect = JsonSerializer.Deserialize<DockerContainerInspectResponse>(content, JsonOptions);
if (inspect == null)
{
return null;
}
return new WindowsContainerInfo
{
Id = inspect.Id,
Name = inspect.Name?.TrimStart('/') ?? inspect.Id[..12],
ImageRef = inspect.Config?.Image,
ImageId = inspect.Image,
State = MapDockerState(inspect.State?.Status),
ProcessId = inspect.State?.Pid ?? 0,
CreatedAt = DateTimeOffset.TryParse(inspect.Created, out var created) ? created : DateTimeOffset.MinValue,
StartedAt = DateTimeOffset.TryParse(inspect.State?.StartedAt, out var started) ? started : null,
FinishedAt = DateTimeOffset.TryParse(inspect.State?.FinishedAt, out var finished) ? finished : null,
ExitCode = inspect.State?.ExitCode,
Command = CombineEntrypointAndCmd(inspect.Config?.Entrypoint, inspect.Config?.Cmd),
Labels = inspect.Config?.Labels ?? new Dictionary<string, string>(),
HyperVIsolated = inspect.HostConfig?.Isolation?.Equals("hyperv", StringComparison.OrdinalIgnoreCase) == true,
RuntimeType = "windows"
};
}
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}
}
public async IAsyncEnumerable<WindowsContainerEvent> StreamEventsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
var filters = new Dictionary<string, IList<string>>
{
["type"] = new List<string> { "container" },
["event"] = new List<string> { "start", "stop", "die", "destroy", "create" }
};
var filterJson = JsonSerializer.Serialize(filters, JsonOptions);
var url = $"/events?filters={Uri.EscapeDataString(filterJson)}";
_logger.LogDebug("Starting Windows Docker event stream: {Url}", url);
using var request = new HttpRequestMessage(HttpMethod.Get, url);
using var response = await _httpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var reader = new StreamReader(stream, Encoding.UTF8);
while (!cancellationToken.IsCancellationRequested)
{
var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
if (line == null)
{
break;
}
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
DockerEventResponse? evt;
try
{
evt = JsonSerializer.Deserialize<DockerEventResponse>(line, JsonOptions);
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Failed to parse Docker event: {Line}", line);
continue;
}
if (evt == null)
{
continue;
}
yield return new WindowsContainerEvent
{
Type = MapDockerEventAction(evt.Action),
ContainerId = evt.Actor?.Id ?? evt.Id ?? "unknown",
ContainerName = evt.Actor?.Attributes?.GetValueOrDefault("name"),
ImageRef = evt.Actor?.Attributes?.GetValueOrDefault("image") ?? evt.From,
Timestamp = DateTimeOffset.FromUnixTimeSeconds(evt.Time),
Data = evt.Actor?.Attributes
};
}
}
private static WindowsContainerState MapDockerState(string? state)
{
return state?.ToLowerInvariant() switch
{
"created" => WindowsContainerState.Created,
"running" => WindowsContainerState.Running,
"paused" => WindowsContainerState.Paused,
"exited" or "dead" => WindowsContainerState.Stopped,
_ => WindowsContainerState.Unknown
};
}
private static WindowsContainerEventType MapDockerEventAction(string? action)
{
return action?.ToLowerInvariant() switch
{
"create" => WindowsContainerEventType.ContainerCreated,
"start" => WindowsContainerEventType.ContainerStarted,
"stop" or "die" => WindowsContainerEventType.ContainerStopped,
"destroy" => WindowsContainerEventType.ContainerDeleted,
"exec_start" => WindowsContainerEventType.ProcessStarted,
"exec_die" => WindowsContainerEventType.ProcessExited,
_ => WindowsContainerEventType.ContainerCreated
};
}
private static int? TryParseOsBuild(string? kernelVersion)
{
if (string.IsNullOrWhiteSpace(kernelVersion))
{
return null;
}
// Windows kernel version is like "10.0 20348 (20348.1.amd64fre.fe_release.210507-1500)"
var parts = kernelVersion.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 2 && int.TryParse(parts[1], out var build))
{
return build;
}
return null;
}
private static IReadOnlyList<string> CombineEntrypointAndCmd(string[]? entrypoint, string[]? cmd)
{
var result = new List<string>();
if (entrypoint != null)
{
result.AddRange(entrypoint);
}
if (cmd != null)
{
result.AddRange(cmd);
}
return result;
}
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
_httpClient.Dispose();
await Task.CompletedTask;
}
#region Docker API DTOs
private sealed class DockerVersionResponse
{
public string? Version { get; set; }
public string? ApiVersion { get; set; }
public string? Os { get; set; }
public string? Arch { get; set; }
public string? KernelVersion { get; set; }
}
private sealed class DockerContainerListItem
{
public string Id { get; set; } = string.Empty;
public string[]? Names { get; set; }
public string? Image { get; set; }
public string? ImageID { get; set; }
public string? Command { get; set; }
public long Created { get; set; }
public string? State { get; set; }
public Dictionary<string, string>? Labels { get; set; }
}
private sealed class DockerContainerInspectResponse
{
public string Id { get; set; } = string.Empty;
public string? Name { get; set; }
public string? Created { get; set; }
public string? Image { get; set; }
public DockerContainerState? State { get; set; }
public DockerContainerConfig? Config { get; set; }
public DockerHostConfig? HostConfig { get; set; }
}
private sealed class DockerContainerState
{
public string? Status { get; set; }
public bool Running { get; set; }
public int Pid { get; set; }
public int ExitCode { get; set; }
public string? StartedAt { get; set; }
public string? FinishedAt { get; set; }
}
private sealed class DockerContainerConfig
{
public string? Image { get; set; }
public string[]? Entrypoint { get; set; }
public string[]? Cmd { get; set; }
public Dictionary<string, string>? Labels { get; set; }
}
private sealed class DockerHostConfig
{
public string? Isolation { get; set; }
}
private sealed class DockerEventResponse
{
public string? Type { get; set; }
public string? Action { get; set; }
public DockerEventActor? Actor { get; set; }
public long Time { get; set; }
public string? Id { get; set; }
public string? From { get; set; }
}
private sealed class DockerEventActor
{
public string? Id { get; set; }
public Dictionary<string, string>? Attributes { get; set; }
}
#endregion
}

View File

@@ -0,0 +1,114 @@
namespace StellaOps.Zastava.Observer.ContainerRuntime.Windows;
/// <summary>
/// Client interface for Windows container runtime (HCS or Docker Windows).
/// </summary>
internal interface IWindowsContainerRuntimeClient : IAsyncDisposable
{
/// <summary>
/// Check if the Windows container runtime is available.
/// </summary>
Task<bool> IsAvailableAsync(CancellationToken cancellationToken);
/// <summary>
/// Get runtime identity information.
/// </summary>
Task<WindowsRuntimeIdentity> GetIdentityAsync(CancellationToken cancellationToken);
/// <summary>
/// List containers matching the specified state filter.
/// </summary>
Task<IReadOnlyList<WindowsContainerInfo>> ListContainersAsync(
WindowsContainerState? stateFilter,
CancellationToken cancellationToken);
/// <summary>
/// Get detailed information about a specific container.
/// </summary>
Task<WindowsContainerInfo?> GetContainerAsync(string containerId, CancellationToken cancellationToken);
/// <summary>
/// Stream container lifecycle events.
/// </summary>
IAsyncEnumerable<WindowsContainerEvent> StreamEventsAsync(CancellationToken cancellationToken);
}
/// <summary>
/// Windows container runtime identity.
/// </summary>
internal sealed class WindowsRuntimeIdentity
{
/// <summary>
/// Runtime name: docker, containerd, hcs.
/// </summary>
public required string RuntimeName { get; init; }
/// <summary>
/// Runtime version.
/// </summary>
public string? RuntimeVersion { get; init; }
/// <summary>
/// Windows OS version (e.g., "10.0.20348").
/// </summary>
public string? OsVersion { get; init; }
/// <summary>
/// Windows OS build number.
/// </summary>
public int? OsBuild { get; init; }
/// <summary>
/// Whether Hyper-V isolation is available.
/// </summary>
public bool HyperVAvailable { get; init; }
}
/// <summary>
/// Windows container lifecycle event.
/// </summary>
internal sealed class WindowsContainerEvent
{
/// <summary>
/// Event type: ContainerCreated, ContainerStarted, ContainerStopped, ContainerDeleted.
/// </summary>
public required WindowsContainerEventType Type { get; init; }
/// <summary>
/// Container ID.
/// </summary>
public required string ContainerId { get; init; }
/// <summary>
/// Container name.
/// </summary>
public string? ContainerName { get; init; }
/// <summary>
/// Container image reference.
/// </summary>
public string? ImageRef { get; init; }
/// <summary>
/// Event timestamp.
/// </summary>
public DateTimeOffset Timestamp { get; init; }
/// <summary>
/// Additional event data.
/// </summary>
public IReadOnlyDictionary<string, string>? Data { get; init; }
}
/// <summary>
/// Windows container event types.
/// </summary>
internal enum WindowsContainerEventType
{
ContainerCreated,
ContainerStarted,
ContainerStopped,
ContainerDeleted,
ProcessStarted,
ProcessExited
}

View File

@@ -0,0 +1,104 @@
namespace StellaOps.Zastava.Observer.ContainerRuntime.Windows;
/// <summary>
/// Windows container information from HCS or Docker Windows API.
/// </summary>
internal sealed class WindowsContainerInfo
{
/// <summary>
/// Container ID (GUID for HCS, or Docker container ID).
/// </summary>
public required string Id { get; init; }
/// <summary>
/// Container name.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Container image reference.
/// </summary>
public string? ImageRef { get; init; }
/// <summary>
/// Container image ID/digest.
/// </summary>
public string? ImageId { get; init; }
/// <summary>
/// Container state: Created, Running, Stopped.
/// </summary>
public WindowsContainerState State { get; init; }
/// <summary>
/// Process ID of the container's main process.
/// </summary>
public int ProcessId { get; init; }
/// <summary>
/// Container creation timestamp.
/// </summary>
public DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Container start timestamp.
/// </summary>
public DateTimeOffset? StartedAt { get; init; }
/// <summary>
/// Container exit timestamp.
/// </summary>
public DateTimeOffset? FinishedAt { get; init; }
/// <summary>
/// Exit code if container has stopped.
/// </summary>
public int? ExitCode { get; init; }
/// <summary>
/// Container command/entrypoint.
/// </summary>
public IReadOnlyList<string> Command { get; init; } = Array.Empty<string>();
/// <summary>
/// Container labels.
/// </summary>
public IReadOnlyDictionary<string, string> Labels { get; init; } = new Dictionary<string, string>();
/// <summary>
/// Container owner (namespace/pod in Kubernetes).
/// </summary>
public WindowsContainerOwner? Owner { get; init; }
/// <summary>
/// Whether this is a Hyper-V isolated container.
/// </summary>
public bool HyperVIsolated { get; init; }
/// <summary>
/// Runtime type: windows, hyperv.
/// </summary>
public string RuntimeType { get; init; } = "windows";
}
/// <summary>
/// Windows container state.
/// </summary>
internal enum WindowsContainerState
{
Unknown,
Created,
Running,
Paused,
Stopped
}
/// <summary>
/// Windows container owner information (for Kubernetes scenarios).
/// </summary>
internal sealed class WindowsContainerOwner
{
public string? Kind { get; init; }
public string? Name { get; init; }
public string? Namespace { get; init; }
}

View File

@@ -0,0 +1,179 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Security.Cryptography;
using Microsoft.Extensions.Logging;
namespace StellaOps.Zastava.Observer.ContainerRuntime.Windows;
/// <summary>
/// Collects loaded library hashes from Windows processes (PE format).
/// </summary>
[SupportedOSPlatform("windows")]
internal sealed class WindowsLibraryHashCollector
{
private readonly ILogger<WindowsLibraryHashCollector> _logger;
private readonly int _maxLibraries;
private readonly long _maxFileBytes;
private readonly long _maxTotalHashBytes;
public WindowsLibraryHashCollector(
ILogger<WindowsLibraryHashCollector> logger,
int maxLibraries = 256,
long maxFileBytes = 33554432, // 32 MiB
long maxTotalHashBytes = 64_000_000) // ~61 MiB
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_maxLibraries = maxLibraries;
_maxFileBytes = maxFileBytes;
_maxTotalHashBytes = maxTotalHashBytes;
}
/// <summary>
/// Collect loaded libraries from a Windows process.
/// </summary>
public async Task<IReadOnlyList<WindowsLoadedLibrary>> CollectAsync(
int processId,
CancellationToken cancellationToken)
{
var libraries = new List<WindowsLoadedLibrary>();
var totalBytesHashed = 0L;
try
{
using var process = Process.GetProcessById(processId);
var modules = GetProcessModules(process);
foreach (var module in modules.Take(_maxLibraries))
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(module.Path))
{
continue;
}
var library = new WindowsLoadedLibrary
{
Path = module.Path,
ModuleName = module.ModuleName,
BaseAddress = module.BaseAddress,
ModuleSize = module.ModuleSize
};
// Try to hash the file if within limits
if (File.Exists(module.Path) && totalBytesHashed < _maxTotalHashBytes)
{
try
{
var fileInfo = new FileInfo(module.Path);
if (fileInfo.Length <= _maxFileBytes && totalBytesHashed + fileInfo.Length <= _maxTotalHashBytes)
{
var hash = await ComputeFileHashAsync(module.Path, cancellationToken).ConfigureAwait(false);
library = library with { Sha256 = hash };
totalBytesHashed += fileInfo.Length;
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogDebug(ex, "Failed to hash library: {Path}", module.Path);
}
}
libraries.Add(library);
}
}
catch (ArgumentException ex)
{
_logger.LogDebug(ex, "Process {ProcessId} not found or inaccessible", processId);
}
catch (InvalidOperationException ex)
{
_logger.LogDebug(ex, "Process {ProcessId} has exited", processId);
}
catch (System.ComponentModel.Win32Exception ex)
{
_logger.LogDebug(ex, "Access denied to process {ProcessId} modules", processId);
}
return libraries;
}
private static IReadOnlyList<WindowsModuleInfo> GetProcessModules(Process process)
{
var modules = new List<WindowsModuleInfo>();
try
{
foreach (ProcessModule module in process.Modules)
{
modules.Add(new WindowsModuleInfo
{
Path = module.FileName,
ModuleName = module.ModuleName,
BaseAddress = module.BaseAddress.ToInt64(),
ModuleSize = module.ModuleMemorySize
});
}
}
catch (System.ComponentModel.Win32Exception)
{
// Access denied - return what we have
}
return modules;
}
private static async Task<string> ComputeFileHashAsync(string path, CancellationToken cancellationToken)
{
await using var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite,
bufferSize: 81920,
useAsync: true);
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
private sealed class WindowsModuleInfo
{
public string Path { get; init; } = string.Empty;
public string ModuleName { get; init; } = string.Empty;
public long BaseAddress { get; init; }
public int ModuleSize { get; init; }
}
}
/// <summary>
/// Loaded library information from a Windows process.
/// </summary>
internal sealed record WindowsLoadedLibrary
{
/// <summary>
/// Full path to the DLL/EXE.
/// </summary>
public required string Path { get; init; }
/// <summary>
/// Module name (filename without path).
/// </summary>
public string? ModuleName { get; init; }
/// <summary>
/// Base address where the module is loaded.
/// </summary>
public long BaseAddress { get; init; }
/// <summary>
/// Size of the module in memory.
/// </summary>
public int ModuleSize { get; init; }
/// <summary>
/// SHA-256 hash of the file (sha256:...).
/// </summary>
public string? Sha256 { get; init; }
}

View File

@@ -0,0 +1,418 @@
using System.Globalization;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using StellaOps.Signals.Models;
namespace StellaOps.Zastava.Observer.Runtime.ProcSnapshot;
/// <summary>
/// Collects Java classpath information from a running JVM process.
/// Parses /proc/<pid>/cmdline for -cp/-classpath arguments and extracts JAR metadata.
/// </summary>
internal sealed partial class JavaClasspathCollector
{
private static readonly Regex JavaRegex = GenerateJavaRegex();
private static readonly char[] ClasspathSeparators = { ':', ';' };
private const int MaxJarFiles = 256;
private const long MaxJarSize = 100 * 1024 * 1024; // 100 MiB
private readonly string _procRoot;
private readonly ILogger<JavaClasspathCollector> _logger;
public JavaClasspathCollector(string procRoot, ILogger<JavaClasspathCollector> 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 Java process.
/// </summary>
public async Task<bool> IsJavaProcessAsync(int pid, CancellationToken cancellationToken)
{
var cmdline = await ReadCmdlineAsync(pid, cancellationToken).ConfigureAwait(false);
if (cmdline.Count == 0)
{
return false;
}
return JavaRegex.IsMatch(cmdline[0]);
}
/// <summary>
/// Collect classpath entries from a Java process.
/// </summary>
public async Task<IReadOnlyList<ClasspathEntry>> CollectAsync(int pid, CancellationToken cancellationToken)
{
var cmdline = await ReadCmdlineAsync(pid, cancellationToken).ConfigureAwait(false);
if (cmdline.Count == 0)
{
return Array.Empty<ClasspathEntry>();
}
if (!JavaRegex.IsMatch(cmdline[0]))
{
_logger.LogDebug("Process {Pid} is not a Java process", pid);
return Array.Empty<ClasspathEntry>();
}
var classpathValue = ExtractClasspath(cmdline);
if (string.IsNullOrWhiteSpace(classpathValue))
{
// Try to find classpath from environment or use jcmd if available
classpathValue = await TryGetClasspathFromJcmdAsync(pid, cancellationToken).ConfigureAwait(false);
}
if (string.IsNullOrWhiteSpace(classpathValue))
{
_logger.LogDebug("No classpath found for Java process {Pid}", pid);
return Array.Empty<ClasspathEntry>();
}
var entries = new List<ClasspathEntry>();
var paths = classpathValue.Split(ClasspathSeparators, StringSplitOptions.RemoveEmptyEntries);
foreach (var path in paths.Take(MaxJarFiles))
{
cancellationToken.ThrowIfCancellationRequested();
var entry = await ProcessClasspathEntryAsync(path.Trim(), cancellationToken).ConfigureAwait(false);
if (entry != null)
{
entries.Add(entry);
}
}
_logger.LogDebug("Collected {Count} classpath entries for Java 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 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 static string? ExtractClasspath(IReadOnlyList<string> cmdline)
{
for (var i = 0; i < cmdline.Count; i++)
{
var arg = cmdline[i];
// -cp <classpath> or -classpath <classpath>
if ((string.Equals(arg, "-cp", StringComparison.Ordinal) ||
string.Equals(arg, "-classpath", StringComparison.Ordinal)) &&
i + 1 < cmdline.Count)
{
return cmdline[i + 1];
}
// -cp:<classpath> or -classpath:<classpath> (some JVMs)
if (arg.StartsWith("-cp:", StringComparison.Ordinal))
{
return arg[4..];
}
if (arg.StartsWith("-classpath:", StringComparison.Ordinal))
{
return arg[11..];
}
// -jar <jarfile> - the jar file is effectively the classpath
if (string.Equals(arg, "-jar", StringComparison.Ordinal) && i + 1 < cmdline.Count)
{
return cmdline[i + 1];
}
}
return null;
}
private async Task<string?> TryGetClasspathFromJcmdAsync(int pid, CancellationToken cancellationToken)
{
// Try to use jcmd to get the classpath
// This requires jcmd to be available and the process to be accessible
try
{
var jcmdPath = FindJcmd();
if (jcmdPath == null)
{
return null;
}
var startInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = jcmdPath,
Arguments = $"{pid} VM.system_properties",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = System.Diagnostics.Process.Start(startInfo);
if (process == null)
{
return null;
}
var output = await process.StandardOutput.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
if (process.ExitCode != 0)
{
return null;
}
// Parse java.class.path from output
foreach (var line in output.Split('\n'))
{
if (line.StartsWith("java.class.path=", StringComparison.Ordinal))
{
return line[16..].Trim();
}
}
return null;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to get classpath from jcmd for PID {Pid}", pid);
return null;
}
}
private static string? FindJcmd()
{
var javaHome = Environment.GetEnvironmentVariable("JAVA_HOME");
if (!string.IsNullOrWhiteSpace(javaHome))
{
var jcmdPath = Path.Combine(javaHome, "bin", "jcmd");
if (File.Exists(jcmdPath))
{
return jcmdPath;
}
}
// Try common paths
var paths = new[]
{
"/usr/bin/jcmd",
"/usr/local/bin/jcmd",
"/opt/java/bin/jcmd"
};
return paths.FirstOrDefault(File.Exists);
}
private async Task<ClasspathEntry?> ProcessClasspathEntryAsync(string path, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
var type = DetermineEntryType(path);
if (type == "jar" && File.Exists(path))
{
return await ProcessJarFileAsync(path, cancellationToken).ConfigureAwait(false);
}
if (type == "directory" && Directory.Exists(path))
{
return new ClasspathEntry
{
Path = path,
Type = "directory"
};
}
// Entry doesn't exist or is a wildcard
return new ClasspathEntry
{
Path = path,
Type = type
};
}
private async Task<ClasspathEntry> ProcessJarFileAsync(string jarPath, CancellationToken cancellationToken)
{
var entry = new ClasspathEntry
{
Path = jarPath,
Type = "jar"
};
try
{
var fileInfo = new FileInfo(jarPath);
entry = entry with { SizeBytes = fileInfo.Length };
if (fileInfo.Length <= MaxJarSize)
{
// Compute hash
var hash = await ComputeFileHashAsync(jarPath, cancellationToken).ConfigureAwait(false);
entry = entry with { Sha256 = hash };
// Try to extract Maven coordinates from manifest
var (groupId, artifactId, version) = await ExtractMavenCoordinatesAsync(jarPath, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(groupId) && !string.IsNullOrWhiteSpace(artifactId))
{
var coordinate = string.IsNullOrWhiteSpace(version)
? $"{groupId}:{artifactId}"
: $"{groupId}:{artifactId}:{version}";
entry = entry with
{
MavenCoordinate = coordinate,
Purl = $"pkg:maven/{groupId}/{artifactId}" + (string.IsNullOrWhiteSpace(version) ? "" : $"@{version}")
};
}
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
{
_logger.LogDebug(ex, "Failed to process JAR file: {Path}", jarPath);
}
return entry;
}
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? groupId, string? artifactId, string? version)> ExtractMavenCoordinatesAsync(
string jarPath,
CancellationToken cancellationToken)
{
try
{
await using var stream = new FileStream(jarPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var archive = new ZipArchive(stream, ZipArchiveMode.Read);
// Try to find pom.properties in META-INF/maven/
var pomProperties = archive.Entries
.FirstOrDefault(e => e.FullName.EndsWith("pom.properties", StringComparison.OrdinalIgnoreCase) &&
e.FullName.StartsWith("META-INF/maven/", StringComparison.OrdinalIgnoreCase));
if (pomProperties != null)
{
await using var entryStream = pomProperties.Open();
using var reader = new StreamReader(entryStream);
var content = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
string? groupId = null, artifactId = null, version = null;
foreach (var line in content.Split('\n'))
{
var trimmed = line.Trim();
if (trimmed.StartsWith("groupId=", StringComparison.Ordinal))
{
groupId = trimmed[8..];
}
else if (trimmed.StartsWith("artifactId=", StringComparison.Ordinal))
{
artifactId = trimmed[11..];
}
else if (trimmed.StartsWith("version=", StringComparison.Ordinal))
{
version = trimmed[8..];
}
}
return (groupId, artifactId, version);
}
// Fallback: try to parse from MANIFEST.MF
var manifest = archive.GetEntry("META-INF/MANIFEST.MF");
if (manifest != null)
{
await using var entryStream = manifest.Open();
using var reader = new StreamReader(entryStream);
var content = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
string? implTitle = null, implVersion = null, implVendor = null;
foreach (var line in content.Split('\n'))
{
var trimmed = line.Trim();
if (trimmed.StartsWith("Implementation-Title:", StringComparison.OrdinalIgnoreCase))
{
implTitle = trimmed[21..].Trim();
}
else if (trimmed.StartsWith("Implementation-Version:", StringComparison.OrdinalIgnoreCase))
{
implVersion = trimmed[23..].Trim();
}
else if (trimmed.StartsWith("Implementation-Vendor-Id:", StringComparison.OrdinalIgnoreCase))
{
implVendor = trimmed[25..].Trim();
}
}
if (!string.IsNullOrWhiteSpace(implTitle))
{
return (implVendor, implTitle, implVersion);
}
}
}
catch
{
// Ignore errors extracting coordinates
}
return (null, null, null);
}
private static string DetermineEntryType(string path)
{
if (path.EndsWith(".jar", StringComparison.OrdinalIgnoreCase))
{
return "jar";
}
if (path.EndsWith(".jmod", StringComparison.OrdinalIgnoreCase))
{
return "jmod";
}
if (path.EndsWith("/*", StringComparison.Ordinal) || path.EndsWith("\\*", StringComparison.Ordinal))
{
return "wildcard";
}
return "directory";
}
[GeneratedRegex(@"(^|/)(java|javaw)(\.exe)?$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex GenerateJavaRegex();
}