367 lines
13 KiB
C#
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
|
|
}
|