using Microsoft.Extensions.Logging; using StellaOps.Signals.Models; using System.Globalization; using System.IO.Compression; using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; namespace StellaOps.Zastava.Observer.Runtime.ProcSnapshot; /// /// Collects Java classpath information from a running JVM process. /// Parses /proc//cmdline for -cp/-classpath arguments and extracts JAR metadata. /// 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 _logger; public JavaClasspathCollector(string procRoot, ILogger logger) { _procRoot = procRoot?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) ?? throw new ArgumentNullException(nameof(procRoot)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// /// Check if a process appears to be a Java process. /// public async Task IsJavaProcessAsync(int pid, CancellationToken cancellationToken) { var cmdline = await ReadCmdlineAsync(pid, cancellationToken).ConfigureAwait(false); if (cmdline.Count == 0) { return false; } return JavaRegex.IsMatch(cmdline[0]); } /// /// Collect classpath entries from a Java process. /// public async Task> CollectAsync(int pid, CancellationToken cancellationToken) { var cmdline = await ReadCmdlineAsync(pid, cancellationToken).ConfigureAwait(false); if (cmdline.Count == 0) { return Array.Empty(); } if (!JavaRegex.IsMatch(cmdline[0])) { _logger.LogDebug("Process {Pid} is not a Java process", pid); return Array.Empty(); } 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(); } var entries = new List(); 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> ReadCmdlineAsync(int pid, CancellationToken cancellationToken) { var cmdlinePath = Path.Combine(_procRoot, pid.ToString(CultureInfo.InvariantCulture), "cmdline"); if (!File.Exists(cmdlinePath)) { return new List(); } try { var content = await File.ReadAllBytesAsync(cmdlinePath, cancellationToken).ConfigureAwait(false); if (content.Length == 0) { return new List(); } 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(); } } private static string? ExtractClasspath(IReadOnlyList cmdline) { for (var i = 0; i < cmdline.Count; i++) { var arg = cmdline[i]; // -cp or -classpath if ((string.Equals(arg, "-cp", StringComparison.Ordinal) || string.Equals(arg, "-classpath", StringComparison.Ordinal)) && i + 1 < cmdline.Count) { return cmdline[i + 1]; } // -cp: or -classpath: (some JVMs) if (arg.StartsWith("-cp:", StringComparison.Ordinal)) { return arg[4..]; } if (arg.StartsWith("-classpath:", StringComparison.Ordinal)) { return arg[11..]; } // -jar - 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 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 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 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 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(); }