Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d2f7e7595b | |||
| 3935efb6ed | |||
| 88c5afba9c | |||
| 852b061b40 | |||
| b4db6c19b9 | |||
| 3f50f20892 | |||
| 25d99c265d | |||
| 0b09092973 | |||
| adcad167df | |||
| 15fb9eb510 | |||
| 01113551fb | |||
| e6f91d0bc7 | |||
| 615c129376 | |||
| bf63fb83bf | |||
| 976b45043b | |||
| e578a8fd97 | |||
| 58b521987d | |||
| 69657e4c46 | |||
| d349b38047 | |||
| 3e498365a3 | |||
| b47351f4ee | |||
| c9e2e8dff8 | |||
| bde1b23753 | |||
| a5d3623a72 | |||
| d14b2837d0 | |||
| 9c54a22bcf | |||
| b855f0eaec | |||
| e835629643 | |||
| 53f527feff | |||
| 02ed2ce046 | |||
| ec307bc91f | |||
| 192ce28d89 | |||
| 2b6c4eb4cd | |||
| 9d385bb6b0 | |||
| d28b8b1211 | |||
| 7cdee4b62c | |||
| 9512022a73 | |||
| 967b0c1de1 |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 4.2 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 832 KiB |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||

|
||||
|
||||
## 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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
### 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
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### 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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
**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.
|
||||
|
||||

|
||||
|
||||
**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
|
||||

|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package eliminate
|
||||
|
||||
import "os"
|
||||
|
||||
// EliminateBinary removes a binary from the system
|
||||
func EliminateBinary(path string) error {
|
||||
return os.Remove(path)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package eliminate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/Kraken-OffSec/Scurvy/core/firewall"
|
||||
)
|
||||
|
||||
// EliminateConnection adds an outbound block for the connection to the Windows firewall
|
||||
func EliminateConnection(dst string) error {
|
||||
// Create a new WindowsFirewall instance
|
||||
fw, err := firewall.NewWindowsFirewall()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if firewall is enabled
|
||||
if !fw.Enabled() {
|
||||
return fmt.Errorf("windows firewall is currently disabled. please enable it and try again")
|
||||
}
|
||||
|
||||
// Add a block rule for the destination
|
||||
return fw.AddRule(firewall.FirewallRule{
|
||||
Name: fmt.Sprintf("Block Outgoing %s", dst),
|
||||
Direction: "outbound",
|
||||
Protocol: "*",
|
||||
LocalPort: "",
|
||||
RemotePort: "",
|
||||
LocalAddress: "",
|
||||
RemoteIPAddresses: "",
|
||||
Action: "block",
|
||||
Profile: "",
|
||||
DestinationHostname: dst,
|
||||
Source: "",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package eliminate
|
||||
|
||||
import "os"
|
||||
|
||||
func EliminateDirectory(path string) error {
|
||||
return os.RemoveAll(path)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
package eliminate
|
||||
@@ -0,0 +1,25 @@
|
||||
package eliminate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
. "rmm-hunter/internal/suspicious"
|
||||
|
||||
scurvy "github.com/Kraken-OffSec/Scurvy"
|
||||
)
|
||||
|
||||
// EliminateProcess kills a process and removes its binary from the system
|
||||
func EliminateProcess(p Process) error {
|
||||
err, procs := scurvy.ListProcesses()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, proc := range procs {
|
||||
if proc.Pid() == p.PID {
|
||||
return proc.Kill()
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("process %d not found", p.PID)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package eliminate
|
||||
|
||||
import (
|
||||
. "rmm-hunter/internal/suspicious"
|
||||
|
||||
scurvy "github.com/Kraken-OffSec/Scurvy"
|
||||
)
|
||||
|
||||
func EliminateScheduledTask(t ScheduledTask) error {
|
||||
return scurvy.DeleteScheduledTask(t.Name)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package eliminate
|
||||
|
||||
import (
|
||||
. "rmm-hunter/internal/suspicious"
|
||||
|
||||
scurvy "github.com/Kraken-OffSec/Scurvy"
|
||||
)
|
||||
|
||||
// EliminateService stops and removes a service from the system
|
||||
func EliminateService(s Service) error {
|
||||
return scurvy.RemoveService(s.Name)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ const htmlTemplate = `<!DOCTYPE html>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.ReportName}} - RMM Hunter Report</title>
|
||||
|
||||
<!-- Modern font -->
|
||||
<!-- Font -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;500;700&display=swap" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
@@ -434,11 +434,14 @@ const htmlTemplate = `<!DOCTYPE html>
|
||||
{{if .Findings.AutoRuns}}
|
||||
{{range .Findings.AutoRuns}}
|
||||
<div class="item">
|
||||
<div class="item-title">{{.Name}}</div>
|
||||
<div class="item-detail"><strong>Command:</strong> {{.Command}}</div>
|
||||
<div class="item-title">{{.ImageName}}</div>
|
||||
<div class="item-detail"><strong>Entry:</strong> {{.Entry}}</div>
|
||||
<div class="item-detail"><strong>Type:</strong> {{.Type}}</div>
|
||||
<div class="item-detail"><strong>Location:</strong> {{.Location}}</div>
|
||||
<div class="item-detail"><strong>Enabled:</strong> {{.Enabled}}</div>
|
||||
{{if .Description}}<div class="item-detail"><strong>Description:</strong> {{.Description}}</div>{{end}}
|
||||
<div class="item-detail"><strong>Image:</strong> {{.ImagePath}}</div>
|
||||
{{if .Arguments}}<div class="item-detail"><strong>Arguments:</strong> {{.Arguments}}</div>{{end}}
|
||||
{{if .LaunchString}}<div class="item-detail"><strong>Launch:</strong> {{.LaunchString}}</div>{{end}}
|
||||
<div class="item-detail"><strong>Hashes:</strong> MD5={{.MD5}} SHA1={{.SHA1}} SHA256={{.SHA256}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
@@ -457,7 +460,7 @@ const htmlTemplate = `<!DOCTYPE html>
|
||||
{{if .Findings.Binaries}}
|
||||
{{range .Findings.Binaries}}
|
||||
<div class="item">
|
||||
<div class="item-detail">{{.}}</div>
|
||||
<div class="item-detail">{{.Path}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
@@ -476,7 +479,7 @@ const htmlTemplate = `<!DOCTYPE html>
|
||||
{{if .Findings.Directories}}
|
||||
{{range .Findings.Directories}}
|
||||
<div class="item">
|
||||
<div class="item-detail">{{.}}</div>
|
||||
<div class="item-detail">{{.Path}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package suspicious
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
/*
|
||||
Suspicious
|
||||
The object used to resemble the Suspicious artifacts and activities.
|
||||
@@ -8,8 +12,8 @@ type Suspicious struct {
|
||||
Artifacts []Artifact `json:"artifacts"`
|
||||
Persistence Persistence `json:"persistence"`
|
||||
RootFolder string `json:"rootFolder"`
|
||||
Binaries []string `json:"binaries"`
|
||||
Directories []string `json:"directories"`
|
||||
Binaries []Binary `json:"binaries"`
|
||||
Directories []Directory `json:"directories"`
|
||||
Services []*Service `json:"services"`
|
||||
Processes []Process `json:"processes"`
|
||||
OutboundConnections []NetworkConnection `json:"outboundConnections"`
|
||||
@@ -17,13 +21,24 @@ type Suspicious struct {
|
||||
ScheduledTasks []*ScheduledTask `json:"scheduledTasks"`
|
||||
}
|
||||
|
||||
type Binary struct {
|
||||
Path string `json:"path"`
|
||||
Eliminated bool `json:"eliminated,omitempty"`
|
||||
}
|
||||
|
||||
type Directory struct {
|
||||
Path string `json:"path"`
|
||||
Eliminated bool `json:"eliminated,omitempty"`
|
||||
}
|
||||
|
||||
type NetworkConnection struct {
|
||||
LocalAddr string
|
||||
RemoteAddr string
|
||||
RemoteHost string
|
||||
State string
|
||||
PID string
|
||||
Process string
|
||||
LocalAddr string `json:"localAddr"`
|
||||
RemoteAddr string `json:"remoteAddr"`
|
||||
RemoteHost string `json:"remoteHost"`
|
||||
State string `json:"state"`
|
||||
PID string `json:"pid"`
|
||||
Process string `json:"process"`
|
||||
Eliminated bool `json:"eliminated,omitempty"`
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -50,11 +65,17 @@ AutoRun
|
||||
The object used to resemble the auto run methods used by the Suspicious software.
|
||||
*/
|
||||
type AutoRun struct {
|
||||
Name string `json:"name"`
|
||||
Command string `json:"command"`
|
||||
Location string `json:"location"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Description string `json:"description"`
|
||||
Type string `json:"type"`
|
||||
Location string `json:"location"`
|
||||
ImagePath string `json:"image_path"`
|
||||
ImageName string `json:"image_name"`
|
||||
Arguments string `json:"arguments"`
|
||||
MD5 string `json:"md5"`
|
||||
SHA1 string `json:"sha1"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Entry string `json:"entry"`
|
||||
LaunchString string `json:"launch_string"`
|
||||
Eliminated bool `json:"eliminated,omitempty"`
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -73,6 +94,7 @@ type ScheduledTask struct {
|
||||
NextRun string `json:"nextRun"`
|
||||
LastRun string `json:"lastRun"`
|
||||
Path string `json:"path"`
|
||||
Eliminated bool `json:"eliminated,omitempty"`
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -80,13 +102,14 @@ Process
|
||||
The object used to resemble the processes used by the Suspicious software.
|
||||
*/
|
||||
type Process struct {
|
||||
Name string `json:"name"`
|
||||
PID int `json:"pid"`
|
||||
PPID int `json:"ppid"`
|
||||
Parent string `json:"parent"`
|
||||
Args string `json:"args"`
|
||||
Created string `json:"created"`
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
PID int `json:"pid"`
|
||||
PPID int `json:"ppid"`
|
||||
Parent string `json:"parent"`
|
||||
Args string `json:"args"`
|
||||
Created string `json:"created"`
|
||||
Path string `json:"path"`
|
||||
Eliminated bool `json:"eliminated,omitempty"`
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -111,4 +134,49 @@ type Service struct {
|
||||
Description string `json:"description"`
|
||||
SidType uint32 `json:"sidType"`
|
||||
DelayedAutoStart bool `json:"delayedAutoStart"`
|
||||
Eliminated bool `json:"eliminated,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements custom unmarshaling for Binary to support both string and object formats
|
||||
func (b *Binary) UnmarshalJSON(data []byte) error {
|
||||
// Try to unmarshal as string first (old format)
|
||||
var str string
|
||||
if err := json.Unmarshal(data, &str); err == nil {
|
||||
b.Path = str
|
||||
b.Eliminated = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try to unmarshal as object (new format)
|
||||
type Alias Binary
|
||||
aux := &struct{ *Alias }{Alias: (*Alias)(b)}
|
||||
return json.Unmarshal(data, aux)
|
||||
}
|
||||
|
||||
// MarshalJSON implements custom marshaling for Binary to always use object format
|
||||
func (b Binary) MarshalJSON() ([]byte, error) {
|
||||
type Alias Binary
|
||||
return json.Marshal(&struct{ *Alias }{Alias: (*Alias)(&b)})
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements custom unmarshaling for Directory to support both string and object formats
|
||||
func (d *Directory) UnmarshalJSON(data []byte) error {
|
||||
// Try to unmarshal as string first (old format)
|
||||
var str string
|
||||
if err := json.Unmarshal(data, &str); err == nil {
|
||||
d.Path = str
|
||||
d.Eliminated = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try to unmarshal as object (new format)
|
||||
type Alias Directory
|
||||
aux := &struct{ *Alias }{Alias: (*Alias)(d)}
|
||||
return json.Unmarshal(data, aux)
|
||||
}
|
||||
|
||||
// MarshalJSON implements custom marshaling for Directory to always use object format
|
||||
func (d Directory) MarshalJSON() ([]byte, error) {
|
||||
type Alias Directory
|
||||
return json.Marshal(&struct{ *Alias }{Alias: (*Alias)(&d)})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"rmm-hunter/internal/pkg/hunt/eliminate"
|
||||
"rmm-hunter/internal/suspicious"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// WarnBlock is a non-fatal warning condition (rendered as a warning modal)
|
||||
type WarnBlock struct{ Reason string }
|
||||
|
||||
func (w WarnBlock) Error() string { return w.Reason }
|
||||
|
||||
// normalize a Windows-like path for robust comparisons
|
||||
func normPath(p string) string {
|
||||
p = strings.TrimSpace(p)
|
||||
p = strings.Trim(p, "\"") // strip surrounding quotes if any
|
||||
p = strings.ReplaceAll(p, "\\", "/")
|
||||
return strings.ToLower(p)
|
||||
}
|
||||
|
||||
// extract the executable path from a command/BinaryPathName that may include quotes/args
|
||||
func exeFromCommand(cmd string) string {
|
||||
s := strings.TrimSpace(cmd)
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
if strings.HasPrefix(s, "\"") {
|
||||
s = s[1:]
|
||||
if i := strings.Index(s, "\""); i >= 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
// no quotes; split on space
|
||||
if i := strings.IndexAny(s, " \t"); i >= 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// CheckBinaryBlocked returns a WarnBlock if the path is in use by an active process or enabled+active service
|
||||
func CheckBinaryBlocked(path string, data suspicious.Suspicious) error {
|
||||
np := normPath(path)
|
||||
// active process: listed in data.Processes (skip if already eliminated)
|
||||
for _, p := range data.Processes {
|
||||
if p.Eliminated {
|
||||
continue // Skip eliminated processes
|
||||
}
|
||||
if normPath(p.Path) == np {
|
||||
return WarnBlock{Reason: fmt.Sprintf("Binary in use by running process %s (PID %d). Eliminate the process first.", p.Name, p.PID)}
|
||||
}
|
||||
}
|
||||
// enabled+active service: service uses this binary AND a running process exists for it
|
||||
for _, s := range data.Services {
|
||||
if s.Eliminated {
|
||||
continue // Skip eliminated services
|
||||
}
|
||||
sp := normPath(exeFromCommand(s.BinaryPathName))
|
||||
if sp == np && !strings.EqualFold(strings.TrimSpace(s.StartType), "disabled") {
|
||||
// Is it active? infer by checking matching running process
|
||||
for _, p := range data.Processes {
|
||||
if p.Eliminated {
|
||||
continue // Skip eliminated processes
|
||||
}
|
||||
if normPath(p.Path) == sp {
|
||||
return WarnBlock{Reason: fmt.Sprintf("Binary used by active and enabled service %s. Stop/delete the service first.", s.Name)}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckDirectoryBlocked returns a WarnBlock if any process or enabled+active service binary is inside the directory
|
||||
func CheckDirectoryBlocked(dir string, data suspicious.Suspicious) error {
|
||||
dn := normPath(dir)
|
||||
if !strings.HasSuffix(dn, "/") {
|
||||
dn += "/"
|
||||
}
|
||||
inDir := func(p string) bool {
|
||||
pp := normPath(p)
|
||||
if pp == "" {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(pp, dn) {
|
||||
return true
|
||||
}
|
||||
// try with filepath.Rel for robustness
|
||||
rel, err := filepath.Rel(dn, pp)
|
||||
return err == nil && rel != ".." && !strings.HasPrefix(rel, "../")
|
||||
}
|
||||
for _, p := range data.Processes {
|
||||
if p.Eliminated {
|
||||
continue // Skip eliminated processes
|
||||
}
|
||||
if inDir(p.Path) {
|
||||
return WarnBlock{Reason: fmt.Sprintf("Directory contains running process %s (PID %d). Eliminate the process first.", p.Name, p.PID)}
|
||||
}
|
||||
}
|
||||
for _, s := range data.Services {
|
||||
if s.Eliminated {
|
||||
continue // Skip eliminated services
|
||||
}
|
||||
sp := exeFromCommand(s.BinaryPathName)
|
||||
if inDir(sp) && !strings.EqualFold(strings.TrimSpace(s.StartType), "disabled") {
|
||||
// infer active via running process
|
||||
for _, p := range data.Processes {
|
||||
if p.Eliminated {
|
||||
continue // Skip eliminated processes
|
||||
}
|
||||
if normPath(p.Path) == normPath(sp) {
|
||||
return WarnBlock{Reason: fmt.Sprintf("Directory contains active and enabled service binary for %s. Stop/delete the service first.", s.Name)}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Elimination functions
|
||||
var (
|
||||
EliminateAutoRun = func(ar suspicious.AutoRun) error { return eliminateAutoRun(ar) }
|
||||
EliminateBinary = func(path string) error { return eliminateBinary(path) }
|
||||
EliminateConnection = func(conn suspicious.NetworkConnection) error { return eliminateConnection(conn) }
|
||||
EliminateDirectory = func(path string) error { return eliminateDirectory(path) }
|
||||
EliminateProcess = func(p suspicious.Process) error { return eliminateProcess(p) }
|
||||
EliminateScheduledTask = func(t suspicious.ScheduledTask) error { return eliminateScheduledTask(t) }
|
||||
EliminateService = func(s suspicious.Service) error { return eliminateService(s) }
|
||||
)
|
||||
|
||||
func eliminateAutoRun(ar suspicious.AutoRun) error {
|
||||
return eliminate.EliminateAutoRun(ar)
|
||||
}
|
||||
|
||||
func eliminateBinary(path string) error {
|
||||
return eliminate.EliminateBinary(path)
|
||||
}
|
||||
|
||||
func eliminateConnection(conn suspicious.NetworkConnection) error {
|
||||
return eliminate.EliminateConnection(conn.RemoteHost)
|
||||
}
|
||||
|
||||
func eliminateDirectory(path string) error {
|
||||
return eliminate.EliminateDirectory(path)
|
||||
}
|
||||
|
||||
func eliminateProcess(p suspicious.Process) error {
|
||||
return eliminate.EliminateProcess(p)
|
||||
}
|
||||
|
||||
func eliminateScheduledTask(t suspicious.ScheduledTask) error {
|
||||
return eliminate.EliminateScheduledTask(t)
|
||||
}
|
||||
|
||||
func eliminateService(s suspicious.Service) error {
|
||||
return eliminate.EliminateService(s)
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"rmm-hunter/internal/suspicious"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type screen int
|
||||
|
||||
const (
|
||||
screenFilePicker screen = iota
|
||||
screenTypePicker
|
||||
screenList
|
||||
screenDetail
|
||||
screenError
|
||||
)
|
||||
|
||||
type AppModel struct {
|
||||
current screen
|
||||
filePick FilePickerModel
|
||||
typePick TypePickerModel
|
||||
listView ListViewModel
|
||||
detail DetailViewModel
|
||||
err error
|
||||
selected string
|
||||
data suspicious.Suspicious
|
||||
width int
|
||||
height int
|
||||
eliminated map[string]map[int]bool // tracks eliminated items: typeKey -> index -> eliminated
|
||||
filePath string // path to the loaded JSON file
|
||||
}
|
||||
|
||||
func NewApp() AppModel {
|
||||
return AppModel{
|
||||
current: screenFilePicker,
|
||||
filePick: NewFilePicker(),
|
||||
typePick: NewTypePicker(),
|
||||
eliminated: make(map[string]map[int]bool),
|
||||
}
|
||||
}
|
||||
|
||||
func (m AppModel) Init() tea.Cmd { return m.filePick.Init() }
|
||||
|
||||
func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// remember the latest terminal size so we can size new screens
|
||||
if ws, ok := msg.(tea.WindowSizeMsg); ok {
|
||||
m.width, m.height = ws.Width, ws.Height
|
||||
}
|
||||
|
||||
switch m.current {
|
||||
case screenFilePicker:
|
||||
var cmd tea.Cmd
|
||||
var tm tea.Model
|
||||
tm, cmd = m.filePick.Update(msg)
|
||||
if fp, ok := tm.(FilePickerModel); ok {
|
||||
m.filePick = fp
|
||||
}
|
||||
switch v := msg.(type) {
|
||||
case FileSelectedMsg:
|
||||
if err := m.loadSelectedFile(v.Path); err != nil {
|
||||
m.err = err
|
||||
m.current = screenError
|
||||
return m, nil
|
||||
}
|
||||
m.filePath = v.Path
|
||||
m.current = screenTypePicker
|
||||
return m, nil
|
||||
}
|
||||
return m, cmd
|
||||
|
||||
case screenTypePicker:
|
||||
var cmd tea.Cmd
|
||||
var tm tea.Model
|
||||
tm, cmd = m.typePick.Update(msg)
|
||||
if tp, ok := tm.(TypePickerModel); ok {
|
||||
m.typePick = tp
|
||||
}
|
||||
switch v := msg.(type) {
|
||||
case BackMsg:
|
||||
m.current = screenFilePicker
|
||||
return m, nil
|
||||
case SelectedTypeMsg:
|
||||
m.selected = v.Type
|
||||
m.listView = NewListView(v.Type, m.data, m.width, m.height, m.eliminated)
|
||||
m.current = screenList
|
||||
return m, nil
|
||||
}
|
||||
return m, cmd
|
||||
|
||||
case screenList:
|
||||
var cmd tea.Cmd
|
||||
var tm tea.Model
|
||||
tm, cmd = m.listView.Update(msg)
|
||||
if lv, ok := tm.(ListViewModel); ok {
|
||||
m.listView = lv
|
||||
}
|
||||
switch v := msg.(type) {
|
||||
case BackMsg:
|
||||
m.current = screenTypePicker
|
||||
return m, nil
|
||||
case ListSelectedMsg:
|
||||
m.detail = NewDetailView(v.TypeKey, v.Index, m.data, m.eliminated)
|
||||
m.current = screenDetail
|
||||
return m, nil
|
||||
}
|
||||
return m, cmd
|
||||
|
||||
case screenDetail:
|
||||
var cmd tea.Cmd
|
||||
var tm tea.Model
|
||||
tm, cmd = m.detail.Update(msg)
|
||||
if dv, ok := tm.(DetailViewModel); ok {
|
||||
m.detail = dv
|
||||
}
|
||||
switch v := msg.(type) {
|
||||
case BackMsg:
|
||||
m.current = screenList
|
||||
return m, nil
|
||||
case RequestEliminateMsg:
|
||||
// Check if already eliminated
|
||||
if m.eliminated[v.TypeKey] != nil && m.eliminated[v.TypeKey][v.Index] {
|
||||
m.detail.modalWarn = "This item has already been eliminated"
|
||||
return m, nil
|
||||
}
|
||||
|
||||
if err := m.performEliminate(v.TypeKey, v.Index); err != nil {
|
||||
var wb WarnBlock
|
||||
if errors.As(err, &wb) {
|
||||
m.detail.modalWarn = wb.Error()
|
||||
} else {
|
||||
m.detail.modalErr = err.Error()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
// success -> mark as eliminated, save file, and rebuild list
|
||||
if m.eliminated[v.TypeKey] == nil {
|
||||
m.eliminated[v.TypeKey] = make(map[int]bool)
|
||||
}
|
||||
m.eliminated[v.TypeKey][v.Index] = true
|
||||
|
||||
// Save the updated data to file
|
||||
if err := m.saveDataToFile(); err != nil {
|
||||
m.detail.modalErr = fmt.Sprintf("Eliminated successfully but failed to save: %v", err)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
m.detail = NewDetailView(v.TypeKey, v.Index, m.data, m.eliminated)
|
||||
m.listView = NewListView(m.selected, m.data, m.width, m.height, m.eliminated)
|
||||
m.current = screenList
|
||||
return m, nil
|
||||
}
|
||||
return m, cmd
|
||||
|
||||
case screenError:
|
||||
// Any key quits after error is shown
|
||||
if _, ok := msg.(tea.KeyMsg); ok {
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m AppModel) View() string {
|
||||
switch m.current {
|
||||
case screenFilePicker:
|
||||
return m.filePick.View()
|
||||
case screenTypePicker:
|
||||
return m.typePick.View()
|
||||
case screenList:
|
||||
return m.listView.View()
|
||||
case screenDetail:
|
||||
return m.detail.View()
|
||||
case screenError:
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("203")).Render(fmt.Sprintf("Failed to load JSON: %v\nPress any key to exit.", m.err))
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// performEliminate routes to eliminate functions without removing items from data
|
||||
func (m *AppModel) performEliminate(typeKey string, idx int) error {
|
||||
switch typeKey {
|
||||
case "autoruns":
|
||||
ar := m.data.AutoRuns[idx]
|
||||
if err := EliminateAutoRun(ar); err != nil {
|
||||
return err
|
||||
}
|
||||
m.data.AutoRuns[idx].Eliminated = true
|
||||
case "binaries":
|
||||
b := m.data.Binaries[idx]
|
||||
if err := CheckBinaryBlocked(b.Path, m.data); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := EliminateBinary(b.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
m.data.Binaries[idx].Eliminated = true
|
||||
case "connections":
|
||||
c := m.data.OutboundConnections[idx]
|
||||
if err := EliminateConnection(c); err != nil {
|
||||
return err
|
||||
}
|
||||
m.data.OutboundConnections[idx].Eliminated = true
|
||||
case "directories":
|
||||
d := m.data.Directories[idx]
|
||||
if err := CheckDirectoryBlocked(d.Path, m.data); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := EliminateDirectory(d.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
m.data.Directories[idx].Eliminated = true
|
||||
case "processes":
|
||||
p := m.data.Processes[idx]
|
||||
if err := EliminateProcess(p); err != nil {
|
||||
return err
|
||||
}
|
||||
m.data.Processes[idx].Eliminated = true
|
||||
case "scheduledTasks":
|
||||
t := m.data.ScheduledTasks[idx]
|
||||
if err := EliminateScheduledTask(*t); err != nil {
|
||||
return err
|
||||
}
|
||||
m.data.ScheduledTasks[idx].Eliminated = true
|
||||
case "services":
|
||||
s := m.data.Services[idx]
|
||||
if err := EliminateService(*s); err != nil {
|
||||
return err
|
||||
}
|
||||
m.data.Services[idx].Eliminated = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadSelectedFile reads the JSON file and populates m.data
|
||||
func (m *AppModel) loadSelectedFile(path string) error {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Support both wrapped report (with findings) and bare Suspicious JSON
|
||||
var envelope struct {
|
||||
Findings json.RawMessage `json:"findings"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &envelope); err == nil && len(envelope.Findings) > 0 {
|
||||
var sus suspicious.Suspicious
|
||||
if err := json.Unmarshal(envelope.Findings, &sus); err != nil {
|
||||
return err
|
||||
}
|
||||
m.data = sus
|
||||
m.loadEliminatedState()
|
||||
return nil
|
||||
}
|
||||
// Try bare suspicious structure
|
||||
var sus suspicious.Suspicious
|
||||
if err := json.Unmarshal(b, &sus); err != nil {
|
||||
return fmt.Errorf("no findings in report")
|
||||
}
|
||||
m.data = sus
|
||||
m.loadEliminatedState()
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadEliminatedState populates the eliminated map from the data structures
|
||||
func (m *AppModel) loadEliminatedState() {
|
||||
m.eliminated = make(map[string]map[int]bool)
|
||||
|
||||
// Load eliminated autoruns
|
||||
for i, ar := range m.data.AutoRuns {
|
||||
if ar.Eliminated {
|
||||
if m.eliminated["autoruns"] == nil {
|
||||
m.eliminated["autoruns"] = make(map[int]bool)
|
||||
}
|
||||
m.eliminated["autoruns"][i] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Load eliminated binaries
|
||||
for i, b := range m.data.Binaries {
|
||||
if b.Eliminated {
|
||||
if m.eliminated["binaries"] == nil {
|
||||
m.eliminated["binaries"] = make(map[int]bool)
|
||||
}
|
||||
m.eliminated["binaries"][i] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Load eliminated connections
|
||||
for i, c := range m.data.OutboundConnections {
|
||||
if c.Eliminated {
|
||||
if m.eliminated["connections"] == nil {
|
||||
m.eliminated["connections"] = make(map[int]bool)
|
||||
}
|
||||
m.eliminated["connections"][i] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Load eliminated directories
|
||||
for i, d := range m.data.Directories {
|
||||
if d.Eliminated {
|
||||
if m.eliminated["directories"] == nil {
|
||||
m.eliminated["directories"] = make(map[int]bool)
|
||||
}
|
||||
m.eliminated["directories"][i] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Load eliminated processes
|
||||
for i, p := range m.data.Processes {
|
||||
if p.Eliminated {
|
||||
if m.eliminated["processes"] == nil {
|
||||
m.eliminated["processes"] = make(map[int]bool)
|
||||
}
|
||||
m.eliminated["processes"][i] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Load eliminated scheduled tasks
|
||||
for i, t := range m.data.ScheduledTasks {
|
||||
if t.Eliminated {
|
||||
if m.eliminated["scheduledTasks"] == nil {
|
||||
m.eliminated["scheduledTasks"] = make(map[int]bool)
|
||||
}
|
||||
m.eliminated["scheduledTasks"][i] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Load eliminated services
|
||||
for i, s := range m.data.Services {
|
||||
if s.Eliminated {
|
||||
if m.eliminated["services"] == nil {
|
||||
m.eliminated["services"] = make(map[int]bool)
|
||||
}
|
||||
m.eliminated["services"][i] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// saveDataToFile saves the current data back to the JSON file
|
||||
func (m *AppModel) saveDataToFile() error {
|
||||
if m.filePath == "" {
|
||||
return fmt.Errorf("no file path set")
|
||||
}
|
||||
|
||||
// Read the original file to determine format
|
||||
b, err := os.ReadFile(m.filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if it's wrapped format
|
||||
var envelope struct {
|
||||
Findings json.RawMessage `json:"findings"`
|
||||
}
|
||||
isWrapped := json.Unmarshal(b, &envelope) == nil && len(envelope.Findings) > 0
|
||||
|
||||
var output []byte
|
||||
if isWrapped {
|
||||
// Re-read the full envelope
|
||||
var fullEnvelope map[string]interface{}
|
||||
if err := json.Unmarshal(b, &fullEnvelope); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update the findings
|
||||
findingsJSON, err := json.MarshalIndent(m.data, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fullEnvelope["findings"] = json.RawMessage(findingsJSON)
|
||||
|
||||
output, err = json.MarshalIndent(fullEnvelope, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Bare format
|
||||
output, err = json.MarshalIndent(m.data, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return os.WriteFile(m.filePath, output, 0644)
|
||||
}
|
||||
|
||||
// RunEliminateUI starts the Bubble Tea program for elimination UI
|
||||
func RunEliminateUI() error {
|
||||
p := tea.NewProgram(NewApp())
|
||||
_, err := p.Run()
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// writeTestReport creates a minimal JSON report envelope with empty findings
|
||||
func writeTestReport(t *testing.T, dir, name string, withFindings bool) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
var content string
|
||||
if withFindings {
|
||||
content = `{
|
||||
"reportName": "rmm-hunter-report",
|
||||
"generatedAt": "2025-01-01T00:00:00Z",
|
||||
"riskRating": {"score":0, "rating":"Low", "summary":""},
|
||||
"findings": {"processes":[],"services":[],"binaries":[],"autoRuns":[],"scheduledTasks":[],"outboundConnections":[],"directories":[]}
|
||||
}`
|
||||
} else {
|
||||
content = `{}`
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("failed to write test report: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestAppFlow_SelectFile_SelectType_Back_Quit(t *testing.T) {
|
||||
// Run in a temp dir so file picker sees our .json
|
||||
tmp := t.TempDir()
|
||||
if err := os.Chdir(tmp); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
_ = writeTestReport(t, tmp, "test.json", true)
|
||||
|
||||
p := tea.NewProgram(NewApp(), tea.WithoutRenderer())
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := p.Run()
|
||||
done <- err
|
||||
}()
|
||||
|
||||
// Give init a moment to load files
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Select file
|
||||
p.Send(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Choose type 1 (autoruns)
|
||||
p.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'1'}})
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Go back to type picker
|
||||
p.Send(tea.KeyMsg{Type: tea.KeyLeft})
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Quit
|
||||
p.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("program error: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("program did not exit in time")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApp_ErrorOnBadJSON(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
if err := os.Chdir(tmp); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
_ = writeTestReport(t, tmp, "bad.json", false)
|
||||
|
||||
p := tea.NewProgram(NewApp(), tea.WithoutRenderer())
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := p.Run()
|
||||
done <- err
|
||||
}()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
// Select file -> should error
|
||||
p.Send(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
// Any key quits on error screen
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
p.Send(tea.KeyMsg{Type: tea.KeyEsc})
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("program error: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("program did not exit in time")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"rmm-hunter/internal/suspicious"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// RequestEliminateMsg is emitted by the detail view when '!' is pressed
|
||||
type RequestEliminateMsg struct {
|
||||
TypeKey string
|
||||
Index int
|
||||
}
|
||||
|
||||
// DeletedMsg is emitted after successful elimination to update lists
|
||||
type DeletedMsg struct {
|
||||
TypeKey string
|
||||
Index int
|
||||
}
|
||||
|
||||
type DetailViewModel struct {
|
||||
typeKey string
|
||||
index int
|
||||
data suspicious.Suspicious
|
||||
eliminated map[string]map[int]bool
|
||||
// When modal* != "", show modal and require ESC to dismiss
|
||||
modalErr string
|
||||
modalWarn string
|
||||
}
|
||||
|
||||
func NewDetailView(typeKey string, index int, data suspicious.Suspicious, eliminated map[string]map[int]bool) DetailViewModel {
|
||||
return DetailViewModel{typeKey: typeKey, index: index, data: data, eliminated: eliminated}
|
||||
}
|
||||
|
||||
func (m DetailViewModel) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m DetailViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch v := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
if m.modalErr != "" || m.modalWarn != "" {
|
||||
// Modal active: only ESC dismisses
|
||||
if v.String() == "esc" {
|
||||
m.modalErr = ""
|
||||
m.modalWarn = ""
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
switch v.String() {
|
||||
case "left":
|
||||
return m, func() tea.Msg { return BackMsg{} }
|
||||
case "!":
|
||||
return m, func() tea.Msg { return RequestEliminateMsg{TypeKey: m.typeKey, Index: m.index} }
|
||||
case "q", "esc", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m DetailViewModel) View() string {
|
||||
isEliminated := m.eliminated[m.typeKey] != nil && m.eliminated[m.typeKey][m.index]
|
||||
|
||||
var title string
|
||||
if isEliminated {
|
||||
title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("10")).Render("Details — ELIMINATED — Left to go back, q to quit")
|
||||
} else {
|
||||
title = lipgloss.NewStyle().Bold(true).Render("Details — press ! to eliminate, Left to go back, q to quit")
|
||||
}
|
||||
|
||||
body := m.renderDetails()
|
||||
|
||||
// Apply green styling to body if eliminated
|
||||
if isEliminated {
|
||||
body = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render(body)
|
||||
}
|
||||
|
||||
view := title + "\n\n" + body
|
||||
if m.modalWarn != "" {
|
||||
modal := lipgloss.NewStyle().Padding(1, 2).Foreground(lipgloss.Color("214")).Border(lipgloss.RoundedBorder()).Render("Warning:\n" + m.modalWarn + "\n\nPress ESC to dismiss")
|
||||
view += "\n\n" + modal
|
||||
}
|
||||
if m.modalErr != "" {
|
||||
modal := lipgloss.NewStyle().Padding(1, 2).Foreground(lipgloss.Color("203")).Border(lipgloss.RoundedBorder()).Render("Elimination failed:\n" + m.modalErr + "\n\nPress ESC to dismiss")
|
||||
view += "\n\n" + modal
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func (m DetailViewModel) renderDetails() string {
|
||||
switch m.typeKey {
|
||||
case "autoruns":
|
||||
if m.index >= len(m.data.AutoRuns) {
|
||||
return "Item no longer available"
|
||||
}
|
||||
ar := m.data.AutoRuns[m.index]
|
||||
return fmt.Sprintf("Type: %s\nEntry: %s\nLaunch: %s\nLocation: %s\nImage: %s\nArgs: %s\nMD5: %s\nSHA1: %s\nSHA256: %s", ar.Type, ar.Entry, ar.LaunchString, ar.Location, ar.ImagePath, ar.Arguments, ar.MD5, ar.SHA1, ar.SHA256)
|
||||
case "binaries":
|
||||
if m.index >= len(m.data.Binaries) {
|
||||
return "Item no longer available"
|
||||
}
|
||||
b := m.data.Binaries[m.index]
|
||||
return fmt.Sprintf("Binary: %s\nAction: delete file", b.Path)
|
||||
case "connections":
|
||||
if m.index >= len(m.data.OutboundConnections) {
|
||||
return "Item no longer available"
|
||||
}
|
||||
c := m.data.OutboundConnections[m.index]
|
||||
return fmt.Sprintf("Local: %s\nRemote: %s\nHost: %s\nState: %s\nPID: %s\nProcess: %s\nAction: add firewall block", 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 ""
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// FileSelectedMsg is emitted when a file is chosen
|
||||
type FileSelectedMsg struct{ Path string }
|
||||
|
||||
// FilePicker is a simple list of .json files in the current directory
|
||||
// Press Enter to pick, q/esc to quit
|
||||
|
||||
type fileItem struct {
|
||||
title string
|
||||
path string
|
||||
}
|
||||
|
||||
func (i fileItem) Title() string { return i.title }
|
||||
func (i fileItem) Description() string { return i.path }
|
||||
func (i fileItem) FilterValue() string { return i.title }
|
||||
|
||||
type FilePickerModel struct {
|
||||
list list.Model
|
||||
spinner spinner.Model
|
||||
error error
|
||||
loading bool
|
||||
}
|
||||
|
||||
func NewFilePicker() FilePickerModel {
|
||||
delegate := list.NewDefaultDelegate()
|
||||
delegate.ShowDescription = true
|
||||
l := list.New([]list.Item{}, delegate, 0, 0)
|
||||
// Set a sane default; will be updated on WindowSizeMsg
|
||||
l.SetSize(80, 20)
|
||||
l.Title = "Select JSON report"
|
||||
l.Styles.Title = lipgloss.NewStyle().Bold(true)
|
||||
l.SetShowHelp(false)
|
||||
sp := spinner.New()
|
||||
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
|
||||
m := FilePickerModel{list: l, spinner: sp}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m FilePickerModel) Init() tea.Cmd {
|
||||
return tea.Batch(m.spinner.Tick, m.loadFilesCmd())
|
||||
}
|
||||
|
||||
func (m FilePickerModel) loadFilesCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
entries, err := os.ReadDir(".")
|
||||
if err != nil {
|
||||
return errMsg{err}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var items []list.Item
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if filepath.Ext(name) != ".json" {
|
||||
continue
|
||||
}
|
||||
fi, err := e.Info()
|
||||
if err != nil || !fi.Mode().IsRegular() {
|
||||
continue
|
||||
}
|
||||
if seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
items = append(items, fileItem{title: name, path: name})
|
||||
}
|
||||
return filesLoadedMsg{items}
|
||||
}
|
||||
}
|
||||
|
||||
type errMsg struct{ error }
|
||||
type filesLoadedMsg struct{ items []list.Item }
|
||||
|
||||
func (m FilePickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.list.SetSize(msg.Width, msg.Height-2)
|
||||
case filesLoadedMsg:
|
||||
m.loading = false
|
||||
m.list.SetItems(msg.items)
|
||||
case errMsg:
|
||||
m.error = msg
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "q", "esc", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "enter":
|
||||
if it, ok := m.list.SelectedItem().(fileItem); ok {
|
||||
return m, func() tea.Msg { return FileSelectedMsg{Path: it.path} }
|
||||
}
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.list, cmd = m.list.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m FilePickerModel) View() string {
|
||||
if m.error != nil {
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("203")).Render(fmt.Sprintf("Error: %v\n", m.error))
|
||||
}
|
||||
if m.loading {
|
||||
return "Loading files...\n" + m.spinner.View()
|
||||
}
|
||||
if len(m.list.Items()) == 0 {
|
||||
return "No .json files found in current directory. Press q to exit.\n"
|
||||
}
|
||||
return m.list.View()
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// FileSelectedMsg is emitted when a file is chosen
|
||||
type FileSelectedMsg struct{ Path string }
|
||||
|
||||
// FilePicker is a simple list of .json files in the current directory
|
||||
// Press Enter to pick, q/esc to quit
|
||||
|
||||
type fileItem struct {
|
||||
title string
|
||||
path string
|
||||
}
|
||||
|
||||
func (i fileItem) Title() string { return i.title }
|
||||
func (i fileItem) Description() string { return i.path }
|
||||
func (i fileItem) FilterValue() string { return i.title }
|
||||
|
||||
type FilePickerModel struct {
|
||||
list list.Model
|
||||
spinner spinner.Model
|
||||
error error
|
||||
loading bool
|
||||
}
|
||||
|
||||
func NewFilePicker() FilePickerModel {
|
||||
delegate := list.NewDefaultDelegate()
|
||||
delegate.ShowDescription = true
|
||||
l := list.New([]list.Item{}, delegate, 0, 0)
|
||||
l.Title = "Select JSON report"
|
||||
l.Styles.Title = lipgloss.NewStyle().Bold(true)
|
||||
l.SetShowHelp(false)
|
||||
sp := spinner.New()
|
||||
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
|
||||
m := FilePickerModel{list: l, spinner: sp}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m FilePickerModel) Init() tea.Cmd {
|
||||
return tea.Batch(m.spinner.Tick, m.loadFilesCmd())
|
||||
}
|
||||
|
||||
func (m FilePickerModel) loadFilesCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
files, err := os.ReadDir(".")
|
||||
if err != nil {
|
||||
return errMsg{err}
|
||||
}
|
||||
var items []list.Item
|
||||
for _, f := range files {
|
||||
if f.IsDir() { continue }
|
||||
name := f.Name()
|
||||
if filepath.Ext(name) == ".json" {
|
||||
items = append(items, fileItem{title: name, path: name})
|
||||
}
|
||||
}
|
||||
return filesLoadedMsg{items}
|
||||
}
|
||||
}
|
||||
|
||||
type errMsg struct{ error }
|
||||
type filesLoadedMsg struct{ items []list.Item }
|
||||
|
||||
func (m FilePickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.list.SetSize(msg.Width, msg.Height-2)
|
||||
case filesLoadedMsg:
|
||||
m.loading = false
|
||||
m.list.SetItems(msg.items)
|
||||
case errMsg:
|
||||
m.error = msg
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "q", "esc", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "enter":
|
||||
if it, ok := m.list.SelectedItem().(fileItem); ok {
|
||||
return m, func() tea.Msg { return FileSelectedMsg{Path: it.path} }
|
||||
}
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.list, cmd = m.list.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m FilePickerModel) View() string {
|
||||
if m.error != nil {
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("203")).Render(fmt.Sprintf("Error: %v\n", m.error))
|
||||
}
|
||||
if m.loading {
|
||||
return "Loading files...\n" + m.spinner.View()
|
||||
}
|
||||
if len(m.list.Items()) == 0 {
|
||||
return "No .json files found in current directory. Press q to exit.\n"
|
||||
}
|
||||
return m.list.View()
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"rmm-hunter/internal/suspicious"
|
||||
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// ListSelectedMsg indicates which index/type was selected for detail
|
||||
type ListSelectedMsg struct {
|
||||
TypeKey string
|
||||
Index int
|
||||
}
|
||||
|
||||
type listItem struct {
|
||||
title string
|
||||
desc string
|
||||
eliminated bool
|
||||
}
|
||||
|
||||
func (i listItem) Title() string { return i.title }
|
||||
func (i listItem) Description() string { return i.desc }
|
||||
func (i listItem) FilterValue() string { return i.title }
|
||||
|
||||
// customDelegate is a custom list item delegate that renders eliminated items in green
|
||||
type customDelegate struct {
|
||||
list.DefaultDelegate
|
||||
}
|
||||
|
||||
func (d customDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) {
|
||||
i, ok := item.(listItem)
|
||||
if !ok {
|
||||
d.DefaultDelegate.Render(w, m, index, item)
|
||||
return
|
||||
}
|
||||
|
||||
title := i.Title()
|
||||
desc := i.Description()
|
||||
|
||||
// Style for eliminated items (green)
|
||||
if i.eliminated {
|
||||
titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("10"))
|
||||
descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("10"))
|
||||
|
||||
if index == m.Index() {
|
||||
// Selected item - add background
|
||||
titleStyle = titleStyle.Background(lipgloss.Color("240"))
|
||||
descStyle = descStyle.Background(lipgloss.Color("240"))
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "%s\n%s", titleStyle.Render("✓ "+title), descStyle.Render(" "+desc))
|
||||
} else {
|
||||
// Normal rendering for non-eliminated items
|
||||
d.DefaultDelegate.Render(w, m, index, item)
|
||||
}
|
||||
}
|
||||
|
||||
type ListViewModel struct {
|
||||
typeKey string
|
||||
list list.Model
|
||||
header string
|
||||
eliminated map[string]map[int]bool
|
||||
}
|
||||
|
||||
func NewListView(typeKey string, sus suspicious.Suspicious, width, height int, eliminated map[string]map[int]bool) ListViewModel {
|
||||
defaultDelegate := list.NewDefaultDelegate()
|
||||
defaultDelegate.ShowDescription = true
|
||||
delegate := customDelegate{DefaultDelegate: defaultDelegate}
|
||||
l := list.New([]list.Item{}, delegate, 0, 0)
|
||||
if width > 0 && height > 0 {
|
||||
l.SetSize(width, height-2)
|
||||
} else {
|
||||
l.SetSize(80, 20)
|
||||
}
|
||||
l.Styles.Title = lipgloss.NewStyle().Bold(true)
|
||||
|
||||
header := ""
|
||||
var items []list.Item
|
||||
switch typeKey {
|
||||
case "autoruns":
|
||||
header = "Suspicious AutoRuns"
|
||||
for i, ar := range sus.AutoRuns {
|
||||
title := ar.ImageName
|
||||
if title == "" {
|
||||
title = ar.Entry
|
||||
}
|
||||
desc := fmt.Sprintf("%s (%s)", ar.ImagePath, ar.Location)
|
||||
isEliminated := eliminated[typeKey] != nil && eliminated[typeKey][i]
|
||||
items = append(items, listItem{title: title, desc: desc, eliminated: isEliminated})
|
||||
}
|
||||
case "binaries":
|
||||
header = "Suspicious Binaries"
|
||||
for i, b := range sus.Binaries {
|
||||
isEliminated := eliminated[typeKey] != nil && eliminated[typeKey][i]
|
||||
items = append(items, listItem{title: b.Path, desc: "binary file", eliminated: isEliminated})
|
||||
}
|
||||
case "connections":
|
||||
header = "Suspicious Connections"
|
||||
for i, c := range sus.OutboundConnections {
|
||||
label := fmt.Sprintf("%s -> %s (%s)", c.LocalAddr, c.RemoteAddr, c.RemoteHost)
|
||||
isEliminated := eliminated[typeKey] != nil && eliminated[typeKey][i]
|
||||
items = append(items, listItem{title: label, desc: fmt.Sprintf("PID %s %s", c.PID, c.Process), eliminated: isEliminated})
|
||||
}
|
||||
case "directories":
|
||||
header = "Suspicious Directories"
|
||||
for i, d := range sus.Directories {
|
||||
isEliminated := eliminated[typeKey] != nil && eliminated[typeKey][i]
|
||||
items = append(items, listItem{title: d.Path, desc: "directory", eliminated: isEliminated})
|
||||
}
|
||||
case "processes":
|
||||
header = "Suspicious Processes"
|
||||
for i, p := range sus.Processes {
|
||||
label := fmt.Sprintf("%s (PID %d)", p.Name, p.PID)
|
||||
desc := p.Path
|
||||
isEliminated := eliminated[typeKey] != nil && eliminated[typeKey][i]
|
||||
items = append(items, listItem{title: label, desc: desc, eliminated: isEliminated})
|
||||
}
|
||||
case "scheduledTasks":
|
||||
header = "Suspicious Scheduled Tasks"
|
||||
for i, t := range sus.ScheduledTasks {
|
||||
label := t.Name
|
||||
desc := t.Path
|
||||
isEliminated := eliminated[typeKey] != nil && eliminated[typeKey][i]
|
||||
items = append(items, listItem{title: label, desc: desc, eliminated: isEliminated})
|
||||
}
|
||||
case "services":
|
||||
header = "Suspicious Services"
|
||||
for i, s := range sus.Services {
|
||||
label := fmt.Sprintf("%s (%s)", s.Name, s.DisplayName)
|
||||
desc := s.BinaryPathName
|
||||
isEliminated := eliminated[typeKey] != nil && eliminated[typeKey][i]
|
||||
items = append(items, listItem{title: label, desc: desc, eliminated: isEliminated})
|
||||
}
|
||||
}
|
||||
|
||||
l.Title = header + " — Left: Back Enter: Details q: Quit"
|
||||
l.SetItems(items)
|
||||
return ListViewModel{typeKey: typeKey, list: l, header: header, eliminated: eliminated}
|
||||
}
|
||||
|
||||
func (m ListViewModel) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m ListViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.list.SetSize(msg.Width, msg.Height-2)
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "left":
|
||||
return m, func() tea.Msg { return BackMsg{} }
|
||||
case "q", "esc", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "enter":
|
||||
return m, func() tea.Msg { return ListSelectedMsg{TypeKey: m.typeKey, Index: m.list.Index()} }
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.list, cmd = m.list.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m ListViewModel) View() string { return m.list.View() }
|
||||
@@ -0,0 +1,120 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// SelectedTypeMsg is sent when the user chooses a type (1-7)
|
||||
// Valid Type values: "autoruns", "binaries", "connections", "directories", "processes", "scheduledTasks", "services"
|
||||
type SelectedTypeMsg struct{ Type string }
|
||||
|
||||
// BackMsg is sent when the user presses Left to go back
|
||||
type BackMsg struct{}
|
||||
|
||||
// keyMap defines keybindings for the type picker
|
||||
// It must satisfy key.Map for the help component
|
||||
// We only need: 1-7, left/back, help, quit
|
||||
type keyMap struct {
|
||||
Help key.Binding
|
||||
Quit key.Binding
|
||||
Back key.Binding
|
||||
One key.Binding
|
||||
Two key.Binding
|
||||
Three key.Binding
|
||||
Four key.Binding
|
||||
Five key.Binding
|
||||
Six key.Binding
|
||||
Seven key.Binding
|
||||
}
|
||||
|
||||
func (k keyMap) ShortHelp() []key.Binding {
|
||||
return []key.Binding{k.Back, k.Help, k.Quit}
|
||||
}
|
||||
|
||||
func (k keyMap) FullHelp() [][]key.Binding {
|
||||
return [][]key.Binding{
|
||||
{k.One, k.Two, k.Three, k.Four, k.Five, k.Six, k.Seven},
|
||||
{k.Back, k.Help, k.Quit},
|
||||
}
|
||||
}
|
||||
|
||||
var keys = keyMap{
|
||||
One: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "AutoRuns")),
|
||||
Two: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "Binaries")),
|
||||
Three: key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "Connections")),
|
||||
Four: key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "Directories")),
|
||||
Five: key.NewBinding(key.WithKeys("5"), key.WithHelp("5", "Processes")),
|
||||
Six: key.NewBinding(key.WithKeys("6"), key.WithHelp("6", "Scheduled Tasks")),
|
||||
Seven: key.NewBinding(key.WithKeys("7"), key.WithHelp("7", "Services")),
|
||||
Back: key.NewBinding(key.WithKeys("left"), key.WithHelp("←", "back")),
|
||||
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "toggle help")),
|
||||
Quit: key.NewBinding(key.WithKeys("q", "esc", "ctrl+c"), key.WithHelp("q", "quit")),
|
||||
}
|
||||
|
||||
type TypePickerModel struct {
|
||||
keys keyMap
|
||||
help help.Model
|
||||
inputStyle lipgloss.Style
|
||||
quitting bool
|
||||
}
|
||||
|
||||
func NewTypePicker() TypePickerModel {
|
||||
return TypePickerModel{
|
||||
keys: keys,
|
||||
help: help.New(),
|
||||
inputStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#ef8430")),
|
||||
}
|
||||
}
|
||||
|
||||
func (m TypePickerModel) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m TypePickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.help.Width = msg.Width
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Quit):
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
case key.Matches(msg, m.keys.Help):
|
||||
m.help.ShowAll = !m.help.ShowAll
|
||||
case key.Matches(msg, m.keys.Back):
|
||||
return m, func() tea.Msg { return BackMsg{} }
|
||||
case key.Matches(msg, m.keys.One):
|
||||
return m, func() tea.Msg { return SelectedTypeMsg{Type: "autoruns"} }
|
||||
case key.Matches(msg, m.keys.Two):
|
||||
return m, func() tea.Msg { return SelectedTypeMsg{Type: "binaries"} }
|
||||
case key.Matches(msg, m.keys.Three):
|
||||
return m, func() tea.Msg { return SelectedTypeMsg{Type: "connections"} }
|
||||
case key.Matches(msg, m.keys.Four):
|
||||
return m, func() tea.Msg { return SelectedTypeMsg{Type: "directories"} }
|
||||
case key.Matches(msg, m.keys.Five):
|
||||
return m, func() tea.Msg { return SelectedTypeMsg{Type: "processes"} }
|
||||
case key.Matches(msg, m.keys.Six):
|
||||
return m, func() tea.Msg { return SelectedTypeMsg{Type: "scheduledTasks"} }
|
||||
case key.Matches(msg, m.keys.Seven):
|
||||
return m, func() tea.Msg { return SelectedTypeMsg{Type: "services"} }
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m TypePickerModel) View() string {
|
||||
if m.quitting {
|
||||
return "Bye!\n"
|
||||
}
|
||||
title := lipgloss.NewStyle().Bold(true).Render("Select a type to manage")
|
||||
menu := "\n 1) AutoRuns\n 2) Binaries\n 3) Connections\n 4) Directories\n 5) Processes\n 6) Scheduled Tasks\n 7) Services\n"
|
||||
helpView := m.help.View(m.keys)
|
||||
height := 8 - strings.Count(menu, "\n") - strings.Count(helpView, "\n")
|
||||
if height < 0 {
|
||||
height = 0
|
||||
}
|
||||
return title + "\n" + menu + strings.Repeat("\n", height) + helpView
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 469 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -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"}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package main
|
||||
|
||||
import "rmm-hunter/internal/tui"
|
||||
|
||||
func main() {
|
||||
err := tui.RunEliminateUI()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||