using System.Globalization; using System.Text; using System.Text.Json; namespace StellaOps.Cli.Plugins.Vex; internal static class VexCliOutput { private static readonly JsonSerializerOptions OutputJsonOptions = new() { WriteIndented = true }; public static IReadOnlyList OrderCandidates( IReadOnlyList? candidates) { if (candidates is null || candidates.Count == 0) { return Array.Empty(); } return candidates .OrderBy(candidate => candidate.CveId, StringComparer.Ordinal) .ThenBy(candidate => candidate.Symbol, StringComparer.Ordinal) .ThenBy(candidate => candidate.ComponentPath, StringComparer.Ordinal) .ThenBy(candidate => candidate.ObservationCount) .ThenBy(candidate => candidate.CpuPercentage) .ThenBy(candidate => candidate.Confidence) .ThenBy(candidate => candidate.ProductId, StringComparer.Ordinal) .ThenBy(candidate => candidate.BuildId, StringComparer.Ordinal) .ToList(); } public static IReadOnlyList OrderAnalyses( IReadOnlyList? analyses) { if (analyses is null || analyses.Count == 0) { return Array.Empty(); } return analyses .OrderBy(entry => entry.CveId, StringComparer.Ordinal) .ThenBy(entry => entry.Symbol, StringComparer.Ordinal) .ThenBy(entry => entry.ComponentPath, StringComparer.Ordinal) .ThenBy(entry => entry.Confidence) .ThenBy(entry => entry.ProductId, StringComparer.Ordinal) .ToList(); } public static async Task WriteAutoDowngradeResultsAsync( AutoDowngradeCheckResult result, bool dryRun, OutputFormat format, string? outputPath, CancellationToken cancellationToken) { var candidates = result.Candidates ?? Array.Empty(); if (candidates.Count == 0 && format == OutputFormat.Table) { await Console.Out.WriteLineAsync("No hot vulnerable symbols detected.") .ConfigureAwait(false); return 0; } var content = format switch { OutputFormat.Json => JsonSerializer.Serialize(result, OutputJsonOptions), OutputFormat.Csv => BuildAutoDowngradeCsv(candidates, dryRun), _ => BuildAutoDowngradeTable(candidates, dryRun, result.ImageDigest) }; if (!string.IsNullOrWhiteSpace(outputPath)) { EnsureOutputDirectory(outputPath); await File.WriteAllTextAsync(outputPath, content, cancellationToken).ConfigureAwait(false); await Console.Out.WriteLineAsync($"Results written to: {outputPath}").ConfigureAwait(false); } else { await Console.Out.WriteLineAsync(content).ConfigureAwait(false); } return 0; } public static async Task WriteNotReachableResultsAsync( NotReachableAnalysisResult result, bool dryRun, CancellationToken cancellationToken) { _ = cancellationToken; var analyses = result.Analyses ?? Array.Empty(); if (analyses.Count == 0) { await Console.Out.WriteLineAsync("No unreached vulnerable symbols found requiring VEX.") .ConfigureAwait(false); return; } var content = BuildNotReachableTable(analyses, dryRun); await Console.Out.WriteLineAsync(content).ConfigureAwait(false); } public static async Task WriteStatementsAsync( IReadOnlyList? statements, string outputPath, CancellationToken cancellationToken) { EnsureOutputDirectory(outputPath); var content = JsonSerializer.Serialize(statements ?? Array.Empty(), OutputJsonOptions); await File.WriteAllTextAsync(outputPath, content, cancellationToken).ConfigureAwait(false); } public static async Task WriteErrorAsync(string message) { await Console.Error.WriteLineAsync(message).ConfigureAwait(false); return 1; } public static async Task WriteNotImplementedAsync(string commandName) { await Console.Error.WriteLineAsync($"{commandName} is not implemented.").ConfigureAwait(false); return 2; } private static string BuildAutoDowngradeCsv(IReadOnlyList candidates, bool dryRun) { var builder = new StringBuilder(); builder.AppendLine("cve_id,symbol,component_path,cpu_percentage,observations,confidence,status"); foreach (var candidate in candidates) { var status = dryRun ? "pending" : "downgrade"; builder .Append(EscapeCsv(candidate.CveId)).Append(',') .Append(EscapeCsv(candidate.Symbol)).Append(',') .Append(EscapeCsv(candidate.ComponentPath)).Append(',') .Append(candidate.CpuPercentage.ToString("F2", CultureInfo.InvariantCulture)).Append(',') .Append(candidate.ObservationCount.ToString(CultureInfo.InvariantCulture)).Append(',') .Append(candidate.Confidence.ToString("F2", CultureInfo.InvariantCulture)).Append(',') .Append(EscapeCsv(status)) .AppendLine(); } return builder.ToString().TrimEnd(); } private static string BuildAutoDowngradeTable( IReadOnlyList candidates, bool dryRun, string? imageDigest) { var builder = new StringBuilder(); builder.AppendLine(dryRun ? "Auto-downgrade candidates (dry run)" : "Hot vulnerable symbols"); builder.AppendLine("CVE | Symbol | CPU% | Observations | Confidence | Status"); foreach (var candidate in candidates) { var status = dryRun ? "pending" : "downgrade"; builder .Append(candidate.CveId).Append(" | ") .Append(Truncate(candidate.Symbol, 40)).Append(" | ") .Append(candidate.CpuPercentage.ToString("F1", CultureInfo.InvariantCulture)).Append(" | ") .Append(candidate.ObservationCount.ToString(CultureInfo.InvariantCulture)).Append(" | ") .Append(candidate.Confidence.ToString("F2", CultureInfo.InvariantCulture)).Append(" | ") .Append(status) .AppendLine(); } builder.AppendLine(); builder.AppendLine($"Total candidates: {candidates.Count.ToString(CultureInfo.InvariantCulture)}"); if (candidates.Count > 0) { var maxCpu = candidates.Max(candidate => candidate.CpuPercentage); builder.AppendLine($"Highest CPU: {maxCpu.ToString("F1", CultureInfo.InvariantCulture)}%"); } if (!string.IsNullOrWhiteSpace(imageDigest)) { builder.AppendLine($"Image: {imageDigest}"); } return builder.ToString().TrimEnd(); } private static string BuildNotReachableTable(IReadOnlyList analyses, bool dryRun) { var builder = new StringBuilder(); builder.AppendLine("Symbols not reachable at runtime"); builder.AppendLine("CVE | Symbol | Component | Confidence | Reason"); foreach (var analysis in analyses) { var reason = string.IsNullOrWhiteSpace(analysis.PrimaryReason) ? "Unknown" : analysis.PrimaryReason; builder .Append(analysis.CveId).Append(" | ") .Append(Truncate(analysis.Symbol, 30)).Append(" | ") .Append(TruncatePath(analysis.ComponentPath, 30)).Append(" | ") .Append(analysis.Confidence.ToString("F2", CultureInfo.InvariantCulture)).Append(" | ") .Append(reason) .AppendLine(); } builder.AppendLine(); builder.AppendLine($"Total analyses: {analyses.Count.ToString(CultureInfo.InvariantCulture)}"); builder.AppendLine(dryRun ? "Mode: dry run" : "Mode: generate VEX"); return builder.ToString().TrimEnd(); } private static string Truncate(string value, int maxLength) { if (string.IsNullOrEmpty(value) || value.Length <= maxLength) { return value; } return value[..(maxLength - 3)] + "..."; } private static string TruncatePath(string value, int maxLength) { if (string.IsNullOrEmpty(value) || value.Length <= maxLength) { return value; } return "..." + value[^Math.Min(maxLength - 3, value.Length)..]; } private static string EscapeCsv(string value) { if (value.IndexOfAny([',', '"', '\n', '\r']) < 0) { return value; } var escaped = value.Replace("\"", "\"\""); return $"\"{escaped}\""; } private static void EnsureOutputDirectory(string outputPath) { var directory = Path.GetDirectoryName(outputPath); if (string.IsNullOrWhiteSpace(directory)) { return; } Directory.CreateDirectory(directory); } }