14 Commits

Author SHA1 Message Date
Evan Hosinski c9e2e8dff8 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. 2025-10-11 21:01:07 -04:00
Evan Hosinski bde1b23753 Enhance detection logic to include process-based suspicious connection checks and refine firewall rule attributes in eliminate package. Add PID-to-process name mapping functionality. 2025-10-11 19:49:21 -04:00
Evan Hosinski a5d3623a72 Upgrade Scurvy library and add forced elevation logic with improved usage examples 2025-10-11 19:23:44 -04:00
Evan Hosinski d14b2837d0 Update eliminate connection logic to refine firewall rules and upgrade Scurvy library to latest version 2025-10-11 18:42:58 -04:00
Evan Hosinski 9c54a22bcf Update eliminate connection logic to refine firewall rules and upgrade Scurvy library to latest version 2025-10-11 18:06:42 -04:00
Evan Hosinski b855f0eaec Add eliminate package with functions for removing suspicious artifacts (files, directories, processes, services, scheduled tasks) and enhance detection logic to include whitelist checks and multi-indicator scoring 2025-10-11 17:22:44 -04:00
Evan Hosinski e835629643 Improve AutoRun and service detection with enhanced vendor/token matching, reduced false positives, and isolated changes 2025-10-11 15:26:42 -04:00
Evan Hosinski 53f527feff Refactor AutoRun detection to use Scurvy library, enhance suspicious entry checks, and update UI rendering for detailed info 2025-10-11 15:15:35 -04:00
Evan Hosinski 02ed2ce046 Initialize web package with placeholder file 2025-10-10 23:00:13 -04:00
Evan Hosinski ec307bc91f Add web and CLI UI options for eliminate command with mutual exclusivity and required flag checks
Introduce `--web` and `--cli` flags to select alternative interfaces for the elimination flow. Add validation to enforce mutual exclusivity and ensure one option is specified. Include placeholder logic for web UI implementation.
2025-10-10 22:59:46 -04:00
Evan Hosinski 192ce28d89 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.
2025-10-10 22:53:20 -04:00
Evan Hosinski 2b6c4eb4cd 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.
2025-10-10 22:43:47 -04:00
KrakenTech 9d385bb6b0 Update htmlTemplate.go 2025-10-10 18:03:35 -04:00
Evan Hosinski d28b8b1211 Refine AutoRun logging to display key counts, remove redundant entry count log 2025-10-10 17:01:14 -04:00
31 changed files with 2087 additions and 232 deletions
+68 -17
View File
@@ -5,14 +5,18 @@ import (
"os" "os"
"rmm-hunter/internal/pkg" "rmm-hunter/internal/pkg"
"rmm-hunter/internal/pkg/hunter" "rmm-hunter/internal/pkg/hunter"
"rmm-hunter/internal/tui"
scurvy "github.com/Kraken-OffSec/Scurvy"
"github.com/Kraken-OffSec/Scurvy/core/escalator"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var ( var (
excludeRMMs []string excludeRMMs []string
inputFile string
outputFile string outputFile string
webUI bool
cliUI bool
) )
// rootCmd represents the base command when called without any subcommands // rootCmd represents the base command when called without any subcommands
@@ -21,7 +25,24 @@ var rootCmd = &cobra.Command{
Short: "RMM-Hunter - Detect and eliminate Remote Monitoring and Management software", Short: "RMM-Hunter - Detect and eliminate Remote Monitoring and Management software",
Long: `RMM-Hunter is a tool designed to detect and eliminate Remote Monitoring Long: `RMM-Hunter is a tool designed to detect and eliminate Remote Monitoring
and Management (RMM) software on Windows systems. It can hunt for suspicious and Management (RMM) software on Windows systems. It can hunt for suspicious
processes, services, binaries, and network connections associated with RMM tools.`, processes, services, binaries, and network connections associated with RMM tools.
Steps:
- Click start
- Type Powershell (see Windows Powershell)
- Right click and select "Run as administrator"
- Navigate to the directory containing rmm-hunter.exe
> If you downloaded the executable, it will be in your Downloads folder
> cd ~\Downloads\
- To start the enumeration process, run the following command:
> .\rmm-hunter.exe hunt
- To remove detected RMM software, run the following command:
> CLI - A command line interface with interactive prompts
-> .\rmm-hunter.exe eliminate--cli
> Web - A web interface for browser based elimination (Under Construction)
-> .\rmm-hunter.exe eliminate --web
`,
Version: "1.0.0", Version: "1.0.0",
} }
@@ -38,7 +59,10 @@ var huntCmd = &cobra.Command{
- Processes - Processes
- Outbound Network Connections - Outbound Network Connections
- Scheduled Tasks - Scheduled Tasks
- Registry Entries`, - Registry Entries
> .\rmm-hunter.exe hunt
`,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Starting RMM Hunt...") fmt.Println("Starting RMM Hunt...")
runHunt() runHunt()
@@ -49,16 +73,25 @@ var huntCmd = &cobra.Command{
var eliminateCmd = &cobra.Command{ var eliminateCmd = &cobra.Command{
Use: "eliminate", Use: "eliminate",
Short: "Eliminate Sus software based on hunt results", Short: "Eliminate Sus software based on hunt results",
Long: `Eliminate mode removes detected Sus software from the system. Long: `Eliminate mode removes detected RMM Software from the system.
Requires a JSON input file containing hunt results to determine what to remove.`, Requires a JSON input file containing hunt results to determine what to remove.
Administrative Privileges are required. The executable will run a UAC prompt asking for escalation permissions to adjust.
> CLI - A command line interface with interactive prompts
-> .\rmm-hunter.exe eliminate --cli
> Web - A web interface for browser based elimination (Under Construction)
-> .\rmm-hunter.exe eliminate --web
`,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
if inputFile == "" { if admin, err := scurvy.IsAdmin(); err != nil || !admin {
fmt.Println("Error: --input flag is required for eliminate command") escErr := escalator.RequireAdmin()
if err != nil {
fmt.Printf("Failed to elevate: %v\n", escErr)
os.Exit(1)
}
fmt.Println("User is not admin, please run as administrator")
os.Exit(1) os.Exit(1)
} }
fmt.Println("Starting Elimination UI...")
fmt.Printf("Starting RMM Elimination using input file: %s\n", inputFile)
// TODO: Call eliminate.Eliminate() function
runEliminate() runEliminate()
}, },
} }
@@ -87,9 +120,16 @@ func init() {
"Output file to write hunt results (optional) Default: suspicious-hunter.json") "Output file to write hunt results (optional) Default: suspicious-hunter.json")
// Eliminate command flags // Eliminate command flags
eliminateCmd.Flags().StringVarP(&inputFile, "input", "i", "", eliminateCmd.Flags().BoolVarP(&webUI, "web", "w", false,
"JSON input file containing hunt results (required)") "Use web UI instead of TUI (optional)")
eliminateCmd.MarkFlagRequired("input") eliminateCmd.Flags().BoolVarP(&cliUI, "cli", "c", false,
"Use CLI UI instead of TUI (optional)")
// Mark web and cli flags as mutually exclusive
eliminateCmd.MarkFlagsMutuallyExclusive("web", "cli")
// Mark one of web or cli as required
eliminateCmd.MarkFlagsOneRequired("web", "cli")
} }
func runHunt() { func runHunt() {
@@ -104,8 +144,19 @@ func runHunt() {
} }
func runEliminate() { func runEliminate() {
// TODO: Implement eliminate functionality if webUI {
fmt.Println("Eliminate functionality not yet implemented") // Launch the web UI for elimination flow
fmt.Printf("Input file: %s\n", inputFile) // TODO: Launch web UI
fmt.Printf("Excluded RMMs: %v\n", excludeRMMs) fmt.Println("Web UI not implemented yet")
return
} else if cliUI {
// Launch the TUI for elimination flow
if err := tui.RunEliminateUI(); err != nil {
fmt.Printf("[-] TUI error: %v\n", err)
os.Exit(1)
}
} else {
fmt.Println("No UI specified")
os.Exit(1)
}
} }
+27 -2
View File
@@ -3,27 +3,52 @@ module rmm-hunter
go 1.24.7 go 1.24.7
require ( require (
github.com/Kraken-OffSec/Scurvy v0.0.0-20251010192328-967933276439 github.com/Kraken-OffSec/Scurvy v0.0.0-20251011230527-75a5d96453a7
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 github.com/spf13/cobra v1.10.1
golang.org/x/sys v0.29.0 golang.org/x/sys v0.36.0
) )
require ( require (
github.com/Binject/debug v0.0.0-20230508195519-26db73212a7a // indirect github.com/Binject/debug v0.0.0-20230508195519-26db73212a7a // indirect
github.com/alwindoss/morse v1.0.1 // indirect github.com/alwindoss/morse v1.0.1 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/awgh/rawreader v0.0.0-20200626064944-56820a9c6da4 // indirect github.com/awgh/rawreader v0.0.0-20200626064944-56820a9c6da4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/botherder/go-savetime v1.5.0 // indirect
github.com/cakturk/go-netstat v0.0.0-20200220111822-e5b49efee7a5 // indirect github.com/cakturk/go-netstat v0.0.0-20200220111822-e5b49efee7a5 // 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/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
github.com/ecies/go/v2 v2.0.10 // indirect github.com/ecies/go/v2 v2.0.10 // indirect
github.com/elastic/go-sysinfo v1.15.1 // indirect github.com/elastic/go-sysinfo v1.15.1 // indirect
github.com/elastic/go-windows v1.0.2 // indirect github.com/elastic/go-windows v1.0.2 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/ethereum/go-ethereum v1.14.12 // indirect github.com/ethereum/go-ethereum v1.14.12 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // 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/mattn/go-shellwords v1.0.12 // 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/prometheus/procfs v0.15.1 // indirect github.com/prometheus/procfs v0.15.1 // indirect
github.com/rickb777/date v1.21.1 // indirect
github.com/rickb777/plural v1.4.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/ryanuber/columnize v2.1.2+incompatible // indirect github.com/ryanuber/columnize v2.1.2+incompatible // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/spf13/pflag v1.0.9 // indirect github.com/spf13/pflag v1.0.9 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/crypto v0.31.0 // indirect golang.org/x/crypto v0.31.0 // indirect
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect
golang.org/x/text v0.21.0 // indirect golang.org/x/text v0.21.0 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
howett.net/plist v1.0.1 // indirect howett.net/plist v1.0.1 // indirect
+71 -16
View File
@@ -1,27 +1,47 @@
github.com/Binject/debug v0.0.0-20230508195519-26db73212a7a h1:4c0nc0krv8eh7gD809n+swLaCuFyHpxdrxwx0ZmHvBw= 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/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 h1:n/B4+1K6vpKX34iISUKHzEKEND53PmxePHrtsy693Jo=
github.com/Kraken-OffSec/Scurvy v0.0.0-20251010192328-967933276439/go.mod h1:hljxQLLV5S60GVVG51+u3r1agCjZ45x8jd2WiJxy0wQ= github.com/Kraken-OffSec/Scurvy v0.0.0-20251010192328-967933276439/go.mod h1:hljxQLLV5S60GVVG51+u3r1agCjZ45x8jd2WiJxy0wQ=
github.com/Kraken-OffSec/Scurvy v0.0.0-20251011184544-e9265efd21c6 h1:CRH0t964ocRHXspOo8cB0DPcSfEtsGh8FenjML252HI=
github.com/Kraken-OffSec/Scurvy v0.0.0-20251011184544-e9265efd21c6/go.mod h1:0pPwYHy+r8KGzXZ8vBgyYd6qy3vX+AMRo9XLiGc8WGE=
github.com/Kraken-OffSec/Scurvy v0.0.0-20251011204529-faafd6327395 h1:5VcLiLUs33Hvqp5Jiyft+ZzzhfjTVb6fOB3MWiIDp1M=
github.com/Kraken-OffSec/Scurvy v0.0.0-20251011204529-faafd6327395/go.mod h1:0pPwYHy+r8KGzXZ8vBgyYd6qy3vX+AMRo9XLiGc8WGE=
github.com/Kraken-OffSec/Scurvy v0.0.0-20251011211525-6bf6bee1b100 h1:Om4wnKb+fpfYi3uRfc27Pz8uG/3CNrM2G3sSBwerSXA=
github.com/Kraken-OffSec/Scurvy v0.0.0-20251011211525-6bf6bee1b100/go.mod h1:0pPwYHy+r8KGzXZ8vBgyYd6qy3vX+AMRo9XLiGc8WGE=
github.com/Kraken-OffSec/Scurvy v0.0.0-20251011220403-fbfc55b9d87a h1:PWSQPeqWs5kKreTgIyddGWF+EwmlbYYZa4mRUVLpINc=
github.com/Kraken-OffSec/Scurvy v0.0.0-20251011220403-fbfc55b9d87a/go.mod h1:0pPwYHy+r8KGzXZ8vBgyYd6qy3vX+AMRo9XLiGc8WGE=
github.com/Kraken-OffSec/Scurvy v0.0.0-20251011230527-75a5d96453a7 h1:bBfOGqMzoWM/9Dqg+f1EmgyrKquINqT8jBgk9PrrKWQ=
github.com/Kraken-OffSec/Scurvy v0.0.0-20251011230527-75a5d96453a7/go.mod h1:0pPwYHy+r8KGzXZ8vBgyYd6qy3vX+AMRo9XLiGc8WGE=
github.com/alwindoss/morse v1.0.1 h1:PkUh5m1UHMcZ1Upvl7CmSIBMxdEBejWoQ4rQQtgJsCQ= github.com/alwindoss/morse v1.0.1 h1:PkUh5m1UHMcZ1Upvl7CmSIBMxdEBejWoQ4rQQtgJsCQ=
github.com/alwindoss/morse v1.0.1/go.mod h1:qAqJOep3jEpIpiLgqSGgLk5Zh4BZKsyzMQHuAwVPMXc= github.com/alwindoss/morse v1.0.1/go.mod h1:qAqJOep3jEpIpiLgqSGgLk5Zh4BZKsyzMQHuAwVPMXc=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/awgh/rawreader v0.0.0-20200626064944-56820a9c6da4 h1:cIAK2NNf2yafdgpFRNJrgZMwvy61BEVpGoHc2n4/yWs= 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/awgh/rawreader v0.0.0-20200626064944-56820a9c6da4/go.mod h1:SalMPBCab3yuID8nIhLfzwoBV+lBRyaC7NhuN8qL8xE=
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/botherder/go-savetime v1.5.0 h1:i4vt4d4IcXgFXnIK5FBuSCUUZSV8E+s4S8TLm+9tYdM=
github.com/botherder/go-savetime v1.5.0/go.mod h1:w8rKlqwexRgSmekdFAZVfenmaZKhXBIew2tDvuox2sI=
github.com/cakturk/go-netstat v0.0.0-20200220111822-e5b49efee7a5 h1:BjkPE3785EwPhhyuFkbINB+2a1xATwk8SNDWnJiD41g= 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/cakturk/go-netstat v0.0.0-20200220111822-e5b49efee7a5/go.mod h1:jtAfVaU/2cu1+wdSRPWE2c1N2qeAA3K4RH9pYgqwets=
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/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 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
@@ -31,6 +51,8 @@ github.com/elastic/go-sysinfo v1.15.1 h1:zBmTnFEXxIQ3iwcQuk7MzaUotmKRp3OabbbWM8T
github.com/elastic/go-sysinfo v1.15.1/go.mod h1:jPSuTgXG+dhhh0GKIyI2Cso+w5lPJ5PvVqKlL8LV/Hk= 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 h1:yoLLsAsV5cfg9FLhZ9EXZ2n2sQFKeDYrHenkcivY4vI=
github.com/elastic/go-windows v1.0.2/go.mod h1:bGcDpBzXgYSqM0Gx3DM4+UxFj300SZLixie9u9ixLM8= github.com/elastic/go-windows v1.0.2/go.mod h1:bGcDpBzXgYSqM0Gx3DM4+UxFj300SZLixie9u9ixLM8=
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/ethereum/go-ethereum v1.14.12 h1:8hl57x77HSUo+cXExrURjU/w1VhL+ShCTJrTwcCQSe4= 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/ethereum/go-ethereum v1.14.12/go.mod h1:RAC2gVMWJ6FkxSPESfbshrcKpIokgQKsVKmAuqdekDY=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
@@ -38,20 +60,53 @@ github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 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/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
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/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
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/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 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/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/rickb777/date v1.21.1 h1:tUcQS8riIRoYK5kUAv5aevllFEYUEk2x8OYDyoldOn4=
github.com/rickb777/date v1.21.1/go.mod h1:gnDexsbXViZr2fCKMrY3m6IfAF5U2vSkEaiGJcNFaLQ=
github.com/rickb777/plural v1.4.2 h1:Kl/syFGLFZ5EbuV8c9SVud8s5HI2HpCCtOMw2U1kS+A=
github.com/rickb777/plural v1.4.2/go.mod h1:kdmXUpmKBJTS0FtG/TFumd//VBWsNTD7zOw7x4umxNw=
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/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 h1:C89EOx/XBWwIXl8wm8OPJBd7kPF25UfsK2X7Ph/zCAk=
github.com/ryanuber/columnize v2.1.2+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= 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 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= 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 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
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/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
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.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 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 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
+134 -133
View File
@@ -6,165 +6,166 @@ import (
. "rmm-hunter/internal/suspicious" . "rmm-hunter/internal/suspicious"
"strings" "strings"
"golang.org/x/sys/windows/registry" "github.com/Kraken-OffSec/Scurvy/core/autoruns"
) )
// Whitelist for our own tool and legitimate system components
var whitelist = []string{
"rmm-hunter",
}
func isWhitelisted(ar AutoRun) bool {
allText := strings.ToLower(strings.Join([]string{
ar.ImageName, ar.ImagePath, ar.Entry, ar.LaunchString,
}, "|"))
for _, w := range whitelist {
if strings.Contains(allText, w) {
return true
}
}
return false
}
func Detect() []AutoRun { func Detect() []AutoRun {
var suspiciousAutoRuns []AutoRun var suspiciousAutoRuns []AutoRun
fmt.Printf("[*] Enumerating AutoRun Applications\n") fmt.Printf("[*] Enumerating AutoRun Applications\n")
// Check common autorun registry locations // Enumerate autoruns from Registry and COM Services
autorunKeys := []string{ autoRuns := autoruns.GetAllAutoruns()
`SOFTWARE\Microsoft\Windows\CurrentVersion\Run`, fmt.Printf(" [>] Dispositioning %d AutoRun Entries\n", len(autoRuns))
`SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce`,
`SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run`,
`SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\RunOnce`,
`SOFTWARE\Microsoft\Windows\CurrentVersion\RunServices`,
`SOFTWARE\Microsoft\Windows\CurrentVersion\RunServicesOnce`,
`SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\Explorer\Run`,
`SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\Userinit`,
`SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\Shell`,
`SOFTWARE\Microsoft\Active Setup\Installed Components`,
}
// Check both HKLM and HKCU for _, ar := range autoRuns {
roots := []registry.Key{registry.LOCAL_MACHINE, registry.CURRENT_USER} // Map Scurvy autorun to our Suspicious.AutoRun struct
rootNames := []string{"HKLM", "HKCU"} sar := AutoRun{
Type: ar.Type,
totalEntries := 0 Location: ar.Location,
for i, root := range roots { ImagePath: ar.ImagePath,
for _, keyPath := range autorunKeys { ImageName: ar.ImageName,
entries := checkAutoRunKey(root, keyPath, rootNames[i]) Arguments: ar.Arguments,
totalEntries += len(entries) MD5: ar.MD5,
suspiciousAutoRuns = append(suspiciousAutoRuns, entries...) SHA1: ar.SHA1,
SHA256: ar.SHA256,
Entry: ar.Entry,
LaunchString: ar.LaunchString,
} }
}
fmt.Printf(" [>] Dispositioning %d AutoRun Entries\n", totalEntries) // Skip whitelisted entries (our own tool)
fmt.Printf("[+] Found %d Suspicious AutoRun Applications\n", len(suspiciousAutoRuns)) if isWhitelisted(sar) {
return suspiciousAutoRuns
}
func checkAutoRunKey(root registry.Key, keyPath, rootName string) []AutoRun {
var autoRuns []AutoRun
key, err := registry.OpenKey(root, keyPath, registry.QUERY_VALUE)
if err != nil {
return autoRuns
}
defer key.Close()
valueNames, err := key.ReadValueNames(-1)
if err != nil {
return autoRuns
}
for _, valueName := range valueNames {
value, _, err := key.GetStringValue(valueName)
if err != nil {
continue continue
} }
// Check if this autorun entry matches any known Suspicious patterns if isSuspiciousAutoRunEntry(sar) {
if isSuspiciousAutoRun(valueName, value) { fmt.Printf(" [?] Found %s | %s | %s\n", sar.Location, sar.Entry, sar.ImagePath)
// Analyze the executable path for additional suspicious indicators suspiciousAutoRuns = append(suspiciousAutoRuns, sar)
isPathSuspicious, pathReason := analyzeExecutablePath(value) }
description := extractDescription(value) }
if isPathSuspicious {
description += fmt.Sprintf(" [%s]", pathReason) fmt.Printf("[+] Found %d Suspicious AutoRun Applications\n", len(suspiciousAutoRuns))
return suspiciousAutoRuns
}
// isSuspiciousAutoRunEntry uses multi-Indicator scoring to detect RMMs
// Requires at least 2 independent Indicators to flag as suspicious
// Hash match alone is sufficient (high confidence)
func isSuspiciousAutoRunEntry(ar AutoRun) bool {
score := 0
// Build searchable text from all fields
allText := strings.ToLower(strings.Join([]string{
ar.ImageName, ar.ImagePath, ar.Entry, ar.LaunchString, ar.Location, ar.Arguments,
}, "|"))
// Indicator 0: Known RMM hash match (SHA256 or SHA1) - HIGHEST CONFIDENCE
// A hash match alone is sufficient to flag as suspicious
if ar.SHA256 != "" {
sha256Lower := strings.ToLower(ar.SHA256)
for _, hash := range common.CommonRMMHashes {
if strings.ToLower(hash) == sha256Lower {
return true // Hash match is definitive
}
}
}
if ar.SHA1 != "" {
sha1Lower := strings.ToLower(ar.SHA1)
for _, hash := range common.CommonRMMHashesSHA1 {
if strings.ToLower(hash) == sha1Lower {
return true // Hash match is definitive
} }
fmt.Printf(" [?] Found %s\\%s: %s = %s\n", rootName, keyPath, valueName, value)
autoRuns = append(autoRuns, AutoRun{
Name: valueName,
Command: value,
Location: fmt.Sprintf("%s\\%s", rootName, keyPath),
Enabled: true,
Description: description,
})
} }
} }
return autoRuns // Indicator 1: Known RMM vendor name match (CommonRMMs)
} rmmNameHit := false
func isSuspiciousAutoRun(name, command string) bool {
// Convert to lowercase for case-insensitive comparison
nameLower := strings.ToLower(name)
commandLower := strings.ToLower(command)
// Check against known Suspicious names
for _, rmm := range common.CommonRMMs { for _, rmm := range common.CommonRMMs {
rmmLower := strings.ToLower(rmm) if strings.Contains(allText, strings.ToLower(rmm)) {
if strings.Contains(nameLower, rmmLower) || strings.Contains(commandLower, rmmLower) { rmmNameHit = true
return true break
} }
} }
if rmmNameHit {
score++
}
// Check against common Suspicious executable patterns // Indicator 2: Known RMM executable/binary pattern (CommonImageSuffixes)
for _, imageEnd := range common.CommonImageSuffixes { binaryPatternHit := false
imageEndLower := strings.ToLower(imageEnd) imgPathLower := strings.ToLower(ar.ImagePath)
if strings.Contains(commandLower, imageEndLower) { imgNameLower := strings.ToLower(ar.ImageName)
return true launchLower := strings.ToLower(ar.LaunchString)
for _, pattern := range common.CommonImageSuffixes {
patternLower := strings.ToLower(pattern)
if strings.Contains(imgPathLower, patternLower) ||
strings.Contains(imgNameLower, patternLower) ||
strings.Contains(launchLower, patternLower) {
binaryPatternHit = true
break
} }
} }
if binaryPatternHit {
// Additional suspicious patterns score++
suspiciousPatterns := []string{
"remote", "control", "assist", "support", "vnc", "rdp", "teamview",
"anydesk", "logmein", "screenconnect", "splashtop", "ultravnc",
} }
for _, pattern := range suspiciousPatterns { // Indicator 3: Known RMM DNS/domain in command line or launch string (CommonDNS)
if strings.Contains(nameLower, pattern) || strings.Contains(commandLower, pattern) { dnsHit := false
return true argsLower := strings.ToLower(ar.Arguments)
for _, dns := range common.CommonDNS {
dnsLower := strings.ToLower(dns)
// Handle wildcard patterns: *.example.com should match anything.example.com
if strings.HasPrefix(dnsLower, "*.") {
// Match the domain suffix (e.g., ".example.com")
domainSuffix := dnsLower[1:] // Remove the * but keep the dot
if strings.Contains(launchLower, domainSuffix) || strings.Contains(argsLower, domainSuffix) {
dnsHit = true
break
}
} else if strings.HasSuffix(dnsLower, ".*") {
// Handle patterns like example.* - match the prefix
domainPrefix := dnsLower[:len(dnsLower)-2] // Remove the .*
if strings.Contains(launchLower, domainPrefix) || strings.Contains(argsLower, domainPrefix) {
dnsHit = true
break
}
} else {
// Exact domain match (no wildcard)
if strings.Contains(launchLower, dnsLower) || strings.Contains(argsLower, dnsLower) {
dnsHit = true
break
}
} }
} }
if dnsHit {
score++
}
return false // Indicator 4: Suspicious installation path (temp, public, programdata)
} pathSuspicious, _ := common.AnalyzeExecutablePath(ar.ImagePath)
if !pathSuspicious && ar.LaunchString != "" {
func extractDescription(command string) string { pathSuspicious, _ = common.AnalyzeExecutablePath(ar.LaunchString)
// Extract just the executable name from the command }
parts := strings.Fields(command) if pathSuspicious {
if len(parts) > 0 { score++
return parts[0] }
}
return command // Require at least 2 independent Indicator to reduce false positives
} return score >= 2
func analyzeExecutablePath(command string) (bool, string) {
// Extract executable path from command
var execPath string
if strings.HasPrefix(command, "\"") {
// Handle quoted paths
endQuote := strings.Index(command[1:], "\"")
if endQuote != -1 {
execPath = command[1 : endQuote+1]
}
} else {
// Handle unquoted paths
parts := strings.Fields(command)
if len(parts) > 0 {
execPath = parts[0]
}
}
// Check for suspicious installation paths
suspiciousPaths := []string{
"\\temp\\", "\\tmp\\", "\\appdata\\local\\temp\\",
"\\users\\public\\", "\\programdata\\",
"\\windows\\temp\\", "\\%temp%\\",
}
execPathLower := strings.ToLower(execPath)
for _, suspPath := range suspiciousPaths {
if strings.Contains(execPathLower, suspPath) {
return true, fmt.Sprintf("Suspicious installation path: %s", suspPath)
}
}
return false, ""
} }
@@ -6,11 +6,14 @@ func TestAutoRun(t *testing.T) {
autoruns := Detect() autoruns := Detect()
for _, ar := range autoruns { for _, ar := range autoruns {
t.Logf("-----") t.Logf("-----")
t.Logf("Name: %s", ar.Name) t.Logf("Type: %s", ar.Type)
t.Logf("Command: %s", ar.Command) t.Logf("Entry: %s", ar.Entry)
t.Logf("Location: %s", ar.Location) t.Logf("Location: %s", ar.Location)
t.Logf("Enabled: %t", ar.Enabled) t.Logf("Image: %s", ar.ImagePath)
t.Logf("Description: %s", ar.Description) t.Logf("Args: %s", ar.Arguments)
t.Logf("MD5: %s", ar.MD5)
t.Logf("SHA1: %s", ar.SHA1)
t.Logf("SHA256: %s", ar.SHA256)
t.Logf("-----") t.Logf("-----")
} }
} }
@@ -5,12 +5,13 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"rmm-hunter/internal/pkg/hunt/detect/common" "rmm-hunter/internal/pkg/hunt/detect/common"
. "rmm-hunter/internal/suspicious"
"strings" "strings"
"sync" "sync"
) )
func Detect() []string { func Detect() []Binary {
var foundBinaries []string var foundBinaries []Binary
var mu sync.Mutex var mu sync.Mutex
var wg sync.WaitGroup var wg sync.WaitGroup
@@ -52,7 +53,7 @@ func Detect() []string {
// Collect results // Collect results
for result := range resultChan { for result := range resultChan {
mu.Lock() mu.Lock()
foundBinaries = append(foundBinaries, result) foundBinaries = append(foundBinaries, Binary{Path: result})
mu.Unlock() mu.Unlock()
fmt.Printf(" [?] Found %s\n", result) fmt.Printf(" [?] Found %s\n", result)
} }
@@ -0,0 +1,15 @@
package common
// CommonRMMHashes contains known SHA256 hashes of RMM executables
// These are high-confidence indicators - a hash match is a strong signal
// Sources: VirusTotal, LOLRMM, threat intelligence reports
var CommonRMMHashes = []string{
// TODO: Add hashes here
}
// CommonRMMHashesSHA1 contains known SHA1 hashes of RMM executables
var CommonRMMHashesSHA1 = []string{
// TODO: Add hashes here
// SHA256 is preferred for collision resistance
}
@@ -40,16 +40,57 @@ func DetectOutboundConnections() []NetworkConnection {
func compareConnections(connections []NetworkConnection) []NetworkConnection { func compareConnections(connections []NetworkConnection) []NetworkConnection {
var suspiciousConnections []NetworkConnection var suspiciousConnections []NetworkConnection
for _, conn := range connections { // Get process names for all PIDs
remote := conn.RemoteHost pidToProcessName := getProcessNamesForPIDs(connections)
for _, conn := range connections {
isSuspicious := false
reason := ""
// Check 1: DNS pattern match (domain-based detection)
remote := conn.RemoteHost
for _, dns := range common.CommonDNS { for _, dns := range common.CommonDNS {
if matchesDNSPattern(remote, dns) { if matchesDNSPattern(remote, dns) {
fmt.Printf(" [?] Found %s\n", conn.RemoteHost) isSuspicious = true
suspiciousConnections = append(suspiciousConnections, conn) reason = fmt.Sprintf("DNS match: %s", conn.RemoteHost)
break break
} }
} }
// Check 2: Process name match (catches RMMs using custom relay servers)
if !isSuspicious && conn.PID != "" {
if processName, exists := pidToProcessName[conn.PID]; exists {
processNameLower := strings.ToLower(processName)
// Check against known RMM names
for _, rmm := range common.CommonRMMs {
if strings.Contains(processNameLower, strings.ToLower(rmm)) {
isSuspicious = true
reason = fmt.Sprintf("RMM process: %s", processName)
break
}
}
// Check against known RMM executable patterns
if !isSuspicious {
for _, pattern := range common.CommonImageSuffixes {
patternLower := strings.ToLower(pattern)
// Remove leading backslash for matching
patternClean := strings.TrimPrefix(patternLower, "\\")
if strings.Contains(processNameLower, patternClean) {
isSuspicious = true
reason = fmt.Sprintf("RMM executable: %s", processName)
break
}
}
}
}
}
if isSuspicious {
fmt.Printf(" [?] Found %s (%s)\n", conn.RemoteHost, reason)
suspiciousConnections = append(suspiciousConnections, conn)
}
} }
fmt.Printf("[+] Found %d Suspicious Outbound Connections\n", len(suspiciousConnections)) fmt.Printf("[+] Found %d Suspicious Outbound Connections\n", len(suspiciousConnections))
@@ -190,3 +231,39 @@ func GetHTTPHostnames() []string {
return hostnames return hostnames
} }
// getProcessNamesForPIDs returns a map of PID -> process name for all connections
func getProcessNamesForPIDs(connections []NetworkConnection) map[string]string {
pidMap := make(map[string]string)
// Collect unique PIDs
uniquePIDs := make(map[string]bool)
for _, conn := range connections {
if conn.PID != "" && conn.PID != "0" {
uniquePIDs[conn.PID] = true
}
}
// Query process names for each PID
for pid := range uniquePIDs {
processName := getProcessNameByPID(pid)
if processName != "" {
pidMap[pid] = processName
}
}
return pidMap
}
// getProcessNameByPID returns the process name for a given PID
func getProcessNameByPID(pid string) string {
cmd := exec.Command("powershell", "-Command",
fmt.Sprintf("(Get-Process -Id %s -ErrorAction SilentlyContinue).ProcessName", pid))
output, err := cmd.Output()
if err != nil {
return ""
}
processName := strings.TrimSpace(string(output))
return processName
}
@@ -5,13 +5,14 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"rmm-hunter/internal/pkg/hunt/detect/common" "rmm-hunter/internal/pkg/hunt/detect/common"
. "rmm-hunter/internal/suspicious"
"strings" "strings"
) )
var appData = os.Getenv("APPDATA") var appData = os.Getenv("APPDATA")
func Detect() []string { func Detect() []Directory {
var suspiciousDirectories []string var suspiciousDirectories []Directory
seen := make(map[string]bool) // Prevent duplicates seen := make(map[string]bool) // Prevent duplicates
fmt.Printf("[*] Enumerating Suspicious Directories \n") fmt.Printf("[*] Enumerating Suspicious Directories \n")
@@ -26,7 +27,7 @@ func Detect() []string {
for _, match := range matches { for _, match := range matches {
if !seen[match] { if !seen[match] {
fmt.Printf(" [?] Found %s\n", match) fmt.Printf(" [?] Found %s\n", match)
suspiciousDirectories = append(suspiciousDirectories, match) suspiciousDirectories = append(suspiciousDirectories, Directory{Path: match})
seen[match] = true seen[match] = true
} }
} }
@@ -35,7 +36,7 @@ func Detect() []string {
if _, err := os.Stat(dir); err == nil { if _, err := os.Stat(dir); err == nil {
if !seen[dir] { if !seen[dir] {
fmt.Printf(" [?] Found %s\n", dir) fmt.Printf(" [?] Found %s\n", dir)
suspiciousDirectories = append(suspiciousDirectories, dir) suspiciousDirectories = append(suspiciousDirectories, Directory{Path: dir})
seen[dir] = true seen[dir] = true
} }
} }
@@ -9,6 +9,23 @@ import (
"github.com/Kraken-OffSec/Scurvy/core/process" "github.com/Kraken-OffSec/Scurvy/core/process"
) )
// Whitelist for our own tool and legitimate system components
var whitelist = []string{
"rmm-hunter",
}
func isWhitelisted(proc process.Process) bool {
allText := strings.ToLower(strings.Join([]string{
proc.Executable(), proc.Path(),
}, "|"))
for _, w := range whitelist {
if strings.Contains(allText, w) {
return true
}
}
return false
}
func Detect() []Process { func Detect() []Process {
fmt.Printf("[*] Enumerating Processes \n") fmt.Printf("[*] Enumerating Processes \n")
@@ -27,6 +44,11 @@ func compareProcesses(processes []process.Process) []Process {
var suspiciousProcesses []Process var suspiciousProcesses []Process
for _, proc := range processes { for _, proc := range processes {
// Skip whitelisted processes (our own tool)
if isWhitelisted(proc) {
continue
}
procName := proc.Executable() procName := proc.Executable()
procNameLower := strings.ToLower(procName) procNameLower := strings.ToLower(procName)
+99 -21
View File
@@ -10,6 +10,23 @@ import (
"golang.org/x/sys/windows" "golang.org/x/sys/windows"
) )
// Whitelist for our own tool and legitimate system components
var whitelist = []string{
"rmm-hunter",
}
func isWhitelisted(config service.ServiceConfig) bool {
allText := strings.ToLower(strings.Join([]string{
config.DisplayName, config.ServiceStartName, config.BinaryPathName, config.Description,
}, "|"))
for _, w := range whitelist {
if strings.Contains(allText, w) {
return true
}
}
return false
}
func Detect() []*Service { func Detect() []*Service {
fmt.Printf("[*] Enumerating Services \n") fmt.Printf("[*] Enumerating Services \n")
@@ -45,29 +62,13 @@ func compareServices(serviceStrings []string, scm *service.Mgr) []*Service {
fmt.Printf(" [>-] Error getting service config %s: %s\n", serviceString, err.Error()) fmt.Printf(" [>-] Error getting service config %s: %s\n", serviceString, err.Error())
continue continue
} }
svcStartName := strings.ToLower(config.ServiceStartName)
svcDisplayName := strings.ToLower(config.DisplayName)
svcBinaryPath := strings.ToLower(config.BinaryPathName)
// Check against known RMMs // Skip whitelisted services (our own tool)
isRMMMatch := false if isWhitelisted(config) {
for _, rmm := range common.CommonRMMs { continue
rmmLower := strings.ToLower(rmm)
if strings.Contains(svcDisplayName, rmmLower) || strings.Contains(svcStartName, rmmLower) || strings.Contains(svcBinaryPath, rmmLower) {
isRMMMatch = true
break
}
} }
// Check for suspicious path regardless of RMM match if isSuspiciousService(config) {
isPathSuspicious, pathReason := common.AnalyzeExecutablePath(config.BinaryPathName)
if isRMMMatch || isPathSuspicious {
description := config.Description
if isPathSuspicious {
description += fmt.Sprintf(" [%s]", pathReason)
}
fmt.Printf(" [?] Found %s\n", config.DisplayName) fmt.Printf(" [?] Found %s\n", config.DisplayName)
suspiciousServices = append(suspiciousServices, &Service{ suspiciousServices = append(suspiciousServices, &Service{
Name: serviceString, Name: serviceString,
@@ -84,7 +85,7 @@ func compareServices(serviceStrings []string, scm *service.Mgr) []*Service {
Dependencies: config.Dependencies, Dependencies: config.Dependencies,
ServiceStartName: config.ServiceStartName, ServiceStartName: config.ServiceStartName,
Password: config.Password, Password: config.Password,
Description: description, Description: config.Description,
SidType: config.SidType, SidType: config.SidType,
DelayedAutoStart: config.DelayedAutoStart, DelayedAutoStart: config.DelayedAutoStart,
}) })
@@ -95,6 +96,83 @@ func compareServices(serviceStrings []string, scm *service.Mgr) []*Service {
return suspiciousServices return suspiciousServices
} }
// isSuspiciousService uses multi-indicator scoring to detect RMM services
// Requires at least 2 independent indicators to flag as suspicious
func isSuspiciousService(config service.ServiceConfig) bool {
score := 0
// Build searchable text from all service fields
allText := strings.ToLower(strings.Join([]string{
config.DisplayName, config.ServiceStartName, config.BinaryPathName, config.Description,
}, "|"))
// Indicator 1: Known RMM vendor name match (CommonRMMs)
rmmNameHit := false
for _, rmm := range common.CommonRMMs {
if strings.Contains(allText, strings.ToLower(rmm)) {
rmmNameHit = true
break
}
}
if rmmNameHit {
score++
}
// Indicator 2: Known RMM executable/binary pattern in service binary path (CommonImageSuffixes)
binaryPatternHit := false
binaryPathLower := strings.ToLower(config.BinaryPathName)
for _, pattern := range common.CommonImageSuffixes {
patternLower := strings.ToLower(pattern)
if strings.Contains(binaryPathLower, patternLower) {
binaryPatternHit = true
break
}
}
if binaryPatternHit {
score++
}
// Indicator 3: Known RMM DNS/domain in binary path or description (CommonDNS)
dnsHit := false
for _, dns := range common.CommonDNS {
dnsLower := strings.ToLower(dns)
// Handle wildcard patterns: *.example.com should match anything.example.com
if strings.HasPrefix(dnsLower, "*.") {
// Match the domain suffix (e.g., ".example.com")
domainSuffix := dnsLower[1:] // Remove the * but keep the dot
if strings.Contains(allText, domainSuffix) {
dnsHit = true
break
}
} else if strings.HasSuffix(dnsLower, ".*") {
// Handle patterns like example.* - match the prefix
domainPrefix := dnsLower[:len(dnsLower)-2] // Remove the .*
if strings.Contains(allText, domainPrefix) {
dnsHit = true
break
}
} else {
// Exact domain match (no wildcard)
if strings.Contains(allText, dnsLower) {
dnsHit = true
break
}
}
}
if dnsHit {
score++
}
// Indicator 4: Suspicious installation path (temp, public, programdata)
pathSuspicious, _ := common.AnalyzeExecutablePath(config.BinaryPathName)
if pathSuspicious {
score++
}
// Require at least 2 independent Indicators to reduce false positives
return score >= 2
}
func getServiceType(raw uint32) string { func getServiceType(raw uint32) string {
switch raw { switch raw {
case 1: case 1:
+20
View File
@@ -0,0 +1,20 @@
package eliminate
import (
"fmt"
. "rmm-hunter/internal/suspicious"
"github.com/Kraken-OffSec/Scurvy"
)
// EliminateAutoRun removes an autorun entry from the system
func EliminateAutoRun(ar AutoRun) error {
all := scurvy.ListAutoruns()
for _, a := range all {
if a.MD5 == ar.MD5 {
// Found it, delete it
return scurvy.DeleteAutorun(a)
}
}
return fmt.Errorf("%s | %s not found", ar.Location, ar.Entry)
}
+8
View File
@@ -0,0 +1,8 @@
package eliminate
import "os"
// EliminateBinary removes a binary from the system
func EliminateBinary(path string) error {
return os.Remove(path)
}
+36
View File
@@ -0,0 +1,36 @@
package eliminate
import (
"fmt"
"github.com/Kraken-OffSec/Scurvy/core/firewall"
)
// EliminateConnection adds an outbound block for the connection to the Windows firewall
func EliminateConnection(dst string) error {
// Create a new WindowsFirewall instance
fw, err := firewall.NewWindowsFirewall()
if err != nil {
return err
}
// Check if firewall is enabled
if !fw.Enabled() {
return fmt.Errorf("windows firewall is currently disabled. please enable it and try again")
}
// Add a block rule for the destination
return fw.AddRule(firewall.FirewallRule{
Name: fmt.Sprintf("Block Outgoing %s", dst),
Direction: "outbound",
Protocol: "*",
LocalPort: "",
RemotePort: "",
LocalAddress: "",
RemoteIPAddresses: "",
Action: "block",
Profile: "",
DestinationHostname: dst,
Source: "",
})
}
+7
View File
@@ -0,0 +1,7 @@
package eliminate
import "os"
func EliminateDirectory(path string) error {
return os.RemoveAll(path)
}
-1
View File
@@ -1 +0,0 @@
package eliminate
+25
View File
@@ -0,0 +1,25 @@
package eliminate
import (
"fmt"
. "rmm-hunter/internal/suspicious"
scurvy "github.com/Kraken-OffSec/Scurvy"
)
// EliminateProcess kills a process and removes its binary from the system
func EliminateProcess(p Process) error {
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)
}
@@ -0,0 +1,11 @@
package eliminate
import (
. "rmm-hunter/internal/suspicious"
scurvy "github.com/Kraken-OffSec/Scurvy"
)
func EliminateScheduledTask(t ScheduledTask) error {
return scurvy.DeleteScheduledTask(t.Name)
}
+12
View File
@@ -0,0 +1,12 @@
package eliminate
import (
. "rmm-hunter/internal/suspicious"
scurvy "github.com/Kraken-OffSec/Scurvy"
)
// EliminateService stops and removes a service from the system
func EliminateService(s Service) error {
return scurvy.RemoveService(s.Name)
}
+10 -7
View File
@@ -7,7 +7,7 @@ const htmlTemplate = `<!DOCTYPE html>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.ReportName}} - RMM Hunter Report</title> <title>{{.ReportName}} - RMM Hunter Report</title>
<!-- Modern font --> <!-- Font -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;500;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;500;700&display=swap" rel="stylesheet">
<style> <style>
@@ -434,11 +434,14 @@ const htmlTemplate = `<!DOCTYPE html>
{{if .Findings.AutoRuns}} {{if .Findings.AutoRuns}}
{{range .Findings.AutoRuns}} {{range .Findings.AutoRuns}}
<div class="item"> <div class="item">
<div class="item-title">{{.Name}}</div> <div class="item-title">{{.ImageName}}</div>
<div class="item-detail"><strong>Command:</strong> {{.Command}}</div> <div class="item-detail"><strong>Entry:</strong> {{.Entry}}</div>
<div class="item-detail"><strong>Type:</strong> {{.Type}}</div>
<div class="item-detail"><strong>Location:</strong> {{.Location}}</div> <div class="item-detail"><strong>Location:</strong> {{.Location}}</div>
<div class="item-detail"><strong>Enabled:</strong> {{.Enabled}}</div> <div class="item-detail"><strong>Image:</strong> {{.ImagePath}}</div>
{{if .Description}}<div class="item-detail"><strong>Description:</strong> {{.Description}}</div>{{end}} {{if .Arguments}}<div class="item-detail"><strong>Arguments:</strong> {{.Arguments}}</div>{{end}}
{{if .LaunchString}}<div class="item-detail"><strong>Launch:</strong> {{.LaunchString}}</div>{{end}}
<div class="item-detail"><strong>Hashes:</strong> MD5={{.MD5}} SHA1={{.SHA1}} SHA256={{.SHA256}}</div>
</div> </div>
{{end}} {{end}}
{{else}} {{else}}
@@ -457,7 +460,7 @@ const htmlTemplate = `<!DOCTYPE html>
{{if .Findings.Binaries}} {{if .Findings.Binaries}}
{{range .Findings.Binaries}} {{range .Findings.Binaries}}
<div class="item"> <div class="item">
<div class="item-detail">{{.}}</div> <div class="item-detail">{{.Path}}</div>
</div> </div>
{{end}} {{end}}
{{else}} {{else}}
@@ -476,7 +479,7 @@ const htmlTemplate = `<!DOCTYPE html>
{{if .Findings.Directories}} {{if .Findings.Directories}}
{{range .Findings.Directories}} {{range .Findings.Directories}}
<div class="item"> <div class="item">
<div class="item-detail">{{.}}</div> <div class="item-detail">{{.Path}}</div>
</div> </div>
{{end}} {{end}}
{{else}} {{else}}
+88 -20
View File
@@ -1,5 +1,9 @@
package suspicious package suspicious
import (
"encoding/json"
)
/* /*
Suspicious Suspicious
The object used to resemble the Suspicious artifacts and activities. The object used to resemble the Suspicious artifacts and activities.
@@ -8,8 +12,8 @@ type Suspicious struct {
Artifacts []Artifact `json:"artifacts"` Artifacts []Artifact `json:"artifacts"`
Persistence Persistence `json:"persistence"` Persistence Persistence `json:"persistence"`
RootFolder string `json:"rootFolder"` RootFolder string `json:"rootFolder"`
Binaries []string `json:"binaries"` Binaries []Binary `json:"binaries"`
Directories []string `json:"directories"` Directories []Directory `json:"directories"`
Services []*Service `json:"services"` Services []*Service `json:"services"`
Processes []Process `json:"processes"` Processes []Process `json:"processes"`
OutboundConnections []NetworkConnection `json:"outboundConnections"` OutboundConnections []NetworkConnection `json:"outboundConnections"`
@@ -17,13 +21,24 @@ type Suspicious struct {
ScheduledTasks []*ScheduledTask `json:"scheduledTasks"` 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 { type NetworkConnection struct {
LocalAddr string LocalAddr string `json:"localAddr"`
RemoteAddr string RemoteAddr string `json:"remoteAddr"`
RemoteHost string RemoteHost string `json:"remoteHost"`
State string State string `json:"state"`
PID string PID string `json:"pid"`
Process string Process string `json:"process"`
Eliminated bool `json:"eliminated,omitempty"`
} }
/* /*
@@ -50,11 +65,17 @@ AutoRun
The object used to resemble the auto run methods used by the Suspicious software. The object used to resemble the auto run methods used by the Suspicious software.
*/ */
type AutoRun struct { type AutoRun struct {
Name string `json:"name"` Type string `json:"type"`
Command string `json:"command"` Location string `json:"location"`
Location string `json:"location"` ImagePath string `json:"image_path"`
Enabled bool `json:"enabled"` ImageName string `json:"image_name"`
Description string `json:"description"` Arguments string `json:"arguments"`
MD5 string `json:"md5"`
SHA1 string `json:"sha1"`
SHA256 string `json:"sha256"`
Entry string `json:"entry"`
LaunchString string `json:"launch_string"`
Eliminated bool `json:"eliminated,omitempty"`
} }
/* /*
@@ -73,6 +94,7 @@ type ScheduledTask struct {
NextRun string `json:"nextRun"` NextRun string `json:"nextRun"`
LastRun string `json:"lastRun"` LastRun string `json:"lastRun"`
Path string `json:"path"` Path string `json:"path"`
Eliminated bool `json:"eliminated,omitempty"`
} }
/* /*
@@ -80,13 +102,14 @@ Process
The object used to resemble the processes used by the Suspicious software. The object used to resemble the processes used by the Suspicious software.
*/ */
type Process struct { type Process struct {
Name string `json:"name"` Name string `json:"name"`
PID int `json:"pid"` PID int `json:"pid"`
PPID int `json:"ppid"` PPID int `json:"ppid"`
Parent string `json:"parent"` Parent string `json:"parent"`
Args string `json:"args"` Args string `json:"args"`
Created string `json:"created"` Created string `json:"created"`
Path string `json:"path"` Path string `json:"path"`
Eliminated bool `json:"eliminated,omitempty"`
} }
/* /*
@@ -111,4 +134,49 @@ type Service struct {
Description string `json:"description"` Description string `json:"description"`
SidType uint32 `json:"sidType"` SidType uint32 `json:"sidType"`
DelayedAutoStart bool `json:"delayedAutoStart"` 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)})
} }
+160
View File
@@ -0,0 +1,160 @@
package tui
import (
"fmt"
"path/filepath"
"rmm-hunter/internal/pkg/hunt/eliminate"
"rmm-hunter/internal/suspicious"
"strings"
)
// 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 (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)}
}
}
}
}
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 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)}
}
}
}
}
return nil
}
// Elimination functions
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 eliminate.EliminateAutoRun(ar)
}
func eliminateBinary(path string) error {
return eliminate.EliminateBinary(path)
}
func eliminateConnection(conn suspicious.NetworkConnection) error {
return eliminate.EliminateConnection(conn.RemoteHost)
}
func eliminateDirectory(path string) error {
return eliminate.EliminateDirectory(path)
}
func eliminateProcess(p suspicious.Process) error {
return eliminate.EliminateProcess(p)
}
func eliminateScheduledTask(t suspicious.ScheduledTask) error {
return eliminate.EliminateScheduledTask(t)
}
func eliminateService(s suspicious.Service) error {
return eliminate.EliminateService(s)
}
+400
View File
@@ -0,0 +1,400 @@
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
eliminated map[string]map[int]bool // tracks eliminated items: typeKey -> index -> eliminated
filePath string // path to the loaded JSON file
}
func NewApp() AppModel {
return AppModel{
current: screenFilePicker,
filePick: NewFilePicker(),
typePick: NewTypePicker(),
eliminated: make(map[string]map[int]bool),
}
}
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.filePath = v.Path
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.eliminated)
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.eliminated)
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:
// 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) {
m.detail.modalWarn = wb.Error()
} else {
m.detail.modalErr = err.Error()
}
return m, nil
}
// 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
}
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 without removing items from data
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[idx].Eliminated = true
case "binaries":
b := m.data.Binaries[idx]
if err := CheckBinaryBlocked(b.Path, m.data); err != nil {
return err
}
if err := EliminateBinary(b.Path); err != nil {
return err
}
m.data.Binaries[idx].Eliminated = true
case "connections":
c := m.data.OutboundConnections[idx]
if err := EliminateConnection(c); err != nil {
return err
}
m.data.OutboundConnections[idx].Eliminated = true
case "directories":
d := m.data.Directories[idx]
if err := CheckDirectoryBlocked(d.Path, m.data); err != nil {
return err
}
if err := EliminateDirectory(d.Path); err != nil {
return err
}
m.data.Directories[idx].Eliminated = true
case "processes":
p := m.data.Processes[idx]
if err := EliminateProcess(p); err != nil {
return err
}
m.data.Processes[idx].Eliminated = true
case "scheduledTasks":
t := m.data.ScheduledTasks[idx]
if err := EliminateScheduledTask(*t); err != nil {
return err
}
m.data.ScheduledTasks[idx].Eliminated = true
case "services":
s := m.data.Services[idx]
if err := EliminateService(*s); err != nil {
return err
}
m.data.Services[idx].Eliminated = true
}
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
m.loadEliminatedState()
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
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())
_, err := p.Run()
return err
}
+105
View File
@@ -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")
}
}
+138
View File
@@ -0,0 +1,138 @@
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
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, 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 }
func (m DetailViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch v := msg.(type) {
case tea.KeyMsg:
if m.modalErr != "" || m.modalWarn != "" {
// Modal active: only ESC dismisses
if v.String() == "esc" {
m.modalErr = ""
m.modalWarn = ""
}
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 {
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")
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
}
return view
}
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.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.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)
}
return ""
}
+123
View File
@@ -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()
}
+109
View File
@@ -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()
}
+167
View File
@@ -0,0 +1,167 @@
package tui
import (
"fmt"
"io"
"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 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
eliminated map[string]map[int]bool
}
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)
} 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 i, ar := range sus.AutoRuns {
title := ar.ImageName
if title == "" {
title = ar.Entry
}
desc := fmt.Sprintf("%s (%s)", ar.ImagePath, ar.Location)
isEliminated := eliminated[typeKey] != nil && eliminated[typeKey][i]
items = append(items, listItem{title: title, desc: desc, eliminated: isEliminated})
}
case "binaries":
header = "Suspicious Binaries"
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 i, c := range sus.OutboundConnections {
label := fmt.Sprintf("%s -> %s (%s)", c.LocalAddr, c.RemoteAddr, c.RemoteHost)
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 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 i, p := range sus.Processes {
label := fmt.Sprintf("%s (PID %d)", p.Name, p.PID)
desc := p.Path
isEliminated := eliminated[typeKey] != nil && eliminated[typeKey][i]
items = append(items, listItem{title: label, desc: desc, eliminated: isEliminated})
}
case "scheduledTasks":
header = "Suspicious Scheduled Tasks"
for i, t := range sus.ScheduledTasks {
label := t.Name
desc := t.Path
isEliminated := eliminated[typeKey] != nil && eliminated[typeKey][i]
items = append(items, listItem{title: label, desc: desc, eliminated: isEliminated})
}
case "services":
header = "Suspicious Services"
for i, s := range sus.Services {
label := fmt.Sprintf("%s (%s)", s.Name, s.DisplayName)
desc := s.BinaryPathName
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, eliminated: eliminated}
}
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() }
+120
View File
@@ -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
}
+5
View File
@@ -0,0 +1,5 @@
package web
func StartWebServer() {
// TODO: Start web server
}
+10
View File
@@ -0,0 +1,10 @@
package main
import "rmm-hunter/internal/tui"
func main() {
err := tui.RunEliminateUI()
if err != nil {
panic(err)
}
}