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}}
{{end}}
{{else}}
@@ -479,7 +479,7 @@ const htmlTemplate = `
{{if .Findings.Directories}}
{{range .Findings.Directories}}
{{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 }