save progress

This commit is contained in:
StellaOps Bot
2026-01-03 11:02:24 +02:00
parent ca578801fd
commit 83c37243e0
446 changed files with 22798 additions and 4031 deletions

View 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}.";
}
}

View File

@@ -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>

View File

@@ -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. |

View 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);
}
}

View File

@@ -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;
}
}