Implement TUI for managing suspicious artifacts (FilePicker, TypePicker, ListView, and DetailView)
Introduce Bubble Tea-based terminal UI to manage suspicious artifact findings, including file selection, type filtering, list view, and details.
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"rmm-hunter/internal/suspicious"
|
||||
)
|
||||
|
||||
// Elimination placeholders; replace with real implementations later
|
||||
var (
|
||||
EliminateAutoRun = func(ar suspicious.AutoRun) error { return eliminateAutoRun(ar) }
|
||||
EliminateBinary = func(path string) error { return eliminateBinary(path) }
|
||||
EliminateConnection = func(conn suspicious.NetworkConnection) error { return eliminateConnection(conn) }
|
||||
EliminateDirectory = func(path string) error { return eliminateDirectory(path) }
|
||||
EliminateProcess = func(p suspicious.Process) error { return eliminateProcess(p) }
|
||||
EliminateScheduledTask = func(t suspicious.ScheduledTask) error { return eliminateScheduledTask(t) }
|
||||
EliminateService = func(s suspicious.Service) error { return eliminateService(s) }
|
||||
)
|
||||
|
||||
func eliminateAutoRun(ar suspicious.AutoRun) error {
|
||||
return fmt.Errorf("eliminate autorun not implemented")
|
||||
}
|
||||
|
||||
func eliminateBinary(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func eliminateConnection(conn suspicious.NetworkConnection) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func eliminateDirectory(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func eliminateProcess(p suspicious.Process) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func eliminateScheduledTask(t suspicious.ScheduledTask) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func eliminateService(s suspicious.Service) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"rmm-hunter/internal/suspicious"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type screen int
|
||||
|
||||
const (
|
||||
screenFilePicker screen = iota
|
||||
screenTypePicker
|
||||
screenList
|
||||
screenDetail
|
||||
screenError
|
||||
)
|
||||
|
||||
type AppModel struct {
|
||||
current screen
|
||||
filePick FilePickerModel
|
||||
typePick TypePickerModel
|
||||
listView ListViewModel
|
||||
detail DetailViewModel
|
||||
err error
|
||||
selected string
|
||||
data suspicious.Suspicious
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
func NewApp() AppModel {
|
||||
return AppModel{
|
||||
current: screenFilePicker,
|
||||
filePick: NewFilePicker(),
|
||||
typePick: NewTypePicker(),
|
||||
}
|
||||
}
|
||||
|
||||
func (m AppModel) Init() tea.Cmd { return m.filePick.Init() }
|
||||
|
||||
func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// remember the latest terminal size so we can size new screens
|
||||
if ws, ok := msg.(tea.WindowSizeMsg); ok {
|
||||
m.width, m.height = ws.Width, ws.Height
|
||||
}
|
||||
|
||||
switch m.current {
|
||||
case screenFilePicker:
|
||||
var cmd tea.Cmd
|
||||
var tm tea.Model
|
||||
tm, cmd = m.filePick.Update(msg)
|
||||
if fp, ok := tm.(FilePickerModel); ok {
|
||||
m.filePick = fp
|
||||
}
|
||||
switch v := msg.(type) {
|
||||
case FileSelectedMsg:
|
||||
if err := m.loadSelectedFile(v.Path); err != nil {
|
||||
m.err = err
|
||||
m.current = screenError
|
||||
return m, nil
|
||||
}
|
||||
m.current = screenTypePicker
|
||||
return m, nil
|
||||
}
|
||||
return m, cmd
|
||||
|
||||
case screenTypePicker:
|
||||
var cmd tea.Cmd
|
||||
var tm tea.Model
|
||||
tm, cmd = m.typePick.Update(msg)
|
||||
if tp, ok := tm.(TypePickerModel); ok {
|
||||
m.typePick = tp
|
||||
}
|
||||
switch v := msg.(type) {
|
||||
case BackMsg:
|
||||
m.current = screenFilePicker
|
||||
return m, nil
|
||||
case SelectedTypeMsg:
|
||||
m.selected = v.Type
|
||||
m.listView = NewListView(v.Type, m.data, m.width, m.height)
|
||||
m.current = screenList
|
||||
return m, nil
|
||||
}
|
||||
return m, cmd
|
||||
|
||||
case screenList:
|
||||
var cmd tea.Cmd
|
||||
var tm tea.Model
|
||||
tm, cmd = m.listView.Update(msg)
|
||||
if lv, ok := tm.(ListViewModel); ok {
|
||||
m.listView = lv
|
||||
}
|
||||
switch v := msg.(type) {
|
||||
case BackMsg:
|
||||
m.current = screenTypePicker
|
||||
return m, nil
|
||||
case ListSelectedMsg:
|
||||
m.detail = NewDetailView(v.TypeKey, v.Index, m.data)
|
||||
m.current = screenDetail
|
||||
return m, nil
|
||||
}
|
||||
return m, cmd
|
||||
|
||||
case screenDetail:
|
||||
var cmd tea.Cmd
|
||||
var tm tea.Model
|
||||
tm, cmd = m.detail.Update(msg)
|
||||
if dv, ok := tm.(DetailViewModel); ok {
|
||||
m.detail = dv
|
||||
}
|
||||
switch v := msg.(type) {
|
||||
case BackMsg:
|
||||
m.current = screenList
|
||||
return m, nil
|
||||
case RequestEliminateMsg:
|
||||
if err := m.performEliminate(v.TypeKey, v.Index); err != nil {
|
||||
m.detail.modalErr = err.Error()
|
||||
return m, nil
|
||||
}
|
||||
// success -> rebuild list and go back
|
||||
m.listView = NewListView(m.selected, m.data, m.width, m.height)
|
||||
m.current = screenList
|
||||
return m, nil
|
||||
}
|
||||
return m, cmd
|
||||
|
||||
case screenError:
|
||||
// Any key quits after error is shown
|
||||
if _, ok := msg.(tea.KeyMsg); ok {
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m AppModel) View() string {
|
||||
switch m.current {
|
||||
case screenFilePicker:
|
||||
return m.filePick.View()
|
||||
case screenTypePicker:
|
||||
return m.typePick.View()
|
||||
case screenList:
|
||||
return m.listView.View()
|
||||
case screenDetail:
|
||||
return m.detail.View()
|
||||
case screenError:
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("203")).Render(fmt.Sprintf("Failed to load JSON: %v\nPress any key to exit.", m.err))
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// performEliminate routes to placeholder eliminate functions and mutates data on success
|
||||
func (m *AppModel) performEliminate(typeKey string, idx int) error {
|
||||
switch typeKey {
|
||||
case "autoruns":
|
||||
ar := m.data.AutoRuns[idx]
|
||||
if err := EliminateAutoRun(ar); err != nil {
|
||||
return err
|
||||
}
|
||||
m.data.AutoRuns = append(m.data.AutoRuns[:idx], m.data.AutoRuns[idx+1:]...)
|
||||
case "binaries":
|
||||
b := m.data.Binaries[idx]
|
||||
if err := EliminateBinary(b); err != nil {
|
||||
return err
|
||||
}
|
||||
m.data.Binaries = append(m.data.Binaries[:idx], m.data.Binaries[idx+1:]...)
|
||||
case "connections":
|
||||
c := m.data.OutboundConnections[idx]
|
||||
if err := EliminateConnection(c); err != nil {
|
||||
return err
|
||||
}
|
||||
m.data.OutboundConnections = append(m.data.OutboundConnections[:idx], m.data.OutboundConnections[idx+1:]...)
|
||||
case "directories":
|
||||
d := m.data.Directories[idx]
|
||||
if err := EliminateDirectory(d); err != nil {
|
||||
return err
|
||||
}
|
||||
m.data.Directories = append(m.data.Directories[:idx], m.data.Directories[idx+1:]...)
|
||||
case "processes":
|
||||
p := m.data.Processes[idx]
|
||||
if err := EliminateProcess(p); err != nil {
|
||||
return err
|
||||
}
|
||||
m.data.Processes = append(m.data.Processes[:idx], m.data.Processes[idx+1:]...)
|
||||
case "scheduledTasks":
|
||||
t := m.data.ScheduledTasks[idx]
|
||||
if err := EliminateScheduledTask(*t); err != nil {
|
||||
return err
|
||||
}
|
||||
m.data.ScheduledTasks = append(m.data.ScheduledTasks[:idx], m.data.ScheduledTasks[idx+1:]...)
|
||||
case "services":
|
||||
s := m.data.Services[idx]
|
||||
if err := EliminateService(*s); err != nil {
|
||||
return err
|
||||
}
|
||||
m.data.Services = append(m.data.Services[:idx], m.data.Services[idx+1:]...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadSelectedFile reads the JSON file and populates m.data
|
||||
func (m *AppModel) loadSelectedFile(path string) error {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Support both wrapped report (with findings) and bare Suspicious JSON
|
||||
var envelope struct {
|
||||
Findings json.RawMessage `json:"findings"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &envelope); err == nil && len(envelope.Findings) > 0 {
|
||||
var sus suspicious.Suspicious
|
||||
if err := json.Unmarshal(envelope.Findings, &sus); err != nil {
|
||||
return err
|
||||
}
|
||||
m.data = sus
|
||||
return nil
|
||||
}
|
||||
// Try bare suspicious structure
|
||||
var sus suspicious.Suspicious
|
||||
if err := json.Unmarshal(b, &sus); err != nil {
|
||||
return fmt.Errorf("no findings in report")
|
||||
}
|
||||
m.data = sus
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunEliminateUI starts the Bubble Tea program for elimination UI
|
||||
func RunEliminateUI() error {
|
||||
p := tea.NewProgram(NewApp())
|
||||
_, err := p.Run()
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// writeTestReport creates a minimal JSON report envelope with empty findings
|
||||
func writeTestReport(t *testing.T, dir, name string, withFindings bool) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
var content string
|
||||
if withFindings {
|
||||
content = `{
|
||||
"reportName": "rmm-hunter-report",
|
||||
"generatedAt": "2025-01-01T00:00:00Z",
|
||||
"riskRating": {"score":0, "rating":"Low", "summary":""},
|
||||
"findings": {"processes":[],"services":[],"binaries":[],"autoRuns":[],"scheduledTasks":[],"outboundConnections":[],"directories":[]}
|
||||
}`
|
||||
} else {
|
||||
content = `{}`
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("failed to write test report: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestAppFlow_SelectFile_SelectType_Back_Quit(t *testing.T) {
|
||||
// Run in a temp dir so file picker sees our .json
|
||||
tmp := t.TempDir()
|
||||
if err := os.Chdir(tmp); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
_ = writeTestReport(t, tmp, "test.json", true)
|
||||
|
||||
p := tea.NewProgram(NewApp(), tea.WithoutRenderer())
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := p.Run()
|
||||
done <- err
|
||||
}()
|
||||
|
||||
// Give init a moment to load files
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Select file
|
||||
p.Send(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Choose type 1 (autoruns)
|
||||
p.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'1'}})
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Go back to type picker
|
||||
p.Send(tea.KeyMsg{Type: tea.KeyLeft})
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Quit
|
||||
p.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("program error: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("program did not exit in time")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApp_ErrorOnBadJSON(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
if err := os.Chdir(tmp); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
_ = writeTestReport(t, tmp, "bad.json", false)
|
||||
|
||||
p := tea.NewProgram(NewApp(), tea.WithoutRenderer())
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := p.Run()
|
||||
done <- err
|
||||
}()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
// Select file -> should error
|
||||
p.Send(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
// Any key quits on error screen
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
p.Send(tea.KeyMsg{Type: tea.KeyEsc})
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("program error: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("program did not exit in time")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"rmm-hunter/internal/suspicious"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// RequestEliminateMsg is emitted by the detail view when '!' is pressed
|
||||
type RequestEliminateMsg struct {
|
||||
TypeKey string
|
||||
Index int
|
||||
}
|
||||
|
||||
// DeletedMsg is emitted after successful elimination to update lists
|
||||
type DeletedMsg struct {
|
||||
TypeKey string
|
||||
Index int
|
||||
}
|
||||
|
||||
type DetailViewModel struct {
|
||||
typeKey string
|
||||
index int
|
||||
data suspicious.Suspicious
|
||||
// When modalErr != "", show modal and require ESC to dismiss
|
||||
modalErr string
|
||||
}
|
||||
|
||||
func NewDetailView(typeKey string, index int, data suspicious.Suspicious) DetailViewModel {
|
||||
return DetailViewModel{typeKey: typeKey, index: index, data: data}
|
||||
}
|
||||
|
||||
func (m DetailViewModel) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m DetailViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch v := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
if m.modalErr != "" {
|
||||
// Modal active: only ESC dismisses
|
||||
if v.String() == "esc" {
|
||||
m.modalErr = ""
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
switch v.String() {
|
||||
case "left":
|
||||
return m, func() tea.Msg { return BackMsg{} }
|
||||
case "!":
|
||||
return m, func() tea.Msg { return RequestEliminateMsg{TypeKey: m.typeKey, Index: m.index} }
|
||||
case "q", "esc", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m DetailViewModel) View() string {
|
||||
title := lipgloss.NewStyle().Bold(true).Render("Details — press ! to eliminate, Left to go back, q to quit")
|
||||
body := m.renderDetails()
|
||||
view := title + "\n\n" + body
|
||||
if m.modalErr != "" {
|
||||
modal := lipgloss.NewStyle().Padding(1, 2).Foreground(lipgloss.Color("203")).Border(lipgloss.RoundedBorder()).Render("Elimination failed:\n" + m.modalErr + "\n\nPress ESC to dismiss")
|
||||
view += "\n\n" + modal
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func (m DetailViewModel) renderDetails() string {
|
||||
switch m.typeKey {
|
||||
case "autoruns":
|
||||
ar := m.data.AutoRuns[m.index]
|
||||
return fmt.Sprintf("Name: %s\nCommand: %s\nLocation: %s\nEnabled: %v\nDescription: %s", ar.Name, ar.Command, ar.Location, ar.Enabled, ar.Description)
|
||||
case "binaries":
|
||||
b := m.data.Binaries[m.index]
|
||||
return fmt.Sprintf("Binary: %s\nAction: delete file", b)
|
||||
case "connections":
|
||||
c := m.data.OutboundConnections[m.index]
|
||||
return fmt.Sprintf("Local: %s\nRemote: %s\nHost: %s\nState: %s\nPID: %s\nProcess: %s\nAction: add firewall block (placeholder)", c.LocalAddr, c.RemoteAddr, c.RemoteHost, c.State, c.PID, c.Process)
|
||||
case "directories":
|
||||
d := m.data.Directories[m.index]
|
||||
return fmt.Sprintf("Directory: %s\nAction: delete recursively", d)
|
||||
case "processes":
|
||||
p := m.data.Processes[m.index]
|
||||
return fmt.Sprintf("Name: %s\nPID: %d\nPPID: %d\nParent: %s\nArgs: %s\nCreated: %s\nPath: %s\nAction: stop then delete (placeholder)", p.Name, p.PID, p.PPID, p.Parent, p.Args, p.Created, p.Path)
|
||||
case "scheduledTasks":
|
||||
t := m.data.ScheduledTasks[m.index]
|
||||
return fmt.Sprintf("Name: %s\nAuthor: %s\nState: %s\nEnabled: %v\nLastResult: %s\nNextRun: %s\nLastRun: %s\nPath: %s\nAction: disable then delete (placeholder)", t.Name, t.Author, t.State, t.Enabled, t.LastResult, t.NextRun, t.LastRun, t.Path)
|
||||
case "services":
|
||||
s := m.data.Services[m.index]
|
||||
return fmt.Sprintf("Name: %s\nDisplay: %s\nType: %s\nStartType: %s\nBinPath: %s\nStartName: %s\nDescription: %s\nAction: stop then delete (placeholder)", s.Name, s.DisplayName, s.ServiceType, s.StartType, s.BinaryPathName, s.ServiceStartName, s.Description)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// FileSelectedMsg is emitted when a file is chosen
|
||||
type FileSelectedMsg struct{ Path string }
|
||||
|
||||
// FilePicker is a simple list of .json files in the current directory
|
||||
// Press Enter to pick, q/esc to quit
|
||||
|
||||
type fileItem struct {
|
||||
title string
|
||||
path string
|
||||
}
|
||||
|
||||
func (i fileItem) Title() string { return i.title }
|
||||
func (i fileItem) Description() string { return i.path }
|
||||
func (i fileItem) FilterValue() string { return i.title }
|
||||
|
||||
type FilePickerModel struct {
|
||||
list list.Model
|
||||
spinner spinner.Model
|
||||
error error
|
||||
loading bool
|
||||
}
|
||||
|
||||
func NewFilePicker() FilePickerModel {
|
||||
delegate := list.NewDefaultDelegate()
|
||||
delegate.ShowDescription = true
|
||||
l := list.New([]list.Item{}, delegate, 0, 0)
|
||||
// Set a sane default; will be updated on WindowSizeMsg
|
||||
l.SetSize(80, 20)
|
||||
l.Title = "Select JSON report"
|
||||
l.Styles.Title = lipgloss.NewStyle().Bold(true)
|
||||
l.SetShowHelp(false)
|
||||
sp := spinner.New()
|
||||
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
|
||||
m := FilePickerModel{list: l, spinner: sp}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m FilePickerModel) Init() tea.Cmd {
|
||||
return tea.Batch(m.spinner.Tick, m.loadFilesCmd())
|
||||
}
|
||||
|
||||
func (m FilePickerModel) loadFilesCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
entries, err := os.ReadDir(".")
|
||||
if err != nil {
|
||||
return errMsg{err}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var items []list.Item
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if filepath.Ext(name) != ".json" {
|
||||
continue
|
||||
}
|
||||
fi, err := e.Info()
|
||||
if err != nil || !fi.Mode().IsRegular() {
|
||||
continue
|
||||
}
|
||||
if seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
items = append(items, fileItem{title: name, path: name})
|
||||
}
|
||||
return filesLoadedMsg{items}
|
||||
}
|
||||
}
|
||||
|
||||
type errMsg struct{ error }
|
||||
type filesLoadedMsg struct{ items []list.Item }
|
||||
|
||||
func (m FilePickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.list.SetSize(msg.Width, msg.Height-2)
|
||||
case filesLoadedMsg:
|
||||
m.loading = false
|
||||
m.list.SetItems(msg.items)
|
||||
case errMsg:
|
||||
m.error = msg
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "q", "esc", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "enter":
|
||||
if it, ok := m.list.SelectedItem().(fileItem); ok {
|
||||
return m, func() tea.Msg { return FileSelectedMsg{Path: it.path} }
|
||||
}
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.list, cmd = m.list.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m FilePickerModel) View() string {
|
||||
if m.error != nil {
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("203")).Render(fmt.Sprintf("Error: %v\n", m.error))
|
||||
}
|
||||
if m.loading {
|
||||
return "Loading files...\n" + m.spinner.View()
|
||||
}
|
||||
if len(m.list.Items()) == 0 {
|
||||
return "No .json files found in current directory. Press q to exit.\n"
|
||||
}
|
||||
return m.list.View()
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// FileSelectedMsg is emitted when a file is chosen
|
||||
type FileSelectedMsg struct{ Path string }
|
||||
|
||||
// FilePicker is a simple list of .json files in the current directory
|
||||
// Press Enter to pick, q/esc to quit
|
||||
|
||||
type fileItem struct {
|
||||
title string
|
||||
path string
|
||||
}
|
||||
|
||||
func (i fileItem) Title() string { return i.title }
|
||||
func (i fileItem) Description() string { return i.path }
|
||||
func (i fileItem) FilterValue() string { return i.title }
|
||||
|
||||
type FilePickerModel struct {
|
||||
list list.Model
|
||||
spinner spinner.Model
|
||||
error error
|
||||
loading bool
|
||||
}
|
||||
|
||||
func NewFilePicker() FilePickerModel {
|
||||
delegate := list.NewDefaultDelegate()
|
||||
delegate.ShowDescription = true
|
||||
l := list.New([]list.Item{}, delegate, 0, 0)
|
||||
l.Title = "Select JSON report"
|
||||
l.Styles.Title = lipgloss.NewStyle().Bold(true)
|
||||
l.SetShowHelp(false)
|
||||
sp := spinner.New()
|
||||
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
|
||||
m := FilePickerModel{list: l, spinner: sp}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m FilePickerModel) Init() tea.Cmd {
|
||||
return tea.Batch(m.spinner.Tick, m.loadFilesCmd())
|
||||
}
|
||||
|
||||
func (m FilePickerModel) loadFilesCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
files, err := os.ReadDir(".")
|
||||
if err != nil {
|
||||
return errMsg{err}
|
||||
}
|
||||
var items []list.Item
|
||||
for _, f := range files {
|
||||
if f.IsDir() { continue }
|
||||
name := f.Name()
|
||||
if filepath.Ext(name) == ".json" {
|
||||
items = append(items, fileItem{title: name, path: name})
|
||||
}
|
||||
}
|
||||
return filesLoadedMsg{items}
|
||||
}
|
||||
}
|
||||
|
||||
type errMsg struct{ error }
|
||||
type filesLoadedMsg struct{ items []list.Item }
|
||||
|
||||
func (m FilePickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.list.SetSize(msg.Width, msg.Height-2)
|
||||
case filesLoadedMsg:
|
||||
m.loading = false
|
||||
m.list.SetItems(msg.items)
|
||||
case errMsg:
|
||||
m.error = msg
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "q", "esc", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "enter":
|
||||
if it, ok := m.list.SelectedItem().(fileItem); ok {
|
||||
return m, func() tea.Msg { return FileSelectedMsg{Path: it.path} }
|
||||
}
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.list, cmd = m.list.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m FilePickerModel) View() string {
|
||||
if m.error != nil {
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("203")).Render(fmt.Sprintf("Error: %v\n", m.error))
|
||||
}
|
||||
if m.loading {
|
||||
return "Loading files...\n" + m.spinner.View()
|
||||
}
|
||||
if len(m.list.Items()) == 0 {
|
||||
return "No .json files found in current directory. Press q to exit.\n"
|
||||
}
|
||||
return m.list.View()
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"rmm-hunter/internal/suspicious"
|
||||
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// ListSelectedMsg indicates which index/type was selected for detail
|
||||
type ListSelectedMsg struct {
|
||||
TypeKey string
|
||||
Index int
|
||||
}
|
||||
|
||||
type listItem struct{ title, desc string }
|
||||
|
||||
func (i listItem) Title() string { return i.title }
|
||||
func (i listItem) Description() string { return i.desc }
|
||||
func (i listItem) FilterValue() string { return i.title }
|
||||
|
||||
type ListViewModel struct {
|
||||
typeKey string
|
||||
list list.Model
|
||||
header string
|
||||
// In the future we can add action status per-item
|
||||
}
|
||||
|
||||
func NewListView(typeKey string, sus suspicious.Suspicious, width, height int) ListViewModel {
|
||||
delegate := list.NewDefaultDelegate()
|
||||
delegate.ShowDescription = true
|
||||
l := list.New([]list.Item{}, delegate, 0, 0)
|
||||
if width > 0 && height > 0 {
|
||||
l.SetSize(width, height-2)
|
||||
} else {
|
||||
l.SetSize(80, 20)
|
||||
}
|
||||
l.Styles.Title = lipgloss.NewStyle().Bold(true)
|
||||
|
||||
header := ""
|
||||
var items []list.Item
|
||||
switch typeKey {
|
||||
case "autoruns":
|
||||
header = "Suspicious AutoRuns"
|
||||
for _, ar := range sus.AutoRuns {
|
||||
title := ar.Name
|
||||
desc := fmt.Sprintf("%s (%s)", ar.Command, ar.Location)
|
||||
items = append(items, listItem{title: title, desc: desc})
|
||||
}
|
||||
case "binaries":
|
||||
header = "Suspicious Binaries"
|
||||
for _, b := range sus.Binaries {
|
||||
items = append(items, listItem{title: b, desc: "binary file"})
|
||||
}
|
||||
case "connections":
|
||||
header = "Suspicious Connections"
|
||||
for _, c := range sus.OutboundConnections {
|
||||
label := fmt.Sprintf("%s -> %s (%s)", c.LocalAddr, c.RemoteAddr, c.RemoteHost)
|
||||
items = append(items, listItem{title: label, desc: fmt.Sprintf("PID %s %s", c.PID, c.Process)})
|
||||
}
|
||||
case "directories":
|
||||
header = "Suspicious Directories"
|
||||
for _, d := range sus.Directories {
|
||||
items = append(items, listItem{title: d, desc: "directory"})
|
||||
}
|
||||
case "processes":
|
||||
header = "Suspicious Processes"
|
||||
for _, p := range sus.Processes {
|
||||
label := fmt.Sprintf("%s (PID %d)", p.Name, p.PID)
|
||||
desc := p.Path
|
||||
items = append(items, listItem{title: label, desc: desc})
|
||||
}
|
||||
case "scheduledTasks":
|
||||
header = "Suspicious Scheduled Tasks"
|
||||
for _, t := range sus.ScheduledTasks {
|
||||
label := t.Name
|
||||
desc := t.Path
|
||||
items = append(items, listItem{title: label, desc: desc})
|
||||
}
|
||||
case "services":
|
||||
header = "Suspicious Services"
|
||||
for _, s := range sus.Services {
|
||||
label := fmt.Sprintf("%s (%s)", s.Name, s.DisplayName)
|
||||
desc := s.BinaryPathName
|
||||
items = append(items, listItem{title: label, desc: desc})
|
||||
}
|
||||
}
|
||||
|
||||
l.Title = header + " — Left: Back Enter: Details q: Quit"
|
||||
l.SetItems(items)
|
||||
return ListViewModel{typeKey: typeKey, list: l, header: header}
|
||||
}
|
||||
|
||||
func (m ListViewModel) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m ListViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.list.SetSize(msg.Width, msg.Height-2)
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "left":
|
||||
return m, func() tea.Msg { return BackMsg{} }
|
||||
case "q", "esc", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "enter":
|
||||
return m, func() tea.Msg { return ListSelectedMsg{TypeKey: m.typeKey, Index: m.list.Index()} }
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.list, cmd = m.list.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m ListViewModel) View() string { return m.list.View() }
|
||||
@@ -0,0 +1,120 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// SelectedTypeMsg is sent when the user chooses a type (1-7)
|
||||
// Valid Type values: "autoruns", "binaries", "connections", "directories", "processes", "scheduledTasks", "services"
|
||||
type SelectedTypeMsg struct{ Type string }
|
||||
|
||||
// BackMsg is sent when the user presses Left to go back
|
||||
type BackMsg struct{}
|
||||
|
||||
// keyMap defines keybindings for the type picker
|
||||
// It must satisfy key.Map for the help component
|
||||
// We only need: 1-7, left/back, help, quit
|
||||
type keyMap struct {
|
||||
Help key.Binding
|
||||
Quit key.Binding
|
||||
Back key.Binding
|
||||
One key.Binding
|
||||
Two key.Binding
|
||||
Three key.Binding
|
||||
Four key.Binding
|
||||
Five key.Binding
|
||||
Six key.Binding
|
||||
Seven key.Binding
|
||||
}
|
||||
|
||||
func (k keyMap) ShortHelp() []key.Binding {
|
||||
return []key.Binding{k.Back, k.Help, k.Quit}
|
||||
}
|
||||
|
||||
func (k keyMap) FullHelp() [][]key.Binding {
|
||||
return [][]key.Binding{
|
||||
{k.One, k.Two, k.Three, k.Four, k.Five, k.Six, k.Seven},
|
||||
{k.Back, k.Help, k.Quit},
|
||||
}
|
||||
}
|
||||
|
||||
var keys = keyMap{
|
||||
One: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "AutoRuns")),
|
||||
Two: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "Binaries")),
|
||||
Three: key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "Connections")),
|
||||
Four: key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "Directories")),
|
||||
Five: key.NewBinding(key.WithKeys("5"), key.WithHelp("5", "Processes")),
|
||||
Six: key.NewBinding(key.WithKeys("6"), key.WithHelp("6", "Scheduled Tasks")),
|
||||
Seven: key.NewBinding(key.WithKeys("7"), key.WithHelp("7", "Services")),
|
||||
Back: key.NewBinding(key.WithKeys("left"), key.WithHelp("←", "back")),
|
||||
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "toggle help")),
|
||||
Quit: key.NewBinding(key.WithKeys("q", "esc", "ctrl+c"), key.WithHelp("q", "quit")),
|
||||
}
|
||||
|
||||
type TypePickerModel struct {
|
||||
keys keyMap
|
||||
help help.Model
|
||||
inputStyle lipgloss.Style
|
||||
quitting bool
|
||||
}
|
||||
|
||||
func NewTypePicker() TypePickerModel {
|
||||
return TypePickerModel{
|
||||
keys: keys,
|
||||
help: help.New(),
|
||||
inputStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#ef8430")),
|
||||
}
|
||||
}
|
||||
|
||||
func (m TypePickerModel) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m TypePickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.help.Width = msg.Width
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Quit):
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
case key.Matches(msg, m.keys.Help):
|
||||
m.help.ShowAll = !m.help.ShowAll
|
||||
case key.Matches(msg, m.keys.Back):
|
||||
return m, func() tea.Msg { return BackMsg{} }
|
||||
case key.Matches(msg, m.keys.One):
|
||||
return m, func() tea.Msg { return SelectedTypeMsg{Type: "autoruns"} }
|
||||
case key.Matches(msg, m.keys.Two):
|
||||
return m, func() tea.Msg { return SelectedTypeMsg{Type: "binaries"} }
|
||||
case key.Matches(msg, m.keys.Three):
|
||||
return m, func() tea.Msg { return SelectedTypeMsg{Type: "connections"} }
|
||||
case key.Matches(msg, m.keys.Four):
|
||||
return m, func() tea.Msg { return SelectedTypeMsg{Type: "directories"} }
|
||||
case key.Matches(msg, m.keys.Five):
|
||||
return m, func() tea.Msg { return SelectedTypeMsg{Type: "processes"} }
|
||||
case key.Matches(msg, m.keys.Six):
|
||||
return m, func() tea.Msg { return SelectedTypeMsg{Type: "scheduledTasks"} }
|
||||
case key.Matches(msg, m.keys.Seven):
|
||||
return m, func() tea.Msg { return SelectedTypeMsg{Type: "services"} }
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m TypePickerModel) View() string {
|
||||
if m.quitting {
|
||||
return "Bye!\n"
|
||||
}
|
||||
title := lipgloss.NewStyle().Bold(true).Render("Select a type to manage")
|
||||
menu := "\n 1) AutoRuns\n 2) Binaries\n 3) Connections\n 4) Directories\n 5) Processes\n 6) Scheduled Tasks\n 7) Services\n"
|
||||
helpView := m.help.View(m.keys)
|
||||
height := 8 - strings.Count(menu, "\n") - strings.Count(helpView, "\n")
|
||||
if height < 0 {
|
||||
height = 0
|
||||
}
|
||||
return title + "\n" + menu + strings.Repeat("\n", height) + helpView
|
||||
}
|
||||
Reference in New Issue
Block a user