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.

This commit is contained in:
Evan Hosinski
2025-10-11 21:01:07 -04:00
parent bde1b23753
commit c9e2e8dff8
9 changed files with 409 additions and 88 deletions
@@ -5,12 +5,13 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"rmm-hunter/internal/pkg/hunt/detect/common" "rmm-hunter/internal/pkg/hunt/detect/common"
. "rmm-hunter/internal/suspicious"
"strings" "strings"
"sync" "sync"
) )
func Detect() []string { func Detect() []Binary {
var foundBinaries []string var foundBinaries []Binary
var mu sync.Mutex var mu sync.Mutex
var wg sync.WaitGroup var wg sync.WaitGroup
@@ -52,7 +53,7 @@ func Detect() []string {
// Collect results // Collect results
for result := range resultChan { for result := range resultChan {
mu.Lock() mu.Lock()
foundBinaries = append(foundBinaries, result) foundBinaries = append(foundBinaries, Binary{Path: result})
mu.Unlock() mu.Unlock()
fmt.Printf(" [?] Found %s\n", result) fmt.Printf(" [?] Found %s\n", result)
} }
@@ -5,13 +5,14 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"rmm-hunter/internal/pkg/hunt/detect/common" "rmm-hunter/internal/pkg/hunt/detect/common"
. "rmm-hunter/internal/suspicious"
"strings" "strings"
) )
var appData = os.Getenv("APPDATA") var appData = os.Getenv("APPDATA")
func Detect() []string { func Detect() []Directory {
var suspiciousDirectories []string var suspiciousDirectories []Directory
seen := make(map[string]bool) // Prevent duplicates seen := make(map[string]bool) // Prevent duplicates
fmt.Printf("[*] Enumerating Suspicious Directories \n") fmt.Printf("[*] Enumerating Suspicious Directories \n")
@@ -26,7 +27,7 @@ func Detect() []string {
for _, match := range matches { for _, match := range matches {
if !seen[match] { if !seen[match] {
fmt.Printf(" [?] Found %s\n", match) fmt.Printf(" [?] Found %s\n", match)
suspiciousDirectories = append(suspiciousDirectories, match) suspiciousDirectories = append(suspiciousDirectories, Directory{Path: match})
seen[match] = true seen[match] = true
} }
} }
@@ -35,7 +36,7 @@ func Detect() []string {
if _, err := os.Stat(dir); err == nil { if _, err := os.Stat(dir); err == nil {
if !seen[dir] { if !seen[dir] {
fmt.Printf(" [?] Found %s\n", dir) fmt.Printf(" [?] Found %s\n", dir)
suspiciousDirectories = append(suspiciousDirectories, dir) suspiciousDirectories = append(suspiciousDirectories, Directory{Path: dir})
seen[dir] = true seen[dir] = true
} }
} }
+11 -2
View File
@@ -1,6 +1,8 @@
package eliminate package eliminate
import ( import (
"fmt"
. "rmm-hunter/internal/suspicious" . "rmm-hunter/internal/suspicious"
scurvy "github.com/Kraken-OffSec/Scurvy" scurvy "github.com/Kraken-OffSec/Scurvy"
@@ -8,9 +10,16 @@ import (
// EliminateProcess kills a process and removes its binary from the system // EliminateProcess kills a process and removes its binary from the system
func EliminateProcess(p Process) error { func EliminateProcess(p Process) error {
err, proc := scurvy.FindProcessByPID(p.PID) err, procs := scurvy.ListProcesses()
if err != nil { if err != nil {
return err 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)
} }
+2 -2
View File
@@ -460,7 +460,7 @@ const htmlTemplate = `<!DOCTYPE html>
{{if .Findings.Binaries}} {{if .Findings.Binaries}}
{{range .Findings.Binaries}} {{range .Findings.Binaries}}
<div class="item"> <div class="item">
<div class="item-detail">{{.}}</div> <div class="item-detail">{{.Path}}</div>
</div> </div>
{{end}} {{end}}
{{else}} {{else}}
@@ -479,7 +479,7 @@ const htmlTemplate = `<!DOCTYPE html>
{{if .Findings.Directories}} {{if .Findings.Directories}}
{{range .Findings.Directories}} {{range .Findings.Directories}}
<div class="item"> <div class="item">
<div class="item-detail">{{.}}</div> <div class="item-detail">{{.Path}}</div>
</div> </div>
{{end}} {{end}}
{{else}} {{else}}
+78 -15
View File
@@ -1,5 +1,9 @@
package suspicious package suspicious
import (
"encoding/json"
)
/* /*
Suspicious Suspicious
The object used to resemble the Suspicious artifacts and activities. The object used to resemble the Suspicious artifacts and activities.
@@ -8,8 +12,8 @@ type Suspicious struct {
Artifacts []Artifact `json:"artifacts"` Artifacts []Artifact `json:"artifacts"`
Persistence Persistence `json:"persistence"` Persistence Persistence `json:"persistence"`
RootFolder string `json:"rootFolder"` RootFolder string `json:"rootFolder"`
Binaries []string `json:"binaries"` Binaries []Binary `json:"binaries"`
Directories []string `json:"directories"` Directories []Directory `json:"directories"`
Services []*Service `json:"services"` Services []*Service `json:"services"`
Processes []Process `json:"processes"` Processes []Process `json:"processes"`
OutboundConnections []NetworkConnection `json:"outboundConnections"` OutboundConnections []NetworkConnection `json:"outboundConnections"`
@@ -17,13 +21,24 @@ type Suspicious struct {
ScheduledTasks []*ScheduledTask `json:"scheduledTasks"` 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 { type NetworkConnection struct {
LocalAddr string LocalAddr string `json:"localAddr"`
RemoteAddr string RemoteAddr string `json:"remoteAddr"`
RemoteHost string RemoteHost string `json:"remoteHost"`
State string State string `json:"state"`
PID string PID string `json:"pid"`
Process string Process string `json:"process"`
Eliminated bool `json:"eliminated,omitempty"`
} }
/* /*
@@ -60,6 +75,7 @@ type AutoRun struct {
SHA256 string `json:"sha256"` SHA256 string `json:"sha256"`
Entry string `json:"entry"` Entry string `json:"entry"`
LaunchString string `json:"launch_string"` LaunchString string `json:"launch_string"`
Eliminated bool `json:"eliminated,omitempty"`
} }
/* /*
@@ -78,6 +94,7 @@ type ScheduledTask struct {
NextRun string `json:"nextRun"` NextRun string `json:"nextRun"`
LastRun string `json:"lastRun"` LastRun string `json:"lastRun"`
Path string `json:"path"` 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. The object used to resemble the processes used by the Suspicious software.
*/ */
type Process struct { type Process struct {
Name string `json:"name"` Name string `json:"name"`
PID int `json:"pid"` PID int `json:"pid"`
PPID int `json:"ppid"` PPID int `json:"ppid"`
Parent string `json:"parent"` Parent string `json:"parent"`
Args string `json:"args"` Args string `json:"args"`
Created string `json:"created"` Created string `json:"created"`
Path string `json:"path"` Path string `json:"path"`
Eliminated bool `json:"eliminated,omitempty"`
} }
/* /*
@@ -116,4 +134,49 @@ type Service struct {
Description string `json:"description"` Description string `json:"description"`
SidType uint32 `json:"sidType"` SidType uint32 `json:"sidType"`
DelayedAutoStart bool `json:"delayedAutoStart"` 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)})
} }
+19 -1
View File
@@ -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 // 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 { func CheckBinaryBlocked(path string, data suspicious.Suspicious) error {
np := normPath(path) np := normPath(path)
// active process: listed in data.Processes // active process: listed in data.Processes (skip if already eliminated)
for _, p := range data.Processes { for _, p := range data.Processes {
if p.Eliminated {
continue // Skip eliminated processes
}
if normPath(p.Path) == np { 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)} 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 // enabled+active service: service uses this binary AND a running process exists for it
for _, s := range data.Services { for _, s := range data.Services {
if s.Eliminated {
continue // Skip eliminated services
}
sp := normPath(exeFromCommand(s.BinaryPathName)) sp := normPath(exeFromCommand(s.BinaryPathName))
if sp == np && !strings.EqualFold(strings.TrimSpace(s.StartType), "disabled") { if sp == np && !strings.EqualFold(strings.TrimSpace(s.StartType), "disabled") {
// Is it active? infer by checking matching running process // Is it active? infer by checking matching running process
for _, p := range data.Processes { for _, p := range data.Processes {
if p.Eliminated {
continue // Skip eliminated processes
}
if normPath(p.Path) == sp { 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)} 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, "../") return err == nil && rel != ".." && !strings.HasPrefix(rel, "../")
} }
for _, p := range data.Processes { for _, p := range data.Processes {
if p.Eliminated {
continue // Skip eliminated processes
}
if inDir(p.Path) { if inDir(p.Path) {
return WarnBlock{Reason: fmt.Sprintf("Directory contains running process %s (PID %d). Eliminate the process first.", p.Name, p.PID)} 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 { for _, s := range data.Services {
if s.Eliminated {
continue // Skip eliminated services
}
sp := exeFromCommand(s.BinaryPathName) sp := exeFromCommand(s.BinaryPathName)
if inDir(sp) && !strings.EqualFold(strings.TrimSpace(s.StartType), "disabled") { if inDir(sp) && !strings.EqualFold(strings.TrimSpace(s.StartType), "disabled") {
// infer active via running process // infer active via running process
for _, p := range data.Processes { for _, p := range data.Processes {
if p.Eliminated {
continue // Skip eliminated processes
}
if normPath(p.Path) == normPath(sp) { 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)} return WarnBlock{Reason: fmt.Sprintf("Directory contains active and enabled service binary for %s. Stop/delete the service first.", s.Name)}
} }
+176 -29
View File
@@ -23,23 +23,26 @@ const (
) )
type AppModel struct { type AppModel struct {
current screen current screen
filePick FilePickerModel filePick FilePickerModel
typePick TypePickerModel typePick TypePickerModel
listView ListViewModel listView ListViewModel
detail DetailViewModel detail DetailViewModel
err error err error
selected string selected string
data suspicious.Suspicious data suspicious.Suspicious
width int width int
height 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 { func NewApp() AppModel {
return AppModel{ return AppModel{
current: screenFilePicker, current: screenFilePicker,
filePick: NewFilePicker(), filePick: NewFilePicker(),
typePick: NewTypePicker(), 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 m.current = screenError
return m, nil return m, nil
} }
m.filePath = v.Path
m.current = screenTypePicker m.current = screenTypePicker
return m, nil return m, nil
} }
@@ -84,7 +88,7 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
case SelectedTypeMsg: case SelectedTypeMsg:
m.selected = v.Type 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 m.current = screenList
return m, nil return m, nil
} }
@@ -102,7 +106,7 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.current = screenTypePicker m.current = screenTypePicker
return m, nil return m, nil
case ListSelectedMsg: 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 m.current = screenDetail
return m, nil return m, nil
} }
@@ -120,6 +124,12 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.current = screenList m.current = screenList
return m, nil return m, nil
case RequestEliminateMsg: 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 { if err := m.performEliminate(v.TypeKey, v.Index); err != nil {
var wb WarnBlock var wb WarnBlock
if errors.As(err, &wb) { if errors.As(err, &wb) {
@@ -129,8 +139,20 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
return m, nil return m, nil
} }
// success -> rebuild list and go back // success -> mark as eliminated, save file, and rebuild list
m.listView = NewListView(m.selected, m.data, m.width, m.height) 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 m.current = screenList
return m, nil 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 { func (m *AppModel) performEliminate(typeKey string, idx int) error {
switch typeKey { switch typeKey {
case "autoruns": case "autoruns":
@@ -171,49 +193,49 @@ func (m *AppModel) performEliminate(typeKey string, idx int) error {
if err := EliminateAutoRun(ar); err != nil { if err := EliminateAutoRun(ar); err != nil {
return err return err
} }
m.data.AutoRuns = append(m.data.AutoRuns[:idx], m.data.AutoRuns[idx+1:]...) m.data.AutoRuns[idx].Eliminated = true
case "binaries": case "binaries":
b := m.data.Binaries[idx] b := m.data.Binaries[idx]
if err := CheckBinaryBlocked(b, m.data); err != nil { if err := CheckBinaryBlocked(b.Path, m.data); err != nil {
return err return err
} }
if err := EliminateBinary(b); err != nil { if err := EliminateBinary(b.Path); err != nil {
return err return err
} }
m.data.Binaries = append(m.data.Binaries[:idx], m.data.Binaries[idx+1:]...) m.data.Binaries[idx].Eliminated = true
case "connections": case "connections":
c := m.data.OutboundConnections[idx] c := m.data.OutboundConnections[idx]
if err := EliminateConnection(c); err != nil { if err := EliminateConnection(c); err != nil {
return err return err
} }
m.data.OutboundConnections = append(m.data.OutboundConnections[:idx], m.data.OutboundConnections[idx+1:]...) m.data.OutboundConnections[idx].Eliminated = true
case "directories": case "directories":
d := m.data.Directories[idx] d := m.data.Directories[idx]
if err := CheckDirectoryBlocked(d, m.data); err != nil { if err := CheckDirectoryBlocked(d.Path, m.data); err != nil {
return err return err
} }
if err := EliminateDirectory(d); err != nil { if err := EliminateDirectory(d.Path); err != nil {
return err return err
} }
m.data.Directories = append(m.data.Directories[:idx], m.data.Directories[idx+1:]...) m.data.Directories[idx].Eliminated = true
case "processes": case "processes":
p := m.data.Processes[idx] p := m.data.Processes[idx]
if err := EliminateProcess(p); err != nil { if err := EliminateProcess(p); err != nil {
return err return err
} }
m.data.Processes = append(m.data.Processes[:idx], m.data.Processes[idx+1:]...) m.data.Processes[idx].Eliminated = true
case "scheduledTasks": case "scheduledTasks":
t := m.data.ScheduledTasks[idx] t := m.data.ScheduledTasks[idx]
if err := EliminateScheduledTask(*t); err != nil { if err := EliminateScheduledTask(*t); err != nil {
return err return err
} }
m.data.ScheduledTasks = append(m.data.ScheduledTasks[:idx], m.data.ScheduledTasks[idx+1:]...) m.data.ScheduledTasks[idx].Eliminated = true
case "services": case "services":
s := m.data.Services[idx] s := m.data.Services[idx]
if err := EliminateService(*s); err != nil { if err := EliminateService(*s); err != nil {
return err return err
} }
m.data.Services = append(m.data.Services[:idx], m.data.Services[idx+1:]...) m.data.Services[idx].Eliminated = true
} }
return nil return nil
} }
@@ -234,6 +256,7 @@ func (m *AppModel) loadSelectedFile(path string) error {
return err return err
} }
m.data = sus m.data = sus
m.loadEliminatedState()
return nil return nil
} }
// Try bare suspicious structure // Try bare suspicious structure
@@ -242,9 +265,133 @@ func (m *AppModel) loadSelectedFile(path string) error {
return fmt.Errorf("no findings in report") return fmt.Errorf("no findings in report")
} }
m.data = sus m.data = sus
m.loadEliminatedState()
return nil 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 // RunEliminateUI starts the Bubble Tea program for elimination UI
func RunEliminateUI() error { func RunEliminateUI() error {
p := tea.NewProgram(NewApp()) p := tea.NewProgram(NewApp())
+44 -8
View File
@@ -22,16 +22,17 @@ type DeletedMsg struct {
} }
type DetailViewModel struct { type DetailViewModel struct {
typeKey string typeKey string
index int index int
data suspicious.Suspicious data suspicious.Suspicious
eliminated map[string]map[int]bool
// When modal* != "", show modal and require ESC to dismiss // When modal* != "", show modal and require ESC to dismiss
modalErr string modalErr string
modalWarn string modalWarn string
} }
func NewDetailView(typeKey string, index int, data suspicious.Suspicious) DetailViewModel { func NewDetailView(typeKey string, index int, data suspicious.Suspicious, eliminated map[string]map[int]bool) DetailViewModel {
return DetailViewModel{typeKey: typeKey, index: index, data: data} return DetailViewModel{typeKey: typeKey, index: index, data: data, eliminated: eliminated}
} }
func (m DetailViewModel) Init() tea.Cmd { return nil } 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 { 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() 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 view := title + "\n\n" + body
if m.modalWarn != "" { 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") 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 { func (m DetailViewModel) renderDetails() string {
switch m.typeKey { switch m.typeKey {
case "autoruns": case "autoruns":
if m.index >= len(m.data.AutoRuns) {
return "Item no longer available"
}
ar := m.data.AutoRuns[m.index] 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) 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": case "binaries":
if m.index >= len(m.data.Binaries) {
return "Item no longer available"
}
b := m.data.Binaries[m.index] 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": case "connections":
if m.index >= len(m.data.OutboundConnections) {
return "Item no longer available"
}
c := m.data.OutboundConnections[m.index] 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) 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": case "directories":
if m.index >= len(m.data.Directories) {
return "Item no longer available"
}
d := m.data.Directories[m.index] 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": case "processes":
if m.index >= len(m.data.Processes) {
return "Item no longer available"
}
p := m.data.Processes[m.index] 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) 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": case "scheduledTasks":
if m.index >= len(m.data.ScheduledTasks) {
return "Item no longer available"
}
t := m.data.ScheduledTasks[m.index] 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) 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": case "services":
if m.index >= len(m.data.Services) {
return "Item no longer available"
}
s := m.data.Services[m.index] 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 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)
} }
+70 -24
View File
@@ -2,6 +2,7 @@ package tui
import ( import (
"fmt" "fmt"
"io"
"rmm-hunter/internal/suspicious" "rmm-hunter/internal/suspicious"
@@ -16,22 +17,60 @@ type ListSelectedMsg struct {
Index int 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) Title() string { return i.title }
func (i listItem) Description() string { return i.desc } func (i listItem) Description() string { return i.desc }
func (i listItem) FilterValue() string { return i.title } func (i listItem) FilterValue() string { return i.title }
type ListViewModel struct { // customDelegate is a custom list item delegate that renders eliminated items in green
typeKey string type customDelegate struct {
list list.Model list.DefaultDelegate
header string
// In the future we can add action status per-item
} }
func NewListView(typeKey string, sus suspicious.Suspicious, width, height int) ListViewModel { func (d customDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) {
delegate := list.NewDefaultDelegate() i, ok := item.(listItem)
delegate.ShowDescription = true 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) l := list.New([]list.Item{}, delegate, 0, 0)
if width > 0 && height > 0 { if width > 0 && height > 0 {
l.SetSize(width, height-2) l.SetSize(width, height-2)
@@ -45,56 +84,63 @@ func NewListView(typeKey string, sus suspicious.Suspicious, width, height int) L
switch typeKey { switch typeKey {
case "autoruns": case "autoruns":
header = "Suspicious AutoRuns" header = "Suspicious AutoRuns"
for _, ar := range sus.AutoRuns { for i, ar := range sus.AutoRuns {
title := ar.ImageName title := ar.ImageName
if title == "" { if title == "" {
title = ar.Entry title = ar.Entry
} }
desc := fmt.Sprintf("%s (%s)", ar.ImagePath, ar.Location) 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": case "binaries":
header = "Suspicious Binaries" header = "Suspicious Binaries"
for _, b := range sus.Binaries { for i, b := range sus.Binaries {
items = append(items, listItem{title: b, desc: "binary file"}) isEliminated := eliminated[typeKey] != nil && eliminated[typeKey][i]
items = append(items, listItem{title: b.Path, desc: "binary file", eliminated: isEliminated})
} }
case "connections": case "connections":
header = "Suspicious 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) 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": case "directories":
header = "Suspicious Directories" header = "Suspicious Directories"
for _, d := range sus.Directories { for i, d := range sus.Directories {
items = append(items, listItem{title: d, desc: "directory"}) isEliminated := eliminated[typeKey] != nil && eliminated[typeKey][i]
items = append(items, listItem{title: d.Path, desc: "directory", eliminated: isEliminated})
} }
case "processes": case "processes":
header = "Suspicious 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) label := fmt.Sprintf("%s (PID %d)", p.Name, p.PID)
desc := p.Path 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": case "scheduledTasks":
header = "Suspicious Scheduled Tasks" header = "Suspicious Scheduled Tasks"
for _, t := range sus.ScheduledTasks { for i, t := range sus.ScheduledTasks {
label := t.Name label := t.Name
desc := t.Path 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": case "services":
header = "Suspicious Services" header = "Suspicious Services"
for _, s := range sus.Services { for i, s := range sus.Services {
label := fmt.Sprintf("%s (%s)", s.Name, s.DisplayName) label := fmt.Sprintf("%s (%s)", s.Name, s.DisplayName)
desc := s.BinaryPathName 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.Title = header + " — Left: Back Enter: Details q: Quit"
l.SetItems(items) 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 } func (m ListViewModel) Init() tea.Cmd { return nil }