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
+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
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
View File
@@ -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())
+44 -8
View File
@@ -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
View File
@@ -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 }