Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9e2e8dff8 | |||
| bde1b23753 | |||
| a5d3623a72 | |||
| d14b2837d0 | |||
| 9c54a22bcf | |||
| b855f0eaec | |||
| e835629643 | |||
| 53f527feff | |||
| 02ed2ce046 | |||
| ec307bc91f | |||
| 192ce28d89 | |||
| 2b6c4eb4cd | |||
| 9d385bb6b0 | |||
| d28b8b1211 | |||
| 7cdee4b62c | |||
| 9512022a73 | |||
| 967b0c1de1 |
@@ -63,14 +63,12 @@ The HTML report includes:
|
||||
### Binary Download
|
||||
|
||||
Download the latest compiled binary from the releases page:
|
||||
```
|
||||
|
||||
powershell
|
||||
```powershell
|
||||
Download rmm-hunter.exe
|
||||
Run with administrator privileges```
|
||||
Run with administrator privileges
|
||||
```
|
||||
|
||||
### Building from Source
|
||||
```
|
||||
|
||||
The Scurvy Library is not publicly accessible making building this tool from source impossible at the moment.
|
||||
|
||||
@@ -79,20 +77,22 @@ The Scurvy Library is not publicly accessible making building this tool from sou
|
||||
### Hunt Mode
|
||||
|
||||
Execute a comprehensive system scan:
|
||||
```
|
||||
|
||||
powershell .\rmm-hunter.exe hunt```
|
||||
```powershell
|
||||
powershell .\rmm-hunter.exe hunt
|
||||
```
|
||||
|
||||
With custom output file:
|
||||
```
|
||||
|
||||
powershell .\rmm-hunter.exe hunt --output custom-report.json```
|
||||
```powershell
|
||||
powershell .\rmm-hunter.exe hunt --output custom-report.json
|
||||
```
|
||||
|
||||
Exclude specific RMM tools from detection:
|
||||
```powershell
|
||||
powershell .\rmm-hunter.exe hunt --exclude TeamViewer,AnyDesk
|
||||
```
|
||||
|
||||
powershell .\rmm-hunter.exe hunt --exclude TeamViewer,AnyDesk```
|
||||
|
||||
### Eliminate Mode
|
||||
|
||||
**Status: Under Construction**
|
||||
@@ -122,7 +122,6 @@ The modular architecture allows for extensible detection capabilities while main
|
||||
## Output Formats
|
||||
|
||||
### JSON Report
|
||||
```
|
||||
|
||||
json { "processes": [...], "services": [...], "binaries": [...], "autoRuns": [...], "scheduledTasks": [...], "outboundConnections": [...], "directories": [...] }```
|
||||
|
||||
@@ -168,7 +167,7 @@ If you use RMM-Hunter in your project or research, please provide attribution by
|
||||
- Credit to **KrakenTech LLC** (https://krakensec.tech)
|
||||
|
||||
Example attribution:
|
||||
```
|
||||
```txt
|
||||
This project uses RMM-Hunter by KrakenTech LLC
|
||||
https://github.com/KrakenTech/RMM-Hunter
|
||||
```
|
||||
|
||||
+68
-17
@@ -5,14 +5,18 @@ import (
|
||||
"os"
|
||||
"rmm-hunter/internal/pkg"
|
||||
"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"
|
||||
)
|
||||
|
||||
var (
|
||||
excludeRMMs []string
|
||||
inputFile string
|
||||
outputFile string
|
||||
webUI bool
|
||||
cliUI bool
|
||||
)
|
||||
|
||||
// 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",
|
||||
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
|
||||
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",
|
||||
}
|
||||
|
||||
@@ -38,7 +59,10 @@ var huntCmd = &cobra.Command{
|
||||
- Processes
|
||||
- Outbound Network Connections
|
||||
- Scheduled Tasks
|
||||
- Registry Entries`,
|
||||
- Registry Entries
|
||||
|
||||
> .\rmm-hunter.exe hunt
|
||||
`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Println("Starting RMM Hunt...")
|
||||
runHunt()
|
||||
@@ -49,16 +73,25 @@ var huntCmd = &cobra.Command{
|
||||
var eliminateCmd = &cobra.Command{
|
||||
Use: "eliminate",
|
||||
Short: "Eliminate Sus software based on hunt results",
|
||||
Long: `Eliminate mode removes detected Sus software from the system.
|
||||
Requires a JSON input file containing hunt results to determine what to remove.`,
|
||||
Long: `Eliminate mode removes detected RMM Software from the system.
|
||||
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) {
|
||||
if inputFile == "" {
|
||||
fmt.Println("Error: --input flag is required for eliminate command")
|
||||
if admin, err := scurvy.IsAdmin(); err != nil || !admin {
|
||||
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)
|
||||
}
|
||||
|
||||
fmt.Printf("Starting RMM Elimination using input file: %s\n", inputFile)
|
||||
// TODO: Call eliminate.Eliminate() function
|
||||
fmt.Println("Starting Elimination UI...")
|
||||
runEliminate()
|
||||
},
|
||||
}
|
||||
@@ -87,9 +120,16 @@ func init() {
|
||||
"Output file to write hunt results (optional) Default: suspicious-hunter.json")
|
||||
|
||||
// Eliminate command flags
|
||||
eliminateCmd.Flags().StringVarP(&inputFile, "input", "i", "",
|
||||
"JSON input file containing hunt results (required)")
|
||||
eliminateCmd.MarkFlagRequired("input")
|
||||
eliminateCmd.Flags().BoolVarP(&webUI, "web", "w", false,
|
||||
"Use web UI instead of TUI (optional)")
|
||||
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() {
|
||||
@@ -104,8 +144,19 @@ func runHunt() {
|
||||
}
|
||||
|
||||
func runEliminate() {
|
||||
// TODO: Implement eliminate functionality
|
||||
fmt.Println("Eliminate functionality not yet implemented")
|
||||
fmt.Printf("Input file: %s\n", inputFile)
|
||||
fmt.Printf("Excluded RMMs: %v\n", excludeRMMs)
|
||||
if webUI {
|
||||
// Launch the web UI for elimination flow
|
||||
// TODO: Launch web UI
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,27 +3,52 @@ module rmm-hunter
|
||||
go 1.24.7
|
||||
|
||||
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
|
||||
golang.org/x/sys v0.29.0
|
||||
golang.org/x/sys v0.36.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Binject/debug v0.0.0-20230508195519-26db73212a7a // indirect
|
||||
github.com/alwindoss/morse v1.0.1 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // 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/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/ecies/go/v2 v2.0.10 // indirect
|
||||
github.com/elastic/go-sysinfo v1.15.1 // indirect
|
||||
github.com/elastic/go-windows v1.0.2 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/ethereum/go-ethereum v1.14.12 // indirect
|
||||
github.com/go-ole/go-ole v1.3.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/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/sahilm/fuzzy v0.1.1 // 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/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||
howett.net/plist v1.0.1 // indirect
|
||||
|
||||
@@ -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/go.mod h1:QzgxDLY/qdKlvnbnb65eqTedhvQPbaSP2NqIbcuKvsQ=
|
||||
github.com/Kraken-OffSec/Scurvy v0.0.0-20251010174510-8091571ab864 h1:zYVI4GRNB7wjLtorhpnPLP8v8w5T3axCpCtNDKI2LOs=
|
||||
github.com/Kraken-OffSec/Scurvy v0.0.0-20251010174510-8091571ab864/go.mod h1:hljxQLLV5S60GVVG51+u3r1agCjZ45x8jd2WiJxy0wQ=
|
||||
github.com/Kraken-OffSec/Scurvy v0.0.0-20251010183749-8ab59cb85591 h1:APveZhhJVm6tFcpldhMLxln4JR1V3Aw1xegt0SKGybg=
|
||||
github.com/Kraken-OffSec/Scurvy v0.0.0-20251010183749-8ab59cb85591/go.mod h1:hljxQLLV5S60GVVG51+u3r1agCjZ45x8jd2WiJxy0wQ=
|
||||
github.com/Kraken-OffSec/Scurvy v0.0.0-20251010184333-fb8a7710cf48 h1:nyCMY/8w7IsmduLZspdBuCmWutMUY6lzn5DCKVmQGt0=
|
||||
github.com/Kraken-OffSec/Scurvy v0.0.0-20251010184333-fb8a7710cf48/go.mod h1:hljxQLLV5S60GVVG51+u3r1agCjZ45x8jd2WiJxy0wQ=
|
||||
github.com/Kraken-OffSec/Scurvy v0.0.0-20251010184534-de2a2a349253 h1:ZfFDU6Kp9mFlEb0OZniWQR1E3w3Okr9gK2HlRb9lN6E=
|
||||
github.com/Kraken-OffSec/Scurvy v0.0.0-20251010184534-de2a2a349253/go.mod h1:hljxQLLV5S60GVVG51+u3r1agCjZ45x8jd2WiJxy0wQ=
|
||||
github.com/Kraken-OffSec/Scurvy v0.0.0-20251010185212-88db5cc88bc0 h1:g01ZBGUyvJXSWvxs7SVPTtqv3ruhbFsgsRGxCM2yYoY=
|
||||
github.com/Kraken-OffSec/Scurvy v0.0.0-20251010185212-88db5cc88bc0/go.mod h1:hljxQLLV5S60GVVG51+u3r1agCjZ45x8jd2WiJxy0wQ=
|
||||
github.com/Kraken-OffSec/Scurvy v0.0.0-20251010190109-3a4c7586120a h1:Z4cjdwk5DupnEg/F2dv4DPutwSEmDq7WWe565FjZrtQ=
|
||||
github.com/Kraken-OffSec/Scurvy v0.0.0-20251010190109-3a4c7586120a/go.mod h1:hljxQLLV5S60GVVG51+u3r1agCjZ45x8jd2WiJxy0wQ=
|
||||
github.com/Kraken-OffSec/Scurvy v0.0.0-20251010190312-0ad88c5f3f2f h1:XJ9IudxrEjAhodOLCTaWCIxWdj0fIa+JOdzfd1nST9k=
|
||||
github.com/Kraken-OffSec/Scurvy v0.0.0-20251010190312-0ad88c5f3f2f/go.mod h1:hljxQLLV5S60GVVG51+u3r1agCjZ45x8jd2WiJxy0wQ=
|
||||
github.com/Kraken-OffSec/Scurvy v0.0.0-20251010192328-967933276439 h1:n/B4+1K6vpKX34iISUKHzEKEND53PmxePHrtsy693Jo=
|
||||
github.com/Kraken-OffSec/Scurvy v0.0.0-20251010192328-967933276439/go.mod h1:hljxQLLV5S60GVVG51+u3r1agCjZ45x8jd2WiJxy0wQ=
|
||||
github.com/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/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/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/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/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=
|
||||
@@ -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-windows v1.0.2 h1:yoLLsAsV5cfg9FLhZ9EXZ2n2sQFKeDYrHenkcivY4vI=
|
||||
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/go.mod h1:RAC2gVMWJ6FkxSPESfbshrcKpIokgQKsVKmAuqdekDY=
|
||||
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/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
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/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/ryanuber/columnize v2.1.2+incompatible h1:C89EOx/XBWwIXl8wm8OPJBd7kPF25UfsK2X7Ph/zCAk=
|
||||
github.com/ryanuber/columnize v2.1.2+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
|
||||
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
||||
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
||||
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
||||
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
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/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.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
|
||||
@@ -6,165 +6,166 @@ import (
|
||||
. "rmm-hunter/internal/suspicious"
|
||||
"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 {
|
||||
var suspiciousAutoRuns []AutoRun
|
||||
|
||||
fmt.Printf("[*] Enumerating AutoRun Applications\n")
|
||||
|
||||
// Check common autorun registry locations
|
||||
autorunKeys := []string{
|
||||
`SOFTWARE\Microsoft\Windows\CurrentVersion\Run`,
|
||||
`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`,
|
||||
}
|
||||
// Enumerate autoruns from Registry and COM Services
|
||||
autoRuns := autoruns.GetAllAutoruns()
|
||||
fmt.Printf(" [>] Dispositioning %d AutoRun Entries\n", len(autoRuns))
|
||||
|
||||
// Check both HKLM and HKCU
|
||||
roots := []registry.Key{registry.LOCAL_MACHINE, registry.CURRENT_USER}
|
||||
rootNames := []string{"HKLM", "HKCU"}
|
||||
|
||||
totalEntries := 0
|
||||
for i, root := range roots {
|
||||
for _, keyPath := range autorunKeys {
|
||||
entries := checkAutoRunKey(root, keyPath, rootNames[i])
|
||||
totalEntries += len(entries)
|
||||
suspiciousAutoRuns = append(suspiciousAutoRuns, entries...)
|
||||
for _, ar := range autoRuns {
|
||||
// Map Scurvy autorun to our Suspicious.AutoRun struct
|
||||
sar := AutoRun{
|
||||
Type: ar.Type,
|
||||
Location: ar.Location,
|
||||
ImagePath: ar.ImagePath,
|
||||
ImageName: ar.ImageName,
|
||||
Arguments: ar.Arguments,
|
||||
MD5: ar.MD5,
|
||||
SHA1: ar.SHA1,
|
||||
SHA256: ar.SHA256,
|
||||
Entry: ar.Entry,
|
||||
LaunchString: ar.LaunchString,
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf(" [>] Dispositioning %d AutoRun Entries\n", totalEntries)
|
||||
fmt.Printf("[+] Found %d Suspicious AutoRun Applications\n", len(suspiciousAutoRuns))
|
||||
|
||||
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 {
|
||||
// Skip whitelisted entries (our own tool)
|
||||
if isWhitelisted(sar) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this autorun entry matches any known Suspicious patterns
|
||||
if isSuspiciousAutoRun(valueName, value) {
|
||||
// Analyze the executable path for additional suspicious indicators
|
||||
isPathSuspicious, pathReason := analyzeExecutablePath(value)
|
||||
description := extractDescription(value)
|
||||
if isPathSuspicious {
|
||||
description += fmt.Sprintf(" [%s]", pathReason)
|
||||
if isSuspiciousAutoRunEntry(sar) {
|
||||
fmt.Printf(" [?] Found %s | %s | %s\n", sar.Location, sar.Entry, sar.ImagePath)
|
||||
suspiciousAutoRuns = append(suspiciousAutoRuns, sar)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
// Indicator 1: Known RMM vendor name match (CommonRMMs)
|
||||
rmmNameHit := false
|
||||
for _, rmm := range common.CommonRMMs {
|
||||
rmmLower := strings.ToLower(rmm)
|
||||
if strings.Contains(nameLower, rmmLower) || strings.Contains(commandLower, rmmLower) {
|
||||
return true
|
||||
if strings.Contains(allText, strings.ToLower(rmm)) {
|
||||
rmmNameHit = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if rmmNameHit {
|
||||
score++
|
||||
}
|
||||
|
||||
// Check against common Suspicious executable patterns
|
||||
for _, imageEnd := range common.CommonImageSuffixes {
|
||||
imageEndLower := strings.ToLower(imageEnd)
|
||||
if strings.Contains(commandLower, imageEndLower) {
|
||||
return true
|
||||
// Indicator 2: Known RMM executable/binary pattern (CommonImageSuffixes)
|
||||
binaryPatternHit := false
|
||||
imgPathLower := strings.ToLower(ar.ImagePath)
|
||||
imgNameLower := strings.ToLower(ar.ImageName)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Additional suspicious patterns
|
||||
suspiciousPatterns := []string{
|
||||
"remote", "control", "assist", "support", "vnc", "rdp", "teamview",
|
||||
"anydesk", "logmein", "screenconnect", "splashtop", "ultravnc",
|
||||
if binaryPatternHit {
|
||||
score++
|
||||
}
|
||||
|
||||
for _, pattern := range suspiciousPatterns {
|
||||
if strings.Contains(nameLower, pattern) || strings.Contains(commandLower, pattern) {
|
||||
return true
|
||||
// Indicator 3: Known RMM DNS/domain in command line or launch string (CommonDNS)
|
||||
dnsHit := false
|
||||
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
|
||||
}
|
||||
|
||||
func extractDescription(command string) string {
|
||||
// Extract just the executable name from the command
|
||||
parts := strings.Fields(command)
|
||||
if len(parts) > 0 {
|
||||
return parts[0]
|
||||
}
|
||||
return command
|
||||
}
|
||||
|
||||
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, ""
|
||||
// Indicator 4: Suspicious installation path (temp, public, programdata)
|
||||
pathSuspicious, _ := common.AnalyzeExecutablePath(ar.ImagePath)
|
||||
if !pathSuspicious && ar.LaunchString != "" {
|
||||
pathSuspicious, _ = common.AnalyzeExecutablePath(ar.LaunchString)
|
||||
}
|
||||
if pathSuspicious {
|
||||
score++
|
||||
}
|
||||
|
||||
// Require at least 2 independent Indicator to reduce false positives
|
||||
return score >= 2
|
||||
}
|
||||
|
||||
@@ -6,11 +6,14 @@ func TestAutoRun(t *testing.T) {
|
||||
autoruns := Detect()
|
||||
for _, ar := range autoruns {
|
||||
t.Logf("-----")
|
||||
t.Logf("Name: %s", ar.Name)
|
||||
t.Logf("Command: %s", ar.Command)
|
||||
t.Logf("Type: %s", ar.Type)
|
||||
t.Logf("Entry: %s", ar.Entry)
|
||||
t.Logf("Location: %s", ar.Location)
|
||||
t.Logf("Enabled: %t", ar.Enabled)
|
||||
t.Logf("Description: %s", ar.Description)
|
||||
t.Logf("Image: %s", ar.ImagePath)
|
||||
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("-----")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,13 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"rmm-hunter/internal/pkg/hunt/detect/common"
|
||||
. "rmm-hunter/internal/suspicious"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
func Detect() []string {
|
||||
var foundBinaries []string
|
||||
func Detect() []Binary {
|
||||
var foundBinaries []Binary
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
|
||||
@@ -52,7 +53,7 @@ func Detect() []string {
|
||||
// Collect results
|
||||
for result := range resultChan {
|
||||
mu.Lock()
|
||||
foundBinaries = append(foundBinaries, result)
|
||||
foundBinaries = append(foundBinaries, Binary{Path: result})
|
||||
mu.Unlock()
|
||||
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 {
|
||||
var suspiciousConnections []NetworkConnection
|
||||
|
||||
for _, conn := range connections {
|
||||
remote := conn.RemoteHost
|
||||
// Get process names for all PIDs
|
||||
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 {
|
||||
if matchesDNSPattern(remote, dns) {
|
||||
fmt.Printf(" [?] Found %s\n", conn.RemoteHost)
|
||||
suspiciousConnections = append(suspiciousConnections, conn)
|
||||
isSuspicious = true
|
||||
reason = fmt.Sprintf("DNS match: %s", conn.RemoteHost)
|
||||
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))
|
||||
@@ -190,3 +231,39 @@ func GetHTTPHostnames() []string {
|
||||
|
||||
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,21 +5,41 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"rmm-hunter/internal/pkg/hunt/detect/common"
|
||||
. "rmm-hunter/internal/suspicious"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var appData = os.Getenv("APPDATA")
|
||||
|
||||
func Detect() []string {
|
||||
var suspiciousDirectories []string
|
||||
func Detect() []Directory {
|
||||
var suspiciousDirectories []Directory
|
||||
seen := make(map[string]bool) // Prevent duplicates
|
||||
|
||||
fmt.Printf("[*] Enumerating Suspicious Directories \n")
|
||||
// Check for common directories
|
||||
for _, dir := range common.CommonDirectories {
|
||||
dir = replaceAppData(dir)
|
||||
if _, err := os.Stat(dir); err == nil {
|
||||
fmt.Printf(" [?] Found %s\n", dir)
|
||||
suspiciousDirectories = append(suspiciousDirectories, dir)
|
||||
|
||||
// Check if this is a prefix pattern (ends with incomplete path such as Screen Connect "C:\Program Files (x86)\ScreenConnect Client (")
|
||||
if isPrefix(dir) {
|
||||
// Find all directories matching this prefix
|
||||
matches := findPrefixMatches(dir)
|
||||
for _, match := range matches {
|
||||
if !seen[match] {
|
||||
fmt.Printf(" [?] Found %s\n", match)
|
||||
suspiciousDirectories = append(suspiciousDirectories, Directory{Path: match})
|
||||
seen[match] = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Exact match
|
||||
if _, err := os.Stat(dir); err == nil {
|
||||
if !seen[dir] {
|
||||
fmt.Printf(" [?] Found %s\n", dir)
|
||||
suspiciousDirectories = append(suspiciousDirectories, Directory{Path: dir})
|
||||
seen[dir] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Printf("[+] Found %d Suspicious Directories\n", len(suspiciousDirectories))
|
||||
@@ -27,6 +47,7 @@ func Detect() []string {
|
||||
return suspiciousDirectories
|
||||
}
|
||||
|
||||
// replaceAppData replaces {{APPDATA}} with the actual APPDATA path
|
||||
func replaceAppData(path string) string {
|
||||
if strings.Contains(path, "{{APPDATA}}") {
|
||||
p := strings.Replace(path, "{{APPDATA}}", "", -1)
|
||||
@@ -34,3 +55,44 @@ func replaceAppData(path string) string {
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// isPrefix checks if a path is a prefix pattern (incomplete path for matching)
|
||||
func isPrefix(path string) bool {
|
||||
// If path ends with "(" or other incomplete patterns, it's a prefix
|
||||
return strings.HasSuffix(path, "(") || strings.HasSuffix(path, "\\")
|
||||
}
|
||||
|
||||
// findPrefixMatches finds all directories that start with the given prefix
|
||||
func findPrefixMatches(prefix string) []string {
|
||||
var matches []string
|
||||
|
||||
// Get the parent directory to search in
|
||||
parentDir := filepath.Dir(prefix)
|
||||
|
||||
// Check if parent directory exists
|
||||
if _, err := os.Stat(parentDir); os.IsNotExist(err) {
|
||||
return matches
|
||||
}
|
||||
|
||||
// Read all entries in the parent directory
|
||||
entries, err := os.ReadDir(parentDir)
|
||||
if err != nil {
|
||||
return matches
|
||||
}
|
||||
|
||||
// Get the base name prefix
|
||||
basePrefix := filepath.Base(prefix)
|
||||
|
||||
// Check each entry
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
// Check if this directory name starts with our prefix
|
||||
if strings.HasPrefix(entry.Name(), basePrefix) {
|
||||
fullPath := filepath.Join(parentDir, entry.Name())
|
||||
matches = append(matches, fullPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
@@ -9,6 +9,23 @@ import (
|
||||
"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 {
|
||||
fmt.Printf("[*] Enumerating Processes \n")
|
||||
|
||||
@@ -27,6 +44,11 @@ func compareProcesses(processes []process.Process) []Process {
|
||||
var suspiciousProcesses []Process
|
||||
|
||||
for _, proc := range processes {
|
||||
// Skip whitelisted processes (our own tool)
|
||||
if isWhitelisted(proc) {
|
||||
continue
|
||||
}
|
||||
|
||||
procName := proc.Executable()
|
||||
procNameLower := strings.ToLower(procName)
|
||||
|
||||
|
||||
@@ -10,6 +10,23 @@ import (
|
||||
"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 {
|
||||
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())
|
||||
continue
|
||||
}
|
||||
svcStartName := strings.ToLower(config.ServiceStartName)
|
||||
svcDisplayName := strings.ToLower(config.DisplayName)
|
||||
svcBinaryPath := strings.ToLower(config.BinaryPathName)
|
||||
|
||||
// Check against known RMMs
|
||||
isRMMMatch := false
|
||||
for _, rmm := range common.CommonRMMs {
|
||||
rmmLower := strings.ToLower(rmm)
|
||||
if strings.Contains(svcDisplayName, rmmLower) || strings.Contains(svcStartName, rmmLower) || strings.Contains(svcBinaryPath, rmmLower) {
|
||||
isRMMMatch = true
|
||||
break
|
||||
}
|
||||
// Skip whitelisted services (our own tool)
|
||||
if isWhitelisted(config) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for suspicious path regardless of RMM match
|
||||
isPathSuspicious, pathReason := common.AnalyzeExecutablePath(config.BinaryPathName)
|
||||
|
||||
if isRMMMatch || isPathSuspicious {
|
||||
description := config.Description
|
||||
if isPathSuspicious {
|
||||
description += fmt.Sprintf(" [%s]", pathReason)
|
||||
}
|
||||
|
||||
if isSuspiciousService(config) {
|
||||
fmt.Printf(" [?] Found %s\n", config.DisplayName)
|
||||
suspiciousServices = append(suspiciousServices, &Service{
|
||||
Name: serviceString,
|
||||
@@ -84,7 +85,7 @@ func compareServices(serviceStrings []string, scm *service.Mgr) []*Service {
|
||||
Dependencies: config.Dependencies,
|
||||
ServiceStartName: config.ServiceStartName,
|
||||
Password: config.Password,
|
||||
Description: description,
|
||||
Description: config.Description,
|
||||
SidType: config.SidType,
|
||||
DelayedAutoStart: config.DelayedAutoStart,
|
||||
})
|
||||
@@ -95,6 +96,83 @@ func compareServices(serviceStrings []string, scm *service.Mgr) []*Service {
|
||||
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 {
|
||||
switch raw {
|
||||
case 1:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package eliminate
|
||||
|
||||
import "os"
|
||||
|
||||
// EliminateBinary removes a binary from the system
|
||||
func EliminateBinary(path string) error {
|
||||
return os.Remove(path)
|
||||
}
|
||||
@@ -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: "",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package eliminate
|
||||
|
||||
import "os"
|
||||
|
||||
func EliminateDirectory(path string) error {
|
||||
return os.RemoveAll(path)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
package eliminate
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ const htmlTemplate = `<!DOCTYPE html>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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">
|
||||
|
||||
<style>
|
||||
@@ -434,11 +434,14 @@ const htmlTemplate = `<!DOCTYPE html>
|
||||
{{if .Findings.AutoRuns}}
|
||||
{{range .Findings.AutoRuns}}
|
||||
<div class="item">
|
||||
<div class="item-title">{{.Name}}</div>
|
||||
<div class="item-detail"><strong>Command:</strong> {{.Command}}</div>
|
||||
<div class="item-title">{{.ImageName}}</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>Enabled:</strong> {{.Enabled}}</div>
|
||||
{{if .Description}}<div class="item-detail"><strong>Description:</strong> {{.Description}}</div>{{end}}
|
||||
<div class="item-detail"><strong>Image:</strong> {{.ImagePath}}</div>
|
||||
{{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>
|
||||
{{end}}
|
||||
{{else}}
|
||||
@@ -457,7 +460,7 @@ const htmlTemplate = `<!DOCTYPE html>
|
||||
{{if .Findings.Binaries}}
|
||||
{{range .Findings.Binaries}}
|
||||
<div class="item">
|
||||
<div class="item-detail">{{.}}</div>
|
||||
<div class="item-detail">{{.Path}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
@@ -476,7 +479,7 @@ const htmlTemplate = `<!DOCTYPE html>
|
||||
{{if .Findings.Directories}}
|
||||
{{range .Findings.Directories}}
|
||||
<div class="item">
|
||||
<div class="item-detail">{{.}}</div>
|
||||
<div class="item-detail">{{.Path}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
|
||||
+88
-20
@@ -1,5 +1,9 @@
|
||||
package suspicious
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
/*
|
||||
Suspicious
|
||||
The object used to resemble the Suspicious artifacts and activities.
|
||||
@@ -8,8 +12,8 @@ type Suspicious struct {
|
||||
Artifacts []Artifact `json:"artifacts"`
|
||||
Persistence Persistence `json:"persistence"`
|
||||
RootFolder string `json:"rootFolder"`
|
||||
Binaries []string `json:"binaries"`
|
||||
Directories []string `json:"directories"`
|
||||
Binaries []Binary `json:"binaries"`
|
||||
Directories []Directory `json:"directories"`
|
||||
Services []*Service `json:"services"`
|
||||
Processes []Process `json:"processes"`
|
||||
OutboundConnections []NetworkConnection `json:"outboundConnections"`
|
||||
@@ -17,13 +21,24 @@ type Suspicious struct {
|
||||
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 {
|
||||
LocalAddr string
|
||||
RemoteAddr string
|
||||
RemoteHost string
|
||||
State string
|
||||
PID string
|
||||
Process string
|
||||
LocalAddr string `json:"localAddr"`
|
||||
RemoteAddr string `json:"remoteAddr"`
|
||||
RemoteHost string `json:"remoteHost"`
|
||||
State string `json:"state"`
|
||||
PID string `json:"pid"`
|
||||
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.
|
||||
*/
|
||||
type AutoRun struct {
|
||||
Name string `json:"name"`
|
||||
Command string `json:"command"`
|
||||
Location string `json:"location"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Description string `json:"description"`
|
||||
Type string `json:"type"`
|
||||
Location string `json:"location"`
|
||||
ImagePath string `json:"image_path"`
|
||||
ImageName string `json:"image_name"`
|
||||
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"`
|
||||
LastRun string `json:"lastRun"`
|
||||
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.
|
||||
*/
|
||||
type Process struct {
|
||||
Name string `json:"name"`
|
||||
PID int `json:"pid"`
|
||||
PPID int `json:"ppid"`
|
||||
Parent string `json:"parent"`
|
||||
Args string `json:"args"`
|
||||
Created string `json:"created"`
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
PID int `json:"pid"`
|
||||
PPID int `json:"ppid"`
|
||||
Parent string `json:"parent"`
|
||||
Args string `json:"args"`
|
||||
Created string `json:"created"`
|
||||
Path string `json:"path"`
|
||||
Eliminated bool `json:"eliminated,omitempty"`
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -111,4 +134,49 @@ type Service struct {
|
||||
Description string `json:"description"`
|
||||
SidType uint32 `json:"sidType"`
|
||||
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)})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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() }
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package web
|
||||
|
||||
func StartWebServer() {
|
||||
// TODO: Start web server
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package main
|
||||
|
||||
import "rmm-hunter/internal/tui"
|
||||
|
||||
func main() {
|
||||
err := tui.RunEliminateUI()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user