192ce28d89
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.
254 lines
6.0 KiB
Go
254 lines
6.0 KiB
Go
package tui
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
|
|
"rmm-hunter/internal/suspicious"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
type screen int
|
|
|
|
const (
|
|
screenFilePicker screen = iota
|
|
screenTypePicker
|
|
screenList
|
|
screenDetail
|
|
screenError
|
|
)
|
|
|
|
type AppModel struct {
|
|
current screen
|
|
filePick FilePickerModel
|
|
typePick TypePickerModel
|
|
listView ListViewModel
|
|
detail DetailViewModel
|
|
err error
|
|
selected string
|
|
data suspicious.Suspicious
|
|
width int
|
|
height int
|
|
}
|
|
|
|
func NewApp() AppModel {
|
|
return AppModel{
|
|
current: screenFilePicker,
|
|
filePick: NewFilePicker(),
|
|
typePick: NewTypePicker(),
|
|
}
|
|
}
|
|
|
|
func (m AppModel) Init() tea.Cmd { return m.filePick.Init() }
|
|
|
|
func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
// remember the latest terminal size so we can size new screens
|
|
if ws, ok := msg.(tea.WindowSizeMsg); ok {
|
|
m.width, m.height = ws.Width, ws.Height
|
|
}
|
|
|
|
switch m.current {
|
|
case screenFilePicker:
|
|
var cmd tea.Cmd
|
|
var tm tea.Model
|
|
tm, cmd = m.filePick.Update(msg)
|
|
if fp, ok := tm.(FilePickerModel); ok {
|
|
m.filePick = fp
|
|
}
|
|
switch v := msg.(type) {
|
|
case FileSelectedMsg:
|
|
if err := m.loadSelectedFile(v.Path); err != nil {
|
|
m.err = err
|
|
m.current = screenError
|
|
return m, nil
|
|
}
|
|
m.current = screenTypePicker
|
|
return m, nil
|
|
}
|
|
return m, cmd
|
|
|
|
case screenTypePicker:
|
|
var cmd tea.Cmd
|
|
var tm tea.Model
|
|
tm, cmd = m.typePick.Update(msg)
|
|
if tp, ok := tm.(TypePickerModel); ok {
|
|
m.typePick = tp
|
|
}
|
|
switch v := msg.(type) {
|
|
case BackMsg:
|
|
m.current = screenFilePicker
|
|
return m, nil
|
|
case SelectedTypeMsg:
|
|
m.selected = v.Type
|
|
m.listView = NewListView(v.Type, m.data, m.width, m.height)
|
|
m.current = screenList
|
|
return m, nil
|
|
}
|
|
return m, cmd
|
|
|
|
case screenList:
|
|
var cmd tea.Cmd
|
|
var tm tea.Model
|
|
tm, cmd = m.listView.Update(msg)
|
|
if lv, ok := tm.(ListViewModel); ok {
|
|
m.listView = lv
|
|
}
|
|
switch v := msg.(type) {
|
|
case BackMsg:
|
|
m.current = screenTypePicker
|
|
return m, nil
|
|
case ListSelectedMsg:
|
|
m.detail = NewDetailView(v.TypeKey, v.Index, m.data)
|
|
m.current = screenDetail
|
|
return m, nil
|
|
}
|
|
return m, cmd
|
|
|
|
case screenDetail:
|
|
var cmd tea.Cmd
|
|
var tm tea.Model
|
|
tm, cmd = m.detail.Update(msg)
|
|
if dv, ok := tm.(DetailViewModel); ok {
|
|
m.detail = dv
|
|
}
|
|
switch v := msg.(type) {
|
|
case BackMsg:
|
|
m.current = screenList
|
|
return m, nil
|
|
case RequestEliminateMsg:
|
|
if err := m.performEliminate(v.TypeKey, v.Index); err != nil {
|
|
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
|
|
m.listView = NewListView(m.selected, m.data, m.width, m.height)
|
|
m.current = screenList
|
|
return m, nil
|
|
}
|
|
return m, cmd
|
|
|
|
case screenError:
|
|
// Any key quits after error is shown
|
|
if _, ok := msg.(tea.KeyMsg); ok {
|
|
return m, tea.Quit
|
|
}
|
|
return m, nil
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m AppModel) View() string {
|
|
switch m.current {
|
|
case screenFilePicker:
|
|
return m.filePick.View()
|
|
case screenTypePicker:
|
|
return m.typePick.View()
|
|
case screenList:
|
|
return m.listView.View()
|
|
case screenDetail:
|
|
return m.detail.View()
|
|
case screenError:
|
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("203")).Render(fmt.Sprintf("Failed to load JSON: %v\nPress any key to exit.", m.err))
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// performEliminate routes to placeholder eliminate functions and mutates data on success
|
|
func (m *AppModel) performEliminate(typeKey string, idx int) error {
|
|
switch typeKey {
|
|
case "autoruns":
|
|
ar := m.data.AutoRuns[idx]
|
|
if err := EliminateAutoRun(ar); err != nil {
|
|
return err
|
|
}
|
|
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
|
|
}
|
|
m.data.Binaries = append(m.data.Binaries[:idx], m.data.Binaries[idx+1:]...)
|
|
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:]...)
|
|
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
|
|
}
|
|
m.data.Directories = append(m.data.Directories[:idx], m.data.Directories[idx+1:]...)
|
|
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:]...)
|
|
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:]...)
|
|
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:]...)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// loadSelectedFile reads the JSON file and populates m.data
|
|
func (m *AppModel) loadSelectedFile(path string) error {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Support both wrapped report (with findings) and bare Suspicious JSON
|
|
var envelope struct {
|
|
Findings json.RawMessage `json:"findings"`
|
|
}
|
|
if err := json.Unmarshal(b, &envelope); err == nil && len(envelope.Findings) > 0 {
|
|
var sus suspicious.Suspicious
|
|
if err := json.Unmarshal(envelope.Findings, &sus); err != nil {
|
|
return err
|
|
}
|
|
m.data = sus
|
|
return nil
|
|
}
|
|
// Try bare suspicious structure
|
|
var sus suspicious.Suspicious
|
|
if err := json.Unmarshal(b, &sus); err != nil {
|
|
return fmt.Errorf("no findings in report")
|
|
}
|
|
m.data = sus
|
|
return nil
|
|
}
|
|
|
|
// RunEliminateUI starts the Bubble Tea program for elimination UI
|
|
func RunEliminateUI() error {
|
|
p := tea.NewProgram(NewApp())
|
|
_, err := p.Run()
|
|
return err
|
|
}
|