save progress
This commit is contained in:
256
src/Cli/__Libraries/StellaOps.Cli.Plugins.Vex/VexCliOutput.cs
Normal file
256
src/Cli/__Libraries/StellaOps.Cli.Plugins.Vex/VexCliOutput.cs
Normal file
@@ -0,0 +1,256 @@
|
||||
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<AutoDowngradeCandidate> OrderCandidates(
|
||||
IReadOnlyList<AutoDowngradeCandidate>? candidates)
|
||||
{
|
||||
if (candidates is null || candidates.Count == 0)
|
||||
{
|
||||
return Array.Empty<AutoDowngradeCandidate>();
|
||||
}
|
||||
|
||||
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<NotReachableAnalysisEntry> OrderAnalyses(
|
||||
IReadOnlyList<NotReachableAnalysisEntry>? analyses)
|
||||
{
|
||||
if (analyses is null || analyses.Count == 0)
|
||||
{
|
||||
return Array.Empty<NotReachableAnalysisEntry>();
|
||||
}
|
||||
|
||||
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<int> WriteAutoDowngradeResultsAsync(
|
||||
AutoDowngradeCheckResult result,
|
||||
bool dryRun,
|
||||
OutputFormat format,
|
||||
string? outputPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var candidates = result.Candidates ?? Array.Empty<AutoDowngradeCandidate>();
|
||||
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<NotReachableAnalysisEntry>();
|
||||
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<object>? statements,
|
||||
string outputPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureOutputDirectory(outputPath);
|
||||
|
||||
var content = JsonSerializer.Serialize(statements ?? Array.Empty<object>(), OutputJsonOptions);
|
||||
await File.WriteAllTextAsync(outputPath, content, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task<int> WriteErrorAsync(string message)
|
||||
{
|
||||
await Console.Error.WriteLineAsync(message).ConfigureAwait(false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
public static async Task<int> WriteNotImplementedAsync(string commandName)
|
||||
{
|
||||
await Console.Error.WriteLineAsync($"{commandName} is not implemented.").ConfigureAwait(false);
|
||||
return 2;
|
||||
}
|
||||
|
||||
private static string BuildAutoDowngradeCsv(IReadOnlyList<AutoDowngradeCandidate> 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<AutoDowngradeCandidate> 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<NotReachableAnalysisEntry> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user