From c9e2e8dff847f90eb5f6584411c7ef2f7df43a1d Mon Sep 17 00:00:00 2001 From: Evan Hosinski Date: Sat, 11 Oct 2025 21:01:07 -0400 Subject: [PATCH] Refactor suspicious artifact data structures, enhance eliminated state tracking, and update UI rendering for eliminated items. Add JSON marshal/unmarshal support for `Binary` and `Directory` types. --- internal/pkg/hunt/detect/binaries/binaries.go | 7 +- .../pkg/hunt/detect/directory/directories.go | 9 +- internal/pkg/hunt/eliminate/processes.go | 13 +- internal/pkg/writer/htmlTemplate.go | 4 +- internal/suspicious/rmm.go | 93 ++++++-- internal/tui/actions.go | 20 +- internal/tui/app.go | 205 +++++++++++++++--- internal/tui/detailview.go | 52 ++++- internal/tui/listview.go | 94 ++++++-- 9 files changed, 409 insertions(+), 88 deletions(-) diff --git a/internal/pkg/hunt/detect/binaries/binaries.go b/internal/pkg/hunt/detect/binaries/binaries.go index acc99bc..a5828f1 100644 --- a/internal/pkg/hunt/detect/binaries/binaries.go +++ b/internal/pkg/hunt/detect/binaries/binaries.go @@ -5,12 +5,13 @@ import ( "os" "path/filepath" "rmm-hunter/internal/pkg/hunt/detect/common" + . "rmm-hunter/internal/suspicious" "strings" "sync" ) -func Detect() []string { - var foundBinaries []string +func Detect() []Binary { + var foundBinaries []Binary var mu sync.Mutex var wg sync.WaitGroup @@ -52,7 +53,7 @@ func Detect() []string { // Collect results for result := range resultChan { mu.Lock() - foundBinaries = append(foundBinaries, result) + foundBinaries = append(foundBinaries, Binary{Path: result}) mu.Unlock() fmt.Printf(" [?] Found %s\n", result) } diff --git a/internal/pkg/hunt/detect/directory/directories.go b/internal/pkg/hunt/detect/directory/directories.go index 0b402d7..41c7f3b 100644 --- a/internal/pkg/hunt/detect/directory/directories.go +++ b/internal/pkg/hunt/detect/directory/directories.go @@ -5,13 +5,14 @@ import ( "os" "path/filepath" "rmm-hunter/internal/pkg/hunt/detect/common" + . "rmm-hunter/internal/suspicious" "strings" ) var appData = os.Getenv("APPDATA") -func Detect() []string { - var suspiciousDirectories []string +func Detect() []Directory { + var suspiciousDirectories []Directory seen := make(map[string]bool) // Prevent duplicates fmt.Printf("[*] Enumerating Suspicious Directories \n") @@ -26,7 +27,7 @@ func Detect() []string { for _, match := range matches { if !seen[match] { fmt.Printf(" [?] Found %s\n", match) - suspiciousDirectories = append(suspiciousDirectories, match) + suspiciousDirectories = append(suspiciousDirectories, Directory{Path: match}) seen[match] = true } } @@ -35,7 +36,7 @@ func Detect() []string { if _, err := os.Stat(dir); err == nil { if !seen[dir] { fmt.Printf(" [?] Found %s\n", dir) - suspiciousDirectories = append(suspiciousDirectories, dir) + suspiciousDirectories = append(suspiciousDirectories, Directory{Path: dir}) seen[dir] = true } } diff --git a/internal/pkg/hunt/eliminate/processes.go b/internal/pkg/hunt/eliminate/processes.go index 76ef14f..3794d89 100644 --- a/internal/pkg/hunt/eliminate/processes.go +++ b/internal/pkg/hunt/eliminate/processes.go @@ -1,6 +1,8 @@ package eliminate import ( + "fmt" + . "rmm-hunter/internal/suspicious" scurvy "github.com/Kraken-OffSec/Scurvy" @@ -8,9 +10,16 @@ import ( // EliminateProcess kills a process and removes its binary from the system func EliminateProcess(p Process) error { - err, proc := scurvy.FindProcessByPID(p.PID) + err, procs := scurvy.ListProcesses() if err != nil { return err } - return proc.Kill() + + for _, proc := range procs { + if proc.Pid() == p.PID { + return proc.Kill() + } + } + + return fmt.Errorf("process %d not found", p.PID) } diff --git a/internal/pkg/writer/htmlTemplate.go b/internal/pkg/writer/htmlTemplate.go index 4b47c34..71e30ba 100644 --- a/internal/pkg/writer/htmlTemplate.go +++ b/internal/pkg/writer/htmlTemplate.go @@ -460,7 +460,7 @@ const htmlTemplate = ` {{if .Findings.Binaries}} {{range .Findings.Binaries}}
-
{{.}}
+
{{.Path}}
{{end}} {{else}} @@ -479,7 +479,7 @@ const htmlTemplate = ` {{if .Findings.Directories}} {{range .Findings.Directories}}
-
{{.}}
+
{{.Path}}
{{end}} {{else}} diff --git a/internal/suspicious/rmm.go b/internal/suspicious/rmm.go index 622f3ad..aef4066 100644 --- a/internal/suspicious/rmm.go +++ b/internal/suspicious/rmm.go @@ -1,5 +1,9 @@ package suspicious +import ( + "encoding/json" +) + /* Suspicious The object used to resemble the Suspicious artifacts and activities. @@ -8,8 +12,8 @@ type Suspicious struct { Artifacts []Artifact `json:"artifacts"` Persistence Persistence `json:"persistence"` RootFolder string `json:"rootFolder"` - Binaries []string `json:"binaries"` - Directories []string `json:"directories"` + Binaries []Binary `json:"binaries"` + Directories []Directory `json:"directories"` Services []*Service `json:"services"` Processes []Process `json:"processes"` OutboundConnections []NetworkConnection `json:"outboundConnections"` @@ -17,13 +21,24 @@ type Suspicious struct { ScheduledTasks []*ScheduledTask `json:"scheduledTasks"` } +type Binary struct { + Path string `json:"path"` + Eliminated bool `json:"eliminated,omitempty"` +} + +type Directory struct { + Path string `json:"path"` + Eliminated bool `json:"eliminated,omitempty"` +} + type NetworkConnection struct { - LocalAddr string - RemoteAddr string - RemoteHost string - State string - PID string - Process string + LocalAddr string `json:"localAddr"` + RemoteAddr string `json:"remoteAddr"` + RemoteHost string `json:"remoteHost"` + State string `json:"state"` + PID string `json:"pid"` + Process string `json:"process"` + Eliminated bool `json:"eliminated,omitempty"` } /* @@ -60,6 +75,7 @@ type AutoRun struct { SHA256 string `json:"sha256"` Entry string `json:"entry"` LaunchString string `json:"launch_string"` + Eliminated bool `json:"eliminated,omitempty"` } /* @@ -78,6 +94,7 @@ type ScheduledTask struct { NextRun string `json:"nextRun"` LastRun string `json:"lastRun"` Path string `json:"path"` + Eliminated bool `json:"eliminated,omitempty"` } /* @@ -85,13 +102,14 @@ Process The object used to resemble the processes used by the Suspicious software. */ type Process struct { - Name string `json:"name"` - PID int `json:"pid"` - PPID int `json:"ppid"` - Parent string `json:"parent"` - Args string `json:"args"` - Created string `json:"created"` - Path string `json:"path"` + Name string `json:"name"` + PID int `json:"pid"` + PPID int `json:"ppid"` + Parent string `json:"parent"` + Args string `json:"args"` + Created string `json:"created"` + Path string `json:"path"` + Eliminated bool `json:"eliminated,omitempty"` } /* @@ -116,4 +134,49 @@ type Service struct { Description string `json:"description"` SidType uint32 `json:"sidType"` DelayedAutoStart bool `json:"delayedAutoStart"` + Eliminated bool `json:"eliminated,omitempty"` +} + +// UnmarshalJSON implements custom unmarshaling for Binary to support both string and object formats +func (b *Binary) UnmarshalJSON(data []byte) error { + // Try to unmarshal as string first (old format) + var str string + if err := json.Unmarshal(data, &str); err == nil { + b.Path = str + b.Eliminated = false + return nil + } + + // Try to unmarshal as object (new format) + type Alias Binary + aux := &struct{ *Alias }{Alias: (*Alias)(b)} + return json.Unmarshal(data, aux) +} + +// MarshalJSON implements custom marshaling for Binary to always use object format +func (b Binary) MarshalJSON() ([]byte, error) { + type Alias Binary + return json.Marshal(&struct{ *Alias }{Alias: (*Alias)(&b)}) +} + +// UnmarshalJSON implements custom unmarshaling for Directory to support both string and object formats +func (d *Directory) UnmarshalJSON(data []byte) error { + // Try to unmarshal as string first (old format) + var str string + if err := json.Unmarshal(data, &str); err == nil { + d.Path = str + d.Eliminated = false + return nil + } + + // Try to unmarshal as object (new format) + type Alias Directory + aux := &struct{ *Alias }{Alias: (*Alias)(d)} + return json.Unmarshal(data, aux) +} + +// MarshalJSON implements custom marshaling for Directory to always use object format +func (d Directory) MarshalJSON() ([]byte, error) { + type Alias Directory + return json.Marshal(&struct{ *Alias }{Alias: (*Alias)(&d)}) } diff --git a/internal/tui/actions.go b/internal/tui/actions.go index 08954b6..6405f7c 100644 --- a/internal/tui/actions.go +++ b/internal/tui/actions.go @@ -44,18 +44,27 @@ func exeFromCommand(cmd string) string { // CheckBinaryBlocked returns a WarnBlock if the path is in use by an active process or enabled+active service func CheckBinaryBlocked(path string, data suspicious.Suspicious) error { np := normPath(path) - // active process: listed in data.Processes + // active process: listed in data.Processes (skip if already eliminated) for _, p := range data.Processes { + if p.Eliminated { + continue // Skip eliminated processes + } if normPath(p.Path) == np { return WarnBlock{Reason: fmt.Sprintf("Binary in use by running process %s (PID %d). Eliminate the process first.", p.Name, p.PID)} } } // enabled+active service: service uses this binary AND a running process exists for it for _, s := range data.Services { + if s.Eliminated { + continue // Skip eliminated services + } sp := normPath(exeFromCommand(s.BinaryPathName)) if sp == np && !strings.EqualFold(strings.TrimSpace(s.StartType), "disabled") { // Is it active? infer by checking matching running process for _, p := range data.Processes { + if p.Eliminated { + continue // Skip eliminated processes + } if normPath(p.Path) == sp { return WarnBlock{Reason: fmt.Sprintf("Binary used by active and enabled service %s. Stop/delete the service first.", s.Name)} } @@ -84,15 +93,24 @@ func CheckDirectoryBlocked(dir string, data suspicious.Suspicious) error { return err == nil && rel != ".." && !strings.HasPrefix(rel, "../") } for _, p := range data.Processes { + if p.Eliminated { + continue // Skip eliminated processes + } if inDir(p.Path) { return WarnBlock{Reason: fmt.Sprintf("Directory contains running process %s (PID %d). Eliminate the process first.", p.Name, p.PID)} } } for _, s := range data.Services { + if s.Eliminated { + continue // Skip eliminated services + } sp := exeFromCommand(s.BinaryPathName) if inDir(sp) && !strings.EqualFold(strings.TrimSpace(s.StartType), "disabled") { // infer active via running process for _, p := range data.Processes { + if p.Eliminated { + continue // Skip eliminated processes + } if normPath(p.Path) == normPath(sp) { return WarnBlock{Reason: fmt.Sprintf("Directory contains active and enabled service binary for %s. Stop/delete the service first.", s.Name)} } diff --git a/internal/tui/app.go b/internal/tui/app.go index 689dc45..8d07ed6 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -23,23 +23,26 @@ const ( ) type AppModel struct { - current screen - filePick FilePickerModel - typePick TypePickerModel - listView ListViewModel - detail DetailViewModel - err error - selected string - data suspicious.Suspicious - width int - height int + current screen + filePick FilePickerModel + typePick TypePickerModel + listView ListViewModel + detail DetailViewModel + err error + selected string + data suspicious.Suspicious + width int + height int + eliminated map[string]map[int]bool // tracks eliminated items: typeKey -> index -> eliminated + filePath string // path to the loaded JSON file } func NewApp() AppModel { return AppModel{ - current: screenFilePicker, - filePick: NewFilePicker(), - typePick: NewTypePicker(), + current: screenFilePicker, + filePick: NewFilePicker(), + typePick: NewTypePicker(), + eliminated: make(map[string]map[int]bool), } } @@ -66,6 +69,7 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.current = screenError return m, nil } + m.filePath = v.Path m.current = screenTypePicker return m, nil } @@ -84,7 +88,7 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case SelectedTypeMsg: m.selected = v.Type - m.listView = NewListView(v.Type, m.data, m.width, m.height) + m.listView = NewListView(v.Type, m.data, m.width, m.height, m.eliminated) m.current = screenList return m, nil } @@ -102,7 +106,7 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.current = screenTypePicker return m, nil case ListSelectedMsg: - m.detail = NewDetailView(v.TypeKey, v.Index, m.data) + m.detail = NewDetailView(v.TypeKey, v.Index, m.data, m.eliminated) m.current = screenDetail return m, nil } @@ -120,6 +124,12 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.current = screenList return m, nil case RequestEliminateMsg: + // Check if already eliminated + if m.eliminated[v.TypeKey] != nil && m.eliminated[v.TypeKey][v.Index] { + m.detail.modalWarn = "This item has already been eliminated" + return m, nil + } + if err := m.performEliminate(v.TypeKey, v.Index); err != nil { var wb WarnBlock if errors.As(err, &wb) { @@ -129,8 +139,20 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil } - // success -> rebuild list and go back - m.listView = NewListView(m.selected, m.data, m.width, m.height) + // success -> mark as eliminated, save file, and rebuild list + if m.eliminated[v.TypeKey] == nil { + m.eliminated[v.TypeKey] = make(map[int]bool) + } + m.eliminated[v.TypeKey][v.Index] = true + + // Save the updated data to file + if err := m.saveDataToFile(); err != nil { + m.detail.modalErr = fmt.Sprintf("Eliminated successfully but failed to save: %v", err) + return m, nil + } + + m.detail = NewDetailView(v.TypeKey, v.Index, m.data, m.eliminated) + m.listView = NewListView(m.selected, m.data, m.width, m.height, m.eliminated) m.current = screenList return m, nil } @@ -163,7 +185,7 @@ func (m AppModel) View() string { } } -// performEliminate routes to placeholder eliminate functions and mutates data on success +// performEliminate routes to placeholder eliminate functions without removing items from data func (m *AppModel) performEliminate(typeKey string, idx int) error { switch typeKey { case "autoruns": @@ -171,49 +193,49 @@ func (m *AppModel) performEliminate(typeKey string, idx int) error { if err := EliminateAutoRun(ar); err != nil { return err } - m.data.AutoRuns = append(m.data.AutoRuns[:idx], m.data.AutoRuns[idx+1:]...) + m.data.AutoRuns[idx].Eliminated = true case "binaries": b := m.data.Binaries[idx] - if err := CheckBinaryBlocked(b, m.data); err != nil { + if err := CheckBinaryBlocked(b.Path, m.data); err != nil { return err } - if err := EliminateBinary(b); err != nil { + if err := EliminateBinary(b.Path); err != nil { return err } - m.data.Binaries = append(m.data.Binaries[:idx], m.data.Binaries[idx+1:]...) + m.data.Binaries[idx].Eliminated = true 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:]...) + m.data.OutboundConnections[idx].Eliminated = true case "directories": d := m.data.Directories[idx] - if err := CheckDirectoryBlocked(d, m.data); err != nil { + if err := CheckDirectoryBlocked(d.Path, m.data); err != nil { return err } - if err := EliminateDirectory(d); err != nil { + if err := EliminateDirectory(d.Path); err != nil { return err } - m.data.Directories = append(m.data.Directories[:idx], m.data.Directories[idx+1:]...) + m.data.Directories[idx].Eliminated = true 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:]...) + m.data.Processes[idx].Eliminated = true 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:]...) + m.data.ScheduledTasks[idx].Eliminated = true 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:]...) + m.data.Services[idx].Eliminated = true } return nil } @@ -234,6 +256,7 @@ func (m *AppModel) loadSelectedFile(path string) error { return err } m.data = sus + m.loadEliminatedState() return nil } // Try bare suspicious structure @@ -242,9 +265,133 @@ func (m *AppModel) loadSelectedFile(path string) error { return fmt.Errorf("no findings in report") } m.data = sus + m.loadEliminatedState() return nil } +// loadEliminatedState populates the eliminated map from the data structures +func (m *AppModel) loadEliminatedState() { + m.eliminated = make(map[string]map[int]bool) + + // Load eliminated autoruns + for i, ar := range m.data.AutoRuns { + if ar.Eliminated { + if m.eliminated["autoruns"] == nil { + m.eliminated["autoruns"] = make(map[int]bool) + } + m.eliminated["autoruns"][i] = true + } + } + + // Load eliminated binaries + for i, b := range m.data.Binaries { + if b.Eliminated { + if m.eliminated["binaries"] == nil { + m.eliminated["binaries"] = make(map[int]bool) + } + m.eliminated["binaries"][i] = true + } + } + + // Load eliminated connections + for i, c := range m.data.OutboundConnections { + if c.Eliminated { + if m.eliminated["connections"] == nil { + m.eliminated["connections"] = make(map[int]bool) + } + m.eliminated["connections"][i] = true + } + } + + // Load eliminated directories + for i, d := range m.data.Directories { + if d.Eliminated { + if m.eliminated["directories"] == nil { + m.eliminated["directories"] = make(map[int]bool) + } + m.eliminated["directories"][i] = true + } + } + + // Load eliminated processes + for i, p := range m.data.Processes { + if p.Eliminated { + if m.eliminated["processes"] == nil { + m.eliminated["processes"] = make(map[int]bool) + } + m.eliminated["processes"][i] = true + } + } + + // Load eliminated scheduled tasks + for i, t := range m.data.ScheduledTasks { + if t.Eliminated { + if m.eliminated["scheduledTasks"] == nil { + m.eliminated["scheduledTasks"] = make(map[int]bool) + } + m.eliminated["scheduledTasks"][i] = true + } + } + + // Load eliminated services + for i, s := range m.data.Services { + if s.Eliminated { + if m.eliminated["services"] == nil { + m.eliminated["services"] = make(map[int]bool) + } + m.eliminated["services"][i] = true + } + } +} + +// saveDataToFile saves the current data back to the JSON file +func (m *AppModel) saveDataToFile() error { + if m.filePath == "" { + return fmt.Errorf("no file path set") + } + + // Read the original file to determine format + b, err := os.ReadFile(m.filePath) + if err != nil { + return err + } + + // Check if it's wrapped format + var envelope struct { + Findings json.RawMessage `json:"findings"` + } + isWrapped := json.Unmarshal(b, &envelope) == nil && len(envelope.Findings) > 0 + + var output []byte + if isWrapped { + // Re-read the full envelope + var fullEnvelope map[string]interface{} + if err := json.Unmarshal(b, &fullEnvelope); err != nil { + return err + } + + // Update the findings + findingsJSON, err := json.MarshalIndent(m.data, "", " ") + if err != nil { + return err + } + fullEnvelope["findings"] = json.RawMessage(findingsJSON) + + output, err = json.MarshalIndent(fullEnvelope, "", " ") + if err != nil { + return err + } + } else { + // Bare format + output, err = json.MarshalIndent(m.data, "", " ") + if err != nil { + return err + } + } + + return os.WriteFile(m.filePath, output, 0644) +} + // RunEliminateUI starts the Bubble Tea program for elimination UI func RunEliminateUI() error { p := tea.NewProgram(NewApp()) diff --git a/internal/tui/detailview.go b/internal/tui/detailview.go index 68e4d55..b9fffdf 100644 --- a/internal/tui/detailview.go +++ b/internal/tui/detailview.go @@ -22,16 +22,17 @@ type DeletedMsg struct { } type DetailViewModel struct { - typeKey string - index int - data suspicious.Suspicious + typeKey string + index int + data suspicious.Suspicious + eliminated map[string]map[int]bool // When modal* != "", show modal and require ESC to dismiss modalErr string modalWarn string } -func NewDetailView(typeKey string, index int, data suspicious.Suspicious) DetailViewModel { - return DetailViewModel{typeKey: typeKey, index: index, data: data} +func NewDetailView(typeKey string, index int, data suspicious.Suspicious, eliminated map[string]map[int]bool) DetailViewModel { + return DetailViewModel{typeKey: typeKey, index: index, data: data, eliminated: eliminated} } func (m DetailViewModel) Init() tea.Cmd { return nil } @@ -60,8 +61,22 @@ func (m DetailViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m DetailViewModel) View() string { - title := lipgloss.NewStyle().Bold(true).Render("Details — press ! to eliminate, Left to go back, q to quit") + isEliminated := m.eliminated[m.typeKey] != nil && m.eliminated[m.typeKey][m.index] + + var title string + if isEliminated { + title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("10")).Render("Details — ELIMINATED — Left to go back, q to quit") + } else { + title = lipgloss.NewStyle().Bold(true).Render("Details — press ! to eliminate, Left to go back, q to quit") + } + body := m.renderDetails() + + // Apply green styling to body if eliminated + if isEliminated { + body = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render(body) + } + view := title + "\n\n" + body if m.modalWarn != "" { modal := lipgloss.NewStyle().Padding(1, 2).Foreground(lipgloss.Color("214")).Border(lipgloss.RoundedBorder()).Render("Warning:\n" + m.modalWarn + "\n\nPress ESC to dismiss") @@ -77,24 +92,45 @@ func (m DetailViewModel) View() string { func (m DetailViewModel) renderDetails() string { switch m.typeKey { case "autoruns": + if m.index >= len(m.data.AutoRuns) { + return "Item no longer available" + } ar := m.data.AutoRuns[m.index] return fmt.Sprintf("Type: %s\nEntry: %s\nLaunch: %s\nLocation: %s\nImage: %s\nArgs: %s\nMD5: %s\nSHA1: %s\nSHA256: %s", ar.Type, ar.Entry, ar.LaunchString, ar.Location, ar.ImagePath, ar.Arguments, ar.MD5, ar.SHA1, ar.SHA256) case "binaries": + if m.index >= len(m.data.Binaries) { + return "Item no longer available" + } b := m.data.Binaries[m.index] - return fmt.Sprintf("Binary: %s\nAction: delete file", b) + return fmt.Sprintf("Binary: %s\nAction: delete file", b.Path) case "connections": + if m.index >= len(m.data.OutboundConnections) { + return "Item no longer available" + } 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": + if m.index >= len(m.data.Directories) { + return "Item no longer available" + } d := m.data.Directories[m.index] - return fmt.Sprintf("Directory: %s\nAction: delete recursively", d) + return fmt.Sprintf("Directory: %s\nAction: delete recursively", d.Path) case "processes": + if m.index >= len(m.data.Processes) { + return "Item no longer available" + } 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": + if m.index >= len(m.data.ScheduledTasks) { + return "Item no longer available" + } 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": + if m.index >= len(m.data.Services) { + return "Item no longer available" + } 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) } diff --git a/internal/tui/listview.go b/internal/tui/listview.go index bf80453..d4e9c9e 100644 --- a/internal/tui/listview.go +++ b/internal/tui/listview.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "io" "rmm-hunter/internal/suspicious" @@ -16,22 +17,60 @@ type ListSelectedMsg struct { Index int } -type listItem struct{ title, desc string } +type listItem struct { + title string + desc string + eliminated bool +} 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 +// customDelegate is a custom list item delegate that renders eliminated items in green +type customDelegate struct { + list.DefaultDelegate } -func NewListView(typeKey string, sus suspicious.Suspicious, width, height int) ListViewModel { - delegate := list.NewDefaultDelegate() - delegate.ShowDescription = true +func (d customDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { + i, ok := item.(listItem) + if !ok { + d.DefaultDelegate.Render(w, m, index, item) + return + } + + title := i.Title() + desc := i.Description() + + // Style for eliminated items (green) + if i.eliminated { + titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("10")) + descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("10")) + + if index == m.Index() { + // Selected item - add background + titleStyle = titleStyle.Background(lipgloss.Color("240")) + descStyle = descStyle.Background(lipgloss.Color("240")) + } + + fmt.Fprintf(w, "%s\n%s", titleStyle.Render("✓ "+title), descStyle.Render(" "+desc)) + } else { + // Normal rendering for non-eliminated items + d.DefaultDelegate.Render(w, m, index, item) + } +} + +type ListViewModel struct { + typeKey string + list list.Model + header string + eliminated map[string]map[int]bool +} + +func NewListView(typeKey string, sus suspicious.Suspicious, width, height int, eliminated map[string]map[int]bool) ListViewModel { + defaultDelegate := list.NewDefaultDelegate() + defaultDelegate.ShowDescription = true + delegate := customDelegate{DefaultDelegate: defaultDelegate} l := list.New([]list.Item{}, delegate, 0, 0) if width > 0 && height > 0 { l.SetSize(width, height-2) @@ -45,56 +84,63 @@ func NewListView(typeKey string, sus suspicious.Suspicious, width, height int) L switch typeKey { case "autoruns": header = "Suspicious AutoRuns" - for _, ar := range sus.AutoRuns { + for i, ar := range sus.AutoRuns { title := ar.ImageName if title == "" { title = ar.Entry } desc := fmt.Sprintf("%s (%s)", ar.ImagePath, ar.Location) - items = append(items, listItem{title: title, desc: desc}) + isEliminated := eliminated[typeKey] != nil && eliminated[typeKey][i] + items = append(items, listItem{title: title, desc: desc, eliminated: isEliminated}) } case "binaries": header = "Suspicious Binaries" - for _, b := range sus.Binaries { - items = append(items, listItem{title: b, desc: "binary file"}) + for i, b := range sus.Binaries { + isEliminated := eliminated[typeKey] != nil && eliminated[typeKey][i] + items = append(items, listItem{title: b.Path, desc: "binary file", eliminated: isEliminated}) } case "connections": header = "Suspicious Connections" - for _, c := range sus.OutboundConnections { + for i, 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)}) + isEliminated := eliminated[typeKey] != nil && eliminated[typeKey][i] + items = append(items, listItem{title: label, desc: fmt.Sprintf("PID %s %s", c.PID, c.Process), eliminated: isEliminated}) } case "directories": header = "Suspicious Directories" - for _, d := range sus.Directories { - items = append(items, listItem{title: d, desc: "directory"}) + for i, d := range sus.Directories { + isEliminated := eliminated[typeKey] != nil && eliminated[typeKey][i] + items = append(items, listItem{title: d.Path, desc: "directory", eliminated: isEliminated}) } case "processes": header = "Suspicious Processes" - for _, p := range sus.Processes { + for i, 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}) + isEliminated := eliminated[typeKey] != nil && eliminated[typeKey][i] + items = append(items, listItem{title: label, desc: desc, eliminated: isEliminated}) } case "scheduledTasks": header = "Suspicious Scheduled Tasks" - for _, t := range sus.ScheduledTasks { + for i, t := range sus.ScheduledTasks { label := t.Name desc := t.Path - items = append(items, listItem{title: label, desc: desc}) + isEliminated := eliminated[typeKey] != nil && eliminated[typeKey][i] + items = append(items, listItem{title: label, desc: desc, eliminated: isEliminated}) } case "services": header = "Suspicious Services" - for _, s := range sus.Services { + for i, s := range sus.Services { label := fmt.Sprintf("%s (%s)", s.Name, s.DisplayName) desc := s.BinaryPathName - items = append(items, listItem{title: label, desc: desc}) + isEliminated := eliminated[typeKey] != nil && eliminated[typeKey][i] + items = append(items, listItem{title: label, desc: desc, eliminated: isEliminated}) } } l.Title = header + " — Left: Back Enter: Details q: Quit" l.SetItems(items) - return ListViewModel{typeKey: typeKey, list: l, header: header} + return ListViewModel{typeKey: typeKey, list: l, header: header, eliminated: eliminated} } func (m ListViewModel) Init() tea.Cmd { return nil }