diff --git a/projects/msp-tools/guru-scan/.gitignore b/projects/msp-tools/guru-scan/.gitignore new file mode 100644 index 0000000..247f894 --- /dev/null +++ b/projects/msp-tools/guru-scan/.gitignore @@ -0,0 +1,5 @@ +# Scanner binaries — downloaded at runtime, not committed +downloads/ + +# Scan output — machine-local, can be large +C:\ScanLogs\ diff --git a/projects/msp-tools/guru-scan/Download-Scanners.ps1 b/projects/msp-tools/guru-scan/Download-Scanners.ps1 new file mode 100644 index 0000000..590c0eb --- /dev/null +++ b/projects/msp-tools/guru-scan/Download-Scanners.ps1 @@ -0,0 +1,115 @@ +<# +.SYNOPSIS + Downloads or refreshes all scanner executables defined in scanners.json. +.DESCRIPTION + Reads scanner definitions from scanners.json and downloads each scanner EXE + to the downloads\ subdirectory. Skips scanners with null download URLs. +.PARAMETER Force + Re-download all scanners even if they already exist locally. +.EXAMPLE + .\Download-Scanners.ps1 + .\Download-Scanners.ps1 -Force +#> +[CmdletBinding()] +param( + [switch]$Force +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +$ConfigPath = Join-Path $PSScriptRoot 'scanners.json' +$DownloadsDir = Join-Path $PSScriptRoot 'downloads' + +if (-not (Test-Path $ConfigPath)) { + Write-Host "[ERROR] scanners.json not found at: $ConfigPath" -ForegroundColor Red + exit 1 +} + +$config = Get-Content $ConfigPath -Raw | ConvertFrom-Json + +if (-not (Test-Path $DownloadsDir)) { + New-Item -ItemType Directory -Path $DownloadsDir -Force | Out-Null + Write-Host "[INFO] Created downloads directory: $DownloadsDir" -ForegroundColor Cyan +} + +$results = [System.Collections.Generic.List[pscustomobject]]::new() + +foreach ($scanner in $config.scanners) { + $urlToUse = $null + $fileToSave = $null + + # Manual-download entries: print instructions and skip + if ($scanner.PSObject.Properties['manual_download'] -and $scanner.manual_download -eq $true) { + $note = if ($scanner.PSObject.Properties['manual_download_note']) { $scanner.manual_download_note } else { 'Place EXE manually in downloads\' } + Write-Host " [MANUAL] $($scanner.name) — $note" -ForegroundColor Yellow + $results.Add([pscustomobject]@{ Scanner = $scanner.name; Status = 'MANUAL'; File = ''; Size = 0 }) + continue + } + + if ($scanner.installer_exe -and $scanner.download_url) { + $urlToUse = $scanner.download_url + $fileToSave = Join-Path $DownloadsDir (Split-Path $scanner.installer_exe -Leaf) + } + elseif ($scanner.download_url) { + $urlToUse = $scanner.download_url + $leafName = Split-Path $scanner.exe -Leaf + $fileToSave = Join-Path $DownloadsDir $leafName + } + else { + Write-Host " [SKIP] $($scanner.name) — no download URL configured" -ForegroundColor Yellow + $results.Add([pscustomobject]@{ Scanner = $scanner.name; Status = 'SKIPPED'; File = ''; Size = 0 }) + continue + } + + if ((Test-Path $fileToSave) -and -not $Force) { + $existingSize = (Get-Item $fileToSave).Length + Write-Host " [OK] $($scanner.name) already exists ($([math]::Round($existingSize / 1MB, 1)) MB)" -ForegroundColor Green + $results.Add([pscustomobject]@{ Scanner = $scanner.name; Status = 'EXISTS'; File = $fileToSave; Size = $existingSize }) + continue + } + + Write-Host " [....] $($scanner.name) — downloading from $urlToUse" -ForegroundColor Cyan + try { + $wc = New-Object System.Net.WebClient + $wc.Headers.Add('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)') + $wc.DownloadFile($urlToUse, $fileToSave) + $wc.Dispose() + + if (-not (Test-Path $fileToSave)) { + throw "File not found after download attempt" + } + $downloadedSize = (Get-Item $fileToSave).Length + if ($downloadedSize -eq 0) { + throw "Downloaded file is 0 bytes — URL may be invalid or redirected" + } + + Write-Host " [OK] $($scanner.name) downloaded ($([math]::Round($downloadedSize / 1MB, 1)) MB) -> $fileToSave" -ForegroundColor Green + $results.Add([pscustomobject]@{ Scanner = $scanner.name; Status = 'DOWNLOADED'; File = $fileToSave; Size = $downloadedSize }) + } + catch { + Write-Host " [ERROR] $($scanner.name) — $($_.Exception.Message)" -ForegroundColor Red + if (Test-Path $fileToSave) { + Remove-Item $fileToSave -Force -ErrorAction SilentlyContinue + } + $results.Add([pscustomobject]@{ Scanner = $scanner.name; Status = 'FAILED'; File = $fileToSave; Size = 0 }) + } +} + +Write-Host "" +Write-Host "=== Download Summary ===" -ForegroundColor Cyan +$results | Format-Table -AutoSize + +$manual = $results | Where-Object { $_.Status -eq 'MANUAL' } +if ($manual) { + Write-Host "[INFO] $($manual.Count) scanner(s) require manual download — see notes above." -ForegroundColor Yellow +} + +$failed = $results | Where-Object { $_.Status -eq 'FAILED' } +if ($failed) { + Write-Host "[WARNING] $($failed.Count) scanner(s) failed to download. Scan coverage will be incomplete." -ForegroundColor Yellow + exit 1 +} + +exit 0 diff --git a/projects/msp-tools/guru-scan/Get-ScanSummary.ps1 b/projects/msp-tools/guru-scan/Get-ScanSummary.ps1 new file mode 100644 index 0000000..f44c921 --- /dev/null +++ b/projects/msp-tools/guru-scan/Get-ScanSummary.ps1 @@ -0,0 +1,294 @@ +<# +.SYNOPSIS + Parses a GuruScan results.json into a human-readable report. +.DESCRIPTION + Locates a results.json (defaults to the most recent scan in C:\ScanLogs\), + prints a formatted summary with per-scanner results, threat counts, and + a remediation recommendation if threats were found. + Use -AI to send log content to a local Ollama model for threat analysis + and AI-generated remediation recommendations. +.PARAMETER ResultsFile + Full path to a specific results.json. If omitted, the latest file in + C:\ScanLogs\ is used automatically. +.PARAMETER ShowAll + Show every scanner including those that completed clean (no threats). +.PARAMETER AI + Send scan logs to local Ollama (http://localhost:11434) for AI-powered + threat analysis and prioritized remediation recommendations. +.PARAMETER OllamaUrl + Ollama base URL. Defaults to http://localhost:11434. +.PARAMETER OllamaModel + Ollama model to use. Defaults to qwen3.6:latest. +.EXAMPLE + .\Get-ScanSummary.ps1 + .\Get-ScanSummary.ps1 -AI + .\Get-ScanSummary.ps1 -ResultsFile "C:\ScanLogs\DESKTOP-20260523-143000\results.json" -AI +#> +[CmdletBinding()] +param( + [string]$ResultsFile = '', + [switch]$ShowAll, + [switch]$AI, + [string]$OllamaUrl = 'http://localhost:11434', + [string]$OllamaModel = 'qwen3.6:latest' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# --------------------------------------------------------------------------- +# Ollama helper +# --------------------------------------------------------------------------- + +function Invoke-Ollama { + param([string]$Prompt, [string]$Model = $OllamaModel, [string]$BaseUrl = $OllamaUrl) + $body = @{ model = $Model; prompt = $Prompt; stream = $false } | ConvertTo-Json -Depth 3 + try { + $r = Invoke-RestMethod -Uri "$BaseUrl/api/generate" -Method POST ` + -Body $body -ContentType 'application/json' -TimeoutSec 180 + return ($r.response -replace ']*>', '' -replace '(?s).*?', '').Trim() + } catch { + return $null + } +} + +# --------------------------------------------------------------------------- +# Locate results.json +# --------------------------------------------------------------------------- + +if (-not $ResultsFile) { + $scanLogsRoot = 'C:\ScanLogs' + if (-not (Test-Path $scanLogsRoot)) { + Write-Host "[ERROR] No results file specified and C:\ScanLogs does not exist." -ForegroundColor Red + exit 1 + } + + $latestJson = Get-ChildItem -Path $scanLogsRoot -Recurse -Filter 'results.json' -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + + if (-not $latestJson) { + Write-Host "[ERROR] No results.json found under C:\ScanLogs\" -ForegroundColor Red + exit 1 + } + + $ResultsFile = $latestJson.FullName + Write-Host "[INFO] Using latest results: $ResultsFile" -ForegroundColor Cyan +} + +if (-not (Test-Path $ResultsFile)) { + Write-Host "[ERROR] Results file not found: $ResultsFile" -ForegroundColor Red + exit 1 +} + +# --------------------------------------------------------------------------- +# Parse JSON +# --------------------------------------------------------------------------- + +$data = Get-Content $ResultsFile -Raw | ConvertFrom-Json + +# --------------------------------------------------------------------------- +# Header +# --------------------------------------------------------------------------- + +$startDt = [datetime]::Parse($data.started_at).ToLocalTime() +$endDt = [datetime]::Parse($data.completed_at).ToLocalTime() +$totalDurMin = [math]::Round(($endDt - $startDt).TotalMinutes, 1) + +Write-Host "" +Write-Host "================================================================" -ForegroundColor Cyan +Write-Host " GuruScan Report" -ForegroundColor Cyan +Write-Host "================================================================" -ForegroundColor Cyan +Write-Host " Machine : $($data.machine)" +Write-Host " Scan ID : $($data.scan_id)" +Write-Host " Mode : $($data.scan_mode)" +Write-Host " Started : $($startDt.ToString('yyyy-MM-dd HH:mm:ss'))" +Write-Host " Completed : $($endDt.ToString('yyyy-MM-dd HH:mm:ss'))" +Write-Host " Duration : $totalDurMin min total" +Write-Host "================================================================" -ForegroundColor Cyan +Write-Host "" + +# --------------------------------------------------------------------------- +# Per-scanner table +# --------------------------------------------------------------------------- + +$rows = foreach ($s in $data.scanners) { + $threatStr = if ($s.threats_found -gt 0) { "YES ($($s.threats_found))" } else { 'Clean' } + $statusColor = switch ($s.status) { + 'completed' { 'Green' } + { $_ -like 'TIMED*' } { 'Yellow' } + default { 'Red' } + } + [pscustomobject]@{ + Scanner = $s.name + Status = $s.status + ExitCode = $s.exit_code + Duration = "$($s.duration_min) min" + Threats = $threatStr + '_Color' = $statusColor + } +} + +Write-Host " Scanner Results:" -ForegroundColor Cyan +Write-Host "" + +$header = ' {0,-18} {1,-28} {2,-10} {3,-12} {4}' -f 'Scanner', 'Status', 'ExitCode', 'Duration', 'Threats' +Write-Host $header -ForegroundColor Gray +Write-Host (' ' + ('-' * 78)) -ForegroundColor DarkGray + +foreach ($row in $rows) { + $line = ' {0,-18} {1,-28} {2,-10} {3,-12} {4}' -f $row.Scanner, $row.Status, $row.ExitCode, $row.Duration, $row.Threats + Write-Host $line -ForegroundColor $row.'_Color' +} + +Write-Host "" + +# --------------------------------------------------------------------------- +# Threat summary +# --------------------------------------------------------------------------- + +$threatScanners = $data.scanners | Where-Object { $_.threats_found -gt 0 } +$nonClean = $data.scanners | Where-Object { $_.status -ne 'completed' } + +if ($data.total_threats -gt 0) { + Write-Host "================================================================" -ForegroundColor Yellow + Write-Host " [WARNING] THREATS DETECTED" -ForegroundColor Yellow + Write-Host "================================================================" -ForegroundColor Yellow + Write-Host "" + Write-Host " Scanners that detected threats:" -ForegroundColor Yellow + foreach ($ts in $threatScanners) { + Write-Host " - $($ts.name) (exit code $($ts.exit_code), log: $($ts.log_path))" -ForegroundColor Yellow + } + Write-Host "" +} else { + Write-Host " [OK] No threats detected across all scanners." -ForegroundColor Green + Write-Host "" +} + +if ($nonClean) { + Write-Host " [WARNING] Scanners with non-completed status:" -ForegroundColor Yellow + foreach ($nc in $nonClean) { + Write-Host " - $($nc.name): $($nc.status)" -ForegroundColor Yellow + } + Write-Host "" +} + +# --------------------------------------------------------------------------- +# Recommendation +# --------------------------------------------------------------------------- + +Write-Host "================================================================" -ForegroundColor Cyan +Write-Host " Recommendation" -ForegroundColor Cyan +Write-Host "================================================================" -ForegroundColor Cyan + +if ($data.reboot_required) { + Write-Host " [WARNING] A REBOOT IS REQUIRED to complete remediation." -ForegroundColor Yellow + Write-Host " Reboot the machine, then re-run GuruScan to verify." -ForegroundColor Yellow +} +elseif ($data.total_threats -gt 0 -and $data.scan_mode -eq 'scan') { + Write-Host " [INFO] Threats were detected in scan-only mode." -ForegroundColor Cyan + Write-Host " Run: .\Invoke-GuruScan.ps1 (without -ScanOnly)" -ForegroundColor Cyan + Write-Host " Or: .\Invoke-Remediation.ps1 -LogRoot `"$(Split-Path $ResultsFile)`"" -ForegroundColor Cyan +} +elseif ($data.total_threats -gt 0) { + Write-Host " [INFO] Clean mode was run. Review logs to confirm threats removed." -ForegroundColor Cyan + Write-Host " Log folder: $(Split-Path $ResultsFile)" -ForegroundColor Cyan +} +else { + Write-Host " [OK] System appears clean. No further action required." -ForegroundColor Green + Write-Host " Consider running ESET Online Scanner for a second opinion." -ForegroundColor Gray +} + +Write-Host "" +Write-Host " Full logs: $(Split-Path $ResultsFile)" -ForegroundColor Gray +Write-Host "" + +# --------------------------------------------------------------------------- +# Ollama AI analysis (optional — requires -AI switch) +# --------------------------------------------------------------------------- + +if ($AI) { + Write-Host "================================================================" -ForegroundColor Cyan + Write-Host " AI Analysis (Ollama)" -ForegroundColor Cyan + Write-Host "================================================================" -ForegroundColor Cyan + Write-Host " Model : $OllamaModel" + Write-Host " Server : $OllamaUrl" + Write-Host "" + + # Collect log content from all scanner log folders (cap at 8KB each to avoid huge prompts) + $logFolder = Split-Path $ResultsFile + $logContent = [System.Text.StringBuilder]::new() + Get-ChildItem -Path $logFolder -Recurse -Include '*.log','*.txt','*.csv' -ErrorAction SilentlyContinue | + Where-Object { $_.Length -gt 0 -and $_.Length -lt 200KB } | + ForEach-Object { + $snippet = Get-Content $_.FullName -Raw -ErrorAction SilentlyContinue + if ($snippet) { + $snippet = if ($snippet.Length -gt 8192) { $snippet.Substring(0, 8192) + "`n[TRUNCATED]" } else { $snippet } + [void]$logContent.AppendLine("=== $($_.Name) ===") + [void]$logContent.AppendLine($snippet) + [void]$logContent.AppendLine() + } + } + + $logText = if ($logContent.Length -gt 0) { $logContent.ToString() } else { 'No log files found.' } + + $scanJson = $data | ConvertTo-Json -Depth 5 + + # Step 1: Extract specific threat names / findings + Write-Host " [....] Extracting threat details..." -ForegroundColor Cyan + $threatPrompt = @" +You are a malware analyst. Below is a JSON scan summary and raw log excerpts from a multi-engine malware scan. + +SCAN SUMMARY JSON: +$scanJson + +RAW LOG EXCERPTS: +$logText + +Task: Extract a concise list of specific threat names, file paths, registry keys, or other findings from the logs. +Format your response as a plain numbered list. If no specific threats are named in the logs, say "No named threats found in logs." +Do not add commentary — only the list. +"@ + + $threatDetails = Invoke-Ollama -Prompt $threatPrompt + if ($threatDetails) { + Write-Host "" + Write-Host " Identified Threats / Findings:" -ForegroundColor Yellow + $threatDetails -split "`n" | ForEach-Object { Write-Host " $_" -ForegroundColor Yellow } + } else { + Write-Host " [WARNING] Ollama unavailable — skipping threat extraction." -ForegroundColor Yellow + } + + # Step 2: Prioritized remediation recommendations + Write-Host "" + Write-Host " [....] Generating remediation recommendations..." -ForegroundColor Cyan + $remediationPrompt = @" +You are an MSP technician. A multi-engine malware scan produced these results: + +$scanJson + +Threat details extracted from logs: +$threatDetails + +Task: Write a short, prioritized remediation checklist (max 8 steps) for the technician. +Include: immediate actions, follow-up verification steps, and whether a reboot is needed. +Plain numbered list, no markdown headers, no padding text. Be specific. +"@ + + $recommendations = Invoke-Ollama -Prompt $remediationPrompt + if ($recommendations) { + Write-Host "" + Write-Host " Remediation Checklist:" -ForegroundColor Cyan + $recommendations -split "`n" | ForEach-Object { Write-Host " $_" -ForegroundColor Cyan } + } else { + Write-Host " [WARNING] Ollama unavailable — skipping recommendations." -ForegroundColor Yellow + } + + Write-Host "" + Write-Host "================================================================" -ForegroundColor DarkGray + Write-Host " NOTE: AI output is advisory. Review logs before acting." -ForegroundColor DarkGray + Write-Host "================================================================" -ForegroundColor DarkGray + Write-Host "" +} + +exit 0 diff --git a/projects/msp-tools/guru-scan/Invoke-GuruScan.ps1 b/projects/msp-tools/guru-scan/Invoke-GuruScan.ps1 new file mode 100644 index 0000000..09f7a5a --- /dev/null +++ b/projects/msp-tools/guru-scan/Invoke-GuruScan.ps1 @@ -0,0 +1,1094 @@ +#Requires -RunAsAdministrator +<# +.SYNOPSIS + GuruScan - multi-engine malware scanning orchestrator (single-file, RMM-ready). +.DESCRIPTION + Runs a suite of portable malware scanners in sequence, captures logs, + and writes a structured results.json plus a zip archive of all logs. + All scanner definitions and the whitelist are embedded inline - no external + files required beyond the scanner EXEs themselves in C:\GuruScan\downloads\. + + By default runs all scanners in clean (remediation) mode. + Use -ScanOnly to detect without cleaning. + + NOTE: MSERT is no longer included in the default scanner list because it + takes too long for routine runs. To run MSERT, invoke it directly or pass + -Scanners MSERT explicitly if you add it back to the definitions. +.PARAMETER ScanOnly + Use scan args (detect only) instead of clean args for every scanner. +.PARAMETER AutoRemediate + After a scan-only pass, if threats are found, automatically re-run all + scanners in clean mode. +.PARAMETER Scanners + Run only the named scanners (comma-separated or multiple values). + Names must match the Name field in $ScannerDefs exactly. +.PARAMETER TimeoutMin + Override the per-scanner timeout (in minutes) for all scanners. +.PARAMETER SkipEset + Skip the ESET scanner. ESET requires user interaction and is automatically + skipped when running as SYSTEM. Use this flag to skip it explicitly. +.PARAMETER SkipScanners + Skip one or more named scanners by name. Names must match the Name field + in $ScannerDefs exactly. Useful for excluding a single scanner without + respecifying the entire list. +.EXAMPLE + .\Invoke-GuruScan.ps1 + .\Invoke-GuruScan.ps1 -ScanOnly -AutoRemediate + .\Invoke-GuruScan.ps1 -SkipEset + .\Invoke-GuruScan.ps1 -SkipScanners Emsisoft + .\Invoke-GuruScan.ps1 -Headless # suppress scanner windows (used when dispatching via RMM) +#> +[CmdletBinding()] +param( + [switch]$ScanOnly, + [switch]$AutoRemediate, + [string[]]$Scanners, + [int]$TimeoutMin = 0, + [switch]$SkipEset, + [string[]]$SkipScanners = @(), + [switch]$Headless +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Continue' +$ProgressPreference = 'SilentlyContinue' + +# --------------------------------------------------------------------------- +# Base path - hardcoded so the script works inline or from any location +# --------------------------------------------------------------------------- + +$Base = 'C:\GuruScan' +$LogRoot = "C:\ScanLogs" + +# --------------------------------------------------------------------------- +# Whitelist - written to C:\GuruScan\whitelist.txt before any scanner runs. +# +# WHITELIST SUPPORT BY SCANNER: +# Emsisoft YES /wl= flag -- paths and folders excluded from scan +# HitmanPro YES /excludelist= -- paths and folders excluded from scan +# RKill NO no CLI support -- kills by process pattern, no exclusions +# AdwCleaner NO no CLI support -- GUI-only exclusions, not scriptable +# ESET NO no CLI support -- no exclusion flag in silent mode +# +# Add one entry per line. Paths, folders, or file names. +# --------------------------------------------------------------------------- + +$Whitelist = @( + 'C:\GuruScan' + # 'C:\TestWhitelist' # uncomment to test whitelist (place EICAR file here) +) + +# --------------------------------------------------------------------------- +# ForceRemove blacklist - items here are ALWAYS removed after all scanners run, +# regardless of what the scanners detected. The free scanners do not support a +# user-defined removal list, so this PowerShell stage handles it instead. +# +# Supported entry types (set Type accordingly): +# Path - file or folder (Remove-Item -Recurse -Force) +# Registry - registry key (Remove-Item -Recurse -Force on the key path) +# RegValue - registry value (Remove-ItemProperty) +# Service - Windows service (Stop + delete) +# Task - scheduled task (Unregister-ScheduledTask) +# Process - kill by name (Stop-Process -Force) +# +# Example entries (uncomment or add your own): +# @{ Type='Path'; Value='C:\ProgramData\BadTool' } +# @{ Type='Registry'; Value='HKLM:\SOFTWARE\BadTool' } +# @{ Type='RegValue'; Key='HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'; Value='BadTool' } +# @{ Type='Service'; Value='BadToolSvc' } +# @{ Type='Task'; Value='BadToolUpdate' } +# @{ Type='Process'; Value='badtool' } +# --------------------------------------------------------------------------- + +$ForceRemove = @( + # Add entries here to force-remove specific threats after scanning +) + +# --------------------------------------------------------------------------- +# Scanner definitions - embedded inline, no scanners.json dependency +# Args are already fully formed strings; {LOG_ROOT} tokens are expanded at +# runtime by Expand-TokenizedArgs. +# --------------------------------------------------------------------------- + +$ScannerDefs = @( + @{ + Name = 'RKill' + Category = 'process-killer' + Exe = "$Base\downloads\rkill.exe" + InstallerExe = $null + ScanArgs = @('-s', "-l `"$Base\logs\rkill.log`"") + CleanArgs = @('-s', "-l `"$Base\logs\rkill.log`"") + LogSrc = $null + TimeoutMin = 10 + RandomizeExe = $false + PreCloseProcesses = @() + PreCleanPaths = @() + PostCleanPaths = @() + ServiceNames = @() + HitmanProReset = $false + WhitelistArg = $null + WaitOnProcess = $null + }, + @{ + Name = 'AdwCleaner' + Category = 'adware' + Exe = "$Base\downloads\adwcleaner.exe" + InstallerExe = $null + ScanArgs = @('/eula', '/scan', '/noreboot') + CleanArgs = @('/eula', '/clean', '/noreboot') + LogSrc = 'C:\AdwCleaner\Logs' + TimeoutMin = 60 + RandomizeExe = $false + PreCloseProcesses = @() + PreCleanPaths = @('C:\AdwCleaner') + PostCleanPaths = @('C:\AdwCleaner') + ServiceNames = @('AdwCleanerSvc') + HitmanProReset = $false + WhitelistArg = $null + WaitOnProcess = 'AdwCleaner' + }, + @{ + Name = 'Emsisoft' + Category = 'antimalware' + Exe = 'C:\EmsisoftCmd\a2cmd.exe' + InstallerExe = "$Base\downloads\EmsisoftCommandlineScanner64.exe" + ScanArgs = @('/f=C:\', '/deep', '/rk', '/m', '/t', '/pup', '/a', '/n', '/ac', '/d', "/wl=`"$Base\whitelist.txt`"", "/la=`"{LOG_ROOT}\a2cmd_deep_log.txt`"") + CleanArgs = @('/f=C:\', '/deep', '/rk', '/m', '/t', '/c', '/pup', '/a', '/n', '/ac', '/d', "/wl=`"$Base\whitelist.txt`"", "/la=`"{LOG_ROOT}\a2cmd_deep_log.txt`"") + LogSrc = $null + TimeoutMin = 120 + RandomizeExe = $false + PreCloseProcesses = @() + PreCleanPaths = @('C:\EmsisoftCmd') + PostCleanPaths = @('C:\EmsisoftCmd') + ServiceNames = @() + HitmanProReset = $false + WhitelistArg = 'emsisoft' + WaitOnProcess = 'a2cmd' + }, + @{ + Name = 'HitmanPro' + Category = 'antimalware' + Exe = "$Base\downloads\HitmanPro_x64.exe" + InstallerExe = $null + ScanArgs = @('/noinstall', '/scan', "/log=`"{LOG_ROOT}\HitmanPro_Scan_Log.txt`"", "/excludelist=`"$Base\whitelist.txt`"") + CleanArgs = @('/noinstall', '/clean', "/log=`"{LOG_ROOT}\HitmanPro_Scan_Log.txt`"", "/excludelist=`"$Base\whitelist.txt`"") + LogSrc = $null + TimeoutMin = 60 + RandomizeExe = $false + PreCloseProcesses = @('chrome', 'firefox', 'msedge', 'brave', 'opera', 'iexplore', 'operagx', 'MicrosoftEdge') + PreCleanPaths = @('C:\ProgramData\HitmanPro', 'C:\ProgramData\HitmanPro.Alert', '%LOCALAPPDATA%\HitmanPro', '%LOCALAPPDATA%\HitmanPro.Alert') + PostCleanPaths = @('C:\ProgramData\HitmanPro', 'C:\ProgramData\HitmanPro.Alert', '%LOCALAPPDATA%\HitmanPro', '%LOCALAPPDATA%\HitmanPro.Alert') + ServiceNames = @() + HitmanProReset = $true + WhitelistArg = 'hitmanpro' + WaitOnProcess = 'HitmanPro_x64' + }, + @{ + Name = 'ESET' + Category = 'antimalware' + Exe = "$Base\downloads\esetonlinescanner.exe" + InstallerExe = $null + ScanArgs = @('--install-silent', '--scan-potentially-unwanted=yes', '--scan-potentially-unsafe=yes') + CleanArgs = @('--install-silent', '--remove-found-threats=yes', '--scan-potentially-unwanted=yes', '--scan-potentially-unsafe=yes') + LogSrc = $null + TimeoutMin = 120 + RandomizeExe = $false + PreCloseProcesses = @() + PreCleanPaths = @() + PostCleanPaths = @() + ServiceNames = @('ekm', 'epfw', 'epfwwfp', 'EraAgentSvc') + HitmanProReset = $false + WhitelistArg = $null + WaitOnProcess = 'ESETOnlineScanner' + } +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +function Expand-TokenizedArgs { + param( + [string[]]$ArgList, + [string]$LogRoot, + [string]$ExeDir + ) + $out = foreach ($a in $ArgList) { + $a = $a -replace '\{LOG_ROOT\}', $LogRoot + $a = $a -replace '\{EXE_DIR\}', $ExeDir + $a + } + return $out +} + +function Expand-EnvPath { + param([string]$Path) + return [System.Environment]::ExpandEnvironmentVariables($Path) +} + +function Remove-PathSilent { + param([string]$Path) + $expanded = Expand-EnvPath $Path + if (Test-Path $expanded) { + Remove-Item -Path $expanded -Recurse -Force -ErrorAction SilentlyContinue + } +} + +function Wait-ProcessWithTimeout { + param( + [System.Diagnostics.Process]$Process, + [int]$TimeoutSeconds + ) + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while (-not $Process.HasExited) { + if ((Get-Date) -gt $deadline) { + try { $Process.Kill() } catch { } + return $false + } + Start-Sleep -Seconds 5 + } + return $true +} + +function Wait-ServicesToStop { + param([string[]]$ServiceNames, [int]$TimeoutSeconds = 120) + if (-not $ServiceNames -or $ServiceNames.Count -eq 0) { return } + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + foreach ($svc in $ServiceNames) { + while ((Get-Date) -lt $deadline) { + $s = Get-Service -Name $svc -ErrorAction SilentlyContinue + if (-not $s -or $s.Status -ne 'Running') { break } + Start-Sleep -Seconds 3 + } + } +} + +function Wait-ScannerCompletion { + param( + [System.Diagnostics.Process]$Process, + [string]$WaitOnProcess, + [string[]]$ServiceNames, + [int]$TimeoutSeconds + ) + # First wait for the main process + $completed = Wait-ProcessWithTimeout -Process $Process -TimeoutSeconds $TimeoutSeconds + if (-not $completed) { return $false } + + # Then wait for a named child process if specified + if ($WaitOnProcess) { + $deadline = (Get-Date).AddSeconds(60) + while ((Get-Date) -lt $deadline) { + $child = Get-Process -Name $WaitOnProcess -ErrorAction SilentlyContinue + if (-not $child) { break } + Start-Sleep -Seconds 3 + } + } + + # Then wait for services to stop + if ($ServiceNames -and $ServiceNames.Count -gt 0) { + Wait-ServicesToStop -ServiceNames $ServiceNames -TimeoutSeconds 120 + } + + return $true +} + +function Invoke-RebootCleanupSetup { + param( + [string]$OriginalUser, + [string]$ScanId, + [string]$LogRoot + ) + + $tempUser = 'GuruRMM-Temp' + $wlKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' + $specialKey= 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\SpecialAccounts\UserList' + + Write-Host '' + Write-Host '------------------------------------------------------------' -ForegroundColor Yellow + Write-Host ' Reboot required — preparing cleanup session' -ForegroundColor Yellow + Write-Host '------------------------------------------------------------' -ForegroundColor Yellow + + # Write state for the post-reboot script to read + @{ + original_user = $OriginalUser + scan_id = $ScanId + log_root = $LogRoot + created_at = (Get-Date).ToUniversalTime().ToString('o') + } | ConvertTo-Json | Set-Content "$Base\cleanup-state.json" -Encoding UTF8 + + # Remove any leftover temp account from a previous run + Remove-LocalUser -Name $tempUser -ErrorAction SilentlyContinue + + # Create the temp account (random password — Windows requires one for autologon) + $tempPass = [System.Guid]::NewGuid().ToString('N').Substring(0,16) + 'Aa1!' + $secPass = ConvertTo-SecureString $tempPass -AsPlainText -Force + New-LocalUser -Name $tempUser -Password $secPass -PasswordNeverExpires -UserMayNotChangePassword | Out-Null + Add-LocalGroupMember -Group 'Administrators' -Member $tempUser -ErrorAction SilentlyContinue + Write-Host " [OK] Created temp account: $tempUser" -ForegroundColor Green + + # Hide from login screen + New-Item -Path $specialKey -Force | Out-Null + Set-ItemProperty -Path $specialKey -Name $tempUser -Value 0 -Type DWord + Write-Host " [OK] Account hidden from login screen" -ForegroundColor Green + + # One-time autologon (AutoLogonCount=1 means Windows wipes the password entry after first use) + Set-ItemProperty -Path $wlKey -Name 'AutoAdminLogon' -Value '1' + Set-ItemProperty -Path $wlKey -Name 'DefaultUserName' -Value $tempUser + Set-ItemProperty -Path $wlKey -Name 'DefaultDomainName'-Value $env:COMPUTERNAME + Set-ItemProperty -Path $wlKey -Name 'DefaultPassword' -Value $tempPass + Set-ItemProperty -Path $wlKey -Name 'AutoLogonCount' -Value 1 -Type DWord + Write-Host " [OK] One-time autologon configured" -ForegroundColor Green + + # Register cleanup script as logon task for GuruRMM-Temp + $action = New-ScheduledTaskAction -Execute 'powershell.exe' ` + -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$Base\Invoke-PostRebootCleanup.ps1`"" + $trigger = New-ScheduledTaskTrigger -AtLogOn -User $tempUser + $principal = New-ScheduledTaskPrincipal -UserId $tempUser -RunLevel Highest -LogonType Interactive + $settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 30) -MultipleInstances IgnoreNew + Register-ScheduledTask -TaskName 'GuruRMM-PostRebootCleanup' ` + -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null + Write-Host " [OK] Post-reboot cleanup task registered" -ForegroundColor Green + + Write-Host '' + Write-Host ' [INFO] Rebooting in 15 seconds. The machine will log in automatically,' -ForegroundColor Yellow + Write-Host ' complete cleanup, then return to the normal login screen.' -ForegroundColor Yellow + Write-Host '' + Start-Sleep -Seconds 15 + Restart-Computer -Force +} + +function Invoke-HitmanProTrialReset { + Remove-Item -Path 'HKLM:\SOFTWARE\HitmanPro' -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path 'HKLM:\SOFTWARE\HitmanPro.Alert' -Recurse -Force -ErrorAction SilentlyContinue + New-Item -Path 'HKLM:\SOFTWARE\HitmanPro' -Force | Out-Null + New-Item -Path 'HKLM:\SOFTWARE\HitmanPro.Alert' -Force | Out-Null +} + +function Get-ExitCodeThreats { + param([string]$ScannerName, [int]$ExitCode) + switch ($ScannerName) { + # 0=clean 1=cleaned(no reboot) 2=cleaned(reboot needed) 3=found/not cleaned (scan-only) + 'AdwCleaner' { if ($ExitCode -in @(1,2,3)) { return 1 } } + # 0=clean 1=cleaned 2=cleaned(reboot needed) + 'HitmanPro' { if ($ExitCode -in @(1,2)) { return 1 } } + # 0=clean 1=threats found/cleaned 2=found but not fully removed (reboot needed) + 'Emsisoft' { if ($ExitCode -ge 1) { return 1 } } + # 0=clean 1=threats found 2=incomplete removal (reboot may help) + 'ESET' { if ($ExitCode -in @(1,2)) { return 1 } } + 'MSERT' { if ($ExitCode -ne 0) { return 1 } } + 'TDSSKiller' { if ($ExitCode -eq 1) { return 1 } } + 'Stinger' { if ($ExitCode -eq 13) { return 1 } } + # RKill: exit 1 = processes were killed, not a threat count + } + return 0 +} + +function Get-ExitCodeReboot { + param([string]$ScannerName, [int]$ExitCode) + switch ($ScannerName) { + 'AdwCleaner' { if ($ExitCode -eq 2) { return $true } } + 'HitmanPro' { if ($ExitCode -eq 2) { return $true } } + 'Emsisoft' { if ($ExitCode -eq 2) { return $true } } + 'ESET' { if ($ExitCode -eq 2) { return $true } } + } + return $false +} + +function Invoke-ForceRemoveList { + param([array]$RemoveList) + if (-not $RemoveList -or $RemoveList.Count -eq 0) { return } + Write-Host '' + Write-Host '------------------------------------------------------------' -ForegroundColor DarkGray + Write-Host ' Force-Remove Blacklist' -ForegroundColor Cyan + foreach ($entry in $RemoveList) { + $type = $entry.Type + $value = $entry.Value + try { + switch ($type) { + 'Path' { + $expanded = [System.Environment]::ExpandEnvironmentVariables($value) + if (Test-Path $expanded) { + Remove-Item -Path $expanded -Recurse -Force -ErrorAction Stop + Write-Host " [OK] Removed path: $expanded" -ForegroundColor Green + } else { + Write-Host " [SKIP] Path not found: $expanded" -ForegroundColor Gray + } + } + 'Registry' { + if (Test-Path $value) { + Remove-Item -Path $value -Recurse -Force -ErrorAction Stop + Write-Host " [OK] Removed registry key: $value" -ForegroundColor Green + } else { + Write-Host " [SKIP] Registry key not found: $value" -ForegroundColor Gray + } + } + 'RegValue' { + if (Test-Path $entry.Key) { + Remove-ItemProperty -Path $entry.Key -Name $value -Force -ErrorAction Stop + Write-Host " [OK] Removed registry value: $($entry.Key)\$value" -ForegroundColor Green + } else { + Write-Host " [SKIP] Registry key not found: $($entry.Key)" -ForegroundColor Gray + } + } + 'Service' { + $svc = Get-Service -Name $value -ErrorAction SilentlyContinue + if ($svc) { + Stop-Service -Name $value -Force -ErrorAction SilentlyContinue + Start-Sleep -Seconds 2 + & sc.exe delete $value | Out-Null + Write-Host " [OK] Removed service: $value" -ForegroundColor Green + } else { + Write-Host " [SKIP] Service not found: $value" -ForegroundColor Gray + } + } + 'Task' { + $task = Get-ScheduledTask -TaskName $value -ErrorAction SilentlyContinue + if ($task) { + Unregister-ScheduledTask -TaskName $value -Confirm:$false -ErrorAction Stop + Write-Host " [OK] Removed scheduled task: $value" -ForegroundColor Green + } else { + Write-Host " [SKIP] Scheduled task not found: $value" -ForegroundColor Gray + } + } + 'Process' { + $procs = Get-Process -Name $value -ErrorAction SilentlyContinue + if ($procs) { + $procs | Stop-Process -Force -ErrorAction SilentlyContinue + Write-Host " [OK] Killed process: $value ($($procs.Count) instance(s))" -ForegroundColor Green + } else { + Write-Host " [SKIP] Process not running: $value" -ForegroundColor Gray + } + } + default { + Write-Host " [WARNING] Unknown ForceRemove type '$type' for: $value" -ForegroundColor Yellow + } + } + } + catch { + Write-Host " [ERROR] ForceRemove failed ($type): $value - $_" -ForegroundColor Red + } + } +} + +function Test-RunningAsSystem { + $id = [System.Security.Principal.WindowsIdentity]::GetCurrent() + return ($id.IsSystem) +} + +# --------------------------------------------------------------------------- +# Initialization +# --------------------------------------------------------------------------- + +$ScanStamp = "$env:COMPUTERNAME-$(Get-Date -Format yyyyMMdd-HHmmss)" +$RunLogRoot = "C:\ScanLogs\$ScanStamp" +$ScanMode = if ($ScanOnly) { 'scan' } else { 'clean' } + +# Auto-skip ESET when running as SYSTEM (no interactive desktop) +$RunningAsSystem = Test-RunningAsSystem +if ($RunningAsSystem -and -not $SkipEset) { + Write-Host '[INFO] Running as SYSTEM - ESET will be skipped (requires interactive desktop).' -ForegroundColor Cyan + $SkipEset = $true +} + +# Create required directories +New-Item -ItemType Directory -Path $Base -Force | Out-Null +New-Item -ItemType Directory -Path "$Base\downloads" -Force | Out-Null +New-Item -ItemType Directory -Path $RunLogRoot -Force | Out-Null +New-Item -ItemType Directory -Path "$Base\reports" -Force | Out-Null + +# Write whitelist file +$Whitelist | Set-Content -Path "$Base\whitelist.txt" -Encoding UTF8 +Write-Host "[INFO] Whitelist written to $Base\whitelist.txt ($($Whitelist.Count) entries)" -ForegroundColor Cyan + +Write-Host '' +Write-Host '=== GuruScan ===' -ForegroundColor Cyan +Write-Host " Machine : $env:COMPUTERNAME" +Write-Host " Scan ID : $ScanStamp" +Write-Host " Log root : $RunLogRoot" +Write-Host " Mode : $ScanMode" +Write-Host " As SYSTEM : $RunningAsSystem" +Write-Host '' + +# --------------------------------------------------------------------------- +# Filter scanners +# --------------------------------------------------------------------------- + +$scannerList = $ScannerDefs + +if ($SkipEset) { + $scannerList = $scannerList | Where-Object { $_.Name -ne 'ESET' } +} + +if ($SkipScanners -and $SkipScanners.Count -gt 0) { + $scannerList = $scannerList | Where-Object { $SkipScanners -notcontains $_.Name } +} + +if ($Scanners -and $Scanners.Count -gt 0) { + $scannerList = $scannerList | Where-Object { $Scanners -contains $_.Name } + if (-not $scannerList -or @($scannerList).Count -eq 0) { + Write-Host "[ERROR] No scanners matched the provided names: $($Scanners -join ', ')" -ForegroundColor Red + exit 1 + } +} + +# --------------------------------------------------------------------------- +# Run scanners +# --------------------------------------------------------------------------- + +$results = [System.Collections.Generic.List[pscustomobject]]::new() +$startedAt = Get-Date +$totalThreats = 0 +$rebootRequired = $false + +foreach ($s in $scannerList) { + Write-Host '------------------------------------------------------------' -ForegroundColor DarkGray + Write-Host " Scanner : $($s.Name)" -ForegroundColor Cyan + Write-Host " Mode : $ScanMode" + + # ------------------------------------------------------------------ + # Resolve EXE paths + # ------------------------------------------------------------------ + $mainExePath = $s.Exe + $installerExePath = $s.InstallerExe + + # For two-step scanners the main EXE won't exist until after install + $exeToCheck = if ($installerExePath) { $installerExePath } else { $mainExePath } + + if (-not (Test-Path $exeToCheck)) { + Write-Host " [WARNING] EXE not found - skipping: $exeToCheck" -ForegroundColor Yellow + $results.Add([pscustomobject]@{ + Scanner = $s.Name + Status = 'SKIPPED (missing)' + ExitCode = $null + ThreatsFound = 0 + Duration = '0 min' + LogPath = '' + }) + continue + } + + # ------------------------------------------------------------------ + # Resolve args - expand {LOG_ROOT} tokens + # ------------------------------------------------------------------ + $exeDir = Split-Path $mainExePath -Parent + + if ($ScanOnly) { + $rawArgs = @($s.ScanArgs) + } else { + $rawArgs = @($s.CleanArgs) + } + + $expandedArgs = Expand-TokenizedArgs -ArgList $rawArgs -LogRoot $RunLogRoot -ExeDir $exeDir + + # ------------------------------------------------------------------ + # HitmanPro trial registry pre-seed + # ------------------------------------------------------------------ + if ($s.HitmanProReset -eq $true) { + Write-Host ' [INFO] Resetting HitmanPro trial registry...' -ForegroundColor Cyan + Invoke-HitmanProTrialReset + } + + # ------------------------------------------------------------------ + # Pre-clean paths + # ------------------------------------------------------------------ + foreach ($p in $s.PreCleanPaths) { + Remove-PathSilent $p + } + + # ------------------------------------------------------------------ + # Determine effective timeout + # ------------------------------------------------------------------ + $effectiveTimeoutMin = if ($TimeoutMin -gt 0) { $TimeoutMin } else { $s.TimeoutMin } + $timeoutSec = $effectiveTimeoutMin * 60 + + # ------------------------------------------------------------------ + # Two-step install: Emsisoft pattern (installer + /update + scan) + # ------------------------------------------------------------------ + if ($installerExePath) { + Write-Host " [INFO] Running installer: $installerExePath" -ForegroundColor Cyan + try { + $instProc = Start-Process -FilePath $installerExePath -ArgumentList '/S' -PassThru -WindowStyle Hidden + $instCompleted = Wait-ProcessWithTimeout -Process $instProc -TimeoutSeconds 300 + # Close any Explorer folder window the NSIS installer opened (it opens the extract dir) + try { + (New-Object -ComObject Shell.Application).Windows() | + Where-Object { $_.LocationURL -like '*EmsisoftCmd*' -or $_.LocationName -like '*EmsisoftCmd*' } | + ForEach-Object { $_.Quit() } + } catch {} + if (-not $instCompleted) { + Write-Host ' [WARNING] Installer timed out after 5 min - skipping scanner' -ForegroundColor Yellow + $results.Add([pscustomobject]@{ + Scanner = $s.Name + Status = 'FAILED (installer timeout)' + ExitCode = $null + ThreatsFound = 0 + Duration = '0 min' + LogPath = '' + }) + continue + } + } + catch { + Write-Host " [ERROR] Installer failed: $_" -ForegroundColor Red + $results.Add([pscustomobject]@{ + Scanner = $s.Name + Status = "FAILED (installer error: $_)" + ExitCode = $null + ThreatsFound = 0 + Duration = '0 min' + LogPath = '' + }) + continue + } + + if (-not (Test-Path $mainExePath)) { + Write-Host " [ERROR] Main EXE not found after install: $mainExePath" -ForegroundColor Red + $results.Add([pscustomobject]@{ + Scanner = $s.Name + Status = 'FAILED (post-install EXE missing)' + ExitCode = $null + ThreatsFound = 0 + Duration = '0 min' + LogPath = '' + }) + continue + } + + Write-Host ' [INFO] Updating Emsisoft definitions...' -ForegroundColor Cyan + try { + $updateProc = Start-Process -FilePath $mainExePath -ArgumentList '/update' -PassThru -WindowStyle Hidden + Wait-ProcessWithTimeout -Process $updateProc -TimeoutSeconds 300 | Out-Null + } + catch { + Write-Host " [WARNING] Update step failed: $_ - continuing with existing definitions" -ForegroundColor Yellow + } + } + + # ------------------------------------------------------------------ + # Randomize EXE name (TDSSKiller pattern - bypass rootkit self-protection) + # ------------------------------------------------------------------ + $randomizedExePath = $null + $launchExe = $mainExePath + + if ($s.RandomizeExe -eq $true) { + $tempName = [System.IO.Path]::GetRandomFileName() -replace '\..*$', '' + $randomizedExePath = Join-Path $env:TEMP "$tempName.exe" + Copy-Item -Path $mainExePath -Destination $randomizedExePath -Force + $launchExe = $randomizedExePath + Write-Host " [INFO] EXE randomized to: $randomizedExePath" -ForegroundColor Cyan + } + + # ------------------------------------------------------------------ + # Kill processes that would block this scanner (e.g. browsers before HitmanPro) + # ------------------------------------------------------------------ + if ($s.PreCloseProcesses -and $s.PreCloseProcesses.Count -gt 0) { + foreach ($proc in $s.PreCloseProcesses) { + Get-Process -Name $proc -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue + } + Start-Sleep -Seconds 2 + } + + # ------------------------------------------------------------------ + # Launch scanner + # ------------------------------------------------------------------ + Write-Host " [....] Launching $($s.Name)..." -ForegroundColor Cyan + $scanStart = Get-Date + $proc = $null + $status = 'completed' + $exitCode = $null + + try { + $startParams = @{ + FilePath = $launchExe + PassThru = $true + NoNewWindow = [bool]$Headless + } + if ($expandedArgs -and $expandedArgs.Count -gt 0) { + $startParams['ArgumentList'] = $expandedArgs + } + + $proc = Start-Process @startParams + + $completed = Wait-ScannerCompletion -Process $proc -WaitOnProcess $s.WaitOnProcess -ServiceNames $s.ServiceNames -TimeoutSeconds $timeoutSec + + if (-not $completed) { + $status = 'TIMED OUT' + Write-Host " [WARNING] $($s.Name) timed out after $effectiveTimeoutMin min" -ForegroundColor Yellow + } else { + $exitCode = $proc.ExitCode + } + } + catch { + $status = 'FAILED' + Write-Host " [ERROR] $($s.Name) failed to launch: $_" -ForegroundColor Red + } + + # ------------------------------------------------------------------ + # Calculate duration + # ------------------------------------------------------------------ + $scanEnd = Get-Date + $durMin = [math]::Round(($scanEnd - $scanStart).TotalMinutes, 2) + + # ------------------------------------------------------------------ + # Collect logs from scanner-specific log source directory/file + # ------------------------------------------------------------------ + $logDestDir = Join-Path $RunLogRoot "$($s.Name)_Logs" + + if ($s.LogSrc) { + $logSrcExpanded = @(Expand-TokenizedArgs -ArgList @($s.LogSrc) -LogRoot $RunLogRoot -ExeDir $exeDir) + if ($logSrcExpanded.Count -gt 0 -and $logSrcExpanded[0]) { + $logSrcPath = Expand-EnvPath $logSrcExpanded[0] + if (Test-Path $logSrcPath) { + New-Item -ItemType Directory -Path $logDestDir -Force | Out-Null + Copy-Item -Path $logSrcPath -Destination $logDestDir -Recurse -Force -ErrorAction SilentlyContinue + Write-Host " [INFO] Logs copied to: $logDestDir" -ForegroundColor Cyan + } else { + Write-Host " [WARNING] Log source not found: $logSrcPath" -ForegroundColor Yellow + } + } + } + + # ------------------------------------------------------------------ + # Post-clean paths + # ------------------------------------------------------------------ + foreach ($p in $s.PostCleanPaths) { + Remove-PathSilent $p + } + + # ------------------------------------------------------------------ + # Clean up randomized EXE copy + # ------------------------------------------------------------------ + if ($randomizedExePath -and (Test-Path $randomizedExePath)) { + Remove-Item $randomizedExePath -Force -ErrorAction SilentlyContinue + } + + # ------------------------------------------------------------------ + # Threat and reboot detection from exit code + # ------------------------------------------------------------------ + $threatsFound = 0 + if ($null -ne $exitCode) { + $threatsFound = Get-ExitCodeThreats -ScannerName $s.Name -ExitCode $exitCode + if (Get-ExitCodeReboot -ScannerName $s.Name -ExitCode $exitCode) { + $rebootRequired = $true + Write-Host " [WARNING] $($s.Name) signals REBOOT REQUIRED to complete removal" -ForegroundColor Yellow + } + } + $totalThreats += $threatsFound + + # ------------------------------------------------------------------ + # Status reporting + # ------------------------------------------------------------------ + $color = if ($status -eq 'completed') { 'Green' } elseif ($status -eq 'TIMED OUT') { 'Yellow' } else { 'Red' } + Write-Host " [$status] ExitCode=$exitCode Threats=$threatsFound Duration=${durMin}min" -ForegroundColor $color + + $results.Add([pscustomobject]@{ + Scanner = $s.Name + Status = $status + ExitCode = $exitCode + ThreatsFound = $threatsFound + Duration = "$durMin min" + LogPath = $logDestDir + }) +} + +# --------------------------------------------------------------------------- +# Auto-remediate: re-run in clean mode if scan-only found threats +# --------------------------------------------------------------------------- + +if ($AutoRemediate -and $ScanOnly -and $totalThreats -gt 0) { + Write-Host '' + Write-Host ' [INFO] -AutoRemediate: threats found - re-running all scanners in clean mode...' -ForegroundColor Cyan + Write-Host '' + + # Reset accumulators for the clean pass + $results.Clear() + $totalThreats = 0 + $rebootRequired = $false + $ScanMode = 'clean' + + $remList = if ($SkipEset) { + $ScannerDefs | Where-Object { $_.Name -ne 'ESET' } + } else { + $ScannerDefs + } + + if ($Scanners -and $Scanners.Count -gt 0) { + $remList = $remList | Where-Object { $Scanners -contains $_.Name } + } + + foreach ($s in $remList) { + Write-Host '------------------------------------------------------------' -ForegroundColor DarkGray + Write-Host " Scanner (clean) : $($s.Name)" -ForegroundColor Cyan + + $mainExePath = $s.Exe + $installerExePath = $s.InstallerExe + $exeToCheck = if ($installerExePath) { $installerExePath } else { $mainExePath } + + if (-not (Test-Path $exeToCheck)) { + Write-Host " [WARNING] EXE not found - skipping: $exeToCheck" -ForegroundColor Yellow + $results.Add([pscustomobject]@{ + Scanner = $s.Name + Status = 'SKIPPED (missing)' + ExitCode = $null + ThreatsFound = 0 + Duration = '0 min' + LogPath = '' + }) + continue + } + + $exeDir = Split-Path $mainExePath -Parent + $expandedArgs = Expand-TokenizedArgs -ArgList @($s.CleanArgs) -LogRoot $RunLogRoot -ExeDir $exeDir + + if ($s.HitmanProReset -eq $true) { + Write-Host ' [INFO] Resetting HitmanPro trial registry...' -ForegroundColor Cyan + Invoke-HitmanProTrialReset + } + + foreach ($p in $s.PreCleanPaths) { Remove-PathSilent $p } + + $effectiveTimeoutMin = if ($TimeoutMin -gt 0) { $TimeoutMin } else { $s.TimeoutMin } + $timeoutSec = $effectiveTimeoutMin * 60 + + if ($installerExePath) { + Write-Host " [INFO] Running installer: $installerExePath" -ForegroundColor Cyan + try { + $instProc = Start-Process -FilePath $installerExePath -ArgumentList '/S' -PassThru -WindowStyle Hidden + $instCompleted = Wait-ProcessWithTimeout -Process $instProc -TimeoutSeconds 300 + try { + (New-Object -ComObject Shell.Application).Windows() | + Where-Object { $_.LocationURL -like '*EmsisoftCmd*' -or $_.LocationName -like '*EmsisoftCmd*' } | + ForEach-Object { $_.Quit() } + } catch {} + if (-not $instCompleted) { + Write-Host ' [WARNING] Installer timed out - skipping scanner' -ForegroundColor Yellow + $results.Add([pscustomobject]@{ + Scanner = $s.Name; Status = 'FAILED (installer timeout)' + ExitCode = $null; ThreatsFound = 0; Duration = '0 min'; LogPath = '' + }) + continue + } + } + catch { + Write-Host " [ERROR] Installer failed: $_" -ForegroundColor Red + $results.Add([pscustomobject]@{ + Scanner = $s.Name; Status = "FAILED (installer error: $_)" + ExitCode = $null; ThreatsFound = 0; Duration = '0 min'; LogPath = '' + }) + continue + } + + if (-not (Test-Path $mainExePath)) { + Write-Host " [ERROR] Main EXE not found after install: $mainExePath" -ForegroundColor Red + $results.Add([pscustomobject]@{ + Scanner = $s.Name; Status = 'FAILED (post-install EXE missing)' + ExitCode = $null; ThreatsFound = 0; Duration = '0 min'; LogPath = '' + }) + continue + } + + Write-Host ' [INFO] Updating Emsisoft definitions...' -ForegroundColor Cyan + try { + $updateProc = Start-Process -FilePath $mainExePath -ArgumentList '/update' -PassThru -WindowStyle Hidden + Wait-ProcessWithTimeout -Process $updateProc -TimeoutSeconds 300 | Out-Null + } + catch { + Write-Host " [WARNING] Update step failed: $_ - continuing with existing definitions" -ForegroundColor Yellow + } + } + + $randomizedExePath = $null + $launchExe = $mainExePath + + if ($s.RandomizeExe -eq $true) { + $tempName = [System.IO.Path]::GetRandomFileName() -replace '\..*$', '' + $randomizedExePath = Join-Path $env:TEMP "$tempName.exe" + Copy-Item -Path $mainExePath -Destination $randomizedExePath -Force + $launchExe = $randomizedExePath + Write-Host " [INFO] EXE randomized to: $randomizedExePath" -ForegroundColor Cyan + } + + Write-Host " [....] Launching $($s.Name)..." -ForegroundColor Cyan + $scanStart = Get-Date + $status = 'completed' + $exitCode = $null + + try { + $startParams = @{ FilePath = $launchExe; PassThru = $true; NoNewWindow = [bool]$Headless } + if ($expandedArgs -and $expandedArgs.Count -gt 0) { + $startParams['ArgumentList'] = $expandedArgs + } + $proc = Start-Process @startParams + $completed = Wait-ScannerCompletion -Process $proc -WaitOnProcess $s.WaitOnProcess -ServiceNames $s.ServiceNames -TimeoutSeconds $timeoutSec + if (-not $completed) { + $status = 'TIMED OUT' + Write-Host " [WARNING] $($s.Name) timed out after $effectiveTimeoutMin min" -ForegroundColor Yellow + } else { + $exitCode = $proc.ExitCode + } + } + catch { + $status = 'FAILED' + Write-Host " [ERROR] $($s.Name) failed to launch: $_" -ForegroundColor Red + } + + $scanEnd = Get-Date + $durMin = [math]::Round(($scanEnd - $scanStart).TotalMinutes, 2) + $logDestDir = Join-Path $RunLogRoot "$($s.Name)_Clean_Logs" + + if ($s.LogSrc) { + $logSrcExpanded = Expand-TokenizedArgs -ArgList @($s.LogSrc) -LogRoot $RunLogRoot -ExeDir $exeDir + if ($logSrcExpanded -and $logSrcExpanded.Count -gt 0 -and $logSrcExpanded[0]) { + $logSrcPath = Expand-EnvPath $logSrcExpanded[0] + if (Test-Path $logSrcPath) { + New-Item -ItemType Directory -Path $logDestDir -Force | Out-Null + Copy-Item -Path $logSrcPath -Destination $logDestDir -Recurse -Force -ErrorAction SilentlyContinue + Write-Host " [INFO] Logs copied to: $logDestDir" -ForegroundColor Cyan + } else { + Write-Host " [WARNING] Log source not found: $logSrcPath" -ForegroundColor Yellow + } + } + } + + foreach ($p in $s.PostCleanPaths) { Remove-PathSilent $p } + + if ($randomizedExePath -and (Test-Path $randomizedExePath)) { + Remove-Item $randomizedExePath -Force -ErrorAction SilentlyContinue + } + + $threatsFound = 0 + if ($null -ne $exitCode) { + $threatsFound = Get-ExitCodeThreats -ScannerName $s.Name -ExitCode $exitCode + if (Get-ExitCodeReboot -ScannerName $s.Name -ExitCode $exitCode) { + $rebootRequired = $true + Write-Host " [WARNING] $($s.Name) signals REBOOT REQUIRED to complete removal" -ForegroundColor Yellow + } + } + $totalThreats += $threatsFound + + $color = if ($status -eq 'completed') { 'Green' } elseif ($status -eq 'TIMED OUT') { 'Yellow' } else { 'Red' } + Write-Host " [$status] ExitCode=$exitCode Threats=$threatsFound Duration=${durMin}min" -ForegroundColor $color + + $results.Add([pscustomobject]@{ + Scanner = $s.Name + Status = $status + ExitCode = $exitCode + ThreatsFound = $threatsFound + Duration = "$durMin min" + LogPath = $logDestDir + }) + } +} + +# --------------------------------------------------------------------------- +# Force-remove blacklist stage (runs after all scanners) +# --------------------------------------------------------------------------- + +Invoke-ForceRemoveList -RemoveList $ForceRemove + +# --------------------------------------------------------------------------- +# Build results.json +# --------------------------------------------------------------------------- + +$completedAt = Get-Date + +$scannerResults = foreach ($r in $results) { + $durValue = 0 + if ($r.Duration -match '^([\d.]+)') { + $durValue = [double]$Matches[1] + } + [ordered]@{ + name = $r.Scanner + status = $r.Status + exit_code = $r.ExitCode + threats_found = $r.ThreatsFound + duration_min = $durValue + log_path = $r.LogPath + } +} + +$resultsObj = [ordered]@{ + scan_id = $ScanStamp + machine = $env:COMPUTERNAME + started_at = $startedAt.ToUniversalTime().ToString('o') + completed_at = $completedAt.ToUniversalTime().ToString('o') + total_threats = $totalThreats + reboot_required = $rebootRequired + scan_mode = $ScanMode + scanners = @($scannerResults) +} + +$resultsJsonPath = Join-Path $RunLogRoot 'results.json' +$resultsObj | ConvertTo-Json -Depth 10 | Set-Content -Path $resultsJsonPath -Encoding UTF8 + +# --------------------------------------------------------------------------- +# Export CSV summary +# --------------------------------------------------------------------------- + +$csvPath = Join-Path $RunLogRoot '_summary.csv' +$results | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8 + +# --------------------------------------------------------------------------- +# Archive all logs into a zip file for easy retrieval +# --------------------------------------------------------------------------- + +$reportsDir = "$Base\reports" +New-Item -ItemType Directory -Path $reportsDir -Force | Out-Null + +$zipPath = "$reportsDir\$ScanStamp.zip" +try { + Compress-Archive -Path "$RunLogRoot\*" -DestinationPath $zipPath -Force + Write-Host "[INFO] Log archive: $zipPath" -ForegroundColor Cyan +} +catch { + Write-Host "[WARNING] Failed to create log archive: $_" -ForegroundColor Yellow +} + +# --------------------------------------------------------------------------- +# Print summary +# --------------------------------------------------------------------------- + +Write-Host '' +Write-Host '============================================================' -ForegroundColor Cyan +Write-Host " SCAN COMPLETE - $ScanStamp" -ForegroundColor Cyan +Write-Host '============================================================' -ForegroundColor Cyan +Write-Host '' +$results | Format-Table -AutoSize +Write-Host '' +Write-Host " Total threats detected : $totalThreats" +Write-Host " Results JSON : $resultsJsonPath" +Write-Host " Summary CSV : $csvPath" +Write-Host " Log archive : $zipPath" +Write-Host '' + +if ($totalThreats -gt 0) { + Write-Host " [WARNING] THREATS DETECTED - review logs in: $RunLogRoot" -ForegroundColor Yellow +} + +# --------------------------------------------------------------------------- +# If any scanner requires a reboot, set up the temp cleanup session and reboot +# --------------------------------------------------------------------------- +if ($rebootRequired) { + $consoleUser = '' + try { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent().Name + if ($identity -notmatch 'SYSTEM') { + # Running as a real user - whoami returns the actual SAM login name + $consoleUser = (& whoami).Trim() + } else { + # Running as SYSTEM (e.g. via RMM) - find the console session user via quser + $quserLines = & quser 2>$null + $consoleLine = $quserLines | Where-Object { $_ -match '\bconsole\b' } + if ($consoleLine -and $consoleLine -match '^\s*>?\s*(\S+)') { + $samName = $Matches[1] + $ci = Get-CimInstance Win32_ComputerSystem -ErrorAction SilentlyContinue + $prefix = if ($ci -and $ci.PartOfDomain) { $ci.Domain } else { $env:COMPUTERNAME } + $consoleUser = "$prefix\$samName" + } + } + } catch {} + Invoke-RebootCleanupSetup -OriginalUser $consoleUser -ScanId $ScanStamp -LogRoot $RunLogRoot + # Invoke-RebootCleanupSetup does not return - it calls Restart-Computer +} + +exit 0 diff --git a/projects/msp-tools/guru-scan/Invoke-PostRebootCleanup.ps1 b/projects/msp-tools/guru-scan/Invoke-PostRebootCleanup.ps1 new file mode 100644 index 0000000..6fa5dfb --- /dev/null +++ b/projects/msp-tools/guru-scan/Invoke-PostRebootCleanup.ps1 @@ -0,0 +1,215 @@ +#Requires -RunAsAdministrator +<# +.SYNOPSIS + Post-reboot cleanup agent for GuruScan. Runs automatically as GuruRMM-Temp + after a reboot triggered by scanner exit code 2 (pending removal required). + Shows a full-screen splash, verifies cleanup completed, removes scanner files, + restores the original user's login name, then logs off. +#> +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$Base = 'C:\GuruScan' +$StateFile = "$Base\cleanup-state.json" +$TempUser = 'GuruRMM-Temp' +$WlKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' +$SpecialKey= 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\SpecialAccounts\UserList' + +# --------------------------------------------------------------------------- +# Kill Explorer so we have a clean screen before showing splash +# --------------------------------------------------------------------------- +Get-Process -Name explorer -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue +Start-Sleep -Seconds 1 + +# --------------------------------------------------------------------------- +# Full-screen splash in a STA runspace (non-blocking) +# --------------------------------------------------------------------------- +$sync = [hashtable]::Synchronized(@{ Close = $false }) + +$uiRS = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace() +$uiRS.ApartmentState = 'STA' +$uiRS.ThreadOptions = 'ReuseThread' +$uiRS.Open() + +$uiPS = [System.Management.Automation.PowerShell]::Create() +$uiPS.Runspace = $uiRS +[void]$uiPS.AddScript({ + param($s) + Add-Type -AssemblyName PresentationFramework, PresentationCore, WindowsBase + + $win = New-Object System.Windows.Window + $win.WindowStyle = 'None' + $win.WindowState = 'Maximized' + $win.Background = [System.Windows.Media.Brushes]::Black + $win.Topmost = $true + $win.Title = 'GuruRMM' + $win.Cursor = [System.Windows.Input.Cursors]::None + + $panel = New-Object System.Windows.Controls.StackPanel + $panel.VerticalAlignment = 'Center' + $panel.HorizontalAlignment = 'Center' + + $title = New-Object System.Windows.Controls.TextBlock + $title.Text = 'GuruRMM' + $title.Foreground = [System.Windows.Media.Brushes]::White + $title.FontSize = 42 + $title.FontFamily = New-Object System.Windows.Media.FontFamily('Segoe UI') + $title.FontWeight = [System.Windows.FontWeights]::Light + $title.TextAlignment = 'Center' + $title.Margin = New-Object System.Windows.Thickness(0,0,0,24) + + $body = New-Object System.Windows.Controls.TextBlock + $body.Text = "Security cleanup is being completed on this machine.`n`nPlease do not power off this computer.`n`nThis process will finish shortly." + $body.Foreground = [System.Windows.Media.SolidColorBrush][System.Windows.Media.Color]::FromRgb(180,180,180) + $body.FontSize = 22 + $body.FontFamily = New-Object System.Windows.Media.FontFamily('Segoe UI') + $body.TextAlignment = 'Center' + $body.LineHeight = 38 + + [void]$panel.Children.Add($title) + [void]$panel.Children.Add($body) + $win.Content = $panel + + $timer = New-Object System.Windows.Threading.DispatcherTimer + $timer.Interval = [TimeSpan]::FromMilliseconds(500) + $timer.Add_Tick({ if ($s.Close) { $win.Close(); $timer.Stop() } }) + $timer.Start() + + [void]$win.ShowDialog() +}).AddArgument($sync) + +$uiHandle = $uiPS.BeginInvoke() + +# --------------------------------------------------------------------------- +# Read state file +# --------------------------------------------------------------------------- +$state = @{ original_user = ''; scan_id = ''; log_root = '' } +if (Test-Path $StateFile) { + try { $state = Get-Content $StateFile -Raw | ConvertFrom-Json } catch {} +} + +$logRoot = $state.log_root +$scanId = $state.scan_id +$origUser = $state.original_user + +# --------------------------------------------------------------------------- +# Wait for boot-time cleanup to settle +# --------------------------------------------------------------------------- +Start-Sleep -Seconds 60 + +# --------------------------------------------------------------------------- +# Verify: check PendingFileRenameOperations (empty = boot cleanup completed) +# --------------------------------------------------------------------------- +$pendingKey = 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' +$pendingOps = (Get-ItemProperty -Path $pendingKey -Name PendingFileRenameOperations -ErrorAction SilentlyContinue).PendingFileRenameOperations +$pendingDone = (-not $pendingOps -or $pendingOps.Count -eq 0) + +# Check scanner post-reboot logs +$adwLog = Get-ChildItem 'C:\AdwCleaner\Logs' -Filter '*.txt' -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 +$hitLog = if ($logRoot -and (Test-Path "$logRoot\HitmanPro_Scan_Log.txt")) { + Get-Item "$logRoot\HitmanPro_Scan_Log.txt" + } else { $null } + +$verification = [ordered]@{ + checked_at = (Get-Date).ToUniversalTime().ToString('o') + pending_ops_cleared = $pendingDone + adwcleaner_log = if ($adwLog) { $adwLog.FullName } else { 'not found' } + hitmanpro_log = if ($hitLog) { $hitLog.FullName } else { 'not found' } + result = if ($pendingDone) { 'clean' } else { 'pending_items_remain' } +} + +# Write verification result alongside original results.json +if ($logRoot -and (Test-Path $logRoot)) { + $verification | ConvertTo-Json | Set-Content "$logRoot\post_reboot_verification.json" -Encoding UTF8 +} + +# --------------------------------------------------------------------------- +# Remove scanner installation files from this machine +# --------------------------------------------------------------------------- +$scannerPaths = @( + 'C:\EmsisoftCmd', + 'C:\AdwCleaner', + 'C:\ProgramData\HitmanPro', + 'C:\ProgramData\HitmanPro.Alert' +) +foreach ($p in $scannerPaths) { + Remove-Item -Path $p -Recurse -Force -ErrorAction SilentlyContinue +} + +# --------------------------------------------------------------------------- +# Flag logs for GuruRMM to pull (agent reads this file) +# --------------------------------------------------------------------------- +@{ + scan_id = $scanId + log_root = $logRoot + zip_path = "$Base\reports\$scanId.zip" + verified = $verification.result + flagged_at = (Get-Date).ToUniversalTime().ToString('o') +} | ConvertTo-Json | Set-Content "$Base\logs-ready.json" -Encoding UTF8 + +# --------------------------------------------------------------------------- +# Restore original user's name in the login screen. +# Win32_ComputerSystem.UserName returns "DOMAIN\user" or "MACHINE\user". +# Split so the login screen pre-fills both fields correctly. +# --------------------------------------------------------------------------- +if ($origUser) { + try { + if ($origUser -match '^(.+)\\(.+)$') { + $restoreDomain = $Matches[1] + $restoreUser = $Matches[2] + } else { + $restoreDomain = $env:COMPUTERNAME + $restoreUser = $origUser + } + Set-ItemProperty -Path $WlKey -Name 'DefaultUserName' -Value $restoreUser + Set-ItemProperty -Path $WlKey -Name 'DefaultDomainName' -Value $restoreDomain + } catch {} +} + +# --------------------------------------------------------------------------- +# Clear autologon settings +# --------------------------------------------------------------------------- +try { + Set-ItemProperty -Path $WlKey -Name 'AutoAdminLogon' -Value '0' + Remove-ItemProperty -Path $WlKey -Name 'DefaultPassword' -ErrorAction SilentlyContinue +} catch {} + +# --------------------------------------------------------------------------- +# Remove our scheduled logon task +# --------------------------------------------------------------------------- +Unregister-ScheduledTask -TaskName 'GuruRMM-PostRebootCleanup' -Confirm:$false -ErrorAction SilentlyContinue + +# --------------------------------------------------------------------------- +# Schedule SYSTEM task to delete GuruRMM-Temp 2 minutes from now +# (can't delete the account we're currently logged into) +# --------------------------------------------------------------------------- +try { + $deleteScript = @" +Remove-LocalUser -Name '$TempUser' -ErrorAction SilentlyContinue +Remove-ItemProperty -Path '$SpecialKey' -Name '$TempUser' -ErrorAction SilentlyContinue +Remove-Item -Path 'C:\Users\$TempUser' -Recurse -Force -ErrorAction SilentlyContinue +Remove-Item -Path '$StateFile' -Force -ErrorAction SilentlyContinue +Unregister-ScheduledTask -TaskName 'GuruRMM-TempUserDelete' -Confirm:`$false -ErrorAction SilentlyContinue +"@ + $deleteScript | Set-Content "$Base\delete-temp-user.ps1" -Encoding UTF8 + + $action = New-ScheduledTaskAction -Execute 'powershell.exe' ` + -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$Base\delete-temp-user.ps1`"" + $trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(2) + $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 5) + Register-ScheduledTask -TaskName 'GuruRMM-TempUserDelete' -Action $action ` + -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null +} catch {} + +# --------------------------------------------------------------------------- +# Close splash and log off +# --------------------------------------------------------------------------- +$sync.Close = $true +Start-Sleep -Seconds 3 + +$uiPS.Stop() +$uiRS.Close() + +& logoff diff --git a/projects/msp-tools/guru-scan/Invoke-Remediation.ps1 b/projects/msp-tools/guru-scan/Invoke-Remediation.ps1 new file mode 100644 index 0000000..fca95a9 --- /dev/null +++ b/projects/msp-tools/guru-scan/Invoke-Remediation.ps1 @@ -0,0 +1,460 @@ +#Requires -RunAsAdministrator +<# +.SYNOPSIS + Re-runs GuruScan scanners in clean mode against a previous scan's log folder. +.DESCRIPTION + Reads the results.json from a prior scan run, then re-launches each scanner + that completed (or any subset via -Scanners) using clean_args to remove + detected threats. Writes remediation-results.json to the same log folder. +.PARAMETER LogRoot + Path to the scan results folder produced by Invoke-GuruScan.ps1. + This folder must contain a results.json file. +.PARAMETER Scanners + Run only the named scanners. Names must match the "name" field in + scanners.json exactly. If omitted, all scanners that previously ran + successfully are re-run. +.EXAMPLE + .\Invoke-Remediation.ps1 -LogRoot "C:\ScanLogs\DESKTOP-20260523-143000" + .\Invoke-Remediation.ps1 -LogRoot "C:\ScanLogs\DESKTOP-20260523-143000" -Scanners AdwCleaner,MSERT +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$LogRoot, + + [string[]]$Scanners +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Continue' +$ProgressPreference = 'SilentlyContinue' + +# --------------------------------------------------------------------------- +# Helpers (duplicated here so this script is self-contained) +# --------------------------------------------------------------------------- + +function Expand-TokenizedArgs { + param([string[]]$Args, [string]$LogRoot, [string]$ExeDir) + $out = foreach ($a in $Args) { + $a = $a -replace '\{LOG_ROOT\}', $LogRoot + $a = $a -replace '\{EXE_DIR\}', $ExeDir + $a + } + return $out +} + +function Expand-EnvPath { + param([string]$Path) + return [System.Environment]::ExpandEnvironmentVariables($Path) +} + +function Remove-PathSilent { + param([string]$Path) + $expanded = Expand-EnvPath $Path + if (Test-Path $expanded) { + Remove-Item -Path $expanded -Recurse -Force -ErrorAction SilentlyContinue + } +} + +function Wait-ProcessWithTimeout { + param( + [System.Diagnostics.Process]$Process, + [int]$TimeoutSeconds + ) + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while (-not $Process.HasExited) { + if ((Get-Date) -gt $deadline) { + try { $Process.Kill() } catch { } + return $false + } + Start-Sleep -Seconds 5 + } + return $true +} + +function Wait-ServicesToStop { + param([string[]]$ServiceNames, [int]$TimeoutSeconds = 120) + if (-not $ServiceNames -or $ServiceNames.Count -eq 0) { return } + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + foreach ($svc in $ServiceNames) { + while ((Get-Date) -lt $deadline) { + $s = Get-Service -Name $svc -ErrorAction SilentlyContinue + if (-not $s -or $s.Status -ne 'Running') { break } + Start-Sleep -Seconds 3 + } + } +} + +function Invoke-HitmanProTrialReset { + Remove-Item -Path "HKLM:\SOFTWARE\HitmanPro" -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path "HKLM:\SOFTWARE\HitmanPro.Alert" -Recurse -Force -ErrorAction SilentlyContinue + New-Item -Path "HKLM:\SOFTWARE\HitmanPro" -Force | Out-Null + New-Item -Path "HKLM:\SOFTWARE\HitmanPro.Alert" -Force | Out-Null +} + +function Get-ExitCodeThreats { + param([string]$ScannerName, [int]$ExitCode) + switch ($ScannerName) { + 'AdwCleaner' { if ($ExitCode -eq 1) { return 1 } } + 'MSERT' { if ($ExitCode -ne 0) { return 1 } } + 'TDSSKiller' { if ($ExitCode -eq 1) { return 1 } } + 'HitmanPro' { if ($ExitCode -eq 1) { return 1 } } + 'Emsisoft' { if ($ExitCode -ge 1) { return 1 } } + 'Stinger' { if ($ExitCode -eq 13) { return 1 } } + 'ESET' { if ($ExitCode -ne 0) { return 1 } } + } + return 0 +} + +# --------------------------------------------------------------------------- +# Validate inputs +# --------------------------------------------------------------------------- + +$ScriptRoot = $PSScriptRoot +$ConfigPath = Join-Path $ScriptRoot 'scanners.json' +$PriorResults = Join-Path $LogRoot 'results.json' + +if (-not (Test-Path $LogRoot)) { + Write-Host "[ERROR] LogRoot not found: $LogRoot" -ForegroundColor Red + exit 1 +} + +if (-not (Test-Path $PriorResults)) { + Write-Host "[ERROR] results.json not found in: $LogRoot" -ForegroundColor Red + exit 1 +} + +if (-not (Test-Path $ConfigPath)) { + Write-Host "[ERROR] scanners.json not found: $ConfigPath" -ForegroundColor Red + exit 1 +} + +# --------------------------------------------------------------------------- +# Load config and prior results +# --------------------------------------------------------------------------- + +$config = Get-Content $ConfigPath -Raw | ConvertFrom-Json +$priorData = Get-Content $PriorResults -Raw | ConvertFrom-Json + +$priorScannerNames = $priorData.scanners | + Where-Object { $_.status -eq 'completed' } | + ForEach-Object { $_.name } + +$scannerList = $config.scanners | Where-Object { $priorScannerNames -contains $_.name } + +if ($Scanners -and $Scanners.Count -gt 0) { + $scannerList = $scannerList | Where-Object { $Scanners -contains $_.name } +} + +if (-not $scannerList) { + Write-Host "[WARNING] No eligible scanners to remediate." -ForegroundColor Yellow + exit 0 +} + +Write-Host "" +Write-Host "=== GuruScan Remediation ===" -ForegroundColor Cyan +Write-Host " Log root : $LogRoot" +Write-Host " Scanners : $($scannerList.name -join ', ')" +Write-Host "" + +# --------------------------------------------------------------------------- +# Run scanners in clean mode +# --------------------------------------------------------------------------- + +$results = [System.Collections.Generic.List[pscustomobject]]::new() +$startedAt = Get-Date +$totalThreats = 0 + +foreach ($s in $scannerList) { + Write-Host "------------------------------------------------------------" -ForegroundColor DarkGray + Write-Host " Scanner : $($s.name) [CLEAN MODE]" -ForegroundColor Cyan + + # ------------------------------------------------------------------ + # Resolve EXE paths + # ------------------------------------------------------------------ + $mainExePath = $null + $installerExePath = $null + + if ($s.installer_exe) { + $installerExePath = if ([System.IO.Path]::IsPathRooted($s.installer_exe)) { + $s.installer_exe + } else { + Join-Path $ScriptRoot $s.installer_exe + } + } + + $rawExe = $s.exe + if ([System.IO.Path]::IsPathRooted($rawExe)) { + $mainExePath = $rawExe + } else { + $mainExePath = Join-Path $ScriptRoot $rawExe + } + + $exeToCheck = if ($installerExePath) { $installerExePath } else { $mainExePath } + + if (-not (Test-Path $exeToCheck)) { + Write-Host " [WARNING] EXE not found — skipping: $exeToCheck" -ForegroundColor Yellow + $results.Add([pscustomobject]@{ + Scanner = $s.name + Status = 'SKIPPED (missing)' + ExitCode = $null + ThreatsFound = 0 + Duration = '0 min' + LogPath = '' + }) + continue + } + + # ------------------------------------------------------------------ + # Resolve args (always clean_args) + # ------------------------------------------------------------------ + $exeDir = Split-Path $mainExePath -Parent + $expandedArgs = Expand-TokenizedArgs -Args @($s.clean_args) -LogRoot $LogRoot -ExeDir $exeDir + + # ------------------------------------------------------------------ + # HitmanPro trial reset + # ------------------------------------------------------------------ + if ($s.hitmanpro_trial_reset -eq $true) { + Write-Host " [INFO] Resetting HitmanPro trial registry..." -ForegroundColor Cyan + Invoke-HitmanProTrialReset + } + + # ------------------------------------------------------------------ + # Pre-clean + # ------------------------------------------------------------------ + foreach ($p in $s.pre_clean_paths) { + Remove-PathSilent $p + } + + # ------------------------------------------------------------------ + # Timeout + # ------------------------------------------------------------------ + $timeoutSec = $s.timeout_min * 60 + + # ------------------------------------------------------------------ + # Two-step install (Emsisoft) + # ------------------------------------------------------------------ + if ($installerExePath) { + Write-Host " [INFO] Running installer: $installerExePath" -ForegroundColor Cyan + try { + $instProc = Start-Process -FilePath $installerExePath -ArgumentList '/S' -PassThru -NoNewWindow + $instCompleted = Wait-ProcessWithTimeout -Process $instProc -TimeoutSeconds 300 + if (-not $instCompleted) { + Write-Host " [WARNING] Installer timed out — skipping" -ForegroundColor Yellow + $results.Add([pscustomobject]@{ + Scanner = $s.name + Status = 'FAILED (installer timeout)' + ExitCode = $null + ThreatsFound = 0 + Duration = '0 min' + LogPath = '' + }) + continue + } + } + catch { + Write-Host " [ERROR] Installer failed: $_" -ForegroundColor Red + $results.Add([pscustomobject]@{ + Scanner = $s.name + Status = "FAILED (installer error)" + ExitCode = $null + ThreatsFound = 0 + Duration = '0 min' + LogPath = '' + }) + continue + } + + if (-not (Test-Path $mainExePath)) { + Write-Host " [ERROR] Main EXE missing after install: $mainExePath" -ForegroundColor Red + $results.Add([pscustomobject]@{ + Scanner = $s.name + Status = 'FAILED (post-install EXE missing)' + ExitCode = $null + ThreatsFound = 0 + Duration = '0 min' + LogPath = '' + }) + continue + } + + Write-Host " [INFO] Updating Emsisoft definitions..." -ForegroundColor Cyan + try { + $updateProc = Start-Process -FilePath $mainExePath -ArgumentList '/update' -PassThru -NoNewWindow + Wait-ProcessWithTimeout -Process $updateProc -TimeoutSeconds 300 | Out-Null + } + catch { + Write-Host " [WARNING] Update step failed: $_ — continuing with existing definitions" -ForegroundColor Yellow + } + } + + # ------------------------------------------------------------------ + # Randomize EXE (TDSSKiller) + # ------------------------------------------------------------------ + $randomizedExePath = $null + $launchExe = $mainExePath + + if ($s.randomize_exe -eq $true) { + $tempName = [System.IO.Path]::GetRandomFileName() -replace '\..*$', '' + $randomizedExePath = Join-Path $env:TEMP "$tempName.exe" + Copy-Item -Path $mainExePath -Destination $randomizedExePath -Force + $launchExe = $randomizedExePath + Write-Host " [INFO] TDSSKiller randomized to: $randomizedExePath" -ForegroundColor Cyan + } + + # ------------------------------------------------------------------ + # Launch + # ------------------------------------------------------------------ + Write-Host " [....] Launching $($s.name) in clean mode..." -ForegroundColor Cyan + $scanStart = Get-Date + $proc = $null + $status = 'completed' + $exitCode = $null + + try { + $startParams = @{ + FilePath = $launchExe + PassThru = $true + NoNewWindow = $true + } + if ($expandedArgs -and $expandedArgs.Count -gt 0) { + $startParams['ArgumentList'] = $expandedArgs + } + + $proc = Start-Process @startParams + + $completed = Wait-ProcessWithTimeout -Process $proc -TimeoutSeconds $timeoutSec + if (-not $completed) { + $status = 'TIMED OUT' + Write-Host " [WARNING] $($s.name) timed out after $($s.timeout_min) min" -ForegroundColor Yellow + } else { + $exitCode = $proc.ExitCode + } + } + catch { + $status = 'FAILED' + Write-Host " [ERROR] $($s.name) failed: $_" -ForegroundColor Red + } + + # ------------------------------------------------------------------ + # Wait for services + # ------------------------------------------------------------------ + if ($s.service_names -and $s.service_names.Count -gt 0) { + Wait-ServicesToStop -ServiceNames $s.service_names -TimeoutSeconds 180 + } + + # ------------------------------------------------------------------ + # Duration + # ------------------------------------------------------------------ + $durMin = [math]::Round(((Get-Date) - $scanStart).TotalMinutes, 2) + + # ------------------------------------------------------------------ + # Collect logs + # ------------------------------------------------------------------ + $logDestDir = Join-Path $LogRoot "$($s.name)_Remediation_Logs" + + if ($s.log_src) { + $expandedLogSrc = Expand-TokenizedArgs -Args @($s.log_src) -LogRoot $LogRoot -ExeDir $exeDir + $expandedLogSrc = Expand-EnvPath $expandedLogSrc[0] + if (Test-Path $expandedLogSrc) { + New-Item -ItemType Directory -Path $logDestDir -Force | Out-Null + Copy-Item -Path $expandedLogSrc -Destination $logDestDir -Recurse -Force -ErrorAction SilentlyContinue + } + } + + # ------------------------------------------------------------------ + # Post-clean + # ------------------------------------------------------------------ + foreach ($p in $s.post_clean_paths) { + Remove-PathSilent $p + } + + # ------------------------------------------------------------------ + # Randomized EXE cleanup + # ------------------------------------------------------------------ + if ($randomizedExePath -and (Test-Path $randomizedExePath)) { + Remove-Item $randomizedExePath -Force -ErrorAction SilentlyContinue + } + + # ------------------------------------------------------------------ + # Threats + # ------------------------------------------------------------------ + $threatsFound = 0 + if ($null -ne $exitCode) { + $threatsFound = Get-ExitCodeThreats -ScannerName $s.name -ExitCode $exitCode + } + $totalThreats += $threatsFound + + $color = if ($status -eq 'completed') { 'Green' } elseif ($status -eq 'TIMED OUT') { 'Yellow' } else { 'Red' } + Write-Host " [$status] ExitCode=$exitCode Threats=$threatsFound Duration=${durMin}min" -ForegroundColor $color + + $results.Add([pscustomobject]@{ + Scanner = $s.name + Status = $status + ExitCode = $exitCode + ThreatsFound = $threatsFound + Duration = "$durMin min" + LogPath = $logDestDir + }) +} + +# --------------------------------------------------------------------------- +# Write remediation-results.json +# --------------------------------------------------------------------------- + +$completedAt = Get-Date + +$scannerResults = foreach ($r in $results) { + $durValue = 0 + if ($r.Duration -match '^([\d.]+)') { + $durValue = [double]$Matches[1] + } + [ordered]@{ + name = $r.Scanner + status = $r.Status + exit_code = $r.ExitCode + threats_found = $r.ThreatsFound + duration_min = $durValue + log_path = $r.LogPath + } +} + +$remObj = [ordered]@{ + scan_id = $priorData.scan_id + machine = $env:COMPUTERNAME + started_at = $startedAt.ToUniversalTime().ToString('o') + completed_at = $completedAt.ToUniversalTime().ToString('o') + total_threats = $totalThreats + reboot_required = $false + scan_mode = 'clean' + scanners = @($scannerResults) +} + +$remResultsPath = Join-Path $LogRoot 'remediation-results.json' +$remObj | ConvertTo-Json -Depth 10 | Set-Content -Path $remResultsPath -Encoding UTF8 + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- + +Write-Host "" +Write-Host "============================================================" -ForegroundColor Cyan +Write-Host " REMEDIATION COMPLETE" -ForegroundColor Cyan +Write-Host "============================================================" -ForegroundColor Cyan +Write-Host "" +$results | Format-Table -AutoSize +Write-Host "" + +if ($totalThreats -gt 0) { + Write-Host " [WARNING] $totalThreats threat indicator(s) still detected after clean pass." -ForegroundColor Yellow + Write-Host " Review logs carefully — some threats may require a reboot before" -ForegroundColor Yellow + Write-Host " they can be fully removed. Reboot and re-scan." -ForegroundColor Yellow +} else { + Write-Host " [OK] Clean pass complete — no threats detected in output." -ForegroundColor Green +} + +Write-Host "" +Write-Host " Remediation results: $remResultsPath" -ForegroundColor Gray +Write-Host "" + +exit 0