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.
This commit is contained in:
+103
-25
@@ -2,10 +2,106 @@ package tui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
"rmm-hunter/internal/suspicious"
|
"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 (
|
var (
|
||||||
EliminateAutoRun = func(ar suspicious.AutoRun) error { return eliminateAutoRun(ar) }
|
EliminateAutoRun = func(ar suspicious.AutoRun) error { return eliminateAutoRun(ar) }
|
||||||
EliminateBinary = func(path string) error { return eliminateBinary(path) }
|
EliminateBinary = func(path string) error { return eliminateBinary(path) }
|
||||||
@@ -19,27 +115,9 @@ var (
|
|||||||
func eliminateAutoRun(ar suspicious.AutoRun) error {
|
func eliminateAutoRun(ar suspicious.AutoRun) error {
|
||||||
return fmt.Errorf("eliminate autorun not implemented")
|
return fmt.Errorf("eliminate autorun not implemented")
|
||||||
}
|
}
|
||||||
|
func eliminateBinary(path string) error { return nil }
|
||||||
func eliminateBinary(path string) error {
|
func eliminateConnection(conn suspicious.NetworkConnection) error { return nil }
|
||||||
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 eliminateConnection(conn suspicious.NetworkConnection) error {
|
func eliminateService(s suspicious.Service) error { return nil }
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|||||||
+13
-1
@@ -2,6 +2,7 @@ package tui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
@@ -120,7 +121,12 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
case RequestEliminateMsg:
|
case RequestEliminateMsg:
|
||||||
if err := m.performEliminate(v.TypeKey, v.Index); err != nil {
|
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
|
return m, nil
|
||||||
}
|
}
|
||||||
// success -> rebuild list and go back
|
// 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:]...)
|
m.data.AutoRuns = append(m.data.AutoRuns[:idx], m.data.AutoRuns[idx+1:]...)
|
||||||
case "binaries":
|
case "binaries":
|
||||||
b := m.data.Binaries[idx]
|
b := m.data.Binaries[idx]
|
||||||
|
if err := CheckBinaryBlocked(b, m.data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := EliminateBinary(b); err != nil {
|
if err := EliminateBinary(b); err != nil {
|
||||||
return err
|
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:]...)
|
m.data.OutboundConnections = append(m.data.OutboundConnections[:idx], m.data.OutboundConnections[idx+1:]...)
|
||||||
case "directories":
|
case "directories":
|
||||||
d := m.data.Directories[idx]
|
d := m.data.Directories[idx]
|
||||||
|
if err := CheckDirectoryBlocked(d, m.data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := EliminateDirectory(d); err != nil {
|
if err := EliminateDirectory(d); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ type DetailViewModel struct {
|
|||||||
typeKey string
|
typeKey string
|
||||||
index int
|
index int
|
||||||
data suspicious.Suspicious
|
data suspicious.Suspicious
|
||||||
// When modalErr != "", show modal and require ESC to dismiss
|
// When modal* != "", show modal and require ESC to dismiss
|
||||||
modalErr string
|
modalErr string
|
||||||
|
modalWarn string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDetailView(typeKey string, index int, data suspicious.Suspicious) DetailViewModel {
|
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) {
|
func (m DetailViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch v := msg.(type) {
|
switch v := msg.(type) {
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
if m.modalErr != "" {
|
if m.modalErr != "" || m.modalWarn != "" {
|
||||||
// Modal active: only ESC dismisses
|
// Modal active: only ESC dismisses
|
||||||
if v.String() == "esc" {
|
if v.String() == "esc" {
|
||||||
m.modalErr = ""
|
m.modalErr = ""
|
||||||
|
m.modalWarn = ""
|
||||||
}
|
}
|
||||||
return m, nil
|
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")
|
title := lipgloss.NewStyle().Bold(true).Render("Details — press ! to eliminate, Left to go back, q to quit")
|
||||||
body := m.renderDetails()
|
body := m.renderDetails()
|
||||||
view := title + "\n\n" + 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")
|
||||||
|
view += "\n\n" + modal
|
||||||
|
}
|
||||||
if m.modalErr != "" {
|
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")
|
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
|
view += "\n\n" + modal
|
||||||
|
|||||||
Reference in New Issue
Block a user