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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -460,7 +460,7 @@ const htmlTemplate = `<!DOCTYPE html>
|
||||
{{if .Findings.Binaries}}
|
||||
{{range .Findings.Binaries}}
|
||||
<div class="item">
|
||||
<div class="item-detail">{{.}}</div>
|
||||
<div class="item-detail">{{.Path}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
@@ -479,7 +479,7 @@ const htmlTemplate = `<!DOCTYPE html>
|
||||
{{if .Findings.Directories}}
|
||||
{{range .Findings.Directories}}
|
||||
<div class="item">
|
||||
<div class="item-detail">{{.}}</div>
|
||||
<div class="item-detail">{{.Path}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
|
||||
+78
-15
@@ -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)})
|
||||
}
|
||||
|
||||
+19
-1
@@ -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)}
|
||||
}
|
||||
|
||||
+176
-29
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+70
-24
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user