Files
git.stella-ops.org/src/Cli/StellaOps.Cli/Commands/SignalsCommandGroup.cs
2026-01-16 23:30:47 +02:00

367 lines
13 KiB
C#

// -----------------------------------------------------------------------------
// SignalsCommandGroup.cs
// Sprint: SPRINT_20260117_006_CLI_reachability_analysis
// Tasks: RCA-006 - Add stella signals inspect command
// Description: CLI commands for runtime signal inspection
// -----------------------------------------------------------------------------
using System.CommandLine;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace StellaOps.Cli.Commands;
/// <summary>
/// Command group for runtime signal inspection.
/// Implements `stella signals inspect` for viewing collected runtime signals.
/// </summary>
public static class SignalsCommandGroup
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
/// <summary>
/// Build the 'signals' command group.
/// </summary>
public static Command BuildSignalsCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var signalsCommand = new Command("signals", "Runtime signal inspection and analysis");
signalsCommand.Add(BuildInspectCommand(services, verboseOption, cancellationToken));
signalsCommand.Add(BuildListCommand(services, verboseOption, cancellationToken));
signalsCommand.Add(BuildSummaryCommand(services, verboseOption, cancellationToken));
return signalsCommand;
}
#region Inspect Command (RCA-006)
/// <summary>
/// Build the 'signals inspect' command.
/// Sprint: SPRINT_20260117_006_CLI_reachability_analysis (RCA-006)
/// </summary>
private static Command BuildInspectCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var targetArg = new Argument<string>("target")
{
Description = "Digest (sha256:...) or run ID (run-...) to inspect signals for"
};
var typeOption = new Option<string?>("--type", "-t")
{
Description = "Filter by signal type: call, memory, network, file, process"
};
var fromOption = new Option<string?>("--from")
{
Description = "Start time filter (ISO 8601)"
};
var toOption = new Option<string?>("--to")
{
Description = "End time filter (ISO 8601)"
};
var limitOption = new Option<int>("--limit", "-n")
{
Description = "Maximum number of signals to show"
};
limitOption.SetDefaultValue(100);
var formatOption = new Option<string>("--format", "-f")
{
Description = "Output format: table (default), json"
};
formatOption.SetDefaultValue("table");
var inspectCommand = new Command("inspect", "Inspect runtime signals for a digest or run")
{
targetArg,
typeOption,
fromOption,
toOption,
limitOption,
formatOption,
verboseOption
};
inspectCommand.SetAction((parseResult, ct) =>
{
var target = parseResult.GetValue(targetArg) ?? string.Empty;
var type = parseResult.GetValue(typeOption);
var from = parseResult.GetValue(fromOption);
var to = parseResult.GetValue(toOption);
var limit = parseResult.GetValue(limitOption);
var format = parseResult.GetValue(formatOption) ?? "table";
var verbose = parseResult.GetValue(verboseOption);
var signals = GetSignals(target).Take(limit).ToList();
if (!string.IsNullOrEmpty(type))
{
signals = signals.Where(s => s.Type.Equals(type, StringComparison.OrdinalIgnoreCase)).ToList();
}
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
{
Console.WriteLine(JsonSerializer.Serialize(signals, JsonOptions));
return Task.FromResult(0);
}
Console.WriteLine("Runtime Signals");
Console.WriteLine("===============");
Console.WriteLine();
Console.WriteLine($"Target: {target}");
Console.WriteLine();
Console.WriteLine($"{"Timestamp",-22} {"Type",-10} {"Source",-20} {"Details"}");
Console.WriteLine(new string('-', 90));
foreach (var signal in signals)
{
Console.WriteLine($"{signal.Timestamp:yyyy-MM-dd HH:mm:ss,-22} {signal.Type,-10} {signal.Source,-20} {signal.Details}");
}
Console.WriteLine();
Console.WriteLine($"Total: {signals.Count} signals");
if (verbose)
{
Console.WriteLine();
Console.WriteLine("Signal Types:");
var grouped = signals.GroupBy(s => s.Type);
foreach (var group in grouped)
{
Console.WriteLine($" {group.Key}: {group.Count()}");
}
}
return Task.FromResult(0);
});
return inspectCommand;
}
#endregion
#region List Command
/// <summary>
/// Build the 'signals list' command.
/// </summary>
private static Command BuildListCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var limitOption = new Option<int>("--limit", "-n")
{
Description = "Maximum number of signal collections to show"
};
limitOption.SetDefaultValue(20);
var formatOption = new Option<string>("--format", "-f")
{
Description = "Output format: table (default), json"
};
formatOption.SetDefaultValue("table");
var listCommand = new Command("list", "List signal collections")
{
limitOption,
formatOption,
verboseOption
};
listCommand.SetAction((parseResult, ct) =>
{
var limit = parseResult.GetValue(limitOption);
var format = parseResult.GetValue(formatOption) ?? "table";
var verbose = parseResult.GetValue(verboseOption);
var collections = GetSignalCollections().Take(limit).ToList();
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
{
Console.WriteLine(JsonSerializer.Serialize(collections, JsonOptions));
return Task.FromResult(0);
}
Console.WriteLine("Signal Collections");
Console.WriteLine("==================");
Console.WriteLine();
Console.WriteLine($"{"Target",-25} {"Signals",-10} {"First Seen",-12} {"Last Seen",-12}");
Console.WriteLine(new string('-', 70));
foreach (var collection in collections)
{
var shortTarget = collection.Target.Length > 23 ? collection.Target[..23] + "..." : collection.Target;
Console.WriteLine($"{shortTarget,-25} {collection.SignalCount,-10} {collection.FirstSeen:yyyy-MM-dd,-12} {collection.LastSeen:yyyy-MM-dd,-12}");
}
Console.WriteLine();
Console.WriteLine($"Total: {collections.Count} collections");
return Task.FromResult(0);
});
return listCommand;
}
#endregion
#region Summary Command
/// <summary>
/// Build the 'signals summary' command.
/// </summary>
private static Command BuildSummaryCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var targetArg = new Argument<string>("target")
{
Description = "Digest or run ID"
};
var formatOption = new Option<string>("--format", "-f")
{
Description = "Output format: text (default), json"
};
formatOption.SetDefaultValue("text");
var summaryCommand = new Command("summary", "Show signal summary for a target")
{
targetArg,
formatOption,
verboseOption
};
summaryCommand.SetAction((parseResult, ct) =>
{
var target = parseResult.GetValue(targetArg) ?? string.Empty;
var format = parseResult.GetValue(formatOption) ?? "text";
var verbose = parseResult.GetValue(verboseOption);
var summary = new SignalSummary
{
Target = target,
TotalSignals = 147,
SignalsByType = new Dictionary<string, int>
{
["call"] = 89,
["memory"] = 23,
["network"] = 18,
["file"] = 12,
["process"] = 5
},
FirstObserved = DateTimeOffset.UtcNow.AddDays(-7),
LastObserved = DateTimeOffset.UtcNow.AddMinutes(-15),
UniqueEntryPoints = 12,
ReachableVulnerabilities = 3
};
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
{
Console.WriteLine(JsonSerializer.Serialize(summary, JsonOptions));
return Task.FromResult(0);
}
Console.WriteLine("Signal Summary");
Console.WriteLine("==============");
Console.WriteLine();
Console.WriteLine($"Target: {target}");
Console.WriteLine($"Total Signals: {summary.TotalSignals}");
Console.WriteLine($"First Observed: {summary.FirstObserved:u}");
Console.WriteLine($"Last Observed: {summary.LastObserved:u}");
Console.WriteLine($"Unique Entry Points: {summary.UniqueEntryPoints}");
Console.WriteLine($"Reachable Vulns: {summary.ReachableVulnerabilities}");
Console.WriteLine();
Console.WriteLine("Signals by Type:");
foreach (var (type, count) in summary.SignalsByType)
{
var bar = new string('█', Math.Min(count / 5, 20));
Console.WriteLine($" {type,-10} {count,4} {bar}");
}
return Task.FromResult(0);
});
return summaryCommand;
}
#endregion
#region Sample Data
private static List<RuntimeSignal> GetSignals(string target)
{
var now = DateTimeOffset.UtcNow;
return
[
new RuntimeSignal { Timestamp = now.AddMinutes(-5), Type = "call", Source = "main.go:handleRequest", Details = "Called vulnerable function parseJSON" },
new RuntimeSignal { Timestamp = now.AddMinutes(-10), Type = "call", Source = "api.go:processInput", Details = "Entry point invoked" },
new RuntimeSignal { Timestamp = now.AddMinutes(-12), Type = "network", Source = "http:8080", Details = "Incoming request from 10.0.0.5" },
new RuntimeSignal { Timestamp = now.AddMinutes(-15), Type = "memory", Source = "heap:0x7fff", Details = "Allocation in vulnerable path" },
new RuntimeSignal { Timestamp = now.AddMinutes(-20), Type = "file", Source = "/etc/config", Details = "Config file read" },
new RuntimeSignal { Timestamp = now.AddMinutes(-25), Type = "process", Source = "worker:3", Details = "Process spawned for request handling" }
];
}
private static List<SignalCollection> GetSignalCollections()
{
var now = DateTimeOffset.UtcNow;
return
[
new SignalCollection { Target = "sha256:abc123def456...", SignalCount = 147, FirstSeen = now.AddDays(-7), LastSeen = now.AddMinutes(-15) },
new SignalCollection { Target = "sha256:def456ghi789...", SignalCount = 89, FirstSeen = now.AddDays(-5), LastSeen = now.AddHours(-2) },
new SignalCollection { Target = "run-20260116-001", SignalCount = 234, FirstSeen = now.AddDays(-1), LastSeen = now.AddMinutes(-45) }
];
}
#endregion
#region DTOs
private sealed class RuntimeSignal
{
public DateTimeOffset Timestamp { get; set; }
public string Type { get; set; } = string.Empty;
public string Source { get; set; } = string.Empty;
public string Details { get; set; } = string.Empty;
}
private sealed class SignalCollection
{
public string Target { get; set; } = string.Empty;
public int SignalCount { get; set; }
public DateTimeOffset FirstSeen { get; set; }
public DateTimeOffset LastSeen { get; set; }
}
private sealed class SignalSummary
{
public string Target { get; set; } = string.Empty;
public int TotalSignals { get; set; }
public Dictionary<string, int> SignalsByType { get; set; } = [];
public DateTimeOffset FirstObserved { get; set; }
public DateTimeOffset LastObserved { get; set; }
public int UniqueEntryPoints { get; set; }
public int ReachableVulnerabilities { get; set; }
}
#endregion
}