38 Commits

Author SHA1 Message Date
KrakenTech d2f7e7595b Revise KrakenTech Proprietary License
Updated the proprietary license terms and conditions.
2026-03-27 16:07:45 -04:00
KrakenTech 3935efb6ed Revise MIT License terms and conditions
Updated the MIT License to include restrictions on distribution and derivative works.
2026-03-27 16:05:30 -04:00
Evan Hosinski 88c5afba9c "Add documentation for new web-based interface, including usage, features, GIFs, and updated elimination module details" 2025-10-13 12:14:18 -04:00
KrakenTech 852b061b40 Merge pull request #2 from KrakenTech-LLC/webserver
Webserver
2025-10-13 11:47:11 -04:00
Evan Hosinski b4db6c19b9 new gifs 2025-10-13 07:49:15 -04:00
Evan Hosinski 3f50f20892 Enhance API error responses with JSON format, improve suspicious directory detection with worker pool implementation, and refine elimination logic with better index validation and data flow updates. Update UI for active report indicators, item expansion, and eliminated state tracking. 2025-10-12 21:58:11 -04:00
Evan Hosinski 25d99c265d Add elimination API handler, update browser logic for process tracking, and refine UI animations and modal handling. 2025-10-12 20:58:53 -04:00
Evan Hosinski 0b09092973 Refine comments in index.html for clarity, update alert message in elimination workflow, and adjust UI text for improved consistency. 2025-10-12 20:07:16 -04:00
Evan Hosinski adcad167df Add support for dynamic hosts file management, browser opening, and new favicon handling in web server. Add elimination workflow UI enhancements with better state management and design.
TODO: Test elimination per finding type in web view

Figure out where RustDesk registry persistence is located. The installer is aware of it somehow
2025-10-12 20:02:49 -04:00
Evan Hosinski 15fb9eb510 Add web-based user interface with hunting, reporting, and elimination workflow for RMM-Hunter 2025-10-12 18:53:07 -04:00
Evan Hosinski 01113551fb Add web server implementation for RMM-Hunter with API endpoints and WebSocket support 2025-10-12 18:46:59 -04:00
KrakenTech e6f91d0bc7 Update README.md 2025-10-11 23:39:32 -04:00
KrakenTech 615c129376 Update README.md 2025-10-11 23:39:06 -04:00
KrakenTech bf63fb83bf Update README.md 2025-10-11 23:32:21 -04:00
KrakenTech 976b45043b Add files via upload 2025-10-11 23:32:05 -04:00
KrakenTech e578a8fd97 Update README.md 2025-10-11 23:30:21 -04:00
KrakenTech 58b521987d Add files via upload 2025-10-11 23:29:32 -04:00
KrakenTech 69657e4c46 Update README.md
Added Any.Run results
2025-10-11 23:23:58 -04:00
Evan Hosinski d349b38047 Update README to replace banner image and add new rmm-hunter.png asset 2025-10-11 21:56:44 -04:00
Evan Hosinski 3e498365a3 Expand README with detailed elimination module documentation, add new GIF assets, and refine feature descriptions for clarity. 2025-10-11 21:45:08 -04:00
Evan Hosinski b47351f4ee Remove placeholder text from action descriptions in UI and clarify performEliminate function behavior. 2025-10-11 21:08:58 -04:00
Evan Hosinski c9e2e8dff8 Refactor suspicious artifact data structures, enhance eliminated state tracking, and update UI rendering for eliminated items. Add JSON marshal/unmarshal support for Binary and Directory types. 2025-10-11 21:01:07 -04:00
Evan Hosinski bde1b23753 Enhance detection logic to include process-based suspicious connection checks and refine firewall rule attributes in eliminate package. Add PID-to-process name mapping functionality. 2025-10-11 19:49:21 -04:00
Evan Hosinski a5d3623a72 Upgrade Scurvy library and add forced elevation logic with improved usage examples 2025-10-11 19:23:44 -04:00
Evan Hosinski d14b2837d0 Update eliminate connection logic to refine firewall rules and upgrade Scurvy library to latest version 2025-10-11 18:42:58 -04:00
Evan Hosinski 9c54a22bcf Update eliminate connection logic to refine firewall rules and upgrade Scurvy library to latest version 2025-10-11 18:06:42 -04:00
Evan Hosinski b855f0eaec Add eliminate package with functions for removing suspicious artifacts (files, directories, processes, services, scheduled tasks) and enhance detection logic to include whitelist checks and multi-indicator scoring 2025-10-11 17:22:44 -04:00
Evan Hosinski e835629643 Improve AutoRun and service detection with enhanced vendor/token matching, reduced false positives, and isolated changes 2025-10-11 15:26:42 -04:00
Evan Hosinski 53f527feff Refactor AutoRun detection to use Scurvy library, enhance suspicious entry checks, and update UI rendering for detailed info 2025-10-11 15:15:35 -04:00
Evan Hosinski 02ed2ce046 Initialize web package with placeholder file 2025-10-10 23:00:13 -04:00
Evan Hosinski ec307bc91f Add web and CLI UI options for eliminate command with mutual exclusivity and required flag checks
Introduce `--web` and `--cli` flags to select alternative interfaces for the elimination flow. Add validation to enforce mutual exclusivity and ensure one option is specified. Include placeholder logic for web UI implementation.
2025-10-10 22:59:46 -04:00
Evan Hosinski 192ce28d89 Add warning modal support and checks for blocked binaries and directories
Introduce `WarnBlock` to handle non-fatal warnings displayed in a warning modal. Add pre-elimination checks to identify blocked binaries and directories based on running processes or enabled services. Enhance path normalization for robust comparisons.
2025-10-10 22:53:20 -04:00
Evan Hosinski 2b6c4eb4cd Implement TUI for managing suspicious artifacts (FilePicker, TypePicker, ListView, and DetailView)
Introduce Bubble Tea-based terminal UI to manage suspicious artifact findings, including file selection, type filtering, list view, and details.
2025-10-10 22:43:47 -04:00
KrakenTech 9d385bb6b0 Update htmlTemplate.go 2025-10-10 18:03:35 -04:00
Evan Hosinski d28b8b1211 Refine AutoRun logging to display key counts, remove redundant entry count log 2025-10-10 17:01:14 -04:00
Evan Hosinski 7cdee4b62c Enhance directory detection to handle prefix matching and eliminate duplicates
Helps to discover directories with a prefix instead of just by exact match
2025-10-10 16:57:12 -04:00
Evan Hosinski 9512022a73 Standardize and fix README code block formatting for improved readability 2025-10-10 16:48:43 -04:00
Evan Hosinski 967b0c1de1 Fix README formatting for consistent code block styling and improve clarity in usage examples 2025-10-10 16:47:27 -04:00
54 changed files with 4704 additions and 382 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 832 KiB

+21 -16
View File
@@ -1,22 +1,27 @@
MIT License
KrakenTech Proprietary License
Copyright (c) 2025 KrakenTech LLC
Copyright (c) 2025 KrakenTech LLC. All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Permission is hereby granted to view and use this software for personal,
educational, or internal business purposes only, subject to the following
restrictions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
1. You may NOT distribute, sublicense, publish, sell, resell, or otherwise
make this software available to any third party, in whole or in part,
in source or binary form.
2. You may NOT create derivative works for distribution or commercial use.
3. You may NOT use this software to provide a competing product or service.
4. Modifications for personal or internal use are permitted, provided they
are not distributed.
5. This copyright notice and license must be included in all copies.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
IMPLIED. IN NO EVENT SHALL KRAKENTECH LLC BE LIABLE FOR ANY CLAIM, DAMAGES
OR OTHER LIABILITY ARISING FROM THE USE OF THIS SOFTWARE.
For commercial licensing, redistribution rights, or other inquiries,
contact: licensing@krkn.tech
+150 -49
View File
@@ -1,15 +1,35 @@
# RMM-Hunter
A comprehensive Windows security tool designed to detect and analyze Remote Monitoring and Management (RMM) software deployments across enterprise environments.
A comprehensive Windows security tool designed to detect and analyze Remote Monitoring and Management (RMM) software deployments.
![](.img/banner-metadata.png)
![](.img/rmm-hunter.png)
## Overview
RMM-Hunter is a forensic analysis tool that identifies potentially malicious or unauthorized Remote Monitoring and Management software on Windows systems. Built on a proprietary detection framework called **Scurvy** (private repository), RMM-Hunter provides security teams with comprehensive visibility into RMM installations that may pose security risks or compliance concerns.
RMM-Hunter is an analysis tool that identifies potentially malicious or unauthorized Remote Monitoring and Management software/connections on Windows systems. Built on **Scurvy**, a custom low-level OS exploitation repository (private), RMM-Hunter provides security teams with comprehensive visibility into RMM installations that may pose security risks or compliance concerns.
## Features
### Web Interface
RMM-Hunter now includes a modern web-based interface for both hunting and elimination operations. Simply double-click the executable to launch the web server, which automatically:
- **Starts a local web server** on port 80 (http://rmm-hunter)
- **Adds a DNS entry** to your Windows hosts file for easy access via `http://rmm-hunter`
- **Requests UAC elevation** if administrator privileges are not already granted
- **Opens your default browser** automatically to the web interface
- **Cleans up the hosts entry** when the application exits
![](.gif/web_execute.gif)
The web interface provides:
- Real-time hunt execution with live log streaming via WebSockets
- Interactive elimination interface with visual feedback
- Previous hunt report browsing and analysis
- Modern, responsive UI accessible from any browser on the local machine
![](.gif/web_hunt.gif)
### Hunt Module
The hunt module performs deep system analysis across multiple detection vectors:
@@ -21,8 +41,10 @@ The hunt module performs deep system analysis across multiple detection vectors:
- **Network Connection Monitoring** - Identifies active outbound connections to known RMM infrastructure
- **Scheduled Task Detection** - Discovers RMM-related scheduled tasks used for persistence
- **Directory Scanning** - Searches for RMM installation directories and artifacts
- **AutoRun Analysis** - Searches for RMM persistence via Windows AutoRuns utilzing COM Services and Registry Keys
![](.img/hunt.png)
![](.gif/sample_hunt.gif)
### Detection Capabilities
@@ -42,15 +64,13 @@ RMM-Hunter generates comprehensive reports in multiple formats:
- **JSON** - Machine-readable format for integration with SIEM and automation platforms
- **HTML** - Interactive web-based report with filtering and search capabilities
![](.img/findings.png)
The HTML report includes:
- Executive summary with detection statistics
- Detailed findings across all detection categories
- Metadata including detection time and system information
- Built-in search and filter functionality for large result sets
![](.img/search-filter.png)
![](.gif/html_report.gif)
## Installation
@@ -58,71 +78,156 @@ The HTML report includes:
- Windows Operating System (Windows 10/11 or Windows Server 2016+)
- Administrator privileges (required for service and process enumeration)
- The application will automatically request UAC elevation if not running as administrator
- Go 1.24+ (for building from source)
### 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.
## Usage
### Hunt Mode
### Web Interface (Recommended)
Execute a comprehensive system scan:
Launch the web interface by simply running the executable without arguments:
```powershell
.\rmm-hunter.exe
```
powershell .\rmm-hunter.exe hunt```
This will:
1. Check for administrator privileges and request UAC elevation if needed
2. Start a web server on port 80
3. Add `rmm-hunter` to your hosts file (pointing to 127.0.0.1)
4. Automatically open your browser to `http://rmm-hunter`
From the web interface, you can:
- Execute hunts with real-time progress monitoring
- View and analyze previous hunt reports
- Perform elimination operations on detected RMM software
- Access all functionality through an intuitive browser-based UI
The hosts file entry is automatically cleaned up when you exit the application.
### Hunt Mode (CLI)
Execute a comprehensive system scan from the command line:
```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**
The elimination module provides both web-based and command-line interfaces for removing detected RMM installations from your system. Both interfaces automatically request UAC elevation if administrator privileges are required.
The elimination module is currently under active development. This functionality will provide automated remediation capabilities for detected RMM installations.
#### Web Interface
Planned features:
- Service termination and removal
- Process termination
- Binary deletion
- Registry cleanup
- Scheduled task removal
- Backup and rollback capabilities
The web interface provides a modern, browser-based elimination experience:
```powershell
.\rmm-hunter.exe
```
Or explicitly launch the web-based elimination interface:
```powershell
powershell .\rmm-hunter.exe eliminate --web
```
The web interface offers:
- Visual representation of all detected RMM components
- Real-time elimination with progress feedback
- Dependency checking to prevent system instability
- Interactive browsing of previous hunt reports
- Live log streaming during operations
#### CLI Component
Launch the interactive CLI elimination interface:
```powershell
powershell .\rmm-hunter.exe eliminate --cli
```
The CLI component operates through a multi-stage interactive workflow designed to provide granular control over the elimination process. When launched, the interface guides you through the following stages:
**Stage 1: Report Selection**
The interface scans the current directory for JSON hunt reports and presents them in a navigable list. You can browse available reports using arrow keys and select one by pressing Enter. The file picker automatically filters for valid JSON files generated by previous hunt operations.
**Stage 2: Category Selection**
After loading a report, you are presented with seven elimination categories corresponding to the detection vectors from the hunt module. Each category is accessible via numeric keys (1-7):
1. AutoRuns - Registry-based persistence mechanisms
2. Binaries - Executable files on disk
3. Connections - Active network connections
4. Directories - Installation directories
5. Processes - Running processes
6. Scheduled Tasks - Task Scheduler entries
7. Services - Windows services
**Stage 3: Item List View**
Upon selecting a category, the interface displays all detected items within that category. Each item shows relevant identifying information such as process names, file paths, service names, or connection details. Items that have already been eliminated are marked with a checkmark and displayed in green to provide visual feedback on remediation progress. You can navigate through the list using arrow keys and select an item for detailed inspection by pressing Enter.
**Stage 4: Detail View and Elimination**
The detail view presents comprehensive information about the selected item, including all metadata collected during the hunt phase. For each item type, the interface displays specific details:
For processes, you see the process name, PID, parent PID, command-line arguments, creation time, and executable path. For services, the display includes service name, display name, service type, start type, binary path, start account, and description. For autoruns, you see the entry name, launch string, registry location, image path, arguments, and file hashes (MD5, SHA1, SHA256). For binaries and directories, the full path is shown. For network connections, local and remote addresses, remote hostname, connection state, associated PID, and process name are displayed. For scheduled tasks, the name, author, state, enabled status, last result, next run time, last run time, and task path are presented.
From the detail view, pressing the exclamation mark (!) key initiates the elimination process for that specific item. The system performs intelligent dependency checking before elimination to prevent system instability.
**Dependency Validation**
Before eliminating binaries or directories, the system checks whether any active processes or enabled services are currently using those resources. If a dependency is detected, a warning modal appears explaining the conflict and suggesting the proper elimination order. For example, if you attempt to delete a binary that is currently in use by a running process, the system will warn you to eliminate the process first. Similarly, if a directory contains binaries used by active services, you must stop and remove those services before the directory can be deleted.
![](.gif/binary_order_of_operations_removal.gif)
**Elimination Actions**
Each category type performs specific elimination operations:
Processes are terminated using their PID. Services are stopped and then deleted from the service control manager. Binaries are removed from the filesystem. Directories are recursively deleted along with all contents. AutoRun entries are removed from their respective registry locations. Scheduled tasks are disabled and then deleted from the Task Scheduler. Network connections result in the creation of Windows Firewall outbound block rules for the remote host, preventing future connections to that destination.
![](.gif/kill_conn.gif)
**State Persistence**
After each successful elimination, the system updates the JSON report file to mark the item as eliminated. This ensures that if you exit and restart the elimination interface, previously eliminated items remain marked and visually distinguished. The persistent state allows you to work through large result sets across multiple sessions without losing track of your progress.
**Navigation**
Throughout the interface, you can navigate backward using the left arrow key to return to the previous screen. Pressing 'q', 'Esc', or 'Ctrl+C' at any point will exit the application. The interface provides contextual help at each stage, displaying available keyboard shortcuts and actions.
## Architecture
RMM-Hunter is built on **Scurvy**, a proprietary Windows system analysis framework (private repository). Scurvy provides the core capabilities for:
- Low-level Windows API interactions
- Process and service management
- Registry operations
- Network connection enumeration
- WMI query execution
The modular architecture allows for extensible detection capabilities while maintaining performance and stability.
RMM-Hunter is built on **Scurvy**, a custom low-level OS exploitation repository (private). Scurvy provides the core capabilities for low-level Windows API interactions, process and service management, registry operations, network connection enumeration, and WMI query execution. The modular architecture allows for extensible detection capabilities while maintaining performance and stability.
## Output Formats
### JSON Report
```
json { "processes": [...], "services": [...], "binaries": [...], "autoRuns": [...], "scheduledTasks": [...], "outboundConnections": [...], "directories": [...] }```
@@ -145,17 +250,11 @@ RMM-Hunter employs multiple detection strategies:
## Limitations
- Requires administrative privileges for complete system visibility
- May generate false positives in environments with legitimate RMM deployments
- Network detection requires active connections at scan time
- Elimination functionality not yet available
Requires administrative privileges for complete system visibility (UAC elevation prompt will appear if needed). May generate false positives in environments with legitimate RMM deployments. Network detection requires active connections at scan time. The web server requires port 80 to be available on the local machine.
## Contributing
Contributions are welcome. Please submit pull requests with:
- Detailed description of changes
- Test coverage for new detection signatures
- Documentation updates
Contributions are welcome. Please submit pull requests with detailed descriptions of changes, test coverage for new detection signatures, and documentation updates.
## License
@@ -168,7 +267,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
```
@@ -181,7 +280,9 @@ This tool is intended for authorized security assessments and forensic analysis
For issues, questions, or feature requests, please open an issue on the GitHub repository.
---
**Note**: The underlying Scurvy repository is a custom low-level OS exploitation framework that is not publicly accessible and is maintained privately.
**Note**: The underlying Scurvy framework is not publicly accessible and is maintained in a private repository.
## Any.Run Submission
v1.2.0: https://app.any.run/tasks/03b6afcd-308c-4056-bafc-e6514185d922
![](.img/any.run.graph.png)
+68 -17
View File
@@ -5,14 +5,19 @@ import (
"os"
"rmm-hunter/internal/pkg"
"rmm-hunter/internal/pkg/hunter"
"rmm-hunter/internal/tui"
"rmm-hunter/internal/web"
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 +26,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 +60,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 +74,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 +121,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 +145,18 @@ 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 {
fmt.Println("Starting Web UI on http://127.0.0.1:8080 ...")
web.StartWebServer()
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)
}
}
+29 -2
View File
@@ -3,27 +3,54 @@ 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/gorilla/websocket v1.5.1 // 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/net v0.27.0 // 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
+75 -16
View File
@@ -1,27 +1,47 @@
github.com/Binject/debug v0.0.0-20230508195519-26db73212a7a h1:4c0nc0krv8eh7gD809n+swLaCuFyHpxdrxwx0ZmHvBw=
github.com/Binject/debug v0.0.0-20230508195519-26db73212a7a/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,27 +51,66 @@ 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=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
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/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
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=
+134 -133
View File
@@ -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)
}
+125 -75
View File
@@ -1,78 +1,128 @@
package common
var CommonDirectories = []string{
`C:\Program Files (x86)%\mRemoteNG`,
`C:\\Program Files (x86)\Sysprogs`,
`C:\\Program Files (x86)\Sysprogs\SmarTTY`,
`C:\AlpemixSrvc`,
`C:\Downloads\SuperPuTTY`,
`C:\Program Files (x86)\Almageste\DragonDisk`,
`C:\Program Files (x86)\AnyDesk`,
`C:\Program Files (x86)\AnyViewer`,
`C:\Program Files (x86)\Atera Networks`,
`C:\Program Files (x86)\Bitvise SSH Client`,
`C:\Program Files (x86)\Bluetrait Agent`,
`C:\Program Files (x86)\DesktopCentral_Agent`,
`C:\Program Files (x86)\DesktopCentral_Agent\bin`,
`C:\Program Files (x86)\GoTo Opener`,
`C:\Program Files (x86)\GoToMyPC`,
`C:\Program Files (x86)\Google\Chrome Remote Desktop`,
`C:\Program Files (x86)\ISL Online`,
`C:\Program Files (x86)\Kaseya`,
`C:\Program Files (x86)\LANDesk`,
`C:\Program Files (x86)\OnionShare`,
`C:\Program Files (x86)\NetSarang`,
`C:\Program Files (x86)\NetSarang\xShell`,
`C:\Program Files (x86)\PJ Technologies`,
`C:\Program Files (x86)\PJ Technologies\GOVsrv`,
`C:\Program Files (x86)\Radmin Viewer 3`,
`C:\Program Files (x86)\RemotePC`,
`C:\Program Files (x86)\S3 Browser`,
`C:\Program Files (x86)\ScreenConnect Client (`, // C:\Program Files (x86)\ScreenConnect Client (<string ID>)
`C:\Program Files (x86)\SmartFTP Client`,
`C:\Program Files (x86)\Splashtop`,
`C:\Program Files (x86)\TeamViewer`,
`C:\Program Files (x86)\UltraViewer`,
`C:\Program Files (x86)\Xpra`,
`C:\Program Files (x86)\Yandex`,
`C:\Program Files (x86)\mRemoteNG`,
`C:\Program Files\ATERA NETWORKS`,
`C:\Program Files\ATERA NETWORKS\AteraAgent`,
`C:\Program Files\AnyDesk`,
`C:\Program Files\Bitvise SSH Server`,
`C:\Program Files\Danware Data\NetOp Packn Deploy`,
`C:\Program Files\Level`,
`C:\\Program Files\\LiteManager Pro`,
`C:\\Program Files\\LiteManager Pro \u2013 Viewer`,
`C:\Program Files\ManageEngine\ManageEngine Free Tools`,
`C:\Program Files\ManageEngine\ManageEngine Free Tools\Launcher`,
`C:\Program Files\RealVNC`,
`C:\Program Files\RealVNC\VNC Serve`,
`C:\Program Files\Remote Utilities`,
`C:\Program Files\Remote Utilities\Agent`,
`C:\Program Files\Solar-Putty-v4`,
`C:\Program Files\SolarWinds\Dameware Mini Remote Control`,
`C:\Program Files\SysAidServer`,
`C:\Program Files\TeamViewer`,
`C:\Program Files\TightVNC`,
`C:\Program Files\ZOC8`,
`C:\Program Files\uvnc bvba`,
`C:\Program Files\uvnc bvba\UltraVNC`,
`C:\ProgramData\Kaseya`,
`C:\ProgramData\Total Software Deployment`,
`C:\ProgramFiles\GoTo Machine Installer`,
`C:\ProgramFiles (x86)\GoTo Machine Installer`,
`{{APPDATA}}\Local\Google\Chrome\User Data\Default\Extensions\iodihamcpbpeioajjeobimgagajmlibd`,
`{{APPDATA}}\Local\MEGAsync`,
`{{APPDATA}}\Roaming\Mikogo`,
`{{APPDATA}}\Roaming\SyncTrayzor`,
`C:\Users\IEUser\Downloads\WinSCP-5.21.6-Portable`,
`C:\Users\USERNAME\AppData\Roaming\Insync`,
`C:\Users\USERNAME\AppData\Roaming\Insync\App`,
`C:\Windows\Action1`,
`C:\Windows\SysWOW64\rserver30`,
`C:\Windows\SysWOW64\rserver30\FamItrfc`,
`C:\Windows\SysWOW64\rserver30\FamItrf2`,
`C:\Windows\dwrcs`,
`C:\ProgramData\AMMYY`,
// KnownRMMDirectories contains known directory names/paths
// These will be searched in common installation locations defined in SearchBasePaths
var KnownRMMDirectories = []string{
// A
`Action1`,
`Almageste\DragonDisk`,
`AlpemixSrvc`,
`AMMYY`,
`AnyDesk`,
`AnyViewer`,
`Atera Networks`,
`ATERA NETWORKS`,
`ATERA NETWORKS\AteraAgent`,
// B
`Bitvise SSH Client`,
`Bitvise SSH Server`,
`Bluetrait Agent`,
// D
`Danware Data\NetOp Packn Deploy`,
`DesktopCentral_Agent`,
`DesktopCentral_Agent\bin`,
// G
`GoTo Opener`,
`GoTo Machine Installer`,
`GoToMyPC`,
`Google\Chrome Remote Desktop`,
`Google\Chrome\User Data\Default\Extensions\iodihamcpbpeioajjeobimgagajmlibd`,
// I
`Insync`,
`Insync\App`,
`ISL Online`,
// K
`Kaseya`,
// L
`LANDesk`,
`Level`,
`LiteManager Pro`,
`LiteManager Pro Viewer`,
// M
`ManageEngine\ManageEngine Free Tools`,
`ManageEngine\ManageEngine Free Tools\Launcher`,
`MEGAsync`,
`Mikogo`,
`mRemoteNG`,
// N
`NetSarang`,
`NetSarang\xShell`,
// O
`OnionShare`,
// P
`PJ Technologies`,
`PJ Technologies\GOVsrv`,
// R
`Radmin Viewer 3`,
`RealVNC`,
`RealVNC\VNC Serve`,
`Remote Utilities`,
`Remote Utilities\Agent`,
`RemotePC`,
`RustDesk`,
// S
`S3 Browser`,
`ScreenConnect Client (`, // Prefix pattern for ScreenConnect Client (<string ID>)
`SmartFTP Client`,
`Solar-Putty-v4`,
`SolarWinds\Dameware Mini Remote Control`,
`Splashtop`,
`SuperPuTTY`,
`SyncTrayzor`,
`Sysprogs`,
`Sysprogs\SmarTTY`,
`SysAidServer`,
`SysWOW64\rserver30`,
`SysWOW64\rserver30\FamItrfc`,
`SysWOW64\rserver30\FamItrf2`,
// T
`TeamViewer`,
`TightVNC`,
`Total Software Deployment`,
// U
`UltraViewer`,
`uvnc bvba`,
`uvnc bvba\UltraVNC`,
// W
`WinSCP-5.21.6-Portable`,
`dwrcs`,
// X
`Xpra`,
// Y
`Yandex`,
// Z
`ZOC8`,
}
// SearchBasePaths defines the base directories to search within
var SearchBasePaths = []string{
`C:\Program Files`,
`C:\Program Files (x86)`,
`C:\ProgramData`,
`C:\ProgramFiles`, // Installers variant 1
`C:\ProgramFiles (x86)`, // Installers variant 2
`C:\Windows`,
`{{APPDATA}}\Local`,
`{{APPDATA}}\Roaming`,
`{{USERPROFILE}}\Downloads`,
`C:\Downloads`, // Standard downloads location
`C:\`, // Root for edge cases (AlpemixSrvc)
}
@@ -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
}
+128 -12
View File
@@ -5,32 +5,148 @@ import (
"os"
"path/filepath"
"rmm-hunter/internal/pkg/hunt/detect/common"
. "rmm-hunter/internal/suspicious"
"strings"
"sync"
)
var appData = os.Getenv("APPDATA")
var userProfile = os.Getenv("USERPROFILE")
func Detect() []string {
var suspiciousDirectories []string
const numWorkers = 5
type searchJob struct {
basePath string
rmmDir string
}
func Detect() []Directory {
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)
// Create channels
jobs := make(chan searchJob, 100)
results := make(chan Directory, 100)
// WaitGroup to track workers
var wg sync.WaitGroup
// Start worker pool
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go worker(jobs, results, &wg)
}
// Start result collector goroutine
var suspiciousDirectories []Directory
seen := make(map[string]bool)
var resultWg sync.WaitGroup
resultWg.Add(1)
go func() {
defer resultWg.Done()
for dir := range results {
if !seen[dir.Path] {
fmt.Printf(" [?] Found %s\n", dir.Path)
suspiciousDirectories = append(suspiciousDirectories, dir)
seen[dir.Path] = true
}
}
}()
// Send jobs to workers
for _, rmmDir := range common.KnownRMMDirectories {
for _, basePath := range common.SearchBasePaths {
jobs <- searchJob{
basePath: basePath,
rmmDir: rmmDir,
}
}
}
// Close jobs channel and wait for workers to finish
close(jobs)
wg.Wait()
// Close results channel and wait for collector to finish
close(results)
resultWg.Wait()
fmt.Printf("[+] Found %d Suspicious Directories\n", len(suspiciousDirectories))
return suspiciousDirectories
}
func replaceAppData(path string) string {
if strings.Contains(path, "{{APPDATA}}") {
p := strings.Replace(path, "{{APPDATA}}", "", -1)
return filepath.Join(appData, p)
// worker processes search jobs from the jobs channel
func worker(jobs <-chan searchJob, results chan<- Directory, wg *sync.WaitGroup) {
defer wg.Done()
for job := range jobs {
// Replace environment variables
basePath := replaceEnvVars(job.basePath)
// Construct full path
fullPath := filepath.Join(basePath, job.rmmDir)
// Check if this is a prefix pattern (ends with incomplete path like "ScreenConnect Client (")
if isPrefix(job.rmmDir) {
// Find all directories matching this prefix
matches := findPrefixMatches(fullPath)
for _, match := range matches {
results <- Directory{Path: match}
}
} else {
// Exact match
if _, err := os.Stat(fullPath); err == nil {
results <- Directory{Path: fullPath}
}
}
}
}
// replaceEnvVars replaces environment variable placeholders with actual paths
func replaceEnvVars(path string) string {
path = strings.ReplaceAll(path, "{{APPDATA}}", appData)
path = strings.ReplaceAll(path, "{{USERPROFILE}}", userProfile)
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)
+103 -23
View File
@@ -7,9 +7,25 @@ import (
"strings"
"github.com/Kraken-OffSec/Scurvy/core/service"
"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")
@@ -18,7 +34,8 @@ func Detect() []*Service {
fmt.Printf("[-] Error getting Service Manager: %s\n", err.Error())
return []*Service{}
}
defer windows.Close(scm.Handle)
// Note: The service manager handle is managed by the Scurvy library
// and should not be manually closed here to avoid invalid handle errors
services, err := scm.ListServices()
if err != nil {
@@ -40,34 +57,20 @@ func compareServices(serviceStrings []string, scm *service.Mgr) []*Service {
fmt.Printf(" [>-] Error opening service %s: %s\n", serviceString, err.Error())
continue
}
// Note: Individual service handles are also managed by Scurvy library
config, err := svc.Config()
if err != nil {
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 +87,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 +98,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:
+42
View File
@@ -0,0 +1,42 @@
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()
// Try to find by MD5 first
for _, a := range all {
if a.MD5 == ar.MD5 && a.MD5 != "" {
return scurvy.DeleteAutorun(a)
}
}
// If not found by MD5, try to find by location (for registry entries)
for _, a := range all {
if a.Location == ar.Location && ar.Location != "" {
return scurvy.DeleteAutorun(a)
}
}
// Build a descriptive error message
location := ar.Location
if location == "" {
location = "unknown location"
}
entry := ar.Entry
if entry == "" {
entry = ar.ImageName
}
if entry == "" {
entry = "unknown entry"
}
return fmt.Errorf("autorun entry not found at %s (%s) - it may have already been removed", location, entry)
}
+8
View File
@@ -0,0 +1,8 @@
package eliminate
import "os"
// EliminateBinary removes a binary from the system
func EliminateBinary(path string) error {
return os.Remove(path)
}
+36
View File
@@ -0,0 +1,36 @@
package eliminate
import (
"fmt"
"github.com/Kraken-OffSec/Scurvy/core/firewall"
)
// EliminateConnection adds an outbound block for the connection to the Windows firewall
func EliminateConnection(dst string) error {
// Create a new WindowsFirewall instance
fw, err := firewall.NewWindowsFirewall()
if err != nil {
return err
}
// Check if firewall is enabled
if !fw.Enabled() {
return fmt.Errorf("windows firewall is currently disabled. please enable it and try again")
}
// Add a block rule for the destination
return fw.AddRule(firewall.FirewallRule{
Name: fmt.Sprintf("Block Outgoing %s", dst),
Direction: "outbound",
Protocol: "*",
LocalPort: "",
RemotePort: "",
LocalAddress: "",
RemoteIPAddresses: "",
Action: "block",
Profile: "",
DestinationHostname: dst,
Source: "",
})
}
+7
View File
@@ -0,0 +1,7 @@
package eliminate
import "os"
func EliminateDirectory(path string) error {
return os.RemoveAll(path)
}
-1
View File
@@ -1 +0,0 @@
package eliminate
+25
View File
@@ -0,0 +1,25 @@
package eliminate
import (
"fmt"
. "rmm-hunter/internal/suspicious"
scurvy "github.com/Kraken-OffSec/Scurvy"
)
// EliminateProcess kills a process and removes its binary from the system
func EliminateProcess(p Process) error {
err, procs := scurvy.ListProcesses()
if err != nil {
return err
}
for _, proc := range procs {
if proc.Pid() == p.PID {
return proc.Kill()
}
}
return fmt.Errorf("process %d not found", p.PID)
}
@@ -0,0 +1,11 @@
package eliminate
import (
. "rmm-hunter/internal/suspicious"
scurvy "github.com/Kraken-OffSec/Scurvy"
)
func EliminateScheduledTask(t ScheduledTask) error {
return scurvy.DeleteScheduledTask(t.Name)
}
+12
View File
@@ -0,0 +1,12 @@
package eliminate
import (
. "rmm-hunter/internal/suspicious"
scurvy "github.com/Kraken-OffSec/Scurvy"
)
// EliminateService stops and removes a service from the system
func EliminateService(s Service) error {
return scurvy.RemoveService(s.Name)
}
+10 -7
View File
@@ -7,7 +7,7 @@ const htmlTemplate = `<!DOCTYPE html>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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
View File
@@ -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)})
}
+160
View File
@@ -0,0 +1,160 @@
package tui
import (
"fmt"
"path/filepath"
"rmm-hunter/internal/pkg/hunt/eliminate"
"rmm-hunter/internal/suspicious"
"strings"
)
// WarnBlock is a non-fatal warning condition (rendered as a warning modal)
type WarnBlock struct{ Reason string }
func (w WarnBlock) Error() string { return w.Reason }
// normalize a Windows-like path for robust comparisons
func normPath(p string) string {
p = strings.TrimSpace(p)
p = strings.Trim(p, "\"") // strip surrounding quotes if any
p = strings.ReplaceAll(p, "\\", "/")
return strings.ToLower(p)
}
// extract the executable path from a command/BinaryPathName that may include quotes/args
func exeFromCommand(cmd string) string {
s := strings.TrimSpace(cmd)
if s == "" {
return s
}
if strings.HasPrefix(s, "\"") {
s = s[1:]
if i := strings.Index(s, "\""); i >= 0 {
return s[:i]
}
return s
}
// no quotes; split on space
if i := strings.IndexAny(s, " \t"); i >= 0 {
return s[:i]
}
return s
}
// CheckBinaryBlocked returns a WarnBlock if the path is in use by an active process or enabled+active service
func CheckBinaryBlocked(path string, data suspicious.Suspicious) error {
np := normPath(path)
// active process: listed in data.Processes (skip if already eliminated)
for _, p := range data.Processes {
if p.Eliminated {
continue // Skip eliminated processes
}
if normPath(p.Path) == np {
return WarnBlock{Reason: fmt.Sprintf("Binary in use by running process %s (PID %d). Eliminate the process first.", p.Name, p.PID)}
}
}
// enabled+active service: service uses this binary AND a running process exists for it
for _, s := range data.Services {
if s.Eliminated {
continue // Skip eliminated services
}
sp := normPath(exeFromCommand(s.BinaryPathName))
if sp == np && !strings.EqualFold(strings.TrimSpace(s.StartType), "disabled") {
// Is it active? infer by checking matching running process
for _, p := range data.Processes {
if p.Eliminated {
continue // Skip eliminated processes
}
if normPath(p.Path) == sp {
return WarnBlock{Reason: fmt.Sprintf("Binary used by active and enabled service %s. Stop/delete the service first.", s.Name)}
}
}
}
}
return nil
}
// CheckDirectoryBlocked returns a WarnBlock if any process or enabled+active service binary is inside the directory
func CheckDirectoryBlocked(dir string, data suspicious.Suspicious) error {
dn := normPath(dir)
if !strings.HasSuffix(dn, "/") {
dn += "/"
}
inDir := func(p string) bool {
pp := normPath(p)
if pp == "" {
return false
}
if strings.HasPrefix(pp, dn) {
return true
}
// try with filepath.Rel for robustness
rel, err := filepath.Rel(dn, pp)
return err == nil && rel != ".." && !strings.HasPrefix(rel, "../")
}
for _, p := range data.Processes {
if p.Eliminated {
continue // Skip eliminated processes
}
if inDir(p.Path) {
return WarnBlock{Reason: fmt.Sprintf("Directory contains running process %s (PID %d). Eliminate the process first.", p.Name, p.PID)}
}
}
for _, s := range data.Services {
if s.Eliminated {
continue // Skip eliminated services
}
sp := exeFromCommand(s.BinaryPathName)
if inDir(sp) && !strings.EqualFold(strings.TrimSpace(s.StartType), "disabled") {
// infer active via running process
for _, p := range data.Processes {
if p.Eliminated {
continue // Skip eliminated processes
}
if normPath(p.Path) == normPath(sp) {
return WarnBlock{Reason: fmt.Sprintf("Directory contains active and enabled service binary for %s. Stop/delete the service first.", s.Name)}
}
}
}
}
return nil
}
// Elimination functions
var (
EliminateAutoRun = func(ar suspicious.AutoRun) error { return eliminateAutoRun(ar) }
EliminateBinary = func(path string) error { return eliminateBinary(path) }
EliminateConnection = func(conn suspicious.NetworkConnection) error { return eliminateConnection(conn) }
EliminateDirectory = func(path string) error { return eliminateDirectory(path) }
EliminateProcess = func(p suspicious.Process) error { return eliminateProcess(p) }
EliminateScheduledTask = func(t suspicious.ScheduledTask) error { return eliminateScheduledTask(t) }
EliminateService = func(s suspicious.Service) error { return eliminateService(s) }
)
func eliminateAutoRun(ar suspicious.AutoRun) error {
return eliminate.EliminateAutoRun(ar)
}
func eliminateBinary(path string) error {
return eliminate.EliminateBinary(path)
}
func eliminateConnection(conn suspicious.NetworkConnection) error {
return eliminate.EliminateConnection(conn.RemoteHost)
}
func eliminateDirectory(path string) error {
return eliminate.EliminateDirectory(path)
}
func eliminateProcess(p suspicious.Process) error {
return eliminate.EliminateProcess(p)
}
func eliminateScheduledTask(t suspicious.ScheduledTask) error {
return eliminate.EliminateScheduledTask(t)
}
func eliminateService(s suspicious.Service) error {
return eliminate.EliminateService(s)
}
+400
View File
@@ -0,0 +1,400 @@
package tui
import (
"encoding/json"
"errors"
"fmt"
"os"
"rmm-hunter/internal/suspicious"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type screen int
const (
screenFilePicker screen = iota
screenTypePicker
screenList
screenDetail
screenError
)
type AppModel struct {
current screen
filePick FilePickerModel
typePick TypePickerModel
listView ListViewModel
detail DetailViewModel
err error
selected string
data suspicious.Suspicious
width int
height int
eliminated map[string]map[int]bool // tracks eliminated items: typeKey -> index -> eliminated
filePath string // path to the loaded JSON file
}
func NewApp() AppModel {
return AppModel{
current: screenFilePicker,
filePick: NewFilePicker(),
typePick: NewTypePicker(),
eliminated: make(map[string]map[int]bool),
}
}
func (m AppModel) Init() tea.Cmd { return m.filePick.Init() }
func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// remember the latest terminal size so we can size new screens
if ws, ok := msg.(tea.WindowSizeMsg); ok {
m.width, m.height = ws.Width, ws.Height
}
switch m.current {
case screenFilePicker:
var cmd tea.Cmd
var tm tea.Model
tm, cmd = m.filePick.Update(msg)
if fp, ok := tm.(FilePickerModel); ok {
m.filePick = fp
}
switch v := msg.(type) {
case FileSelectedMsg:
if err := m.loadSelectedFile(v.Path); err != nil {
m.err = err
m.current = screenError
return m, nil
}
m.filePath = v.Path
m.current = screenTypePicker
return m, nil
}
return m, cmd
case screenTypePicker:
var cmd tea.Cmd
var tm tea.Model
tm, cmd = m.typePick.Update(msg)
if tp, ok := tm.(TypePickerModel); ok {
m.typePick = tp
}
switch v := msg.(type) {
case BackMsg:
m.current = screenFilePicker
return m, nil
case SelectedTypeMsg:
m.selected = v.Type
m.listView = NewListView(v.Type, m.data, m.width, m.height, m.eliminated)
m.current = screenList
return m, nil
}
return m, cmd
case screenList:
var cmd tea.Cmd
var tm tea.Model
tm, cmd = m.listView.Update(msg)
if lv, ok := tm.(ListViewModel); ok {
m.listView = lv
}
switch v := msg.(type) {
case BackMsg:
m.current = screenTypePicker
return m, nil
case ListSelectedMsg:
m.detail = NewDetailView(v.TypeKey, v.Index, m.data, m.eliminated)
m.current = screenDetail
return m, nil
}
return m, cmd
case screenDetail:
var cmd tea.Cmd
var tm tea.Model
tm, cmd = m.detail.Update(msg)
if dv, ok := tm.(DetailViewModel); ok {
m.detail = dv
}
switch v := msg.(type) {
case BackMsg:
m.current = screenList
return m, nil
case RequestEliminateMsg:
// Check if already eliminated
if m.eliminated[v.TypeKey] != nil && m.eliminated[v.TypeKey][v.Index] {
m.detail.modalWarn = "This item has already been eliminated"
return m, nil
}
if err := m.performEliminate(v.TypeKey, v.Index); err != nil {
var wb WarnBlock
if errors.As(err, &wb) {
m.detail.modalWarn = wb.Error()
} else {
m.detail.modalErr = err.Error()
}
return m, nil
}
// success -> mark as eliminated, save file, and rebuild list
if m.eliminated[v.TypeKey] == nil {
m.eliminated[v.TypeKey] = make(map[int]bool)
}
m.eliminated[v.TypeKey][v.Index] = true
// Save the updated data to file
if err := m.saveDataToFile(); err != nil {
m.detail.modalErr = fmt.Sprintf("Eliminated successfully but failed to save: %v", err)
return m, nil
}
m.detail = NewDetailView(v.TypeKey, v.Index, m.data, m.eliminated)
m.listView = NewListView(m.selected, m.data, m.width, m.height, m.eliminated)
m.current = screenList
return m, nil
}
return m, cmd
case screenError:
// Any key quits after error is shown
if _, ok := msg.(tea.KeyMsg); ok {
return m, tea.Quit
}
return m, nil
}
return m, nil
}
func (m AppModel) View() string {
switch m.current {
case screenFilePicker:
return m.filePick.View()
case screenTypePicker:
return m.typePick.View()
case screenList:
return m.listView.View()
case screenDetail:
return m.detail.View()
case screenError:
return lipgloss.NewStyle().Foreground(lipgloss.Color("203")).Render(fmt.Sprintf("Failed to load JSON: %v\nPress any key to exit.", m.err))
default:
return ""
}
}
// performEliminate routes to eliminate functions without removing items from data
func (m *AppModel) performEliminate(typeKey string, idx int) error {
switch typeKey {
case "autoruns":
ar := m.data.AutoRuns[idx]
if err := EliminateAutoRun(ar); err != nil {
return err
}
m.data.AutoRuns[idx].Eliminated = true
case "binaries":
b := m.data.Binaries[idx]
if err := CheckBinaryBlocked(b.Path, m.data); err != nil {
return err
}
if err := EliminateBinary(b.Path); err != nil {
return err
}
m.data.Binaries[idx].Eliminated = true
case "connections":
c := m.data.OutboundConnections[idx]
if err := EliminateConnection(c); err != nil {
return err
}
m.data.OutboundConnections[idx].Eliminated = true
case "directories":
d := m.data.Directories[idx]
if err := CheckDirectoryBlocked(d.Path, m.data); err != nil {
return err
}
if err := EliminateDirectory(d.Path); err != nil {
return err
}
m.data.Directories[idx].Eliminated = true
case "processes":
p := m.data.Processes[idx]
if err := EliminateProcess(p); err != nil {
return err
}
m.data.Processes[idx].Eliminated = true
case "scheduledTasks":
t := m.data.ScheduledTasks[idx]
if err := EliminateScheduledTask(*t); err != nil {
return err
}
m.data.ScheduledTasks[idx].Eliminated = true
case "services":
s := m.data.Services[idx]
if err := EliminateService(*s); err != nil {
return err
}
m.data.Services[idx].Eliminated = true
}
return nil
}
// loadSelectedFile reads the JSON file and populates m.data
func (m *AppModel) loadSelectedFile(path string) error {
b, err := os.ReadFile(path)
if err != nil {
return err
}
// Support both wrapped report (with findings) and bare Suspicious JSON
var envelope struct {
Findings json.RawMessage `json:"findings"`
}
if err := json.Unmarshal(b, &envelope); err == nil && len(envelope.Findings) > 0 {
var sus suspicious.Suspicious
if err := json.Unmarshal(envelope.Findings, &sus); err != nil {
return err
}
m.data = sus
m.loadEliminatedState()
return nil
}
// Try bare suspicious structure
var sus suspicious.Suspicious
if err := json.Unmarshal(b, &sus); err != nil {
return fmt.Errorf("no findings in report")
}
m.data = sus
m.loadEliminatedState()
return nil
}
// loadEliminatedState populates the eliminated map from the data structures
func (m *AppModel) loadEliminatedState() {
m.eliminated = make(map[string]map[int]bool)
// Load eliminated autoruns
for i, ar := range m.data.AutoRuns {
if ar.Eliminated {
if m.eliminated["autoruns"] == nil {
m.eliminated["autoruns"] = make(map[int]bool)
}
m.eliminated["autoruns"][i] = true
}
}
// Load eliminated binaries
for i, b := range m.data.Binaries {
if b.Eliminated {
if m.eliminated["binaries"] == nil {
m.eliminated["binaries"] = make(map[int]bool)
}
m.eliminated["binaries"][i] = true
}
}
// Load eliminated connections
for i, c := range m.data.OutboundConnections {
if c.Eliminated {
if m.eliminated["connections"] == nil {
m.eliminated["connections"] = make(map[int]bool)
}
m.eliminated["connections"][i] = true
}
}
// Load eliminated directories
for i, d := range m.data.Directories {
if d.Eliminated {
if m.eliminated["directories"] == nil {
m.eliminated["directories"] = make(map[int]bool)
}
m.eliminated["directories"][i] = true
}
}
// Load eliminated processes
for i, p := range m.data.Processes {
if p.Eliminated {
if m.eliminated["processes"] == nil {
m.eliminated["processes"] = make(map[int]bool)
}
m.eliminated["processes"][i] = true
}
}
// Load eliminated scheduled tasks
for i, t := range m.data.ScheduledTasks {
if t.Eliminated {
if m.eliminated["scheduledTasks"] == nil {
m.eliminated["scheduledTasks"] = make(map[int]bool)
}
m.eliminated["scheduledTasks"][i] = true
}
}
// Load eliminated services
for i, s := range m.data.Services {
if s.Eliminated {
if m.eliminated["services"] == nil {
m.eliminated["services"] = make(map[int]bool)
}
m.eliminated["services"][i] = true
}
}
}
// saveDataToFile saves the current data back to the JSON file
func (m *AppModel) saveDataToFile() error {
if m.filePath == "" {
return fmt.Errorf("no file path set")
}
// Read the original file to determine format
b, err := os.ReadFile(m.filePath)
if err != nil {
return err
}
// Check if it's wrapped format
var envelope struct {
Findings json.RawMessage `json:"findings"`
}
isWrapped := json.Unmarshal(b, &envelope) == nil && len(envelope.Findings) > 0
var output []byte
if isWrapped {
// Re-read the full envelope
var fullEnvelope map[string]interface{}
if err := json.Unmarshal(b, &fullEnvelope); err != nil {
return err
}
// Update the findings
findingsJSON, err := json.MarshalIndent(m.data, "", " ")
if err != nil {
return err
}
fullEnvelope["findings"] = json.RawMessage(findingsJSON)
output, err = json.MarshalIndent(fullEnvelope, "", " ")
if err != nil {
return err
}
} else {
// Bare format
output, err = json.MarshalIndent(m.data, "", " ")
if err != nil {
return err
}
}
return os.WriteFile(m.filePath, output, 0644)
}
// RunEliminateUI starts the Bubble Tea program for elimination UI
func RunEliminateUI() error {
p := tea.NewProgram(NewApp())
_, err := p.Run()
return err
}
+105
View File
@@ -0,0 +1,105 @@
package tui
import (
"os"
"path/filepath"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
)
// writeTestReport creates a minimal JSON report envelope with empty findings
func writeTestReport(t *testing.T, dir, name string, withFindings bool) string {
t.Helper()
path := filepath.Join(dir, name)
var content string
if withFindings {
content = `{
"reportName": "rmm-hunter-report",
"generatedAt": "2025-01-01T00:00:00Z",
"riskRating": {"score":0, "rating":"Low", "summary":""},
"findings": {"processes":[],"services":[],"binaries":[],"autoRuns":[],"scheduledTasks":[],"outboundConnections":[],"directories":[]}
}`
} else {
content = `{}`
}
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("failed to write test report: %v", err)
}
return path
}
func TestAppFlow_SelectFile_SelectType_Back_Quit(t *testing.T) {
// Run in a temp dir so file picker sees our .json
tmp := t.TempDir()
if err := os.Chdir(tmp); err != nil {
t.Fatalf("chdir: %v", err)
}
_ = writeTestReport(t, tmp, "test.json", true)
p := tea.NewProgram(NewApp(), tea.WithoutRenderer())
done := make(chan error, 1)
go func() {
_, err := p.Run()
done <- err
}()
// Give init a moment to load files
time.Sleep(100 * time.Millisecond)
// Select file
p.Send(tea.KeyMsg{Type: tea.KeyEnter})
time.Sleep(50 * time.Millisecond)
// Choose type 1 (autoruns)
p.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'1'}})
time.Sleep(50 * time.Millisecond)
// Go back to type picker
p.Send(tea.KeyMsg{Type: tea.KeyLeft})
time.Sleep(50 * time.Millisecond)
// Quit
p.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
select {
case err := <-done:
if err != nil {
t.Fatalf("program error: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("program did not exit in time")
}
}
func TestApp_ErrorOnBadJSON(t *testing.T) {
tmp := t.TempDir()
if err := os.Chdir(tmp); err != nil {
t.Fatalf("chdir: %v", err)
}
_ = writeTestReport(t, tmp, "bad.json", false)
p := tea.NewProgram(NewApp(), tea.WithoutRenderer())
done := make(chan error, 1)
go func() {
_, err := p.Run()
done <- err
}()
time.Sleep(100 * time.Millisecond)
// Select file -> should error
p.Send(tea.KeyMsg{Type: tea.KeyEnter})
// Any key quits on error screen
time.Sleep(50 * time.Millisecond)
p.Send(tea.KeyMsg{Type: tea.KeyEsc})
select {
case err := <-done:
if err != nil {
t.Fatalf("program error: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("program did not exit in time")
}
}
+138
View File
@@ -0,0 +1,138 @@
package tui
import (
"fmt"
"rmm-hunter/internal/suspicious"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// RequestEliminateMsg is emitted by the detail view when '!' is pressed
type RequestEliminateMsg struct {
TypeKey string
Index int
}
// DeletedMsg is emitted after successful elimination to update lists
type DeletedMsg struct {
TypeKey string
Index int
}
type DetailViewModel struct {
typeKey string
index int
data suspicious.Suspicious
eliminated map[string]map[int]bool
// When modal* != "", show modal and require ESC to dismiss
modalErr string
modalWarn string
}
func NewDetailView(typeKey string, index int, data suspicious.Suspicious, eliminated map[string]map[int]bool) DetailViewModel {
return DetailViewModel{typeKey: typeKey, index: index, data: data, eliminated: eliminated}
}
func (m DetailViewModel) Init() tea.Cmd { return nil }
func (m DetailViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch v := msg.(type) {
case tea.KeyMsg:
if m.modalErr != "" || m.modalWarn != "" {
// Modal active: only ESC dismisses
if v.String() == "esc" {
m.modalErr = ""
m.modalWarn = ""
}
return m, nil
}
switch v.String() {
case "left":
return m, func() tea.Msg { return BackMsg{} }
case "!":
return m, func() tea.Msg { return RequestEliminateMsg{TypeKey: m.typeKey, Index: m.index} }
case "q", "esc", "ctrl+c":
return m, tea.Quit
}
}
return m, nil
}
func (m DetailViewModel) View() string {
isEliminated := m.eliminated[m.typeKey] != nil && m.eliminated[m.typeKey][m.index]
var title string
if isEliminated {
title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("10")).Render("Details — ELIMINATED — Left to go back, q to quit")
} else {
title = lipgloss.NewStyle().Bold(true).Render("Details — press ! to eliminate, Left to go back, q to quit")
}
body := m.renderDetails()
// Apply green styling to body if eliminated
if isEliminated {
body = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render(body)
}
view := title + "\n\n" + body
if m.modalWarn != "" {
modal := lipgloss.NewStyle().Padding(1, 2).Foreground(lipgloss.Color("214")).Border(lipgloss.RoundedBorder()).Render("Warning:\n" + m.modalWarn + "\n\nPress ESC to dismiss")
view += "\n\n" + modal
}
if m.modalErr != "" {
modal := lipgloss.NewStyle().Padding(1, 2).Foreground(lipgloss.Color("203")).Border(lipgloss.RoundedBorder()).Render("Elimination failed:\n" + m.modalErr + "\n\nPress ESC to dismiss")
view += "\n\n" + modal
}
return view
}
func (m DetailViewModel) renderDetails() string {
switch m.typeKey {
case "autoruns":
if m.index >= len(m.data.AutoRuns) {
return "Item no longer available"
}
ar := m.data.AutoRuns[m.index]
return fmt.Sprintf("Type: %s\nEntry: %s\nLaunch: %s\nLocation: %s\nImage: %s\nArgs: %s\nMD5: %s\nSHA1: %s\nSHA256: %s", ar.Type, ar.Entry, ar.LaunchString, ar.Location, ar.ImagePath, ar.Arguments, ar.MD5, ar.SHA1, ar.SHA256)
case "binaries":
if m.index >= len(m.data.Binaries) {
return "Item no longer available"
}
b := m.data.Binaries[m.index]
return fmt.Sprintf("Binary: %s\nAction: delete file", b.Path)
case "connections":
if m.index >= len(m.data.OutboundConnections) {
return "Item no longer available"
}
c := m.data.OutboundConnections[m.index]
return fmt.Sprintf("Local: %s\nRemote: %s\nHost: %s\nState: %s\nPID: %s\nProcess: %s\nAction: add firewall block", 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", 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", 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", s.Name, s.DisplayName, s.ServiceType, s.StartType, s.BinaryPathName, s.ServiceStartName, s.Description)
}
return ""
}
+123
View File
@@ -0,0 +1,123 @@
package tui
import (
"fmt"
"os"
"path/filepath"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// FileSelectedMsg is emitted when a file is chosen
type FileSelectedMsg struct{ Path string }
// FilePicker is a simple list of .json files in the current directory
// Press Enter to pick, q/esc to quit
type fileItem struct {
title string
path string
}
func (i fileItem) Title() string { return i.title }
func (i fileItem) Description() string { return i.path }
func (i fileItem) FilterValue() string { return i.title }
type FilePickerModel struct {
list list.Model
spinner spinner.Model
error error
loading bool
}
func NewFilePicker() FilePickerModel {
delegate := list.NewDefaultDelegate()
delegate.ShowDescription = true
l := list.New([]list.Item{}, delegate, 0, 0)
// Set a sane default; will be updated on WindowSizeMsg
l.SetSize(80, 20)
l.Title = "Select JSON report"
l.Styles.Title = lipgloss.NewStyle().Bold(true)
l.SetShowHelp(false)
sp := spinner.New()
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
m := FilePickerModel{list: l, spinner: sp}
return m
}
func (m FilePickerModel) Init() tea.Cmd {
return tea.Batch(m.spinner.Tick, m.loadFilesCmd())
}
func (m FilePickerModel) loadFilesCmd() tea.Cmd {
return func() tea.Msg {
entries, err := os.ReadDir(".")
if err != nil {
return errMsg{err}
}
seen := map[string]bool{}
var items []list.Item
for _, e := range entries {
name := e.Name()
if e.IsDir() {
continue
}
if filepath.Ext(name) != ".json" {
continue
}
fi, err := e.Info()
if err != nil || !fi.Mode().IsRegular() {
continue
}
if seen[name] {
continue
}
seen[name] = true
items = append(items, fileItem{title: name, path: name})
}
return filesLoadedMsg{items}
}
}
type errMsg struct{ error }
type filesLoadedMsg struct{ items []list.Item }
func (m FilePickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.list.SetSize(msg.Width, msg.Height-2)
case filesLoadedMsg:
m.loading = false
m.list.SetItems(msg.items)
case errMsg:
m.error = msg
case tea.KeyMsg:
switch msg.String() {
case "q", "esc", "ctrl+c":
return m, tea.Quit
case "enter":
if it, ok := m.list.SelectedItem().(fileItem); ok {
return m, func() tea.Msg { return FileSelectedMsg{Path: it.path} }
}
}
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
func (m FilePickerModel) View() string {
if m.error != nil {
return lipgloss.NewStyle().Foreground(lipgloss.Color("203")).Render(fmt.Sprintf("Error: %v\n", m.error))
}
if m.loading {
return "Loading files...\n" + m.spinner.View()
}
if len(m.list.Items()) == 0 {
return "No .json files found in current directory. Press q to exit.\n"
}
return m.list.View()
}
+109
View File
@@ -0,0 +1,109 @@
package tui
import (
"fmt"
"os"
"path/filepath"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// FileSelectedMsg is emitted when a file is chosen
type FileSelectedMsg struct{ Path string }
// FilePicker is a simple list of .json files in the current directory
// Press Enter to pick, q/esc to quit
type fileItem struct {
title string
path string
}
func (i fileItem) Title() string { return i.title }
func (i fileItem) Description() string { return i.path }
func (i fileItem) FilterValue() string { return i.title }
type FilePickerModel struct {
list list.Model
spinner spinner.Model
error error
loading bool
}
func NewFilePicker() FilePickerModel {
delegate := list.NewDefaultDelegate()
delegate.ShowDescription = true
l := list.New([]list.Item{}, delegate, 0, 0)
l.Title = "Select JSON report"
l.Styles.Title = lipgloss.NewStyle().Bold(true)
l.SetShowHelp(false)
sp := spinner.New()
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
m := FilePickerModel{list: l, spinner: sp}
return m
}
func (m FilePickerModel) Init() tea.Cmd {
return tea.Batch(m.spinner.Tick, m.loadFilesCmd())
}
func (m FilePickerModel) loadFilesCmd() tea.Cmd {
return func() tea.Msg {
files, err := os.ReadDir(".")
if err != nil {
return errMsg{err}
}
var items []list.Item
for _, f := range files {
if f.IsDir() { continue }
name := f.Name()
if filepath.Ext(name) == ".json" {
items = append(items, fileItem{title: name, path: name})
}
}
return filesLoadedMsg{items}
}
}
type errMsg struct{ error }
type filesLoadedMsg struct{ items []list.Item }
func (m FilePickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.list.SetSize(msg.Width, msg.Height-2)
case filesLoadedMsg:
m.loading = false
m.list.SetItems(msg.items)
case errMsg:
m.error = msg
case tea.KeyMsg:
switch msg.String() {
case "q", "esc", "ctrl+c":
return m, tea.Quit
case "enter":
if it, ok := m.list.SelectedItem().(fileItem); ok {
return m, func() tea.Msg { return FileSelectedMsg{Path: it.path} }
}
}
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
func (m FilePickerModel) View() string {
if m.error != nil {
return lipgloss.NewStyle().Foreground(lipgloss.Color("203")).Render(fmt.Sprintf("Error: %v\n", m.error))
}
if m.loading {
return "Loading files...\n" + m.spinner.View()
}
if len(m.list.Items()) == 0 {
return "No .json files found in current directory. Press q to exit.\n"
}
return m.list.View()
}
+167
View File
@@ -0,0 +1,167 @@
package tui
import (
"fmt"
"io"
"rmm-hunter/internal/suspicious"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// ListSelectedMsg indicates which index/type was selected for detail
type ListSelectedMsg struct {
TypeKey string
Index int
}
type listItem struct {
title string
desc string
eliminated bool
}
func (i listItem) Title() string { return i.title }
func (i listItem) Description() string { return i.desc }
func (i listItem) FilterValue() string { return i.title }
// customDelegate is a custom list item delegate that renders eliminated items in green
type customDelegate struct {
list.DefaultDelegate
}
func (d customDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) {
i, ok := item.(listItem)
if !ok {
d.DefaultDelegate.Render(w, m, index, item)
return
}
title := i.Title()
desc := i.Description()
// Style for eliminated items (green)
if i.eliminated {
titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("10"))
descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("10"))
if index == m.Index() {
// Selected item - add background
titleStyle = titleStyle.Background(lipgloss.Color("240"))
descStyle = descStyle.Background(lipgloss.Color("240"))
}
fmt.Fprintf(w, "%s\n%s", titleStyle.Render("✓ "+title), descStyle.Render(" "+desc))
} else {
// Normal rendering for non-eliminated items
d.DefaultDelegate.Render(w, m, index, item)
}
}
type ListViewModel struct {
typeKey string
list list.Model
header string
eliminated map[string]map[int]bool
}
func NewListView(typeKey string, sus suspicious.Suspicious, width, height int, eliminated map[string]map[int]bool) ListViewModel {
defaultDelegate := list.NewDefaultDelegate()
defaultDelegate.ShowDescription = true
delegate := customDelegate{DefaultDelegate: defaultDelegate}
l := list.New([]list.Item{}, delegate, 0, 0)
if width > 0 && height > 0 {
l.SetSize(width, height-2)
} else {
l.SetSize(80, 20)
}
l.Styles.Title = lipgloss.NewStyle().Bold(true)
header := ""
var items []list.Item
switch typeKey {
case "autoruns":
header = "Suspicious AutoRuns"
for i, ar := range sus.AutoRuns {
title := ar.ImageName
if title == "" {
title = ar.Entry
}
desc := fmt.Sprintf("%s (%s)", ar.ImagePath, ar.Location)
isEliminated := eliminated[typeKey] != nil && eliminated[typeKey][i]
items = append(items, listItem{title: title, desc: desc, eliminated: isEliminated})
}
case "binaries":
header = "Suspicious Binaries"
for i, b := range sus.Binaries {
isEliminated := eliminated[typeKey] != nil && eliminated[typeKey][i]
items = append(items, listItem{title: b.Path, desc: "binary file", eliminated: isEliminated})
}
case "connections":
header = "Suspicious Connections"
for i, c := range sus.OutboundConnections {
label := fmt.Sprintf("%s -> %s (%s)", c.LocalAddr, c.RemoteAddr, c.RemoteHost)
isEliminated := eliminated[typeKey] != nil && eliminated[typeKey][i]
items = append(items, listItem{title: label, desc: fmt.Sprintf("PID %s %s", c.PID, c.Process), eliminated: isEliminated})
}
case "directories":
header = "Suspicious Directories"
for i, d := range sus.Directories {
isEliminated := eliminated[typeKey] != nil && eliminated[typeKey][i]
items = append(items, listItem{title: d.Path, desc: "directory", eliminated: isEliminated})
}
case "processes":
header = "Suspicious Processes"
for i, p := range sus.Processes {
label := fmt.Sprintf("%s (PID %d)", p.Name, p.PID)
desc := p.Path
isEliminated := eliminated[typeKey] != nil && eliminated[typeKey][i]
items = append(items, listItem{title: label, desc: desc, eliminated: isEliminated})
}
case "scheduledTasks":
header = "Suspicious Scheduled Tasks"
for i, t := range sus.ScheduledTasks {
label := t.Name
desc := t.Path
isEliminated := eliminated[typeKey] != nil && eliminated[typeKey][i]
items = append(items, listItem{title: label, desc: desc, eliminated: isEliminated})
}
case "services":
header = "Suspicious Services"
for i, s := range sus.Services {
label := fmt.Sprintf("%s (%s)", s.Name, s.DisplayName)
desc := s.BinaryPathName
isEliminated := eliminated[typeKey] != nil && eliminated[typeKey][i]
items = append(items, listItem{title: label, desc: desc, eliminated: isEliminated})
}
}
l.Title = header + " — Left: Back Enter: Details q: Quit"
l.SetItems(items)
return ListViewModel{typeKey: typeKey, list: l, header: header, eliminated: eliminated}
}
func (m ListViewModel) Init() tea.Cmd { return nil }
func (m ListViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.list.SetSize(msg.Width, msg.Height-2)
case tea.KeyMsg:
switch msg.String() {
case "left":
return m, func() tea.Msg { return BackMsg{} }
case "q", "esc", "ctrl+c":
return m, tea.Quit
case "enter":
return m, func() tea.Msg { return ListSelectedMsg{TypeKey: m.typeKey, Index: m.list.Index()} }
}
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
func (m ListViewModel) View() string { return m.list.View() }
+120
View File
@@ -0,0 +1,120 @@
package tui
import (
"strings"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// SelectedTypeMsg is sent when the user chooses a type (1-7)
// Valid Type values: "autoruns", "binaries", "connections", "directories", "processes", "scheduledTasks", "services"
type SelectedTypeMsg struct{ Type string }
// BackMsg is sent when the user presses Left to go back
type BackMsg struct{}
// keyMap defines keybindings for the type picker
// It must satisfy key.Map for the help component
// We only need: 1-7, left/back, help, quit
type keyMap struct {
Help key.Binding
Quit key.Binding
Back key.Binding
One key.Binding
Two key.Binding
Three key.Binding
Four key.Binding
Five key.Binding
Six key.Binding
Seven key.Binding
}
func (k keyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Back, k.Help, k.Quit}
}
func (k keyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.One, k.Two, k.Three, k.Four, k.Five, k.Six, k.Seven},
{k.Back, k.Help, k.Quit},
}
}
var keys = keyMap{
One: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "AutoRuns")),
Two: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "Binaries")),
Three: key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "Connections")),
Four: key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "Directories")),
Five: key.NewBinding(key.WithKeys("5"), key.WithHelp("5", "Processes")),
Six: key.NewBinding(key.WithKeys("6"), key.WithHelp("6", "Scheduled Tasks")),
Seven: key.NewBinding(key.WithKeys("7"), key.WithHelp("7", "Services")),
Back: key.NewBinding(key.WithKeys("left"), key.WithHelp("←", "back")),
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "toggle help")),
Quit: key.NewBinding(key.WithKeys("q", "esc", "ctrl+c"), key.WithHelp("q", "quit")),
}
type TypePickerModel struct {
keys keyMap
help help.Model
inputStyle lipgloss.Style
quitting bool
}
func NewTypePicker() TypePickerModel {
return TypePickerModel{
keys: keys,
help: help.New(),
inputStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#ef8430")),
}
}
func (m TypePickerModel) Init() tea.Cmd { return nil }
func (m TypePickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.help.Width = msg.Width
case tea.KeyMsg:
switch {
case key.Matches(msg, m.keys.Quit):
m.quitting = true
return m, tea.Quit
case key.Matches(msg, m.keys.Help):
m.help.ShowAll = !m.help.ShowAll
case key.Matches(msg, m.keys.Back):
return m, func() tea.Msg { return BackMsg{} }
case key.Matches(msg, m.keys.One):
return m, func() tea.Msg { return SelectedTypeMsg{Type: "autoruns"} }
case key.Matches(msg, m.keys.Two):
return m, func() tea.Msg { return SelectedTypeMsg{Type: "binaries"} }
case key.Matches(msg, m.keys.Three):
return m, func() tea.Msg { return SelectedTypeMsg{Type: "connections"} }
case key.Matches(msg, m.keys.Four):
return m, func() tea.Msg { return SelectedTypeMsg{Type: "directories"} }
case key.Matches(msg, m.keys.Five):
return m, func() tea.Msg { return SelectedTypeMsg{Type: "processes"} }
case key.Matches(msg, m.keys.Six):
return m, func() tea.Msg { return SelectedTypeMsg{Type: "scheduledTasks"} }
case key.Matches(msg, m.keys.Seven):
return m, func() tea.Msg { return SelectedTypeMsg{Type: "services"} }
}
}
return m, nil
}
func (m TypePickerModel) View() string {
if m.quitting {
return "Bye!\n"
}
title := lipgloss.NewStyle().Bold(true).Render("Select a type to manage")
menu := "\n 1) AutoRuns\n 2) Binaries\n 3) Connections\n 4) Directories\n 5) Processes\n 6) Scheduled Tasks\n 7) Services\n"
helpView := m.help.View(m.keys)
height := 8 - strings.Count(menu, "\n") - strings.Count(helpView, "\n")
if height < 0 {
height = 0
}
return title + "\n" + menu + strings.Repeat("\n", height) + helpView
}
+181
View File
@@ -0,0 +1,181 @@
package web
import (
"fmt"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
var (
shell32 = syscall.NewLazyDLL("shell32.dll")
shellExecuteExW = shell32.NewProc("ShellExecuteExW")
)
const (
SEE_MASK_NOCLOSEPROCESS = 0x00000040
SW_SHOW = 5
)
// SHELLEXECUTEINFO structure for ShellExecuteEx
type shellExecuteInfo struct {
cbSize uint32
fMask uint32
hwnd uintptr
lpVerb *uint16
lpFile *uint16
lpParameters *uint16
lpDirectory *uint16
nShow int32
hInstApp uintptr
lpIDList uintptr
lpClass *uint16
hkeyClass uintptr
dwHotKey uint32
hIconOrMonitor uintptr
hProcess windows.Handle
}
// BrowserHandle represents a handle to the opened browser process
type BrowserHandle struct {
ProcessID uint32
Handle windows.Handle
}
// OpenBrowser opens the default browser to the specified URL using Windows ShellExecute API
// Returns a handle to the browser process that can be used to close it later
func OpenBrowser(url string) (*BrowserHandle, error) {
// Convert strings to UTF16 pointers
operation, err := syscall.UTF16PtrFromString("open")
if err != nil {
return nil, fmt.Errorf("failed to convert operation string: %w", err)
}
urlPtr, err := syscall.UTF16PtrFromString(url)
if err != nil {
return nil, fmt.Errorf("failed to convert URL string: %w", err)
}
// Initialize SHELLEXECUTEINFO structure
sei := shellExecuteInfo{
cbSize: uint32(unsafe.Sizeof(shellExecuteInfo{})),
fMask: SEE_MASK_NOCLOSEPROCESS, // Request process handle
hwnd: 0,
lpVerb: operation,
lpFile: urlPtr,
lpParameters: nil,
lpDirectory: nil,
nShow: SW_SHOW,
hInstApp: 0,
}
// Call ShellExecuteExW
ret, _, err := shellExecuteExW.Call(uintptr(unsafe.Pointer(&sei)))
if ret == 0 {
return nil, fmt.Errorf("ShellExecuteExW failed: %w", err)
}
if sei.hInstApp <= 32 {
return nil, fmt.Errorf("ShellExecuteExW failed with code: %d", sei.hInstApp)
}
// Get process ID from handle
var processID uint32
if sei.hProcess != 0 {
processID, err = windows.GetProcessId(sei.hProcess)
if err != nil {
// If we can't get PID, still return the handle
processID = 0
}
}
fmt.Printf("[web] Browser opened successfully (PID: %d)\n", processID)
return &BrowserHandle{
ProcessID: processID,
Handle: sei.hProcess,
}, nil
}
// Close terminates the browser process and all child processes
func (bh *BrowserHandle) Close() error {
if bh == nil {
return nil
}
// First try to kill the direct process if we have a handle
if bh.Handle != 0 {
windows.CloseHandle(bh.Handle)
}
// Kill all browser processes that might have our URL open
// This is more reliable than trying to track the exact process tree
killed := killBrowserProcesses()
fmt.Printf("[web] Terminated %d browser process(es)\n", killed)
return nil
}
// killBrowserProcesses finds and kills common browser processes
func killBrowserProcesses() int {
browserExes := []string{
"chrome.exe",
"msedge.exe",
"firefox.exe",
"brave.exe",
"opera.exe",
"iexplore.exe",
}
killed := 0
for _, exeName := range browserExes {
count := killProcessByName(exeName)
killed += count
}
return killed
}
// killProcessByName kills all processes with the given executable name
func killProcessByName(exeName string) int {
snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
if err != nil {
return 0
}
defer windows.CloseHandle(snapshot)
var procEntry windows.ProcessEntry32
procEntry.Size = uint32(unsafe.Sizeof(procEntry))
err = windows.Process32First(snapshot, &procEntry)
if err != nil {
return 0
}
killed := 0
for {
// Convert the process name from [260]uint16 to string
processName := syscall.UTF16ToString(procEntry.ExeFile[:])
if processName == exeName {
// Open process with terminate rights
handle, err := windows.OpenProcess(windows.PROCESS_TERMINATE, false, procEntry.ProcessID)
if err == nil {
err = windows.TerminateProcess(handle, 0)
if err == nil {
killed++
fmt.Printf("[web] Killed %s (PID: %d)\n", exeName, procEntry.ProcessID)
}
windows.CloseHandle(handle)
}
}
err = windows.Process32Next(snapshot, &procEntry)
if err != nil {
break
}
}
return killed
}
+129
View File
@@ -0,0 +1,129 @@
package web
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
)
const (
hostsEntry = "127.0.0.1 rmm-hunter"
marker = "# RMM-Hunter entry"
)
// AddHostsEntry adds the rmm-hunter DNS entry to the Windows hosts file
// Requires administrator privileges
func AddHostsEntry() error {
hostsPath := getHostsPath()
// Check if entry already exists
exists, err := hostsEntryExists(hostsPath)
if err != nil {
return fmt.Errorf("failed to check hosts file: %w", err)
}
if exists {
fmt.Println("[+] rmm-hunter hosts entry already exists")
return nil
}
// Read existing hosts file
content, err := os.ReadFile(hostsPath)
if err != nil {
return fmt.Errorf("failed to read hosts file: %w", err)
}
// Append our entry
newContent := string(content)
if !strings.HasSuffix(newContent, "\n") {
newContent += "\n"
}
newContent += fmt.Sprintf("\n%s\n%s\n", marker, hostsEntry)
// Write back to hosts file
err = os.WriteFile(hostsPath, []byte(newContent), 0644)
if err != nil {
return fmt.Errorf("failed to write hosts file: %w", err)
}
fmt.Println("[+] Added rmm-hunter to hosts file")
fmt.Println("[+] You can now access the web UI at: http://rmm-hunter:8080")
return nil
}
// RemoveHostsEntry removes the rmm-hunter DNS entry from the Windows hosts file
func RemoveHostsEntry() error {
hostsPath := getHostsPath()
// Read existing hosts file
file, err := os.Open(hostsPath)
if err != nil {
return fmt.Errorf("failed to open hosts file: %w", err)
}
defer file.Close()
var newLines []string
scanner := bufio.NewScanner(file)
skipNext := false
for scanner.Scan() {
line := scanner.Text()
// Skip the marker line and the next line (our entry)
if strings.Contains(line, marker) {
skipNext = true
continue
}
if skipNext && strings.Contains(line, "rmm-hunter") {
skipNext = false
continue
}
newLines = append(newLines, line)
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("failed to read hosts file: %w", err)
}
// Write back to hosts file
newContent := strings.Join(newLines, "\n")
err = os.WriteFile(hostsPath, []byte(newContent), 0644)
if err != nil {
return fmt.Errorf("failed to write hosts file: %w", err)
}
fmt.Println("[+] Removed rmm-hunter from hosts file")
return nil
}
// hostsEntryExists checks if the rmm-hunter entry already exists in the hosts file
func hostsEntryExists(hostsPath string) (bool, error) {
file, err := os.Open(hostsPath)
if err != nil {
return false, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.Contains(line, "rmm-hunter") && strings.Contains(line, "127.0.0.1") {
return true, nil
}
}
return false, scanner.Err()
}
// getHostsPath returns the path to the Windows hosts file
func getHostsPath() string {
systemRoot := os.Getenv("SystemRoot")
if systemRoot == "" {
systemRoot = "C:\\Windows"
}
return filepath.Join(systemRoot, "System32", "drivers", "etc", "hosts")
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
+574
View File
@@ -0,0 +1,574 @@
package web
import (
"bufio"
"context"
"embed"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"rmm-hunter/internal/suspicious"
"strings"
"sync"
"time"
"rmm-hunter/internal/pkg"
"rmm-hunter/internal/pkg/hunt/eliminate"
"rmm-hunter/internal/pkg/hunter"
"github.com/gorilla/websocket"
)
//go:embed templates/*
var contentFS embed.FS
// broadcaster for hunt logs
type wsHub struct {
mu sync.Mutex
conns map[*websocket.Conn]struct{}
}
func newHub() *wsHub { return &wsHub{conns: make(map[*websocket.Conn]struct{})} }
func (h *wsHub) add(c *websocket.Conn) { h.mu.Lock(); h.conns[c] = struct{}{}; h.mu.Unlock() }
func (h *wsHub) rm(c *websocket.Conn) { h.mu.Lock(); delete(h.conns, c); h.mu.Unlock() }
func (h *wsHub) send(msg string) {
h.mu.Lock()
for c := range h.conns {
_ = c.WriteMessage(websocket.TextMessage, []byte(msg))
}
h.mu.Unlock()
}
// JSONReportMeta is a lightweight descriptor for previous hunts
type JSONReportMeta struct {
File string `json:"file"`
ReportName string `json:"reportName"`
GeneratedAt string `json:"generatedAt"`
}
type server struct {
hub *wsHub
http *http.Server
quitCh chan struct{}
}
func StartWebServer() {
var hostAdded bool
h := newHub()
s := &server{hub: h, quitCh: make(chan struct{})}
// Add hosts file entry for rmm-hunter
if err := AddHostsEntry(); err != nil {
log.Printf("[web] Warning: Failed to add hosts entry: %v\n", err)
} else {
hostAdded = true
}
mux := http.NewServeMux()
mux.HandleFunc("/", s.handleIndex)
mux.HandleFunc("/logo", s.handleLogo)
mux.HandleFunc("/favicon.ico", s.handleFavicon)
mux.HandleFunc("/favicon-32x32.png", s.handleFavicon)
mux.HandleFunc("/favicon-16x16.png", s.handleFavicon)
mux.HandleFunc("/apple-touch-icon.png", s.handleFavicon)
mux.HandleFunc("/site.webmanifest", s.handleManifest)
mux.HandleFunc("/api/hunts", s.handleListHunts)
mux.HandleFunc("/api/hunt/start", s.handleStartHunt)
mux.HandleFunc("/api/report", s.handleGetReport)
mux.HandleFunc("/api/eliminate", s.handleEliminate)
mux.HandleFunc("/api/quit", s.handleQuit)
mux.HandleFunc("/ws/hunt", s.handleWS)
s.http = &http.Server{Addr: ":80", Handler: logRequests(mux)}
// Determine which URL to open in browser
browserURL := "http://rmm-hunter"
if !hostAdded {
browserURL = "http://127.0.0.1"
}
// Channel to signal when server is ready
serverReady := make(chan struct{})
go func() {
// Signal that we're about to start listening
close(serverReady)
if err := s.http.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %v", err)
}
}()
// Wait for server to start, then open browser
<-serverReady
time.Sleep(500 * time.Millisecond) // Give server a moment to fully initialize
log.Printf("[web] Opening browser to %s...\n", browserURL)
_, err := OpenBrowser(browserURL)
if err != nil {
log.Printf("[web] Warning: Failed to open browser: %v\n", err)
if !hostAdded {
log.Printf("[web] Please open your browser and navigate to http://127.0.0.1\n")
}
log.Printf("[web] Please open your browser and navigate to http://rmm-hunter\n")
}
// block until quit
<-s.quitCh
// Clean up hosts entry on exit
log.Printf("[web] Cleaning up hosts entry...\n")
if err := RemoveHostsEntry(); err != nil {
log.Printf("[web] Warning: Failed to remove hosts entry: %v\n", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = s.http.Shutdown(ctx)
os.Exit(0)
}
func logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("%s %s", r.Method, r.URL.Path)
next.ServeHTTP(w, r)
})
}
func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
b, err := contentFS.ReadFile("templates/index.html")
if err != nil {
http.Error(w, "template missing", 500)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(b)
}
// serve logo from repo .img; fallback to 404
func (s *server) handleLogo(w http.ResponseWriter, r *http.Request) {
path := filepath.Join(".img", "rmm-hunter.png")
f, err := os.Open(path)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
w.Header().Set("Content-Type", "image/png")
http.ServeContent(w, r, "rmm-hunter.png", time.Now(), f)
}
// serve favicon files from embedded templates folder
func (s *server) handleFavicon(w http.ResponseWriter, r *http.Request) {
filename := filepath.Base(r.URL.Path)
b, err := contentFS.ReadFile("templates/" + filename)
if err != nil {
http.NotFound(w, r)
return
}
// Set appropriate content type
contentType := "image/x-icon"
if filepath.Ext(filename) == ".png" {
contentType = "image/png"
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "public, max-age=31536000")
w.Write(b)
}
// serve site.webmanifest from embedded templates folder
func (s *server) handleManifest(w http.ResponseWriter, r *http.Request) {
b, err := contentFS.ReadFile("templates/site.webmanifest")
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/manifest+json")
w.Header().Set("Cache-Control", "public, max-age=31536000")
w.Write(b)
}
func (s *server) handleListHunts(w http.ResponseWriter, r *http.Request) {
files, _ := filepath.Glob("*.json")
var out []JSONReportMeta
for _, f := range files {
// read small head of file to verify
b, err := os.ReadFile(f)
if err != nil {
continue
}
var env struct {
ReportName string `json:"reportName"`
GeneratedAt string `json:"generatedAt"`
}
if json.Unmarshal(b, &env) == nil && (env.ReportName != "" || strings.Contains(string(b), "\"findings\"")) {
out = append(out, JSONReportMeta{File: f, ReportName: env.ReportName, GeneratedAt: env.GeneratedAt})
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(out)
}
func (s *server) handleGetReport(w http.ResponseWriter, r *http.Request) {
f := r.URL.Query().Get("file")
if f == "" || strings.Contains(f, "..") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "bad file"})
return
}
b, err := os.ReadFile(f)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"error": "not found"})
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(b)
}
func (s *server) handleStartHunt(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusMethodNotAllowed)
json.NewEncoder(w).Encode(map[string]string{"error": "use POST"})
return
}
name := fmt.Sprintf("hunt-%s", time.Now().Format("20060102-150405"))
go s.runHunt(name)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"reportName": name})
}
func (s *server) runHunt(name string) {
// redirect stdout to our pipe
oldStdout := os.Stdout
pr, pw, _ := os.Pipe()
os.Stdout = pw
// also mirror stderr
oldStderr := os.Stderr
pr2, pw2, _ := os.Pipe()
os.Stderr = pw2
// reader goroutines
done := make(chan struct{})
go func() {
sc := bufio.NewScanner(pr)
for sc.Scan() {
s.hub.send(sc.Text())
}
done <- struct{}{}
}()
go func() {
sc := bufio.NewScanner(pr2)
for sc.Scan() {
s.hub.send(sc.Text())
}
done <- struct{}{}
}()
// run hunter
hunter.Start(pkg.RunOptions{Name: name})
// close writers and restore
_ = pw.Close()
_ = pw2.Close()
<-done
<-done
os.Stdout = oldStdout
os.Stderr = oldStderr
s.hub.send("[+] Hunt complete")
}
func (s *server) handleWS(w http.ResponseWriter, r *http.Request) {
up := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
c, err := up.Upgrade(w, r, nil)
if err != nil {
return
}
s.hub.add(c)
defer func() { s.hub.rm(c); _ = c.Close() }()
for { // keep alive until client closes
if _, _, err := c.ReadMessage(); err != nil {
return
}
}
}
func (s *server) handleEliminate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusMethodNotAllowed)
json.NewEncoder(w).Encode(map[string]string{"error": "method not allowed"})
return
}
var req struct {
ReportFile string `json:"reportFile"`
Type string `json:"type"`
Index int `json:"index"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "invalid request"})
return
}
// Load the report file
reportFile := req.ReportFile
if !strings.HasSuffix(reportFile, ".json") {
reportFile += ".json"
}
reportPath := filepath.Join(".", reportFile)
data, err := os.ReadFile(reportPath)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("failed to read report: %v", err)})
return
}
// Parse the full report structure with findings wrapper
var fullReport struct {
ReportName string `json:"reportName"`
GeneratedAt string `json:"generatedAt"`
RiskRating interface{} `json:"riskRating"`
Findings suspicious.Suspicious `json:"findings"`
}
if err := json.Unmarshal(data, &fullReport); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("failed to parse report: %v", err)})
return
}
// Perform elimination based on type
if err := performElimination(&fullReport.Findings, req.Type, req.Index); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
// Save updated report
updatedData, err := json.MarshalIndent(fullReport, "", " ")
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("failed to marshal report: %v", err)})
return
}
if err := os.WriteFile(reportPath, updatedData, 0644); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("failed to save report: %v", err)})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
}
func (s *server) handleQuit(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusMethodNotAllowed)
json.NewEncoder(w).Encode(map[string]string{"error": "use POST"})
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
go func() { time.Sleep(200 * time.Millisecond); s.quitCh <- struct{}{} }()
}
// performElimination executes the elimination logic for a specific finding type and index
func performElimination(report *suspicious.Suspicious, typeKey string, idx int) error {
switch typeKey {
case "connections":
if idx < 0 || idx >= len(report.OutboundConnections) {
return fmt.Errorf("invalid index: %d (array length: %d)", idx, len(report.OutboundConnections))
}
conn := report.OutboundConnections[idx]
if err := eliminate.EliminateConnection(conn.RemoteHost); err != nil {
return err
}
report.OutboundConnections[idx].Eliminated = true
case "processes":
if idx < 0 || idx >= len(report.Processes) {
return fmt.Errorf("invalid index: %d (array length: %d)", idx, len(report.Processes))
}
proc := report.Processes[idx]
if err := eliminate.EliminateProcess(proc); err != nil {
return err
}
report.Processes[idx].Eliminated = true
case "services":
if idx < 0 || idx >= len(report.Services) {
return fmt.Errorf("invalid index: %d (array length: %d)", idx, len(report.Services))
}
svc := report.Services[idx]
if svc == nil {
return fmt.Errorf("service is nil")
}
if err := eliminate.EliminateService(*svc); err != nil {
return err
}
report.Services[idx].Eliminated = true
case "tasks":
if idx < 0 || idx >= len(report.ScheduledTasks) {
return fmt.Errorf("invalid index: %d (array length: %d)", idx, len(report.ScheduledTasks))
}
task := report.ScheduledTasks[idx]
if task == nil {
return fmt.Errorf("task is nil")
}
if err := eliminate.EliminateScheduledTask(*task); err != nil {
return err
}
report.ScheduledTasks[idx].Eliminated = true
case "autoruns":
if idx < 0 || idx >= len(report.AutoRuns) {
return fmt.Errorf("invalid index: %d (array length: %d)", idx, len(report.AutoRuns))
}
ar := report.AutoRuns[idx]
if err := eliminate.EliminateAutoRun(ar); err != nil {
return err
}
report.AutoRuns[idx].Eliminated = true
case "binaries":
if idx < 0 || idx >= len(report.Binaries) {
return fmt.Errorf("invalid index: %d (array length: %d)", idx, len(report.Binaries))
}
bin := report.Binaries[idx]
// Check if binary is blocked by active processes/services
if err := checkBinaryBlocked(bin.Path, *report); err != nil {
return err
}
if err := eliminate.EliminateBinary(bin.Path); err != nil {
return err
}
report.Binaries[idx].Eliminated = true
case "directories":
if idx < 0 || idx >= len(report.Directories) {
return fmt.Errorf("invalid index: %d (array length: %d)", idx, len(report.Directories))
}
dir := report.Directories[idx]
// Check if directory is blocked by active processes/services
if err := checkDirectoryBlocked(dir.Path, *report); err != nil {
return err
}
if err := eliminate.EliminateDirectory(dir.Path); err != nil {
return err
}
report.Directories[idx].Eliminated = true
default:
return fmt.Errorf("unknown type: %s", typeKey)
}
return nil
}
// checkBinaryBlocked checks if a binary is in use by active processes or services
func checkBinaryBlocked(path string, data suspicious.Suspicious) error {
normPath := func(p string) string {
return strings.ToLower(filepath.Clean(p))
}
np := normPath(path)
// Check active processes
for _, p := range data.Processes {
if p.Eliminated {
continue
}
if normPath(p.Path) == np {
return fmt.Errorf("binary in use by running process %s (PID %d). Eliminate the process first", p.Name, p.PID)
}
}
// Check enabled services
for _, s := range data.Services {
if s == nil || s.Eliminated {
continue
}
sp := normPath(s.BinaryPathName)
if sp == np && !strings.EqualFold(strings.TrimSpace(s.StartType), "disabled") {
// Check if service has a running process
for _, p := range data.Processes {
if p.Eliminated {
continue
}
if normPath(p.Path) == sp {
return fmt.Errorf("binary used by active and enabled service %s. Stop/delete the service first", s.Name)
}
}
}
}
return nil
}
// checkDirectoryBlocked checks if a directory contains binaries used by active processes or services
func checkDirectoryBlocked(dir string, data suspicious.Suspicious) error {
normPath := func(p string) string {
return strings.ToLower(filepath.Clean(p))
}
dn := normPath(dir)
if !strings.HasSuffix(dn, string(filepath.Separator)) {
dn += string(filepath.Separator)
}
inDir := func(p string) bool {
pp := normPath(p)
if pp == "" {
return false
}
return strings.HasPrefix(pp, dn)
}
// Check processes
for _, p := range data.Processes {
if p.Eliminated {
continue
}
if inDir(p.Path) {
return fmt.Errorf("directory contains active process %s (PID %d). Eliminate the process first", p.Name, p.PID)
}
}
// Check services
for _, s := range data.Services {
if s == nil || s.Eliminated {
continue
}
if inDir(s.BinaryPathName) && !strings.EqualFold(strings.TrimSpace(s.StartType), "disabled") {
// Check if service has a running process
for _, p := range data.Processes {
if p.Eliminated {
continue
}
if normPath(p.Path) == normPath(s.BinaryPathName) {
return fmt.Errorf("directory contains active and enabled service binary for %s. Stop/delete the service first", s.Name)
}
}
}
}
return nil
}
+14
View File
@@ -1,9 +1,23 @@
package main
import (
"fmt"
"os"
"rmm-hunter/cmd"
"rmm-hunter/internal/web"
scurvy "github.com/Kraken-OffSec/Scurvy"
)
func main() {
if len(os.Args) == 1 {
escErr := scurvy.CheckAndEscalateBinary()
if escErr != nil {
fmt.Printf("Failed to elevate: %v\n", escErr)
os.Exit(1)
}
web.StartWebServer()
return
}
cmd.Execute()
}
+10
View File
@@ -0,0 +1,10 @@
package main
import "rmm-hunter/internal/tui"
func main() {
err := tui.RunEliminateUI()
if err != nil {
panic(err)
}
}