Add elimination API handler, update browser logic for process tracking, and refine UI animations and modal handling.

This commit is contained in:
Evan Hosinski
2025-10-12 20:58:53 -04:00
parent 0b09092973
commit 25d99c265d
5 changed files with 799 additions and 131 deletions
+125 -75
View File
@@ -1,78 +1,128 @@
package common
var CommonDirectories = []string{
`C:\Program Files (x86)%\mRemoteNG`,
`C:\\Program Files (x86)\Sysprogs`,
`C:\\Program Files (x86)\Sysprogs\SmarTTY`,
`C:\AlpemixSrvc`,
`C:\Downloads\SuperPuTTY`,
`C:\Program Files (x86)\Almageste\DragonDisk`,
`C:\Program Files (x86)\AnyDesk`,
`C:\Program Files (x86)\AnyViewer`,
`C:\Program Files (x86)\Atera Networks`,
`C:\Program Files (x86)\Bitvise SSH Client`,
`C:\Program Files (x86)\Bluetrait Agent`,
`C:\Program Files (x86)\DesktopCentral_Agent`,
`C:\Program Files (x86)\DesktopCentral_Agent\bin`,
`C:\Program Files (x86)\GoTo Opener`,
`C:\Program Files (x86)\GoToMyPC`,
`C:\Program Files (x86)\Google\Chrome Remote Desktop`,
`C:\Program Files (x86)\ISL Online`,
`C:\Program Files (x86)\Kaseya`,
`C:\Program Files (x86)\LANDesk`,
`C:\Program Files (x86)\OnionShare`,
`C:\Program Files (x86)\NetSarang`,
`C:\Program Files (x86)\NetSarang\xShell`,
`C:\Program Files (x86)\PJ Technologies`,
`C:\Program Files (x86)\PJ Technologies\GOVsrv`,
`C:\Program Files (x86)\Radmin Viewer 3`,
`C:\Program Files (x86)\RemotePC`,
`C:\Program Files (x86)\S3 Browser`,
`C:\Program Files (x86)\ScreenConnect Client (`, // C:\Program Files (x86)\ScreenConnect Client (<string ID>)
`C:\Program Files (x86)\SmartFTP Client`,
`C:\Program Files (x86)\Splashtop`,
`C:\Program Files (x86)\TeamViewer`,
`C:\Program Files (x86)\UltraViewer`,
`C:\Program Files (x86)\Xpra`,
`C:\Program Files (x86)\Yandex`,
`C:\Program Files (x86)\mRemoteNG`,
`C:\Program Files\ATERA NETWORKS`,
`C:\Program Files\ATERA NETWORKS\AteraAgent`,
`C:\Program Files\AnyDesk`,
`C:\Program Files\Bitvise SSH Server`,
`C:\Program Files\Danware Data\NetOp Packn Deploy`,
`C:\Program Files\Level`,
`C:\\Program Files\\LiteManager Pro`,
`C:\\Program Files\\LiteManager Pro \u2013 Viewer`,
`C:\Program Files\ManageEngine\ManageEngine Free Tools`,
`C:\Program Files\ManageEngine\ManageEngine Free Tools\Launcher`,
`C:\Program Files\RealVNC`,
`C:\Program Files\RealVNC\VNC Serve`,
`C:\Program Files\Remote Utilities`,
`C:\Program Files\Remote Utilities\Agent`,
`C:\Program Files\Solar-Putty-v4`,
`C:\Program Files\SolarWinds\Dameware Mini Remote Control`,
`C:\Program Files\SysAidServer`,
`C:\Program Files\TeamViewer`,
`C:\Program Files\TightVNC`,
`C:\Program Files\ZOC8`,
`C:\Program Files\uvnc bvba`,
`C:\Program Files\uvnc bvba\UltraVNC`,
`C:\ProgramData\Kaseya`,
`C:\ProgramData\Total Software Deployment`,
`C:\ProgramFiles\GoTo Machine Installer`,
`C:\ProgramFiles (x86)\GoTo Machine Installer`,
`{{APPDATA}}\Local\Google\Chrome\User Data\Default\Extensions\iodihamcpbpeioajjeobimgagajmlibd`,
`{{APPDATA}}\Local\MEGAsync`,
`{{APPDATA}}\Roaming\Mikogo`,
`{{APPDATA}}\Roaming\SyncTrayzor`,
`C:\Users\IEUser\Downloads\WinSCP-5.21.6-Portable`,
`C:\Users\USERNAME\AppData\Roaming\Insync`,
`C:\Users\USERNAME\AppData\Roaming\Insync\App`,
`C:\Windows\Action1`,
`C:\Windows\SysWOW64\rserver30`,
`C:\Windows\SysWOW64\rserver30\FamItrfc`,
`C:\Windows\SysWOW64\rserver30\FamItrf2`,
`C:\Windows\dwrcs`,
`C:\ProgramData\AMMYY`,
// KnownRMMDirectories contains known directory names/paths
// These will be searched in common installation locations defined in SearchBasePaths
var KnownRMMDirectories = []string{
// A
`Action1`,
`Almageste\DragonDisk`,
`AlpemixSrvc`,
`AMMYY`,
`AnyDesk`,
`AnyViewer`,
`Atera Networks`,
`ATERA NETWORKS`,
`ATERA NETWORKS\AteraAgent`,
// B
`Bitvise SSH Client`,
`Bitvise SSH Server`,
`Bluetrait Agent`,
// D
`Danware Data\NetOp Packn Deploy`,
`DesktopCentral_Agent`,
`DesktopCentral_Agent\bin`,
// G
`GoTo Opener`,
`GoTo Machine Installer`,
`GoToMyPC`,
`Google\Chrome Remote Desktop`,
`Google\Chrome\User Data\Default\Extensions\iodihamcpbpeioajjeobimgagajmlibd`,
// I
`Insync`,
`Insync\App`,
`ISL Online`,
// K
`Kaseya`,
// L
`LANDesk`,
`Level`,
`LiteManager Pro`,
`LiteManager Pro Viewer`,
// M
`ManageEngine\ManageEngine Free Tools`,
`ManageEngine\ManageEngine Free Tools\Launcher`,
`MEGAsync`,
`Mikogo`,
`mRemoteNG`,
// N
`NetSarang`,
`NetSarang\xShell`,
// O
`OnionShare`,
// P
`PJ Technologies`,
`PJ Technologies\GOVsrv`,
// R
`Radmin Viewer 3`,
`RealVNC`,
`RealVNC\VNC Serve`,
`Remote Utilities`,
`Remote Utilities\Agent`,
`RemotePC`,
`RustDesk`,
// S
`S3 Browser`,
`ScreenConnect Client (`, // Prefix pattern for ScreenConnect Client (<string ID>)
`SmartFTP Client`,
`Solar-Putty-v4`,
`SolarWinds\Dameware Mini Remote Control`,
`Splashtop`,
`SuperPuTTY`,
`SyncTrayzor`,
`Sysprogs`,
`Sysprogs\SmarTTY`,
`SysAidServer`,
`SysWOW64\rserver30`,
`SysWOW64\rserver30\FamItrfc`,
`SysWOW64\rserver30\FamItrf2`,
// T
`TeamViewer`,
`TightVNC`,
`Total Software Deployment`,
// U
`UltraViewer`,
`uvnc bvba`,
`uvnc bvba\UltraVNC`,
// W
`WinSCP-5.21.6-Portable`,
`dwrcs`,
// X
`Xpra`,
// Y
`Yandex`,
// Z
`ZOC8`,
}
// SearchBasePaths defines the base directories to search within
var SearchBasePaths = []string{
`C:\Program Files`,
`C:\Program Files (x86)`,
`C:\ProgramData`,
`C:\ProgramFiles`, // Installers variant 1
`C:\ProgramFiles (x86)`, // Installers variant 2
`C:\Windows`,
`{{APPDATA}}\Local`,
`{{APPDATA}}\Roaming`,
`{{USERPROFILE}}\Downloads`,
`C:\Downloads`, // Standard downloads location
`C:\`, // Root for edge cases (AlpemixSrvc)
}
@@ -10,49 +10,56 @@ import (
)
var appData = os.Getenv("APPDATA")
var userProfile = os.Getenv("USERPROFILE")
func Detect() []Directory {
var suspiciousDirectories []Directory
seen := make(map[string]bool) // Prevent duplicates
fmt.Printf("[*] Enumerating Suspicious Directories \n")
// Check for common directories
for _, dir := range common.CommonDirectories {
dir = replaceAppData(dir)
// Check if this is a prefix pattern (ends with incomplete path such as Screen Connect "C:\Program Files (x86)\ScreenConnect Client (")
if isPrefix(dir) {
// Find all directories matching this prefix
matches := findPrefixMatches(dir)
for _, match := range matches {
if !seen[match] {
fmt.Printf(" [?] Found %s\n", match)
suspiciousDirectories = append(suspiciousDirectories, Directory{Path: match})
seen[match] = true
// For each known RMM directory, check in all base paths
for _, rmmDir := range common.KnownRMMDirectories {
for _, basePath := range common.SearchBasePaths {
// Replace environment variables
basePath = replaceEnvVars(basePath)
// Construct full path
fullPath := filepath.Join(basePath, rmmDir)
// Check if this is a prefix pattern (ends with incomplete path like "ScreenConnect Client (")
if isPrefix(rmmDir) {
// Find all directories matching this prefix
matches := findPrefixMatches(fullPath)
for _, match := range matches {
if !seen[match] {
fmt.Printf(" [?] Found %s\n", match)
suspiciousDirectories = append(suspiciousDirectories, Directory{Path: match})
seen[match] = true
}
}
}
} else {
// Exact match
if _, err := os.Stat(dir); err == nil {
if !seen[dir] {
fmt.Printf(" [?] Found %s\n", dir)
suspiciousDirectories = append(suspiciousDirectories, Directory{Path: dir})
seen[dir] = true
} else {
// Exact match
if _, err := os.Stat(fullPath); err == nil {
if !seen[fullPath] {
fmt.Printf(" [?] Found %s\n", fullPath)
suspiciousDirectories = append(suspiciousDirectories, Directory{Path: fullPath})
seen[fullPath] = true
}
}
}
}
}
fmt.Printf("[+] Found %d Suspicious Directories\n", len(suspiciousDirectories))
return suspiciousDirectories
}
// replaceAppData replaces {{APPDATA}} with the actual APPDATA path
func replaceAppData(path string) string {
if strings.Contains(path, "{{APPDATA}}") {
p := strings.Replace(path, "{{APPDATA}}", "", -1)
return filepath.Join(appData, p)
}
// 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
}
+156 -20
View File
@@ -4,42 +4,178 @@ import (
"fmt"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
var (
shell32 = syscall.NewLazyDLL("shell32.dll")
shellExecuteW = shell32.NewProc("ShellExecuteW")
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
func OpenBrowser(url string) error {
// 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 fmt.Errorf("failed to convert operation string: %w", err)
return nil, fmt.Errorf("failed to convert operation string: %w", err)
}
urlPtr, err := syscall.UTF16PtrFromString(url)
if err != nil {
return fmt.Errorf("failed to convert URL string: %w", err)
return nil, fmt.Errorf("failed to convert URL string: %w", err)
}
// ShellExecuteW(hwnd, operation, file, parameters, directory, showCmd)
// SW_SHOWNORMAL = 1, SW_SHOW = 5
ret, _, callErr := shellExecuteW.Call(
0, // hwnd (NULL)
uintptr(unsafe.Pointer(operation)), // operation ("open")
uintptr(unsafe.Pointer(urlPtr)), // file (URL)
0, // parameters (NULL)
0, // directory (NULL)
5, // showCmd (SW_SHOW)
)
// ShellExecute returns a value > 32 on success
if ret <= 32 {
return fmt.Errorf("ShellExecute failed with code: %d (error: %v)", ret, callErr)
// 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,
}
fmt.Printf("[web] Browser opened successfully (return code: %d)\n", ret)
// 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
}
+241 -9
View File
@@ -14,6 +14,7 @@
--bg:#0b0f0c; --bg2:#111612; --panel:#0f1511; --accent:#17e46e; --accent2:#0eea5a; --muted:#a7b5a9; --text:#e6f4ea; --danger:#ff5c7a; --warn:#ffd166;
}
*{box-sizing:border-box}
html{background:var(--bg)}
body{margin:0;font-family:Inter,system-ui,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:linear-gradient(180deg,var(--bg),#070a08);color:var(--text);min-height:100vh;display:flex;flex-direction:column}
/* Scrollbar styling */
@@ -66,11 +67,15 @@
.tree-node{cursor:pointer;padding:8px 10px;border-radius:6px;margin:4px 0;user-select:none;font-weight:600;color:var(--text);border:1px solid transparent}
.tree-node:hover{background:#0e1a13;border-color:#124b2b}
.tree-node.expanded{background:#0e1a13;border-color:#124b2b}
.tree-child{cursor:pointer;padding:6px 10px 6px 12px;border-radius:6px;margin:2px 0;font-size:13px;color:var(--muted);border:1px solid transparent;display:flex;align-items:center;gap:8px;transition:all 0.15s}
.tree-child{cursor:pointer;padding:6px 10px 6px 12px;border-radius:6px;margin:2px 0;font-size:13px;color:var(--muted);border:1px solid transparent;display:flex;align-items:center;gap:8px;transition:all 0.15s;position:relative;overflow:hidden}
.tree-child:hover{background:#0a2a18;color:var(--text);border-color:#124b2b}
.tree-child.selected{background:#103e24;color:var(--accent);border-color:#1d7e4a}
.tree-child::before{content:'→';color:var(--accent);opacity:0;transition:opacity 0.15s}
.tree-child:hover::before,.tree-child.selected::before{opacity:1}
.tree-child.eliminating{animation:slideOutRight 0.6s ease-in-out forwards}
.tree-child.eliminating::after{content:'';position:absolute;top:0;left:0;right:0;bottom:0;background:linear-gradient(90deg,transparent,rgba(23,228,110,.3),transparent);animation:shimmer 0.6s ease-in-out}
@keyframes slideOutRight{0%{transform:translateX(0);opacity:1}50%{opacity:0.5}100%{transform:translateX(100%);opacity:0;height:0;margin:0;padding:0}}
@keyframes shimmer{0%{transform:translateX(-100%)}100%{transform:translateX(100%)}}
.detail-field{margin:12px 0;padding:10px;background:#050805;border:1px solid #0c2819;border-radius:8px}
.detail-label{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px}
.detail-value{color:var(--text);font-size:14px;word-break:break-all}
@@ -102,6 +107,35 @@
.splash-loader{width:200px;height:3px;background:rgba(23,228,110,.1);border-radius:3px;overflow:hidden;position:relative}
.splash-loader::after{content:'';position:absolute;top:0;left:0;height:100%;width:40%;background:linear-gradient(90deg,transparent,var(--accent),transparent);animation:loading 1.5s ease-in-out infinite}
@keyframes loading{0%{left:-40%}100%{left:100%}}
/* Shutdown Screen */
#shutdownScreen{position:fixed;top:0;left:0;right:0;bottom:0;background:radial-gradient(circle at center,#0f1511,#0b0f0c,#000);z-index:10000;display:none;flex-direction:column;align-items:center;justify-content:center}
#shutdownScreen.active{display:flex}
.spinner{width:80px;height:80px;border:4px solid rgba(23,228,110,.1);border-top:4px solid var(--accent);border-radius:50%;animation:spin 1s linear infinite;margin-bottom:30px}
.spinner.hide{display:none}
@keyframes spin{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}
.shutdown-title{font-size:28px;font-weight:700;color:var(--accent);margin-bottom:12px;letter-spacing:1.5px}
.shutdown-message{font-size:16px;color:var(--muted);margin-bottom:20px}
.shutdown-success{font-size:18px;color:var(--accent);font-weight:600;display:none}
.shutdown-success.show{display:block}
/* Modal */
.modal{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.8);z-index:10001;display:none;align-items:center;justify-content:center}
.modal.active{display:flex}
.modal-content{background:var(--panel);border:1px solid #133422;border-radius:12px;padding:30px;max-width:500px;width:90%;box-shadow:0 20px 60px rgba(0,0,0,.5)}
.modal-icon{font-size:60px;text-align:center;margin-bottom:20px}
.modal-icon.success{color:var(--accent);animation:checkPop 0.5s ease-out}
.modal-icon.error{color:#ff5c7a}
@keyframes checkPop{0%{transform:scale(0)}50%{transform:scale(1.2)}100%{transform:scale(1)}}
.modal-title{font-size:24px;font-weight:700;text-align:center;margin-bottom:12px}
.modal-title.success{color:var(--accent)}
.modal-title.error{color:#ff5c7a}
.modal-message{text-align:center;color:var(--muted);margin-bottom:24px;line-height:1.6}
.modal-btn{width:100%;padding:14px;border:none;border-radius:8px;font-weight:600;font-size:15px;cursor:pointer;transition:all 0.2s}
.modal-btn.success{background:var(--accent);color:#000}
.modal-btn.success:hover{filter:brightness(1.2)}
.modal-btn.error{background:rgba(255,92,122,.2);color:#ff5c7a;border:1px solid #ff5c7a}
.modal-btn.error:hover{background:rgba(255,92,122,.3)}
</style>
</head>
<body>
@@ -113,6 +147,24 @@
<div class="splash-loader"></div>
</div>
<!-- Shutdown Screen -->
<div id="shutdownScreen">
<div class="spinner"></div>
<div class="shutdown-title">Shutting Down</div>
<div class="shutdown-message">RMM Hunter is closing...</div>
<div class="shutdown-success">✓ You can now close this browser tab</div>
</div>
<!-- Modal -->
<div id="modal" class="modal">
<div class="modal-content">
<div id="modalIcon" class="modal-icon"></div>
<div id="modalTitle" class="modal-title"></div>
<div id="modalMessage" class="modal-message"></div>
<button id="modalBtn" class="modal-btn"></button>
</div>
</div>
<header>
<div class="nav">
<div class="brand"><img src="/logo" alt="RMM Hunter"><span>RMM Hunter</span></div>
@@ -132,6 +184,7 @@
<div class="actions">
<button id="startHunt" class="btn primary">Start Hunt</button>
<span id="huntTag" class="tag hidden"></span>
<button id="viewReportBtn" class="btn hidden" style="margin-left:12px">View Report</button>
</div>
<div id="log" class="log" aria-live="polite"></div>
</section>
@@ -206,6 +259,7 @@
const logEl = document.getElementById('log');
const huntsEl = document.getElementById('hunts');
const huntTag = document.getElementById('huntTag');
const viewReportBtn = document.getElementById('viewReportBtn');
const reportSection = document.getElementById('reportSection');
const reportHeader = document.getElementById('reportHeader');
const reportBody = document.getElementById('reportBody');
@@ -350,27 +404,93 @@
}
// Hunt flow
let lastReportData = null;
let currentReportName = null;
document.getElementById('startHunt').addEventListener('click', async ()=>{
logEl.textContent=''; huntTag.classList.add('hidden');
logEl.textContent='';
huntTag.classList.add('hidden');
viewReportBtn.classList.add('hidden');
currentReportName = null;
let ws;
try{ ws = new WebSocket((location.protocol==='https:'?'wss':'ws')+'://'+location.host+'/ws/hunt');
ws.onmessage = ev => { logEl.textContent += ev.data + '\n'; logEl.scrollTop = logEl.scrollHeight; };
let wsReady = false;
let huntComplete = false;
try{
ws = new WebSocket((location.protocol==='https:'?'wss':'ws')+'://'+location.host+'/ws/hunt');
ws.onopen = () => { wsReady = true; };
ws.onmessage = ev => {
logEl.textContent += ev.data + '\n';
logEl.scrollTop = logEl.scrollHeight;
// Check if hunt is complete
if (ev.data.includes('[+] Hunt complete')) {
huntComplete = true;
}
};
}catch(e){ console.error(e); }
// Wait for WebSocket to be ready before starting hunt
while(!wsReady && ws && ws.readyState !== WebSocket.OPEN) {
await new Promise(resolve => setTimeout(resolve, 50));
}
// Give it a tiny bit more time to ensure connection is stable
await new Promise(resolve => setTimeout(resolve, 100));
const r = await fetch('/api/hunt/start',{method:'POST'});
const data = await r.json();
if(data && data.reportName){
currentReportName = data.reportName;
// Wait for hunt to actually complete
while(!huntComplete) {
await new Promise(resolve => setTimeout(resolve, 100));
}
huntTag.classList.remove('hidden');
await typeText(huntTag, 'Report: '+data.reportName+'.json', 30);
// Load report
const rep = await (await fetch('/api/report?file='+encodeURIComponent(data.reportName+'.json'))).json();
renderReport(rep); location.hash = '#report';
lastReportData = rep;
renderReport(rep);
// Show View Report button AFTER everything is loaded
viewReportBtn.classList.remove('hidden');
}
if(ws) ws.close();
listHunts();
});
// View Report button handler
viewReportBtn.addEventListener('click', () => {
if (lastReportData) {
location.hash = '#report';
}
});
document.getElementById('quitBtn').addEventListener('click', async ()=>{
await fetch('/api/quit',{method:'POST'});
// Show shutdown screen
const shutdownScreen = document.getElementById('shutdownScreen');
const spinner = shutdownScreen.querySelector('.spinner');
const shutdownSuccess = shutdownScreen.querySelector('.shutdown-success');
shutdownScreen.classList.add('active');
// Send quit request
try {
await fetch('/api/quit',{method:'POST'});
} catch(e) {
// Server is shutting down, connection will be lost
}
// After 2 seconds, hide spinner and show success message
setTimeout(() => {
spinner.classList.add('hide');
shutdownSuccess.classList.add('show');
}, 2000);
});
listHunts();
@@ -631,11 +751,123 @@
}).join('');
}
window.eliminateItem = function(type, idx) {
// Modal functions
const modal = document.getElementById('modal');
const modalIcon = document.getElementById('modalIcon');
const modalTitle = document.getElementById('modalTitle');
const modalMessage = document.getElementById('modalMessage');
const modalBtn = document.getElementById('modalBtn');
function showModal(type, title, message) {
modal.classList.add('active');
modalIcon.className = `modal-icon ${type}`;
modalTitle.className = `modal-title ${type}`;
modalTitle.textContent = title;
modalMessage.textContent = message;
modalBtn.className = `modal-btn ${type}`;
modalBtn.textContent = 'OK';
if (type === 'success') {
modalIcon.textContent = '✓';
} else {
modalIcon.textContent = '✕';
}
modalBtn.onclick = () => {
modal.classList.remove('active');
};
}
window.eliminateItem = async function(type, idx) {
if (!confirm(`Are you sure you want to eliminate this ${type.slice(0, -1)}?\n\nThis action cannot be undone.`)) return;
alert('Elimination functionality not yet implemented in web UI.\n\nPlease use the CLI: rmm-hunter eliminate --cli');
// TODO: Implement API call to backend elimination endpoint
if (!currentReport) {
showModal('error', 'Error', 'No report loaded');
return;
}
// Get the report filename from the current report
const reportFile = currentReport.reportName || currentReport.name || 'rmm-hunter-report.json';
// Find the tree child element for this item
const treeChildren = document.querySelectorAll('.tree-child');
let targetElement = null;
// Find the element by matching the index and type
treeChildren.forEach((child, childIdx) => {
const parentNode = child.previousElementSibling;
if (parentNode && parentNode.classList.contains('tree-node')) {
// Check if this is the right category
const categoryText = parentNode.textContent.toLowerCase();
if ((type === 'connections' && categoryText.includes('outbound')) ||
(type === 'processes' && categoryText.includes('processes')) ||
(type === 'services' && categoryText.includes('services')) ||
(type === 'tasks' && categoryText.includes('scheduled')) ||
(type === 'autoruns' && categoryText.includes('autorun')) ||
(type === 'binaries' && categoryText.includes('binaries')) ||
(type === 'directories' && categoryText.includes('directories'))) {
// This is the right category, now check index
const siblings = Array.from(child.parentElement.children).filter(c => c.classList.contains('tree-child'));
const itemIdx = siblings.indexOf(child);
if (itemIdx === idx) {
targetElement = child;
}
}
}
});
try {
const response = await fetch('/api/eliminate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
reportFile: reportFile,
type: type,
index: idx
})
});
const result = await response.json();
if (!response.ok) {
showModal('error', 'Elimination Failed', result.error || 'Unknown error occurred');
return;
}
// Success! Trigger slide-out animation
if (targetElement) {
targetElement.classList.add('eliminating');
// Wait for animation to complete before updating tree
setTimeout(async () => {
// Show success modal
showModal('success', 'Successfully Eliminated', `The ${type.slice(0, -1)} has been removed from your system.`);
// Reload the report to get updated data
const updatedReport = await (await fetch('/api/report?file=' + encodeURIComponent(reportFile))).json();
currentReport = updatedReport;
// Reload elimination tree with updated data
loadEliminationData(updatedReport);
// Clear the center and wiki panels
elimCenter.innerHTML = '<div class="empty-state">Select an item from the tree</div>';
elimWiki.innerHTML = '<div class="empty-state">Item details will appear here</div>';
}, 600); // Match animation duration
} else {
// Fallback if element not found
showModal('success', 'Successfully Eliminated', `The ${type.slice(0, -1)} has been removed from your system.`);
const updatedReport = await (await fetch('/api/report?file=' + encodeURIComponent(reportFile))).json();
currentReport = updatedReport;
loadEliminationData(updatedReport);
elimCenter.innerHTML = '<div class="empty-state">Select an item from the tree</div>';
elimWiki.innerHTML = '<div class="empty-state">Item details will appear here</div>';
}
} catch (error) {
showModal('error', 'Error', `Failed to eliminate: ${error.message}`);
}
};
// Update renderReport to also load elimination data
+244 -1
View File
@@ -10,11 +10,13 @@ import (
"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"
@@ -76,6 +78,7 @@ func StartWebServer() {
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)
@@ -103,7 +106,8 @@ func StartWebServer() {
<-serverReady
time.Sleep(500 * time.Millisecond) // Give server a moment to fully initialize
log.Printf("[web] Opening browser to %s...\n", browserURL)
if err := OpenBrowser(browserURL); err != nil {
_, 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")
@@ -290,6 +294,61 @@ func (s *server) handleWS(w http.ResponseWriter, r *http.Request) {
}
}
func (s *server) handleEliminate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
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 {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
// Load the report file
reportPath := filepath.Join(".", req.ReportFile)
data, err := os.ReadFile(reportPath)
if err != nil {
http.Error(w, fmt.Sprintf("failed to read report: %v", err), http.StatusInternalServerError)
return
}
var report suspicious.Suspicious
if err := json.Unmarshal(data, &report); err != nil {
http.Error(w, fmt.Sprintf("failed to parse report: %v", err), http.StatusInternalServerError)
return
}
// Perform elimination based on type
if err := performElimination(&report, 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(report, "", " ")
if err != nil {
http.Error(w, fmt.Sprintf("failed to marshal report: %v", err), http.StatusInternalServerError)
return
}
if err := os.WriteFile(reportPath, updatedData, 0644); err != nil {
http.Error(w, fmt.Sprintf("failed to save report: %v", err), http.StatusInternalServerError)
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 {
http.Error(w, "use POST", 405)
@@ -299,3 +358,187 @@ func (s *server) handleQuit(w http.ResponseWriter, r *http.Request) {
_, _ = 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 >= len(report.OutboundConnections) {
return fmt.Errorf("invalid index")
}
conn := report.OutboundConnections[idx]
if err := eliminate.EliminateConnection(conn.RemoteHost); err != nil {
return err
}
report.OutboundConnections[idx].Eliminated = true
case "processes":
if idx >= len(report.Processes) {
return fmt.Errorf("invalid index")
}
proc := report.Processes[idx]
if err := eliminate.EliminateProcess(proc); err != nil {
return err
}
report.Processes[idx].Eliminated = true
case "services":
if idx >= len(report.Services) {
return fmt.Errorf("invalid index")
}
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 >= len(report.ScheduledTasks) {
return fmt.Errorf("invalid index")
}
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 >= len(report.AutoRuns) {
return fmt.Errorf("invalid index")
}
ar := report.AutoRuns[idx]
if err := eliminate.EliminateAutoRun(ar); err != nil {
return err
}
report.AutoRuns[idx].Eliminated = true
case "binaries":
if idx >= len(report.Binaries) {
return fmt.Errorf("invalid index")
}
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 >= len(report.Directories) {
return fmt.Errorf("invalid index")
}
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
}