From 2b6c4eb4cd7723e31b037abf0680981c64e6e765 Mon Sep 17 00:00:00 2001 From: Evan Hosinski Date: Fri, 10 Oct 2025 22:43:47 -0400 Subject: [PATCH] Implement TUI for managing suspicious artifacts (FilePicker, TypePicker, ListView, and DetailView) Introduce Bubble Tea-based terminal UI to manage suspicious artifact findings, including file selection, type filtering, list view, and details. --- cmd/root.go | 23 ++-- go.mod | 37 +++-- go.sum | 99 +++++++------- internal/tui/actions.go | 45 ++++++ internal/tui/app.go | 241 +++++++++++++++++++++++++++++++++ internal/tui/app_test.go | 105 ++++++++++++++ internal/tui/detailview.go | 96 +++++++++++++ internal/tui/filepicker.go | 123 +++++++++++++++++ internal/tui/filepicker.go.bak | 109 +++++++++++++++ internal/tui/listview.go | 118 ++++++++++++++++ internal/tui/typePicker.go | 120 ++++++++++++++++ tester/tui.go | 10 ++ 12 files changed, 1051 insertions(+), 75 deletions(-) create mode 100644 internal/tui/actions.go create mode 100644 internal/tui/app.go create mode 100644 internal/tui/app_test.go create mode 100644 internal/tui/detailview.go create mode 100644 internal/tui/filepicker.go create mode 100644 internal/tui/filepicker.go.bak create mode 100644 internal/tui/listview.go create mode 100644 internal/tui/typePicker.go create mode 100644 tester/tui.go diff --git a/cmd/root.go b/cmd/root.go index de4e490..bd1b2d5 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -5,6 +5,7 @@ import ( "os" "rmm-hunter/internal/pkg" "rmm-hunter/internal/pkg/hunter" + "rmm-hunter/internal/tui" "github.com/spf13/cobra" ) @@ -52,13 +53,7 @@ var eliminateCmd = &cobra.Command{ Long: `Eliminate mode removes detected Sus software from the system. Requires a JSON input file containing hunt results to determine what to remove.`, Run: func(cmd *cobra.Command, args []string) { - if inputFile == "" { - fmt.Println("Error: --input flag is required for eliminate command") - os.Exit(1) - } - - fmt.Printf("Starting RMM Elimination using input file: %s\n", inputFile) - // TODO: Call eliminate.Eliminate() function + fmt.Println("Starting Elimination UI...") runEliminate() }, } @@ -86,10 +81,9 @@ func init() { huntCmd.Flags().StringVarP(&outputFile, "output", "o", "suspicious-hunter.json", "Output file to write hunt results (optional) Default: suspicious-hunter.json") - // Eliminate command flags + // Eliminate command flags (optional input; if omitted, TUI will show a file picker) eliminateCmd.Flags().StringVarP(&inputFile, "input", "i", "", - "JSON input file containing hunt results (required)") - eliminateCmd.MarkFlagRequired("input") + "JSON input file containing hunt results (optional)") } func runHunt() { @@ -104,8 +98,9 @@ func runHunt() { } func runEliminate() { - // TODO: Implement eliminate functionality - fmt.Println("Eliminate functionality not yet implemented") - fmt.Printf("Input file: %s\n", inputFile) - fmt.Printf("Excluded RMMs: %v\n", excludeRMMs) + // Launch the Charmbracelet-based TUI for elimination flow + if err := tui.RunEliminateUI(); err != nil { + fmt.Printf("[-] TUI error: %v\n", err) + os.Exit(1) + } } diff --git a/go.mod b/go.mod index aec74e1..85db1d0 100644 --- a/go.mod +++ b/go.mod @@ -4,27 +4,34 @@ go 1.24.7 require ( github.com/Kraken-OffSec/Scurvy v0.0.0-20251010192328-967933276439 + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/spf13/cobra v1.10.1 - golang.org/x/sys v0.29.0 + golang.org/x/sys v0.36.0 ) require ( - github.com/Binject/debug v0.0.0-20230508195519-26db73212a7a // indirect - github.com/alwindoss/morse v1.0.1 // indirect - github.com/awgh/rawreader v0.0.0-20200626064944-56820a9c6da4 // indirect - github.com/cakturk/go-netstat v0.0.0-20200220111822-e5b49efee7a5 // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect - github.com/ecies/go/v2 v2.0.10 // indirect - github.com/elastic/go-sysinfo v1.15.1 // indirect - github.com/elastic/go-windows v1.0.2 // indirect - github.com/ethereum/go-ethereum v1.14.12 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/prometheus/procfs v0.15.1 // indirect - github.com/ryanuber/columnize v2.1.2+incompatible // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect github.com/spf13/pflag v1.0.9 // indirect - golang.org/x/crypto v0.31.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect golang.org/x/text v0.21.0 // indirect - golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect - howett.net/plist v1.0.1 // indirect ) diff --git a/go.sum b/go.sum index 5612973..826d5ff 100644 --- a/go.sum +++ b/go.sum @@ -1,63 +1,70 @@ -github.com/Binject/debug v0.0.0-20230508195519-26db73212a7a h1:4c0nc0krv8eh7gD809n+swLaCuFyHpxdrxwx0ZmHvBw= -github.com/Binject/debug v0.0.0-20230508195519-26db73212a7a/go.mod h1:QzgxDLY/qdKlvnbnb65eqTedhvQPbaSP2NqIbcuKvsQ= -github.com/Kraken-OffSec/Scurvy v0.0.0-20251010174510-8091571ab864 h1:zYVI4GRNB7wjLtorhpnPLP8v8w5T3axCpCtNDKI2LOs= -github.com/Kraken-OffSec/Scurvy v0.0.0-20251010174510-8091571ab864/go.mod h1:hljxQLLV5S60GVVG51+u3r1agCjZ45x8jd2WiJxy0wQ= -github.com/Kraken-OffSec/Scurvy v0.0.0-20251010183749-8ab59cb85591 h1:APveZhhJVm6tFcpldhMLxln4JR1V3Aw1xegt0SKGybg= -github.com/Kraken-OffSec/Scurvy v0.0.0-20251010183749-8ab59cb85591/go.mod h1:hljxQLLV5S60GVVG51+u3r1agCjZ45x8jd2WiJxy0wQ= -github.com/Kraken-OffSec/Scurvy v0.0.0-20251010184333-fb8a7710cf48 h1:nyCMY/8w7IsmduLZspdBuCmWutMUY6lzn5DCKVmQGt0= -github.com/Kraken-OffSec/Scurvy v0.0.0-20251010184333-fb8a7710cf48/go.mod h1:hljxQLLV5S60GVVG51+u3r1agCjZ45x8jd2WiJxy0wQ= -github.com/Kraken-OffSec/Scurvy v0.0.0-20251010184534-de2a2a349253 h1:ZfFDU6Kp9mFlEb0OZniWQR1E3w3Okr9gK2HlRb9lN6E= -github.com/Kraken-OffSec/Scurvy v0.0.0-20251010184534-de2a2a349253/go.mod h1:hljxQLLV5S60GVVG51+u3r1agCjZ45x8jd2WiJxy0wQ= -github.com/Kraken-OffSec/Scurvy v0.0.0-20251010185212-88db5cc88bc0 h1:g01ZBGUyvJXSWvxs7SVPTtqv3ruhbFsgsRGxCM2yYoY= -github.com/Kraken-OffSec/Scurvy v0.0.0-20251010185212-88db5cc88bc0/go.mod h1:hljxQLLV5S60GVVG51+u3r1agCjZ45x8jd2WiJxy0wQ= -github.com/Kraken-OffSec/Scurvy v0.0.0-20251010190109-3a4c7586120a h1:Z4cjdwk5DupnEg/F2dv4DPutwSEmDq7WWe565FjZrtQ= -github.com/Kraken-OffSec/Scurvy v0.0.0-20251010190109-3a4c7586120a/go.mod h1:hljxQLLV5S60GVVG51+u3r1agCjZ45x8jd2WiJxy0wQ= -github.com/Kraken-OffSec/Scurvy v0.0.0-20251010190312-0ad88c5f3f2f h1:XJ9IudxrEjAhodOLCTaWCIxWdj0fIa+JOdzfd1nST9k= -github.com/Kraken-OffSec/Scurvy v0.0.0-20251010190312-0ad88c5f3f2f/go.mod h1:hljxQLLV5S60GVVG51+u3r1agCjZ45x8jd2WiJxy0wQ= github.com/Kraken-OffSec/Scurvy v0.0.0-20251010192328-967933276439 h1:n/B4+1K6vpKX34iISUKHzEKEND53PmxePHrtsy693Jo= github.com/Kraken-OffSec/Scurvy v0.0.0-20251010192328-967933276439/go.mod h1:hljxQLLV5S60GVVG51+u3r1agCjZ45x8jd2WiJxy0wQ= -github.com/alwindoss/morse v1.0.1 h1:PkUh5m1UHMcZ1Upvl7CmSIBMxdEBejWoQ4rQQtgJsCQ= -github.com/alwindoss/morse v1.0.1/go.mod h1:qAqJOep3jEpIpiLgqSGgLk5Zh4BZKsyzMQHuAwVPMXc= -github.com/awgh/rawreader v0.0.0-20200626064944-56820a9c6da4 h1:cIAK2NNf2yafdgpFRNJrgZMwvy61BEVpGoHc2n4/yWs= -github.com/awgh/rawreader v0.0.0-20200626064944-56820a9c6da4/go.mod h1:SalMPBCab3yuID8nIhLfzwoBV+lBRyaC7NhuN8qL8xE= -github.com/cakturk/go-netstat v0.0.0-20200220111822-e5b49efee7a5 h1:BjkPE3785EwPhhyuFkbINB+2a1xATwk8SNDWnJiD41g= -github.com/cakturk/go-netstat v0.0.0-20200220111822-e5b49efee7a5/go.mod h1:jtAfVaU/2cu1+wdSRPWE2c1N2qeAA3K4RH9pYgqwets= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= -github.com/ecies/go/v2 v2.0.10 h1:AaLxGio0MLLbvWur4rKnLzw+K9zI+wMScIDAtqCqOtU= -github.com/ecies/go/v2 v2.0.10/go.mod h1:N73OyuR6tuKznit2LhXjrZ0XAQ234uKbzYz8pEPYzlI= -github.com/elastic/go-sysinfo v1.15.1 h1:zBmTnFEXxIQ3iwcQuk7MzaUotmKRp3OabbbWM8TdzIQ= -github.com/elastic/go-sysinfo v1.15.1/go.mod h1:jPSuTgXG+dhhh0GKIyI2Cso+w5lPJ5PvVqKlL8LV/Hk= -github.com/elastic/go-windows v1.0.2 h1:yoLLsAsV5cfg9FLhZ9EXZ2n2sQFKeDYrHenkcivY4vI= -github.com/elastic/go-windows v1.0.2/go.mod h1:bGcDpBzXgYSqM0Gx3DM4+UxFj300SZLixie9u9ixLM8= -github.com/ethereum/go-ethereum v1.14.12 h1:8hl57x77HSUo+cXExrURjU/w1VhL+ShCTJrTwcCQSe4= -github.com/ethereum/go-ethereum v1.14.12/go.mod h1:RAC2gVMWJ6FkxSPESfbshrcKpIokgQKsVKmAuqdekDY= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= -github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= -github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/ryanuber/columnize v2.1.2+incompatible h1:C89EOx/XBWwIXl8wm8OPJBd7kPF25UfsK2X7Ph/zCAk= -github.com/ryanuber/columnize v2.1.2+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= +golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= -howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= diff --git a/internal/tui/actions.go b/internal/tui/actions.go new file mode 100644 index 0000000..eff8a38 --- /dev/null +++ b/internal/tui/actions.go @@ -0,0 +1,45 @@ +package tui + +import ( + "fmt" + "rmm-hunter/internal/suspicious" +) + +// Elimination placeholders; replace with real implementations later +var ( + EliminateAutoRun = func(ar suspicious.AutoRun) error { return eliminateAutoRun(ar) } + EliminateBinary = func(path string) error { return eliminateBinary(path) } + EliminateConnection = func(conn suspicious.NetworkConnection) error { return eliminateConnection(conn) } + EliminateDirectory = func(path string) error { return eliminateDirectory(path) } + EliminateProcess = func(p suspicious.Process) error { return eliminateProcess(p) } + EliminateScheduledTask = func(t suspicious.ScheduledTask) error { return eliminateScheduledTask(t) } + EliminateService = func(s suspicious.Service) error { return eliminateService(s) } +) + +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 +} diff --git a/internal/tui/app.go b/internal/tui/app.go new file mode 100644 index 0000000..9f98213 --- /dev/null +++ b/internal/tui/app.go @@ -0,0 +1,241 @@ +package tui + +import ( + "encoding/json" + "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 { + 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 := 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 := 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 +} diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go new file mode 100644 index 0000000..84b1929 --- /dev/null +++ b/internal/tui/app_test.go @@ -0,0 +1,105 @@ +package tui + +import ( + "os" + "path/filepath" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +// writeTestReport creates a minimal JSON report envelope with empty findings +func writeTestReport(t *testing.T, dir, name string, withFindings bool) string { + t.Helper() + path := filepath.Join(dir, name) + var content string + if withFindings { + content = `{ + "reportName": "rmm-hunter-report", + "generatedAt": "2025-01-01T00:00:00Z", + "riskRating": {"score":0, "rating":"Low", "summary":""}, + "findings": {"processes":[],"services":[],"binaries":[],"autoRuns":[],"scheduledTasks":[],"outboundConnections":[],"directories":[]} + }` + } else { + content = `{}` + } + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("failed to write test report: %v", err) + } + return path +} + +func TestAppFlow_SelectFile_SelectType_Back_Quit(t *testing.T) { + // Run in a temp dir so file picker sees our .json + tmp := t.TempDir() + if err := os.Chdir(tmp); err != nil { + t.Fatalf("chdir: %v", err) + } + _ = writeTestReport(t, tmp, "test.json", true) + + p := tea.NewProgram(NewApp(), tea.WithoutRenderer()) + done := make(chan error, 1) + go func() { + _, err := p.Run() + done <- err + }() + + // Give init a moment to load files + time.Sleep(100 * time.Millisecond) + + // Select file + p.Send(tea.KeyMsg{Type: tea.KeyEnter}) + time.Sleep(50 * time.Millisecond) + + // Choose type 1 (autoruns) + p.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'1'}}) + time.Sleep(50 * time.Millisecond) + + // Go back to type picker + p.Send(tea.KeyMsg{Type: tea.KeyLeft}) + time.Sleep(50 * time.Millisecond) + + // Quit + p.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + + select { + case err := <-done: + if err != nil { + t.Fatalf("program error: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("program did not exit in time") + } +} + +func TestApp_ErrorOnBadJSON(t *testing.T) { + tmp := t.TempDir() + if err := os.Chdir(tmp); err != nil { + t.Fatalf("chdir: %v", err) + } + _ = writeTestReport(t, tmp, "bad.json", false) + + p := tea.NewProgram(NewApp(), tea.WithoutRenderer()) + done := make(chan error, 1) + go func() { + _, err := p.Run() + done <- err + }() + + time.Sleep(100 * time.Millisecond) + // Select file -> should error + p.Send(tea.KeyMsg{Type: tea.KeyEnter}) + // Any key quits on error screen + time.Sleep(50 * time.Millisecond) + p.Send(tea.KeyMsg{Type: tea.KeyEsc}) + + select { + case err := <-done: + if err != nil { + t.Fatalf("program error: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("program did not exit in time") + } +} diff --git a/internal/tui/detailview.go b/internal/tui/detailview.go new file mode 100644 index 0000000..231501c --- /dev/null +++ b/internal/tui/detailview.go @@ -0,0 +1,96 @@ +package tui + +import ( + "fmt" + + "rmm-hunter/internal/suspicious" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// RequestEliminateMsg is emitted by the detail view when '!' is pressed +type RequestEliminateMsg struct { + TypeKey string + Index int +} + +// DeletedMsg is emitted after successful elimination to update lists +type DeletedMsg struct { + TypeKey string + Index int +} + +type DetailViewModel struct { + typeKey string + index int + data suspicious.Suspicious + // When modalErr != "", show modal and require ESC to dismiss + modalErr string +} + +func NewDetailView(typeKey string, index int, data suspicious.Suspicious) DetailViewModel { + return DetailViewModel{typeKey: typeKey, index: index, data: data} +} + +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 != "" { + // Modal active: only ESC dismisses + if v.String() == "esc" { + m.modalErr = "" + } + return m, nil + } + switch v.String() { + case "left": + return m, func() tea.Msg { return BackMsg{} } + case "!": + return m, func() tea.Msg { return RequestEliminateMsg{TypeKey: m.typeKey, Index: m.index} } + case "q", "esc", "ctrl+c": + return m, tea.Quit + } + } + return m, nil +} + +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.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 + } + return view +} + +func (m DetailViewModel) renderDetails() string { + switch m.typeKey { + case "autoruns": + ar := m.data.AutoRuns[m.index] + return fmt.Sprintf("Name: %s\nCommand: %s\nLocation: %s\nEnabled: %v\nDescription: %s", ar.Name, ar.Command, ar.Location, ar.Enabled, ar.Description) + case "binaries": + b := m.data.Binaries[m.index] + return fmt.Sprintf("Binary: %s\nAction: delete file", b) + case "connections": + 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": + d := m.data.Directories[m.index] + return fmt.Sprintf("Directory: %s\nAction: delete recursively", d) + case "processes": + 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": + 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": + 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) + } + return "" +} diff --git a/internal/tui/filepicker.go b/internal/tui/filepicker.go new file mode 100644 index 0000000..32d0c9c --- /dev/null +++ b/internal/tui/filepicker.go @@ -0,0 +1,123 @@ +package tui + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// FileSelectedMsg is emitted when a file is chosen +type FileSelectedMsg struct{ Path string } + +// FilePicker is a simple list of .json files in the current directory +// Press Enter to pick, q/esc to quit + +type fileItem struct { + title string + path string +} + +func (i fileItem) Title() string { return i.title } +func (i fileItem) Description() string { return i.path } +func (i fileItem) FilterValue() string { return i.title } + +type FilePickerModel struct { + list list.Model + spinner spinner.Model + error error + loading bool +} + +func NewFilePicker() FilePickerModel { + delegate := list.NewDefaultDelegate() + delegate.ShowDescription = true + l := list.New([]list.Item{}, delegate, 0, 0) + // Set a sane default; will be updated on WindowSizeMsg + l.SetSize(80, 20) + l.Title = "Select JSON report" + l.Styles.Title = lipgloss.NewStyle().Bold(true) + l.SetShowHelp(false) + sp := spinner.New() + sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) + m := FilePickerModel{list: l, spinner: sp} + return m +} + +func (m FilePickerModel) Init() tea.Cmd { + return tea.Batch(m.spinner.Tick, m.loadFilesCmd()) +} + +func (m FilePickerModel) loadFilesCmd() tea.Cmd { + return func() tea.Msg { + entries, err := os.ReadDir(".") + if err != nil { + return errMsg{err} + } + seen := map[string]bool{} + var items []list.Item + for _, e := range entries { + name := e.Name() + if e.IsDir() { + continue + } + if filepath.Ext(name) != ".json" { + continue + } + fi, err := e.Info() + if err != nil || !fi.Mode().IsRegular() { + continue + } + if seen[name] { + continue + } + seen[name] = true + items = append(items, fileItem{title: name, path: name}) + } + return filesLoadedMsg{items} + } +} + +type errMsg struct{ error } +type filesLoadedMsg struct{ items []list.Item } + +func (m FilePickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.list.SetSize(msg.Width, msg.Height-2) + case filesLoadedMsg: + m.loading = false + m.list.SetItems(msg.items) + case errMsg: + m.error = msg + case tea.KeyMsg: + switch msg.String() { + case "q", "esc", "ctrl+c": + return m, tea.Quit + case "enter": + if it, ok := m.list.SelectedItem().(fileItem); ok { + return m, func() tea.Msg { return FileSelectedMsg{Path: it.path} } + } + } + } + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd +} + +func (m FilePickerModel) View() string { + if m.error != nil { + return lipgloss.NewStyle().Foreground(lipgloss.Color("203")).Render(fmt.Sprintf("Error: %v\n", m.error)) + } + if m.loading { + return "Loading files...\n" + m.spinner.View() + } + if len(m.list.Items()) == 0 { + return "No .json files found in current directory. Press q to exit.\n" + } + return m.list.View() +} diff --git a/internal/tui/filepicker.go.bak b/internal/tui/filepicker.go.bak new file mode 100644 index 0000000..00868c3 --- /dev/null +++ b/internal/tui/filepicker.go.bak @@ -0,0 +1,109 @@ +package tui + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// FileSelectedMsg is emitted when a file is chosen +type FileSelectedMsg struct{ Path string } + +// FilePicker is a simple list of .json files in the current directory +// Press Enter to pick, q/esc to quit + +type fileItem struct { + title string + path string +} + +func (i fileItem) Title() string { return i.title } +func (i fileItem) Description() string { return i.path } +func (i fileItem) FilterValue() string { return i.title } + +type FilePickerModel struct { + list list.Model + spinner spinner.Model + error error + loading bool +} + +func NewFilePicker() FilePickerModel { + delegate := list.NewDefaultDelegate() + delegate.ShowDescription = true + l := list.New([]list.Item{}, delegate, 0, 0) + l.Title = "Select JSON report" + l.Styles.Title = lipgloss.NewStyle().Bold(true) + l.SetShowHelp(false) + sp := spinner.New() + sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) + m := FilePickerModel{list: l, spinner: sp} + return m +} + +func (m FilePickerModel) Init() tea.Cmd { + return tea.Batch(m.spinner.Tick, m.loadFilesCmd()) +} + +func (m FilePickerModel) loadFilesCmd() tea.Cmd { + return func() tea.Msg { + files, err := os.ReadDir(".") + if err != nil { + return errMsg{err} + } + var items []list.Item + for _, f := range files { + if f.IsDir() { continue } + name := f.Name() + if filepath.Ext(name) == ".json" { + items = append(items, fileItem{title: name, path: name}) + } + } + return filesLoadedMsg{items} + } +} + +type errMsg struct{ error } +type filesLoadedMsg struct{ items []list.Item } + +func (m FilePickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.list.SetSize(msg.Width, msg.Height-2) + case filesLoadedMsg: + m.loading = false + m.list.SetItems(msg.items) + case errMsg: + m.error = msg + case tea.KeyMsg: + switch msg.String() { + case "q", "esc", "ctrl+c": + return m, tea.Quit + case "enter": + if it, ok := m.list.SelectedItem().(fileItem); ok { + return m, func() tea.Msg { return FileSelectedMsg{Path: it.path} } + } + } + } + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd +} + +func (m FilePickerModel) View() string { + if m.error != nil { + return lipgloss.NewStyle().Foreground(lipgloss.Color("203")).Render(fmt.Sprintf("Error: %v\n", m.error)) + } + if m.loading { + return "Loading files...\n" + m.spinner.View() + } + if len(m.list.Items()) == 0 { + return "No .json files found in current directory. Press q to exit.\n" + } + return m.list.View() +} diff --git a/internal/tui/listview.go b/internal/tui/listview.go new file mode 100644 index 0000000..9ab02ab --- /dev/null +++ b/internal/tui/listview.go @@ -0,0 +1,118 @@ +package tui + +import ( + "fmt" + + "rmm-hunter/internal/suspicious" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// ListSelectedMsg indicates which index/type was selected for detail +type ListSelectedMsg struct { + TypeKey string + Index int +} + +type listItem struct{ title, desc string } + +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 +} + +func NewListView(typeKey string, sus suspicious.Suspicious, width, height int) ListViewModel { + delegate := list.NewDefaultDelegate() + delegate.ShowDescription = true + l := list.New([]list.Item{}, delegate, 0, 0) + if width > 0 && height > 0 { + l.SetSize(width, height-2) + } else { + l.SetSize(80, 20) + } + l.Styles.Title = lipgloss.NewStyle().Bold(true) + + header := "" + var items []list.Item + switch typeKey { + case "autoruns": + header = "Suspicious AutoRuns" + for _, ar := range sus.AutoRuns { + title := ar.Name + desc := fmt.Sprintf("%s (%s)", ar.Command, ar.Location) + items = append(items, listItem{title: title, desc: desc}) + } + case "binaries": + header = "Suspicious Binaries" + for _, b := range sus.Binaries { + items = append(items, listItem{title: b, desc: "binary file"}) + } + case "connections": + header = "Suspicious Connections" + for _, 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)}) + } + case "directories": + header = "Suspicious Directories" + for _, d := range sus.Directories { + items = append(items, listItem{title: d, desc: "directory"}) + } + case "processes": + header = "Suspicious Processes" + for _, 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}) + } + case "scheduledTasks": + header = "Suspicious Scheduled Tasks" + for _, t := range sus.ScheduledTasks { + label := t.Name + desc := t.Path + items = append(items, listItem{title: label, desc: desc}) + } + case "services": + header = "Suspicious Services" + for _, s := range sus.Services { + label := fmt.Sprintf("%s (%s)", s.Name, s.DisplayName) + desc := s.BinaryPathName + items = append(items, listItem{title: label, desc: desc}) + } + } + + l.Title = header + " — Left: Back Enter: Details q: Quit" + l.SetItems(items) + return ListViewModel{typeKey: typeKey, list: l, header: header} +} + +func (m ListViewModel) Init() tea.Cmd { return nil } + +func (m ListViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.list.SetSize(msg.Width, msg.Height-2) + case tea.KeyMsg: + switch msg.String() { + case "left": + return m, func() tea.Msg { return BackMsg{} } + case "q", "esc", "ctrl+c": + return m, tea.Quit + case "enter": + return m, func() tea.Msg { return ListSelectedMsg{TypeKey: m.typeKey, Index: m.list.Index()} } + } + } + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd +} + +func (m ListViewModel) View() string { return m.list.View() } diff --git a/internal/tui/typePicker.go b/internal/tui/typePicker.go new file mode 100644 index 0000000..7cf9675 --- /dev/null +++ b/internal/tui/typePicker.go @@ -0,0 +1,120 @@ +package tui + +import ( + "strings" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// SelectedTypeMsg is sent when the user chooses a type (1-7) +// Valid Type values: "autoruns", "binaries", "connections", "directories", "processes", "scheduledTasks", "services" +type SelectedTypeMsg struct{ Type string } + +// BackMsg is sent when the user presses Left to go back +type BackMsg struct{} + +// keyMap defines keybindings for the type picker +// It must satisfy key.Map for the help component +// We only need: 1-7, left/back, help, quit +type keyMap struct { + Help key.Binding + Quit key.Binding + Back key.Binding + One key.Binding + Two key.Binding + Three key.Binding + Four key.Binding + Five key.Binding + Six key.Binding + Seven key.Binding +} + +func (k keyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Back, k.Help, k.Quit} +} + +func (k keyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.One, k.Two, k.Three, k.Four, k.Five, k.Six, k.Seven}, + {k.Back, k.Help, k.Quit}, + } +} + +var keys = keyMap{ + One: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "AutoRuns")), + Two: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "Binaries")), + Three: key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "Connections")), + Four: key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "Directories")), + Five: key.NewBinding(key.WithKeys("5"), key.WithHelp("5", "Processes")), + Six: key.NewBinding(key.WithKeys("6"), key.WithHelp("6", "Scheduled Tasks")), + Seven: key.NewBinding(key.WithKeys("7"), key.WithHelp("7", "Services")), + Back: key.NewBinding(key.WithKeys("left"), key.WithHelp("←", "back")), + Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "toggle help")), + Quit: key.NewBinding(key.WithKeys("q", "esc", "ctrl+c"), key.WithHelp("q", "quit")), +} + +type TypePickerModel struct { + keys keyMap + help help.Model + inputStyle lipgloss.Style + quitting bool +} + +func NewTypePicker() TypePickerModel { + return TypePickerModel{ + keys: keys, + help: help.New(), + inputStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#ef8430")), + } +} + +func (m TypePickerModel) Init() tea.Cmd { return nil } + +func (m TypePickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.help.Width = msg.Width + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keys.Quit): + m.quitting = true + return m, tea.Quit + case key.Matches(msg, m.keys.Help): + m.help.ShowAll = !m.help.ShowAll + case key.Matches(msg, m.keys.Back): + return m, func() tea.Msg { return BackMsg{} } + case key.Matches(msg, m.keys.One): + return m, func() tea.Msg { return SelectedTypeMsg{Type: "autoruns"} } + case key.Matches(msg, m.keys.Two): + return m, func() tea.Msg { return SelectedTypeMsg{Type: "binaries"} } + case key.Matches(msg, m.keys.Three): + return m, func() tea.Msg { return SelectedTypeMsg{Type: "connections"} } + case key.Matches(msg, m.keys.Four): + return m, func() tea.Msg { return SelectedTypeMsg{Type: "directories"} } + case key.Matches(msg, m.keys.Five): + return m, func() tea.Msg { return SelectedTypeMsg{Type: "processes"} } + case key.Matches(msg, m.keys.Six): + return m, func() tea.Msg { return SelectedTypeMsg{Type: "scheduledTasks"} } + case key.Matches(msg, m.keys.Seven): + return m, func() tea.Msg { return SelectedTypeMsg{Type: "services"} } + } + } + return m, nil +} + +func (m TypePickerModel) View() string { + if m.quitting { + return "Bye!\n" + } + title := lipgloss.NewStyle().Bold(true).Render("Select a type to manage") + menu := "\n 1) AutoRuns\n 2) Binaries\n 3) Connections\n 4) Directories\n 5) Processes\n 6) Scheduled Tasks\n 7) Services\n" + helpView := m.help.View(m.keys) + height := 8 - strings.Count(menu, "\n") - strings.Count(helpView, "\n") + if height < 0 { + height = 0 + } + return title + "\n" + menu + strings.Repeat("\n", height) + helpView +} diff --git a/tester/tui.go b/tester/tui.go new file mode 100644 index 0000000..fb09958 --- /dev/null +++ b/tester/tui.go @@ -0,0 +1,10 @@ +package main + +import "rmm-hunter/internal/tui" + +func main() { + err := tui.RunEliminateUI() + if err != nil { + panic(err) + } +}