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"
"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
}
}
+10 -1
View File
@@ -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
}
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}}
{{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}}
+71 -8
View File
@@ -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"`
}
/*
@@ -92,6 +109,7 @@ type Process struct {
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
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)}
}
+163 -16
View File
@@ -33,6 +33,8 @@ type AppModel struct {
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 {
@@ -40,6 +42,7 @@ func NewApp() AppModel {
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())
+41 -5
View File
@@ -25,13 +25,14 @@ type DetailViewModel struct {
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)
}
+66 -20
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 }
// customDelegate is a custom list item delegate that renders eliminated items in green
type customDelegate struct {
list.DefaultDelegate
}
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
// In the future we can add action status per-item
eliminated map[string]map[int]bool
}
func NewListView(typeKey string, sus suspicious.Suspicious, width, height int) ListViewModel {
delegate := list.NewDefaultDelegate()
delegate.ShowDescription = true
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 }