Files
claudetools/projects/msp-tools/guru-scan/Invoke-GuruScan.ps1
Howard Enos 64374e3ecb sync: auto-sync from HOWARD-HOME at 2026-05-26 12:40:52
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-05-26 12:40:52
2026-05-26 12:40:56 -07:00

1098 lines
46 KiB
PowerShell

#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 `"{LOG_ROOT}\rkill.log`"")
CleanArgs = @('-s', "-l `"{LOG_ROOT}\rkill.log`"")
LogSrc = '{LOG_ROOT}\rkill.log'
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 = @('/silent', '/unwanted')
CleanArgs = @('/silent', '/unwanted', '/clean')
LogSrc = $null
TimeoutMin = 240
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 {}
$Process.WaitForExit(5000) | Out-Null
return $false
}
Start-Sleep -Seconds 5
}
# Flush the exit code — required before ExitCode property is readable
$Process.WaitForExit(5000) | Out-Null
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