- Implemented PathViewerComponent for visualizing reachability call paths. - Added RiskDriftCardComponent to display reachability drift results. - Created corresponding HTML templates and SCSS styles for both components. - Introduced test fixtures for reachability analysis in JSON format. - Enhanced user interaction with collapsible and expandable features in PathViewer. - Included risk trend visualization and summary metrics in RiskDriftCard.
321 lines
11 KiB
C#
321 lines
11 KiB
C#
// -----------------------------------------------------------------------------
|
|
// CommandHandlers.Drift.cs
|
|
// Sprint: SPRINT_3600_0004_0001_ui_evidence_chain
|
|
// Tasks: UI-019, UI-020, UI-021
|
|
// Description: Command handlers for reachability drift CLI.
|
|
// -----------------------------------------------------------------------------
|
|
|
|
using System.Text.Json;
|
|
using Spectre.Console;
|
|
|
|
namespace StellaOps.Cli.Commands;
|
|
|
|
internal static partial class CommandHandlers
|
|
{
|
|
private static readonly JsonSerializerOptions DriftJsonOptions = new()
|
|
{
|
|
WriteIndented = true,
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
};
|
|
|
|
/// <summary>
|
|
/// Handler for `drift compare` command.
|
|
/// </summary>
|
|
internal static async Task HandleDriftCompareAsync(
|
|
IServiceProvider services,
|
|
string baseId,
|
|
string? headId,
|
|
string? image,
|
|
string? repo,
|
|
string output,
|
|
string minSeverity,
|
|
bool onlyIncreases,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// TODO: Replace with actual service call when drift API is available
|
|
var console = AnsiConsole.Console;
|
|
|
|
if (verbose)
|
|
{
|
|
console.MarkupLine($"[dim]Comparing drift: base={baseId}, head={headId ?? "(latest)"}[/]");
|
|
}
|
|
|
|
// Placeholder: In real implementation, call drift service
|
|
var driftResult = new DriftResultDto
|
|
{
|
|
Id = Guid.NewGuid().ToString("N")[..8],
|
|
ComparedAt = DateTimeOffset.UtcNow.ToString("O"),
|
|
BaseGraphId = baseId,
|
|
HeadGraphId = headId ?? "latest",
|
|
Summary = new DriftSummaryDto
|
|
{
|
|
TotalSinks = 0,
|
|
IncreasedReachability = 0,
|
|
DecreasedReachability = 0,
|
|
UnchangedReachability = 0,
|
|
NewSinks = 0,
|
|
RemovedSinks = 0,
|
|
RiskTrend = "stable",
|
|
NetRiskDelta = 0
|
|
},
|
|
DriftedSinks = Array.Empty<DriftedSinkDto>()
|
|
};
|
|
|
|
switch (output)
|
|
{
|
|
case "json":
|
|
await WriteJsonOutputAsync(console, driftResult, cancellationToken);
|
|
break;
|
|
case "sarif":
|
|
await WriteSarifOutputAsync(console, driftResult, cancellationToken);
|
|
break;
|
|
default:
|
|
WriteTableOutput(console, driftResult, onlyIncreases, minSeverity);
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handler for `drift show` command.
|
|
/// </summary>
|
|
internal static async Task HandleDriftShowAsync(
|
|
IServiceProvider services,
|
|
string id,
|
|
string output,
|
|
bool expandPaths,
|
|
bool verbose,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var console = AnsiConsole.Console;
|
|
|
|
if (verbose)
|
|
{
|
|
console.MarkupLine($"[dim]Showing drift result: {id}[/]");
|
|
}
|
|
|
|
// Placeholder: In real implementation, call drift service
|
|
var driftResult = new DriftResultDto
|
|
{
|
|
Id = id,
|
|
ComparedAt = DateTimeOffset.UtcNow.ToString("O"),
|
|
BaseGraphId = "base",
|
|
HeadGraphId = "head",
|
|
Summary = new DriftSummaryDto
|
|
{
|
|
TotalSinks = 0,
|
|
IncreasedReachability = 0,
|
|
DecreasedReachability = 0,
|
|
UnchangedReachability = 0,
|
|
NewSinks = 0,
|
|
RemovedSinks = 0,
|
|
RiskTrend = "stable",
|
|
NetRiskDelta = 0
|
|
},
|
|
DriftedSinks = Array.Empty<DriftedSinkDto>()
|
|
};
|
|
|
|
switch (output)
|
|
{
|
|
case "json":
|
|
await WriteJsonOutputAsync(console, driftResult, cancellationToken);
|
|
break;
|
|
case "sarif":
|
|
await WriteSarifOutputAsync(console, driftResult, cancellationToken);
|
|
break;
|
|
default:
|
|
WriteTableOutput(console, driftResult, false, "info");
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Task: UI-020 - Table output using Spectre.Console
|
|
private static void WriteTableOutput(
|
|
IAnsiConsole console,
|
|
DriftResultDto result,
|
|
bool onlyIncreases,
|
|
string minSeverity)
|
|
{
|
|
// Header panel
|
|
var header = new Panel(new Markup($"[bold]Reachability Drift[/] [dim]({result.Id})[/]"))
|
|
.Border(BoxBorder.Rounded)
|
|
.Padding(1, 0);
|
|
console.Write(header);
|
|
|
|
// Summary table
|
|
var summaryTable = new Table()
|
|
.Border(TableBorder.Rounded)
|
|
.AddColumn("Metric")
|
|
.AddColumn("Value");
|
|
|
|
summaryTable.AddRow("Trend", FormatTrend(result.Summary.RiskTrend));
|
|
summaryTable.AddRow("Net Risk Delta", FormatDelta(result.Summary.NetRiskDelta));
|
|
summaryTable.AddRow("Increased", result.Summary.IncreasedReachability.ToString());
|
|
summaryTable.AddRow("Decreased", result.Summary.DecreasedReachability.ToString());
|
|
summaryTable.AddRow("New Sinks", result.Summary.NewSinks.ToString());
|
|
summaryTable.AddRow("Removed Sinks", result.Summary.RemovedSinks.ToString());
|
|
|
|
console.Write(summaryTable);
|
|
|
|
// Sinks table
|
|
if (result.DriftedSinks.Length == 0)
|
|
{
|
|
console.MarkupLine("[green]No drifted sinks found.[/]");
|
|
return;
|
|
}
|
|
|
|
var sinksTable = new Table()
|
|
.Border(TableBorder.Rounded)
|
|
.AddColumn("Severity")
|
|
.AddColumn("Sink")
|
|
.AddColumn("CVE")
|
|
.AddColumn("Bucket Change")
|
|
.AddColumn("Delta");
|
|
|
|
var severityOrder = new Dictionary<string, int>
|
|
{
|
|
["critical"] = 0,
|
|
["high"] = 1,
|
|
["medium"] = 2,
|
|
["low"] = 3,
|
|
["info"] = 4
|
|
};
|
|
|
|
var minSevOrder = severityOrder.GetValueOrDefault(minSeverity, 2);
|
|
|
|
foreach (var sink in result.DriftedSinks)
|
|
{
|
|
var sevOrder = severityOrder.GetValueOrDefault(sink.Severity ?? "info", 4);
|
|
if (sevOrder > minSevOrder) continue;
|
|
if (onlyIncreases && !sink.IsRiskIncrease) continue;
|
|
|
|
sinksTable.AddRow(
|
|
FormatSeverity(sink.Severity),
|
|
sink.SinkSymbol ?? "unknown",
|
|
sink.CveId ?? "-",
|
|
$"{sink.PreviousBucket ?? "N/A"} → {sink.CurrentBucket}",
|
|
FormatDelta(sink.RiskDelta));
|
|
}
|
|
|
|
console.Write(sinksTable);
|
|
}
|
|
|
|
// Task: UI-021 - JSON output
|
|
private static async Task WriteJsonOutputAsync(
|
|
IAnsiConsole console,
|
|
DriftResultDto result,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var json = JsonSerializer.Serialize(result, DriftJsonOptions);
|
|
console.WriteLine(json);
|
|
await Task.CompletedTask;
|
|
}
|
|
|
|
// Task: UI-022, UI-023 - SARIF output (placeholder)
|
|
private static async Task WriteSarifOutputAsync(
|
|
IAnsiConsole console,
|
|
DriftResultDto result,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// TODO: Implement full SARIF 2.1.0 generation in DriftSarifGenerator
|
|
var sarif = new
|
|
{
|
|
version = "2.1.0",
|
|
schema = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
|
|
runs = new[]
|
|
{
|
|
new
|
|
{
|
|
tool = new
|
|
{
|
|
driver = new
|
|
{
|
|
name = "StellaOps Drift",
|
|
version = "1.0.0",
|
|
informationUri = "https://stellaops.io/docs/drift"
|
|
}
|
|
},
|
|
results = result.DriftedSinks.Select(sink => new
|
|
{
|
|
ruleId = sink.CveId ?? $"drift-{sink.SinkSymbol}",
|
|
level = MapSeverityToSarif(sink.Severity),
|
|
message = new
|
|
{
|
|
text = $"Reachability changed: {sink.PreviousBucket ?? "N/A"} → {sink.CurrentBucket}"
|
|
},
|
|
locations = Array.Empty<object>()
|
|
}).ToArray()
|
|
}
|
|
}
|
|
};
|
|
|
|
var json = JsonSerializer.Serialize(sarif, DriftJsonOptions);
|
|
console.WriteLine(json);
|
|
await Task.CompletedTask;
|
|
}
|
|
|
|
private static string FormatTrend(string trend) => trend switch
|
|
{
|
|
"increasing" => "[red]↑ Increasing[/]",
|
|
"decreasing" => "[green]↓ Decreasing[/]",
|
|
_ => "[dim]→ Stable[/]"
|
|
};
|
|
|
|
private static string FormatDelta(int delta) => delta switch
|
|
{
|
|
> 0 => $"[red]+{delta}[/]",
|
|
< 0 => $"[green]{delta}[/]",
|
|
_ => "[dim]0[/]"
|
|
};
|
|
|
|
private static string FormatSeverity(string? severity) => severity switch
|
|
{
|
|
"critical" => "[white on red] CRITICAL [/]",
|
|
"high" => "[black on darkorange] HIGH [/]",
|
|
"medium" => "[black on yellow] MEDIUM [/]",
|
|
"low" => "[black on olive] LOW [/]",
|
|
_ => "[dim] INFO [/]"
|
|
};
|
|
|
|
private static string MapSeverityToSarif(string? severity) => severity switch
|
|
{
|
|
"critical" or "high" => "error",
|
|
"medium" => "warning",
|
|
_ => "note"
|
|
};
|
|
|
|
// DTOs for drift output
|
|
private sealed record DriftResultDto
|
|
{
|
|
public string Id { get; init; } = string.Empty;
|
|
public string ComparedAt { get; init; } = string.Empty;
|
|
public string BaseGraphId { get; init; } = string.Empty;
|
|
public string HeadGraphId { get; init; } = string.Empty;
|
|
public DriftSummaryDto Summary { get; init; } = new();
|
|
public DriftedSinkDto[] DriftedSinks { get; init; } = Array.Empty<DriftedSinkDto>();
|
|
}
|
|
|
|
private sealed record DriftSummaryDto
|
|
{
|
|
public int TotalSinks { get; init; }
|
|
public int IncreasedReachability { get; init; }
|
|
public int DecreasedReachability { get; init; }
|
|
public int UnchangedReachability { get; init; }
|
|
public int NewSinks { get; init; }
|
|
public int RemovedSinks { get; init; }
|
|
public string RiskTrend { get; init; } = "stable";
|
|
public int NetRiskDelta { get; init; }
|
|
}
|
|
|
|
private sealed record DriftedSinkDto
|
|
{
|
|
public string? SinkSymbol { get; init; }
|
|
public string? CveId { get; init; }
|
|
public string? Severity { get; init; }
|
|
public string? PreviousBucket { get; init; }
|
|
public string CurrentBucket { get; init; } = string.Empty;
|
|
public bool IsRiskIncrease { get; init; }
|
|
public int RiskDelta { get; init; }
|
|
}
|
|
}
|