#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