257 lines
9.2 KiB
C#
257 lines
9.2 KiB
C#
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);
|
|
}
|
|
}
|