From 192ce28d89bf9ef85abb16011e85aab7db78bbab Mon Sep 17 00:00:00 2001 From: Evan Hosinski Date: Fri, 10 Oct 2025 22:53:20 -0400 Subject: [PATCH] Add warning modal support and checks for blocked binaries and directories Introduce `WarnBlock` to handle non-fatal warnings displayed in a warning modal. Add pre-elimination checks to identify blocked binaries and directories based on running processes or enabled services. Enhance path normalization for robust comparisons. --- internal/tui/actions.go | 128 +++++++++++++++++++++++++++++-------- internal/tui/app.go | 14 +++- internal/tui/detailview.go | 12 +++- 3 files changed, 125 insertions(+), 29 deletions(-) diff --git a/internal/tui/actions.go b/internal/tui/actions.go index eff8a38..1319166 100644 --- a/internal/tui/actions.go +++ b/internal/tui/actions.go @@ -2,10 +2,106 @@ package tui import ( "fmt" + "path/filepath" "rmm-hunter/internal/suspicious" + "strings" ) -// Elimination placeholders; replace with real implementations later +// WarnBlock is a non-fatal warning condition (rendered as a warning modal) +type WarnBlock struct{ Reason string } + +func (w WarnBlock) Error() string { return w.Reason } + +// normalize a Windows-like path for robust comparisons +func normPath(p string) string { + p = strings.TrimSpace(p) + p = strings.Trim(p, "\"") // strip surrounding quotes if any + p = strings.ReplaceAll(p, "\\", "/") + return strings.ToLower(p) +} + +// extract the executable path from a command/BinaryPathName that may include quotes/args +func exeFromCommand(cmd string) string { + s := strings.TrimSpace(cmd) + if s == "" { + return s + } + if strings.HasPrefix(s, "\"") { + s = s[1:] + if i := strings.Index(s, "\""); i >= 0 { + return s[:i] + } + return s + } + // no quotes; split on space + if i := strings.IndexAny(s, " \t"); i >= 0 { + return s[:i] + } + return s +} + +// 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 + for _, p := range data.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 { + 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 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 nil +} + +// CheckDirectoryBlocked returns a WarnBlock if any process or enabled+active service binary is inside the directory +func CheckDirectoryBlocked(dir string, data suspicious.Suspicious) error { + dn := normPath(dir) + if !strings.HasSuffix(dn, "/") { + dn += "/" + } + inDir := func(p string) bool { + pp := normPath(p) + if pp == "" { + return false + } + if strings.HasPrefix(pp, dn) { + return true + } + // try with filepath.Rel for robustness + rel, err := filepath.Rel(dn, pp) + return err == nil && rel != ".." && !strings.HasPrefix(rel, "../") + } + for _, p := range data.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 { + 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 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 nil +} + +// Elimination placeholders; TODO: replace with internal/pkg/hunt/eliminate/* var ( EliminateAutoRun = func(ar suspicious.AutoRun) error { return eliminateAutoRun(ar) } EliminateBinary = func(path string) error { return eliminateBinary(path) } @@ -19,27 +115,9 @@ var ( func eliminateAutoRun(ar suspicious.AutoRun) error { return fmt.Errorf("eliminate autorun not implemented") } - -func eliminateBinary(path string) error { - return nil -} - -func eliminateConnection(conn suspicious.NetworkConnection) error { - return nil -} - -func eliminateDirectory(path string) error { - return nil -} - -func eliminateProcess(p suspicious.Process) error { - return nil -} - -func eliminateScheduledTask(t suspicious.ScheduledTask) error { - return nil -} - -func eliminateService(s suspicious.Service) error { - return nil -} +func eliminateBinary(path string) error { return nil } +func eliminateConnection(conn suspicious.NetworkConnection) error { return nil } +func eliminateDirectory(path string) error { return nil } +func eliminateProcess(p suspicious.Process) error { return nil } +func eliminateScheduledTask(t suspicious.ScheduledTask) error { return nil } +func eliminateService(s suspicious.Service) error { return nil } diff --git a/internal/tui/app.go b/internal/tui/app.go index 9f98213..689dc45 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -2,6 +2,7 @@ package tui import ( "encoding/json" + "errors" "fmt" "os" @@ -120,7 +121,12 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case RequestEliminateMsg: if err := m.performEliminate(v.TypeKey, v.Index); err != nil { - m.detail.modalErr = err.Error() + var wb WarnBlock + if errors.As(err, &wb) { + m.detail.modalWarn = wb.Error() + } else { + m.detail.modalErr = err.Error() + } return m, nil } // success -> rebuild list and go back @@ -168,6 +174,9 @@ func (m *AppModel) performEliminate(typeKey string, idx int) error { m.data.AutoRuns = append(m.data.AutoRuns[:idx], m.data.AutoRuns[idx+1:]...) case "binaries": b := m.data.Binaries[idx] + if err := CheckBinaryBlocked(b, m.data); err != nil { + return err + } if err := EliminateBinary(b); err != nil { return err } @@ -180,6 +189,9 @@ func (m *AppModel) performEliminate(typeKey string, idx int) error { m.data.OutboundConnections = append(m.data.OutboundConnections[:idx], m.data.OutboundConnections[idx+1:]...) case "directories": d := m.data.Directories[idx] + if err := CheckDirectoryBlocked(d, m.data); err != nil { + return err + } if err := EliminateDirectory(d); err != nil { return err } diff --git a/internal/tui/detailview.go b/internal/tui/detailview.go index 231501c..2f4f7c8 100644 --- a/internal/tui/detailview.go +++ b/internal/tui/detailview.go @@ -25,8 +25,9 @@ type DetailViewModel struct { typeKey string index int data suspicious.Suspicious - // When modalErr != "", show modal and require ESC to dismiss - modalErr string + // When modal* != "", show modal and require ESC to dismiss + modalErr string + modalWarn string } func NewDetailView(typeKey string, index int, data suspicious.Suspicious) DetailViewModel { @@ -38,10 +39,11 @@ func (m DetailViewModel) Init() tea.Cmd { return nil } func (m DetailViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch v := msg.(type) { case tea.KeyMsg: - if m.modalErr != "" { + if m.modalErr != "" || m.modalWarn != "" { // Modal active: only ESC dismisses if v.String() == "esc" { m.modalErr = "" + m.modalWarn = "" } return m, nil } @@ -61,6 +63,10 @@ func (m DetailViewModel) View() string { title := lipgloss.NewStyle().Bold(true).Render("Details — press ! to eliminate, Left to go back, q to quit") body := m.renderDetails() 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") + view += "\n\n" + modal + } if m.modalErr != "" { modal := lipgloss.NewStyle().Padding(1, 2).Foreground(lipgloss.Color("203")).Border(lipgloss.RoundedBorder()).Render("Elimination failed:\n" + m.modalErr + "\n\nPress ESC to dismiss") view += "\n\n" + modal