save progress
This commit is contained in:
302
src/Cli/__Libraries/StellaOps.Cli.Plugins.Vex/AutoVexClient.cs
Normal file
302
src/Cli/__Libraries/StellaOps.Cli.Plugins.Vex/AutoVexClient.cs
Normal file
@@ -0,0 +1,302 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Cli.Plugins.Vex;
|
||||
|
||||
/// <summary>
|
||||
/// Output format for CLI commands.
|
||||
/// </summary>
|
||||
public enum OutputFormat
|
||||
{
|
||||
Table,
|
||||
Json,
|
||||
Csv
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client interface for auto-VEX operations.
|
||||
/// </summary>
|
||||
public interface IAutoVexClient
|
||||
{
|
||||
Task<AutoDowngradeCheckResult> CheckAutoDowngradeAsync(
|
||||
string image,
|
||||
int minObservations,
|
||||
double minCpu,
|
||||
double minConfidence,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AutoDowngradeExecuteResult> ExecuteAutoDowngradeAsync(
|
||||
IReadOnlyList<AutoDowngradeCandidate> candidates,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<NotReachableAnalysisResult> AnalyzeNotReachableAsync(
|
||||
string image,
|
||||
TimeSpan window,
|
||||
double minConfidence,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<NotReachableVexGenerationResult> GenerateNotReachableVexAsync(
|
||||
IReadOnlyList<NotReachableAnalysisEntry> analyses,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of checking for auto-downgrade candidates.
|
||||
/// </summary>
|
||||
public sealed record AutoDowngradeCheckResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public string? ImageDigest { get; init; }
|
||||
public IReadOnlyList<AutoDowngradeCandidate>? Candidates { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A candidate for auto-downgrade.
|
||||
/// </summary>
|
||||
public sealed record AutoDowngradeCandidate
|
||||
{
|
||||
public required string CveId { get; init; }
|
||||
public required string ProductId { get; init; }
|
||||
public required string Symbol { get; init; }
|
||||
public required string ComponentPath { get; init; }
|
||||
public required double CpuPercentage { get; init; }
|
||||
public required int ObservationCount { get; init; }
|
||||
public required double Confidence { get; init; }
|
||||
public required string BuildId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of executing auto-downgrades.
|
||||
/// </summary>
|
||||
public sealed record AutoDowngradeExecuteResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public int DowngradeCount { get; init; }
|
||||
public int Notifications { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of not-reachable analysis.
|
||||
/// </summary>
|
||||
public sealed record NotReachableAnalysisResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public IReadOnlyList<NotReachableAnalysisEntry>? Analyses { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry for not-reachable analysis.
|
||||
/// </summary>
|
||||
public sealed record NotReachableAnalysisEntry
|
||||
{
|
||||
public required string CveId { get; init; }
|
||||
public required string ProductId { get; init; }
|
||||
public required string Symbol { get; init; }
|
||||
public required string ComponentPath { get; init; }
|
||||
public required double Confidence { get; init; }
|
||||
public string? PrimaryReason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of generating not-reachable VEX statements.
|
||||
/// </summary>
|
||||
public sealed record NotReachableVexGenerationResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public int StatementCount { get; init; }
|
||||
public IReadOnlyList<object>? Statements { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client implementation for auto-VEX API.
|
||||
/// </summary>
|
||||
internal sealed class AutoVexHttpClient : IAutoVexClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions ResponseJsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly string _baseUrl;
|
||||
|
||||
public AutoVexHttpClient(HttpClient httpClient, string baseUrl)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_baseUrl = baseUrl?.TrimEnd('/') ?? throw new ArgumentNullException(nameof(baseUrl));
|
||||
}
|
||||
|
||||
public async Task<AutoDowngradeCheckResult> CheckAutoDowngradeAsync(
|
||||
string image,
|
||||
int minObservations,
|
||||
double minCpu,
|
||||
double minConfidence,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var url = $"{_baseUrl}/api/v1/vex/auto-downgrade/check?" +
|
||||
$"image={Uri.EscapeDataString(image)}&" +
|
||||
$"minObservations={minObservations.ToString(CultureInfo.InvariantCulture)}&" +
|
||||
$"minCpu={minCpu.ToString(CultureInfo.InvariantCulture)}&" +
|
||||
$"minConfidence={minConfidence.ToString(CultureInfo.InvariantCulture)}";
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new AutoDowngradeCheckResult
|
||||
{
|
||||
Success = false,
|
||||
Error = FormatStatusError("auto-downgrade check", response)
|
||||
};
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return DeserializeResponse<AutoDowngradeCheckResult>(json, "auto-downgrade check")
|
||||
?? new AutoDowngradeCheckResult { Success = false, Error = "Failed to deserialize response." };
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return new AutoDowngradeCheckResult { Success = false, Error = $"Response JSON error: {ex.Message}" };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AutoDowngradeCheckResult { Success = false, Error = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AutoDowngradeExecuteResult> ExecuteAutoDowngradeAsync(
|
||||
IReadOnlyList<AutoDowngradeCandidate> candidates,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var url = $"{_baseUrl}/api/v1/vex/auto-downgrade/execute";
|
||||
try
|
||||
{
|
||||
using var content = new StringContent(
|
||||
JsonSerializer.Serialize(candidates),
|
||||
System.Text.Encoding.UTF8,
|
||||
"application/json");
|
||||
|
||||
using var response = await _httpClient.PostAsync(url, content, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new AutoDowngradeExecuteResult
|
||||
{
|
||||
Success = false,
|
||||
Error = FormatStatusError("auto-downgrade execution", response)
|
||||
};
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return DeserializeResponse<AutoDowngradeExecuteResult>(json, "auto-downgrade execution")
|
||||
?? new AutoDowngradeExecuteResult { Success = false, Error = "Failed to deserialize response." };
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return new AutoDowngradeExecuteResult { Success = false, Error = $"Response JSON error: {ex.Message}" };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AutoDowngradeExecuteResult { Success = false, Error = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<NotReachableAnalysisResult> AnalyzeNotReachableAsync(
|
||||
string image,
|
||||
TimeSpan window,
|
||||
double minConfidence,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var url = $"{_baseUrl}/api/v1/vex/not-reachable/analyze?" +
|
||||
$"image={Uri.EscapeDataString(image)}&" +
|
||||
$"windowHours={window.TotalHours.ToString(CultureInfo.InvariantCulture)}&" +
|
||||
$"minConfidence={minConfidence.ToString(CultureInfo.InvariantCulture)}";
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new NotReachableAnalysisResult
|
||||
{
|
||||
Success = false,
|
||||
Error = FormatStatusError("not-reachable analysis", response)
|
||||
};
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return DeserializeResponse<NotReachableAnalysisResult>(json, "not-reachable analysis")
|
||||
?? new NotReachableAnalysisResult { Success = false, Error = "Failed to deserialize response." };
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return new NotReachableAnalysisResult { Success = false, Error = $"Response JSON error: {ex.Message}" };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new NotReachableAnalysisResult { Success = false, Error = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<NotReachableVexGenerationResult> GenerateNotReachableVexAsync(
|
||||
IReadOnlyList<NotReachableAnalysisEntry> analyses,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var url = $"{_baseUrl}/api/v1/vex/not-reachable/generate";
|
||||
try
|
||||
{
|
||||
using var content = new StringContent(
|
||||
JsonSerializer.Serialize(analyses),
|
||||
System.Text.Encoding.UTF8,
|
||||
"application/json");
|
||||
|
||||
using var response = await _httpClient.PostAsync(url, content, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new NotReachableVexGenerationResult
|
||||
{
|
||||
Success = false,
|
||||
Error = FormatStatusError("not-reachable generation", response)
|
||||
};
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return DeserializeResponse<NotReachableVexGenerationResult>(json, "not-reachable generation")
|
||||
?? new NotReachableVexGenerationResult { Success = false, Error = "Failed to deserialize response." };
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return new NotReachableVexGenerationResult { Success = false, Error = $"Response JSON error: {ex.Message}" };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new NotReachableVexGenerationResult { Success = false, Error = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
private static T? DeserializeResponse<T>(string json, string context)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<T>(json, ResponseJsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to deserialize {context} response: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatStatusError(string context, HttpResponseMessage response)
|
||||
{
|
||||
var reason = string.IsNullOrWhiteSpace(response.ReasonPhrase) ? "request failed" : response.ReasonPhrase;
|
||||
return $"HTTP {(int)response.StatusCode} {reason} during {context}.";
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<PluginOutputDirectory>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..\plugins\cli\StellaOps.Cli.Plugins.Vex\'))</PluginOutputDirectory>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -18,15 +18,15 @@
|
||||
<ProjectReference Include="..\..\StellaOps.Cli\StellaOps.Cli.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Spectre.Console" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CopyPluginBinaries" AfterTargets="Build">
|
||||
<MakeDir Directories="$(PluginOutputDirectory)" />
|
||||
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(PluginOutputDirectory)" />
|
||||
<Copy SourceFiles="$(TargetDir)$(TargetName).pdb"
|
||||
DestinationFolder="$(PluginOutputDirectory)"
|
||||
Condition="Exists('$(TargetDir)$(TargetName).pdb')" />
|
||||
<ItemGroup>
|
||||
<PluginArtifacts Include="$(TargetDir)$(TargetFileName)" />
|
||||
<PluginArtifacts Include="$(TargetDir)$(TargetName).pdb" Condition="Exists('$(TargetDir)$(TargetName).pdb')" />
|
||||
<PluginArtifacts Include="$(TargetDir)$(TargetName).deps.json" Condition="Exists('$(TargetDir)$(TargetName).deps.json')" />
|
||||
<PluginArtifacts Include="$(TargetDir)$(TargetName).runtimeconfig.json" Condition="Exists('$(TargetDir)$(TargetName).runtimeconfig.json')" />
|
||||
<PluginArtifacts Include="@(ReferenceCopyLocalPaths)" />
|
||||
</ItemGroup>
|
||||
<Copy SourceFiles="@(PluginArtifacts)" DestinationFolder="$(PluginOutputDirectory)" SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
</Project>
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0142-M | DONE | Maintainability audit for StellaOps.Cli.Plugins.Vex. |
|
||||
| AUDIT-0142-T | DONE | Test coverage audit for StellaOps.Cli.Plugins.Vex. |
|
||||
| AUDIT-0142-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0142-A | DONE | Applied plugin hardening + validation + tests. |
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
namespace StellaOps.Cli.Plugins.Vex;
|
||||
|
||||
public static class VexCliValidation
|
||||
{
|
||||
public static bool TryResolveTargetImage(
|
||||
string? image,
|
||||
string? check,
|
||||
out string targetImage,
|
||||
out string errorMessage)
|
||||
{
|
||||
targetImage = string.Empty;
|
||||
errorMessage = string.Empty;
|
||||
|
||||
var hasImage = !string.IsNullOrWhiteSpace(image);
|
||||
var hasCheck = !string.IsNullOrWhiteSpace(check);
|
||||
|
||||
if (!hasImage && !hasCheck)
|
||||
{
|
||||
errorMessage = "Either --image or --check must be specified.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasImage && hasCheck)
|
||||
{
|
||||
errorMessage = "--image and --check are mutually exclusive.";
|
||||
return false;
|
||||
}
|
||||
|
||||
targetImage = hasImage ? image! : check!;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryValidateMin(string name, int value, int minInclusive, out string errorMessage)
|
||||
{
|
||||
errorMessage = string.Empty;
|
||||
|
||||
if (value < minInclusive)
|
||||
{
|
||||
errorMessage = $"{name} must be >= {minInclusive}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryValidateMin(string name, double value, double minInclusive, out string errorMessage)
|
||||
{
|
||||
errorMessage = string.Empty;
|
||||
|
||||
if (value < minInclusive)
|
||||
{
|
||||
errorMessage = $"{name} must be >= {minInclusive}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryValidateRange(
|
||||
string name,
|
||||
double value,
|
||||
double minInclusive,
|
||||
double maxInclusive,
|
||||
out string errorMessage)
|
||||
{
|
||||
errorMessage = string.Empty;
|
||||
|
||||
if (value < minInclusive || value > maxInclusive)
|
||||
{
|
||||
errorMessage = $"{name} must be between {minInclusive} and {maxInclusive}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryValidateOutputPath(string? outputPath, out string errorMessage)
|
||||
{
|
||||
errorMessage = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(outputPath))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fullPath = Path.GetFullPath(outputPath);
|
||||
if (Directory.Exists(fullPath))
|
||||
{
|
||||
errorMessage = "Output path must be a file, not a directory.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = $"Output path is invalid: {ex.Message}";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryValidateServerUrl(string? serverUrl, out Uri? uri, out string errorMessage)
|
||||
{
|
||||
uri = null;
|
||||
errorMessage = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(serverUrl))
|
||||
{
|
||||
errorMessage = "Server URL is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(serverUrl, UriKind.Absolute, out var parsed))
|
||||
{
|
||||
errorMessage = "Server URL must be an absolute URI.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps)
|
||||
{
|
||||
errorMessage = "Server URL must use http or https.";
|
||||
return false;
|
||||
}
|
||||
|
||||
uri = parsed;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user