420 lines
14 KiB
C#
420 lines
14 KiB
C#
|
|
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;
|
|
|
|
/// <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();
|
|
}
|