diff --git a/projects/msp-tools/guru-scan/Get-ScanSummary.ps1 b/projects/msp-tools/guru-scan/Get-ScanSummary.ps1
index f44c921..4659495 100644
--- a/projects/msp-tools/guru-scan/Get-ScanSummary.ps1
+++ b/projects/msp-tools/guru-scan/Get-ScanSummary.ps1
@@ -33,262 +33,11 @@ param(
[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 '?think[^>]*>', '' -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
+$moduleManifest = Join-Path $PSScriptRoot 'GuruScan.psd1'
+if (-not (Test-Path $moduleManifest)) {
+ Write-Host "[ERROR] GuruScan module not found: $moduleManifest" -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
+Import-Module $moduleManifest -Force
+Get-ScanSummary @PSBoundParameters
diff --git a/projects/msp-tools/guru-scan/GuruScan.psd1 b/projects/msp-tools/guru-scan/GuruScan.psd1
new file mode 100644
index 0000000..b9634c8
--- /dev/null
+++ b/projects/msp-tools/guru-scan/GuruScan.psd1
@@ -0,0 +1,27 @@
+@{
+ RootModule = 'GuruScan.psm1'
+ ModuleVersion = '1.0.0'
+ GUID = 'a3f2c1d4-8e5b-4a7f-9c2e-1b3d5f7a9e0c'
+ Author = 'Arizona Computer Guru'
+ CompanyName = 'Arizona Computer Guru LLC'
+ Description = 'Multi-engine malware scan orchestrator for GuruRMM'
+ PowerShellVersion = '5.1'
+
+ FunctionsToExport = @(
+ 'Invoke-GuruScan',
+ 'Invoke-Remediation',
+ 'Get-ScanSummary',
+ 'Invoke-PostRebootCleanup'
+ )
+
+ CmdletsToExport = @()
+ VariablesToExport = @()
+ AliasesToExport = @()
+
+ PrivateData = @{
+ PSData = @{
+ Tags = @('malware', 'scanner', 'remediation', 'msp', 'security')
+ ProjectUri = 'https://git.azcomputerguru.com/azcomputerguru/claudetools'
+ }
+ }
+}
diff --git a/projects/msp-tools/guru-scan/GuruScan.psm1 b/projects/msp-tools/guru-scan/GuruScan.psm1
new file mode 100644
index 0000000..c01cd39
--- /dev/null
+++ b/projects/msp-tools/guru-scan/GuruScan.psm1
@@ -0,0 +1,1391 @@
+#
+# GuruScan.psm1
+# Multi-engine malware scan orchestrator for GuruRMM.
+# PowerShell 5.1 compatible — no ternary, no ??, no ?. operators.
+#
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Continue'
+$ProgressPreference = 'SilentlyContinue'
+
+# ---------------------------------------------------------------------------
+# Module-level constants and scanner definition loading
+# ---------------------------------------------------------------------------
+
+$script:ModuleRoot = $PSScriptRoot
+$script:Base = 'C:\GuruScan'
+$script:LogRoot = 'C:\ScanLogs'
+
+$script:ScannersJson = Join-Path $script:ModuleRoot 'scanners.json'
+$script:ScannerDefs = if (Test-Path $script:ScannersJson) {
+ (Get-Content $script:ScannersJson -Raw | ConvertFrom-Json).scanners
+} else {
+ @()
+}
+
+# ---------------------------------------------------------------------------
+# Private helpers
+# ---------------------------------------------------------------------------
+
+function Expand-TokenizedArgs {
+ <#
+ .SYNOPSIS
+ Replaces {LOG_ROOT} and {EXE_DIR} tokens in a list of argument strings.
+ #>
+ 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 {
+ <#
+ .SYNOPSIS
+ Expands environment variable references in a path string.
+ #>
+ param([string]$Path)
+ return [System.Environment]::ExpandEnvironmentVariables($Path)
+}
+
+function Remove-PathSilent {
+ <#
+ .SYNOPSIS
+ Removes a file or directory silently if it exists.
+ #>
+ param([string]$Path)
+ $expanded = Expand-EnvPath $Path
+ if (Test-Path $expanded) {
+ Remove-Item -Path $expanded -Recurse -Force -ErrorAction SilentlyContinue
+ }
+}
+
+function Wait-ProcessWithTimeout {
+ <#
+ .SYNOPSIS
+ Waits for a process to exit within a deadline; kills if it exceeds timeout.
+ .OUTPUTS
+ $true if the process exited cleanly within the timeout, $false if it was killed.
+ .NOTES
+ Calls WaitForExit(5000) before returning to flush the exit code — required
+ before the ExitCode property is reliably readable on fast-exit processes.
+ #>
+ 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 exit code — required before ExitCode property is readable
+ $Process.WaitForExit(5000) | Out-Null
+ return $true
+}
+
+function Wait-ServicesToStop {
+ <#
+ .SYNOPSIS
+ Waits until all named services have stopped (or the deadline passes).
+ #>
+ 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 {
+ <#
+ .SYNOPSIS
+ Waits for a scanner process to complete, then waits for any named child
+ process and scanner services to stop.
+ .OUTPUTS
+ $true if the main process exited cleanly, $false if it timed out.
+ #>
+ param(
+ [System.Diagnostics.Process]$Process,
+ [string]$WaitOnProcess,
+ [string[]]$ServiceNames,
+ [int]$TimeoutSeconds
+ )
+
+ # Wait for the main process
+ $completed = Wait-ProcessWithTimeout -Process $Process -TimeoutSeconds $TimeoutSeconds
+ if (-not $completed) { return $false }
+
+ # Wait for 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
+ }
+ }
+
+ # Wait for services to stop
+ if ($ServiceNames -and $ServiceNames.Count -gt 0) {
+ Wait-ServicesToStop -ServiceNames $ServiceNames -TimeoutSeconds 120
+ }
+
+ return $true
+}
+
+function Invoke-HitmanProTrialReset {
+ <#
+ .SYNOPSIS
+ Wipes and recreates HitmanPro registry keys to reset the trial window.
+ #>
+ 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 {
+ <#
+ .SYNOPSIS
+ Maps a scanner exit code to a threat indicator (0 = clean, 1 = threats found).
+ .NOTES
+ Exit code semantics per scanner:
+ AdwCleaner : 1=cleaned(no reboot), 2=cleaned(reboot needed), 3=found/not cleaned
+ HitmanPro : 1=cleaned, 2=cleaned(reboot needed)
+ Emsisoft : >=1 = threats found or cleaned
+ MSERT : non-zero = threats
+ TDSSKiller : 1=threats
+ Stinger : 13=threats
+ RKill : exit 1 = processes killed (NOT a threat indicator)
+ #>
+ param([string]$ScannerName, [int]$ExitCode)
+ switch ($ScannerName) {
+ 'AdwCleaner' { if ($ExitCode -in @(1, 2, 3)) { return 1 } }
+ 'HitmanPro' { if ($ExitCode -in @(1, 2)) { return 1 } }
+ 'Emsisoft' { if ($ExitCode -ge 1) { 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 {
+ <#
+ .SYNOPSIS
+ Returns $true if the scanner exit code signals a reboot is required.
+ #>
+ 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 } }
+ }
+ return $false
+}
+
+function Invoke-ForceRemoveList {
+ <#
+ .SYNOPSIS
+ Force-removes a list of blacklisted items (paths, registry keys/values,
+ services, scheduled tasks, and processes).
+ .PARAMETER RemoveList
+ Array of hashtables with Type and Value (and Key for RegValue entries).
+ #>
+ 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 Invoke-ScannerInstallPrep {
+ <#
+ .SYNOPSIS
+ Runs a two-step installer + optional update, then closes any Explorer
+ folder window opened by the NSIS installer.
+ .PARAMETER InstallerExePath
+ Full path to the installer EXE.
+ .PARAMETER MainExePath
+ Full path to the main scanner EXE that appears after install.
+ .PARAMETER InstallerArgs
+ Arguments to pass to the installer. If null or empty, the installer is
+ launched without any ArgumentList parameter.
+ .PARAMETER RunUpdateAfterInstall
+ When $true, runs the main EXE with /update after installation completes.
+ .OUTPUTS
+ $true on success, $false if the installer timed out or the main EXE is missing.
+ #>
+ param(
+ [string]$InstallerExePath,
+ [string]$MainExePath,
+ [string]$ScannerName = '',
+ [string[]]$InstallerArgs = @('/S'),
+ [bool]$RunUpdateAfterInstall = $false
+ )
+
+ Write-Host " [INFO] Running installer: $InstallerExePath" -ForegroundColor Cyan
+ try {
+ $startParams = @{
+ FilePath = $InstallerExePath
+ PassThru = $true
+ WindowStyle = 'Hidden'
+ }
+ if ($InstallerArgs -and $InstallerArgs.Count -gt 0) {
+ $startParams['ArgumentList'] = $InstallerArgs
+ }
+ $instProc = Start-Process @startParams
+ $instCompleted = Wait-ProcessWithTimeout -Process $instProc -TimeoutSeconds 300
+
+ # Close any Explorer folder window the NSIS installer opened
+ 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
+ return $false
+ }
+ }
+ catch {
+ Write-Host " [ERROR] Installer failed: $_" -ForegroundColor Red
+ return $false
+ }
+
+ if (-not (Test-Path $MainExePath)) {
+ Write-Host " [ERROR] Main EXE not found after install: $MainExePath" -ForegroundColor Red
+ return $false
+ }
+
+ if ($RunUpdateAfterInstall -eq $true) {
+ Write-Host ' [INFO] Updating scanner 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
+ }
+ }
+
+ return $true
+}
+
+function Invoke-ScanPass {
+ <#
+ .SYNOPSIS
+ Runs one complete pass of the scanner loop.
+ .DESCRIPTION
+ Iterates over the provided scanner list in the given mode, launches each
+ scanner, waits for completion, collects logs, and accumulates results.
+ Sets $script:LastPassRebootRequired and $script:LastPassTotalThreats.
+ .PARAMETER ScannerList
+ The filtered list of scanner definition objects to run.
+ .PARAMETER ScanMode
+ 'scan' for detect-only, 'clean' for remediation.
+ .PARAMETER RunLogRoot
+ The per-scan log directory for this pass.
+ .PARAMETER TimeoutMinOverride
+ If > 0, overrides the per-scanner timeout_min for all scanners.
+ .PARAMETER Headless
+ When set, suppress scanner UI windows (NoNewWindow).
+ .OUTPUTS
+ [System.Collections.Generic.List[pscustomobject]] of result objects.
+ #>
+ param(
+ [object[]]$ScannerList,
+ [string]$ScanMode,
+ [string]$RunLogRoot,
+ [int]$TimeoutMinOverride = 0,
+ [switch]$Headless
+ )
+
+ $script:LastPassRebootRequired = $false
+ $script:LastPassTotalThreats = 0
+
+ $results = [System.Collections.Generic.List[pscustomobject]]::new()
+
+ foreach ($s in $ScannerList) {
+ Write-Host '------------------------------------------------------------' -ForegroundColor DarkGray
+ Write-Host " Scanner : $($s.Name)" -ForegroundColor Cyan
+ Write-Host " Mode : $ScanMode"
+
+ # Normalize property names — JSON objects use snake_case, PS hashtables use PascalCase
+ $sName = if ($s.PSObject.Properties['Name']) { $s.Name } else { $s.name }
+ $sExe = if ($s.PSObject.Properties['Exe']) { $s.Exe } else { $s.exe }
+ $sInstallerExe = if ($s.PSObject.Properties['InstallerExe']) { $s.InstallerExe } else { $s.installer_exe }
+ $sScanArgs = if ($s.PSObject.Properties['ScanArgs']) { $s.ScanArgs } else { $s.scan_args }
+ $sCleanArgs = if ($s.PSObject.Properties['CleanArgs']) { $s.CleanArgs } else { $s.clean_args }
+ $sLogSrc = if ($s.PSObject.Properties['LogSrc']) { $s.LogSrc } else { $s.log_src }
+ $sTimeoutMin = if ($s.PSObject.Properties['TimeoutMin']) { $s.TimeoutMin } else { $s.timeout_min }
+ $sRandomizeExe = if ($s.PSObject.Properties['RandomizeExe']) { $s.RandomizeExe } else { $s.randomize_exe }
+ $sPreClose = if ($s.PSObject.Properties['PreCloseProcesses']) { $s.PreCloseProcesses } else { $s.pre_close_processes }
+ $sPreClean = if ($s.PSObject.Properties['PreCleanPaths']) { $s.PreCleanPaths } else { $s.pre_clean_paths }
+ $sPostClean = if ($s.PSObject.Properties['PostCleanPaths']) { $s.PostCleanPaths } else { $s.post_clean_paths }
+ $sServiceNames = if ($s.PSObject.Properties['ServiceNames']) { $s.ServiceNames } else { $s.service_names }
+ $sHmpReset = if ($s.PSObject.Properties['HitmanProReset']) { $s.HitmanProReset } else { $s.hitmanpro_trial_reset }
+ $sWaitOnProcess = if ($s.PSObject.Properties['WaitOnProcess']) { $s.WaitOnProcess } else { $s.wait_on_process }
+ $sInstallerArgs = if ($s.PSObject.Properties['InstallerArgs']) { $s.InstallerArgs } else { $s.installer_args }
+ $sRunUpdateAfterInstall = if ($s.PSObject.Properties['RunUpdateAfterInstall']) { $s.RunUpdateAfterInstall } else { $s.run_update_after_install }
+ if ($null -eq $sInstallerArgs) { $sInstallerArgs = @('/S') }
+ if ($null -eq $sRunUpdateAfterInstall) { $sRunUpdateAfterInstall = $false }
+
+ # Coerce null JSON values to empty arrays / nulls
+ if ($null -eq $sInstallerExe) { $sInstallerExe = $null }
+ if ($null -eq $sLogSrc) { $sLogSrc = $null }
+ if ($null -eq $sWaitOnProcess) { $sWaitOnProcess = $null }
+ if ($null -eq $sPreClose) { $sPreClose = @() }
+ if ($null -eq $sPreClean) { $sPreClean = @() }
+ if ($null -eq $sPostClean) { $sPostClean = @() }
+ if ($null -eq $sServiceNames) { $sServiceNames = @() }
+
+ $mainExePath = $sExe
+ $installerExePath = $sInstallerExe
+ $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 = $sName
+ Status = 'SKIPPED (missing)'
+ ExitCode = $null
+ ThreatsFound = 0
+ Duration = '0 min'
+ LogPath = ''
+ })
+ continue
+ }
+
+ $exeDir = Split-Path $mainExePath -Parent
+
+ if ($ScanMode -eq 'scan') {
+ $rawArgs = @($sScanArgs)
+ } else {
+ $rawArgs = @($sCleanArgs)
+ }
+
+ $expandedArgs = Expand-TokenizedArgs -ArgList $rawArgs -LogRoot $RunLogRoot -ExeDir $exeDir
+
+ # HitmanPro trial registry pre-seed
+ if ($sHmpReset -eq $true) {
+ Write-Host ' [INFO] Resetting HitmanPro trial registry...' -ForegroundColor Cyan
+ Invoke-HitmanProTrialReset
+ }
+
+ # Pre-clean paths
+ foreach ($p in $sPreClean) {
+ Remove-PathSilent $p
+ }
+
+ # Effective timeout
+ $effectiveTimeoutMin = if ($TimeoutMinOverride -gt 0) { $TimeoutMinOverride } else { $sTimeoutMin }
+ $timeoutSec = $effectiveTimeoutMin * 60
+
+ # Two-step install (scanners with an installer_exe defined)
+ if ($installerExePath) {
+ $installOk = Invoke-ScannerInstallPrep -InstallerExePath $installerExePath -MainExePath $mainExePath -ScannerName $sName -InstallerArgs $sInstallerArgs -RunUpdateAfterInstall $sRunUpdateAfterInstall
+ if (-not $installOk) {
+ $results.Add([pscustomobject]@{
+ Scanner = $sName
+ Status = 'FAILED (installer)'
+ ExitCode = $null
+ ThreatsFound = 0
+ Duration = '0 min'
+ LogPath = ''
+ })
+ continue
+ }
+ }
+
+ # Randomize EXE (TDSSKiller pattern)
+ $randomizedExePath = $null
+ $launchExe = $mainExePath
+
+ if ($sRandomizeExe -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
+ if ($sPreClose -and $sPreClose.Count -gt 0) {
+ foreach ($proc in $sPreClose) {
+ Get-Process -Name $proc -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
+ }
+ Start-Sleep -Seconds 2
+ }
+
+ # Launch scanner
+ Write-Host " [....] Launching $sName..." -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
+
+ $serviceArr = @()
+ if ($sServiceNames -and $sServiceNames.Count -gt 0) {
+ $serviceArr = @($sServiceNames)
+ }
+
+ $wpStr = $null
+ if ($sWaitOnProcess) { $wpStr = [string]$sWaitOnProcess }
+
+ $completed = Wait-ScannerCompletion -Process $proc -WaitOnProcess $wpStr -ServiceNames $serviceArr -TimeoutSeconds $timeoutSec
+
+ if (-not $completed) {
+ $status = 'TIMED OUT'
+ Write-Host " [WARNING] $sName timed out after $effectiveTimeoutMin min" -ForegroundColor Yellow
+ } else {
+ $exitCode = $proc.ExitCode
+ }
+ }
+ catch {
+ $status = 'FAILED'
+ Write-Host " [ERROR] $sName failed to launch: $_" -ForegroundColor Red
+ }
+
+ # Calculate duration
+ $scanEnd = Get-Date
+ $durMin = [math]::Round(($scanEnd - $scanStart).TotalMinutes, 2)
+
+ # Collect logs from scanner-specific log source
+ $logDestDir = Join-Path $RunLogRoot "$($sName)_Logs"
+
+ if ($sLogSrc) {
+ $logSrcExpanded = @(Expand-TokenizedArgs -ArgList @($sLogSrc) -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 $sPostClean) {
+ 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 $sName -ExitCode $exitCode
+ if (Get-ExitCodeReboot -ScannerName $sName -ExitCode $exitCode) {
+ $script:LastPassRebootRequired = $true
+ Write-Host " [WARNING] $sName signals REBOOT REQUIRED to complete removal" -ForegroundColor Yellow
+ }
+ }
+ $script:LastPassTotalThreats += $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 = $sName
+ Status = $status
+ ExitCode = $exitCode
+ ThreatsFound = $threatsFound
+ Duration = "$durMin min"
+ LogPath = $logDestDir
+ })
+ }
+
+ return $results
+}
+
+function Register-ScannerCleanupTask {
+ <#
+ .SYNOPSIS
+ Writes cleanup state and registers a SYSTEM scheduled task that fires at
+ next user logon + 30 minutes to remove scanner files and unregister itself.
+ #>
+ param(
+ [string]$ScanId,
+ [string]$LogRoot
+ )
+
+ Write-Host ''
+ Write-Host '------------------------------------------------------------' -ForegroundColor Yellow
+ Write-Host ' Reboot recommended — registering post-reboot cleanup task' -ForegroundColor Yellow
+ Write-Host '------------------------------------------------------------' -ForegroundColor Yellow
+
+ # Write state for the cleanup script to read
+ @{
+ scan_id = $ScanId
+ log_root = $LogRoot
+ created_at = (Get-Date).ToUniversalTime().ToString('o')
+ } | ConvertTo-Json | Set-Content "$script:Base\cleanup-state.json" -Encoding UTF8
+
+ # Write the cleanup script that the scheduled task will run
+ $cleanupScript = @'
+$Base = 'C:\GuruScan'
+$state = @{ scan_id = ''; log_root = '' }
+$stateFile = "$Base\cleanup-state.json"
+
+if (Test-Path $stateFile) {
+ try { $state = Get-Content $stateFile -Raw | ConvertFrom-Json } catch {}
+}
+
+# Remove scanner installation files
+$scannerPaths = @(
+ 'C:\EmsisoftCmd',
+ 'C:\AdwCleaner',
+ 'C:\ProgramData\HitmanPro',
+ 'C:\ProgramData\HitmanPro.Alert'
+)
+foreach ($p in $scannerPaths) {
+ Remove-Item -Path $p -Recurse -Force -ErrorAction SilentlyContinue
+}
+
+# Remove scanner download EXEs
+Remove-Item -Path "$Base\downloads" -Recurse -Force -ErrorAction SilentlyContinue
+
+# Flag logs for GuruRMM to pull
+@{
+ scan_id = $state.scan_id
+ log_root = $state.log_root
+ zip_path = "$Base\reports\$($state.scan_id).zip"
+ cleaned_at = (Get-Date).ToUniversalTime().ToString('o')
+} | ConvertTo-Json | Set-Content "$Base\logs-ready.json" -Encoding UTF8
+
+# Remove state file
+Remove-Item -Path $stateFile -Force -ErrorAction SilentlyContinue
+
+# Unregister this task
+Unregister-ScheduledTask -TaskName 'GuruRMM-ScannerCleanup' -Confirm:$false -ErrorAction SilentlyContinue
+'@
+ $cleanupScript | Set-Content "$script:Base\Invoke-ScannerCleanup.ps1" -Encoding UTF8
+
+ # Register as SYSTEM logon task with 30-minute delay
+ try {
+ $action = New-ScheduledTaskAction -Execute 'powershell.exe' `
+ -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$script:Base\Invoke-ScannerCleanup.ps1`""
+ $trigger = New-ScheduledTaskTrigger -AtLogOn
+ $trigger.Delay = 'PT30M'
+ $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -RunLevel Highest
+ $settings = New-ScheduledTaskSettingsSet `
+ -ExecutionTimeLimit (New-TimeSpan -Minutes 10) `
+ -MultipleInstances IgnoreNew `
+ -DeleteExpiredTaskAfter (New-TimeSpan -Seconds 0)
+ Register-ScheduledTask -TaskName 'GuruRMM-ScannerCleanup' `
+ -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
+ Write-Host ' [OK] Scanner cleanup task registered (runs 30 min after next logon)' -ForegroundColor Green
+ }
+ catch {
+ Write-Host " [WARNING] Could not register cleanup task: $_" -ForegroundColor Yellow
+ }
+
+ Write-Host ''
+ Write-Host ' [INFO] Please reboot this machine at your convenience to complete removal.' -ForegroundColor Yellow
+ Write-Host ' Scanner files will be cleaned up automatically 30 minutes after login.' -ForegroundColor Yellow
+ Write-Host ''
+}
+
+function Test-RunningAsSystem {
+ $id = [System.Security.Principal.WindowsIdentity]::GetCurrent()
+ return ($id.IsSystem)
+}
+
+# ---------------------------------------------------------------------------
+# Private Ollama helper (used by Get-ScanSummary)
+# ---------------------------------------------------------------------------
+
+function Invoke-Ollama {
+ param(
+ [string]$Prompt,
+ [string]$Model,
+ [string]$BaseUrl
+ )
+ $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 '?think[^>]*>', '' -replace '(?s).*?', '').Trim()
+ } catch {
+ return $null
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Exported functions
+# ---------------------------------------------------------------------------
+
+function Invoke-GuruScan {
+ <#
+ .SYNOPSIS
+ GuruScan - multi-engine malware scanning orchestrator.
+ .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.
+ Scanner definitions are loaded from scanners.json in the module directory.
+
+ By default runs all scanners in clean (remediation) mode.
+ Use -ScanOnly to detect without cleaning.
+
+ When a scanner signals a reboot is required, GuruScan registers a
+ SYSTEM scheduled task (GuruRMM-ScannerCleanup) that fires 30 minutes
+ after the next user logon to remove scanner files and flag logs for pickup.
+
+ NOTE: MSERT is not included in the default scanner list because it takes
+ too long for routine runs.
+ .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 scanners.json exactly.
+ .PARAMETER TimeoutMin
+ Override the per-scanner timeout (in minutes) for all scanners.
+ .PARAMETER SkipScanners
+ Skip one or more named scanners by name.
+ .PARAMETER Headless
+ Suppress scanner windows (used when dispatching via RMM).
+ .PARAMETER OutputSink
+ 'Disk' (default) writes results.json, CSV, and zip to C:\ScanLogs and
+ C:\GuruScan\reports. 'RMM' returns the result object to the pipeline
+ instead and skips writing to disk.
+ .EXAMPLE
+ Invoke-GuruScan
+ Invoke-GuruScan -ScanOnly -AutoRemediate
+ Invoke-GuruScan -OutputSink RMM
+ #>
+ [CmdletBinding()]
+ param(
+ [switch]$ScanOnly,
+ [switch]$AutoRemediate,
+ [string[]]$Scanners,
+ [int]$TimeoutMin = 0,
+ [string[]]$SkipScanners = @(),
+ [switch]$Headless,
+ [ValidateSet('Disk', 'RMM')]
+ [string]$OutputSink = 'Disk'
+ )
+
+ # Whitelist — written to C:\GuruScan\whitelist.txt before any scanner runs.
+ # Emsisoft and HitmanPro honour this; RKill and AdwCleaner do not.
+ $whitelist = @('C:\GuruScan')
+
+ # ForceRemove blacklist — items removed after all scanners complete.
+ $forceRemove = @()
+
+ $scanStamp = "$env:COMPUTERNAME-$(Get-Date -Format yyyyMMdd-HHmmss)"
+ $runLogRoot = "$script:LogRoot\$scanStamp"
+ $scanMode = if ($ScanOnly) { 'scan' } else { 'clean' }
+
+ $runningAsSystem = Test-RunningAsSystem
+
+ # Create required directories
+ New-Item -ItemType Directory -Path $script:Base -Force | Out-Null
+ New-Item -ItemType Directory -Path "$script:Base\downloads" -Force | Out-Null
+ New-Item -ItemType Directory -Path $runLogRoot -Force | Out-Null
+ New-Item -ItemType Directory -Path "$script:Base\reports" -Force | Out-Null
+
+ # Write whitelist file
+ $whitelist | Set-Content -Path "$script:Base\whitelist.txt" -Encoding UTF8
+ Write-Host "[INFO] Whitelist written to $script: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 ''
+
+ # Load scanner defs from module (already loaded at module scope, but refresh
+ # the reference so callers who import the module later get current data)
+ $allDefs = $script:ScannerDefs
+
+ # Filter scanners
+ $scannerList = @($allDefs)
+
+ if ($SkipScanners -and $SkipScanners.Count -gt 0) {
+ $scannerList = @($scannerList | Where-Object {
+ $n = if ($_.PSObject.Properties['Name']) { $_.Name } else { $_.name }
+ $SkipScanners -notcontains $n
+ })
+ }
+
+ if ($Scanners -and $Scanners.Count -gt 0) {
+ $scannerList = @($scannerList | Where-Object {
+ $n = if ($_.PSObject.Properties['Name']) { $_.Name } else { $_.name }
+ $Scanners -contains $n
+ })
+ if ($scannerList.Count -eq 0) {
+ Write-Host "[ERROR] No scanners matched the provided names: $($Scanners -join ', ')" -ForegroundColor Red
+ if ($OutputSink -eq 'RMM') {
+ return $null
+ }
+ exit 1
+ }
+ }
+
+ # First pass
+ $startedAt = Get-Date
+ $passParams = @{
+ ScannerList = $scannerList
+ ScanMode = $scanMode
+ RunLogRoot = $runLogRoot
+ TimeoutMinOverride = $TimeoutMin
+ Headless = $Headless
+ }
+ $results = Invoke-ScanPass @passParams
+ $totalThreats = $script:LastPassTotalThreats
+ $rebootRequired = $script:LastPassRebootRequired
+
+ # AutoRemediate: 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 ''
+
+ # Rebuild scanner list for the clean pass (same filters honored)
+ $remList = @($allDefs)
+ if ($SkipScanners -and $SkipScanners.Count -gt 0) {
+ $remList = @($remList | Where-Object {
+ $n = if ($_.PSObject.Properties['Name']) { $_.Name } else { $_.name }
+ $SkipScanners -notcontains $n
+ })
+ }
+ if ($Scanners -and $Scanners.Count -gt 0) {
+ $remList = @($remList | Where-Object {
+ $n = if ($_.PSObject.Properties['Name']) { $_.Name } else { $_.name }
+ $Scanners -contains $n
+ })
+ }
+
+ $cleanPassParams = @{
+ ScannerList = $remList
+ ScanMode = 'clean'
+ RunLogRoot = $runLogRoot
+ TimeoutMinOverride = $TimeoutMin
+ Headless = $Headless
+ }
+ $results = Invoke-ScanPass @cleanPassParams
+ $totalThreats = $script:LastPassTotalThreats
+ $rebootRequired = $script:LastPassRebootRequired
+ $scanMode = 'clean'
+ }
+
+ # Force-remove blacklist stage
+ Invoke-ForceRemoveList -RemoveList $forceRemove
+
+ # Build results object
+ $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)
+ }
+
+ # Write to disk (Disk sink)
+ $resultsJsonPath = ''
+ $csvPath = ''
+ $zipPath = ''
+
+ if ($OutputSink -eq 'Disk') {
+ $resultsJsonPath = Join-Path $runLogRoot 'results.json'
+ $resultsObj | ConvertTo-Json -Depth 10 | Set-Content -Path $resultsJsonPath -Encoding UTF8
+
+ $csvPath = Join-Path $runLogRoot '_summary.csv'
+ $results | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8
+
+ $reportsDir = "$script: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"
+ if ($OutputSink -eq 'Disk') {
+ 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
+ }
+
+ # Reboot handling
+ if ($rebootRequired) {
+ Register-ScannerCleanupTask -ScanId $scanStamp -LogRoot $runLogRoot
+ }
+
+ # Return result object (always returned; caller uses it for RMM sink)
+ $resultRecord = [pscustomobject]$resultsObj
+ if ($OutputSink -eq 'RMM') {
+ return $resultRecord
+ }
+}
+
+function Invoke-Remediation {
+ <#
+ .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.
+ 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 -LogRoot "C:\ScanLogs\DESKTOP-20260523-143000"
+ Invoke-Remediation -LogRoot "C:\ScanLogs\DESKTOP-20260523-143000" -Scanners AdwCleaner,MSERT
+ #>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$LogRoot,
+
+ [string[]]$Scanners
+ )
+
+ $priorResultsPath = Join-Path $LogRoot 'results.json'
+
+ if (-not (Test-Path $LogRoot)) {
+ Write-Host "[ERROR] LogRoot not found: $LogRoot" -ForegroundColor Red
+ return
+ }
+
+ if (-not (Test-Path $priorResultsPath)) {
+ Write-Host "[ERROR] results.json not found in: $LogRoot" -ForegroundColor Red
+ return
+ }
+
+ if (-not (Test-Path $script:ScannersJson)) {
+ Write-Host "[ERROR] scanners.json not found: $script:ScannersJson" -ForegroundColor Red
+ return
+ }
+
+ $priorData = Get-Content $priorResultsPath -Raw | ConvertFrom-Json
+
+ $priorScannerNames = $priorData.scanners |
+ Where-Object { $_.status -eq 'completed' } |
+ ForEach-Object { $_.name }
+
+ $allDefs = $script:ScannerDefs
+ $scannerList = @($allDefs | Where-Object { $priorScannerNames -contains $_.name })
+
+ if ($Scanners -and $Scanners.Count -gt 0) {
+ $scannerList = @($scannerList | Where-Object { $Scanners -contains $_.name })
+ }
+
+ if ($scannerList.Count -eq 0) {
+ Write-Host "[WARNING] No eligible scanners to remediate." -ForegroundColor Yellow
+ return
+ }
+
+ Write-Host ""
+ Write-Host "=== GuruScan Remediation ===" -ForegroundColor Cyan
+ Write-Host " Log root : $LogRoot"
+ Write-Host " Scanners : $($scannerList.name -join ', ')"
+ Write-Host ""
+
+ $startedAt = Get-Date
+ $passParams = @{
+ ScannerList = $scannerList
+ ScanMode = 'clean'
+ RunLogRoot = $LogRoot
+ TimeoutMinOverride = 0
+ Headless = $false
+ }
+ $results = Invoke-ScanPass @passParams
+ $totalThreats = $script:LastPassTotalThreats
+
+ # 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 = $script:LastPassRebootRequired
+ 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 ""
+}
+
+function Get-ScanSummary {
+ <#
+ .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 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
+ Get-ScanSummary -AI
+ Get-ScanSummary -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'
+ )
+
+ # Locate results.json
+ if (-not $ResultsFile) {
+ $scanLogsRoot = $script:LogRoot
+ if (-not (Test-Path $scanLogsRoot)) {
+ Write-Host "[ERROR] No results file specified and $scanLogsRoot does not exist." -ForegroundColor Red
+ return
+ }
+
+ $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 $scanLogsRoot\" -ForegroundColor Red
+ return
+ }
+
+ $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
+ return
+ }
+
+ $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 ""
+ 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)
+ $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 -Model $OllamaModel -BaseUrl $OllamaUrl
+ 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 -Model $OllamaModel -BaseUrl $OllamaUrl
+ 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 ""
+ }
+}
+
+function Invoke-PostRebootCleanup {
+ <#
+ .SYNOPSIS
+ Manually triggers GuruScan post-scan cleanup (removes scanner files).
+ Normally this runs automatically via the GuruRMM-ScannerCleanup scheduled task.
+ .PARAMETER StateFile
+ Path to cleanup-state.json. Defaults to C:\GuruScan\cleanup-state.json.
+ (Parameter kept for backward compatibility — the cleanup script reads it directly.)
+ #>
+ [CmdletBinding()]
+ param(
+ [string]$StateFile = 'C:\GuruScan\cleanup-state.json'
+ )
+ # Manually run the cleanup script if the scheduled task was missed
+ $cleanupScript = 'C:\GuruScan\Invoke-ScannerCleanup.ps1'
+ if (Test-Path $cleanupScript) {
+ Write-Host '[INFO] Running scanner cleanup...' -ForegroundColor Cyan
+ & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $cleanupScript
+ } else {
+ Write-Host '[INFO] No cleanup script found at C:\GuruScan\Invoke-ScannerCleanup.ps1' -ForegroundColor Yellow
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Export declarations
+# ---------------------------------------------------------------------------
+
+Export-ModuleMember -Function @(
+ 'Invoke-GuruScan',
+ 'Invoke-Remediation',
+ 'Get-ScanSummary',
+ 'Invoke-PostRebootCleanup'
+)
diff --git a/projects/msp-tools/guru-scan/Invoke-GuruScan.ps1 b/projects/msp-tools/guru-scan/Invoke-GuruScan.ps1
index 2f78363..0f4f2f3 100644
--- a/projects/msp-tools/guru-scan/Invoke-GuruScan.ps1
+++ b/projects/msp-tools/guru-scan/Invoke-GuruScan.ps1
@@ -1,19 +1,18 @@
-#Requires -RunAsAdministrator
+#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\.
+ Scanner definitions are read from scanners.json in the same directory.
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.
+ takes too long for routine runs. To run MSERT, invoke it directly or add
+ it back to scanners.json.
.PARAMETER ScanOnly
Use scan args (detect only) instead of clean args for every scanner.
.PARAMETER AutoRemediate
@@ -21,22 +20,20 @@
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.
+ Names must match the Name field in scanners.json 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
+ in scanners.json exactly. Useful for excluding a single scanner without
respecifying the entire list.
+.PARAMETER Headless
+ Suppress scanner windows (used when dispatching via RMM).
.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)
+ .\Invoke-GuruScan.ps1 -Headless
#>
[CmdletBinding()]
param(
@@ -44,1054 +41,15 @@ param(
[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
+$moduleManifest = Join-Path $PSScriptRoot 'GuruScan.psd1'
+if (-not (Test-Path $moduleManifest)) {
+ Write-Host "[ERROR] GuruScan module not found: $moduleManifest" -ForegroundColor Red
+ exit 1
}
-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
+Import-Module $moduleManifest -Force
+Invoke-GuruScan @PSBoundParameters -OutputSink Disk
diff --git a/projects/msp-tools/guru-scan/Invoke-PostRebootCleanup.ps1 b/projects/msp-tools/guru-scan/Invoke-PostRebootCleanup.ps1
index 6fa5dfb..fb581a6 100644
--- a/projects/msp-tools/guru-scan/Invoke-PostRebootCleanup.ps1
+++ b/projects/msp-tools/guru-scan/Invoke-PostRebootCleanup.ps1
@@ -1,215 +1,13 @@
#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.
+ Manually triggers GuruScan post-scan cleanup (removes scanner files).
+ Normally this runs automatically via the GuruRMM-ScannerCleanup scheduled task.
#>
-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 {}
+$moduleManifest = Join-Path $PSScriptRoot 'GuruScan.psd1'
+if (-not (Test-Path $moduleManifest)) {
+ Write-Host "[ERROR] GuruScan module not found: $moduleManifest" -ForegroundColor Red
+ exit 1
}
-
-$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
+Import-Module $moduleManifest -Force
+Invoke-PostRebootCleanup
diff --git a/projects/msp-tools/guru-scan/Invoke-Remediation.ps1 b/projects/msp-tools/guru-scan/Invoke-Remediation.ps1
index fca95a9..8b498d8 100644
--- a/projects/msp-tools/guru-scan/Invoke-Remediation.ps1
+++ b/projects/msp-tools/guru-scan/Invoke-Remediation.ps1
@@ -1,4 +1,4 @@
-#Requires -RunAsAdministrator
+#Requires -RunAsAdministrator
<#
.SYNOPSIS
Re-runs GuruScan scanners in clean mode against a previous scan's log folder.
@@ -25,436 +25,11 @@ param(
[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
+$moduleManifest = Join-Path $PSScriptRoot 'GuruScan.psd1'
+if (-not (Test-Path $moduleManifest)) {
+ Write-Host "[ERROR] GuruScan module not found: $moduleManifest" -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
+Import-Module $moduleManifest -Force
+Invoke-Remediation @PSBoundParameters
diff --git a/projects/msp-tools/guru-scan/README.md b/projects/msp-tools/guru-scan/README.md
new file mode 100644
index 0000000..9cf8c44
--- /dev/null
+++ b/projects/msp-tools/guru-scan/README.md
@@ -0,0 +1,169 @@
+# GuruScan
+
+Multi-engine malware scan orchestrator for GuruRMM. Runs a sequenced chain of
+portable security scanners, captures all logs, and writes structured JSON results
+for downstream processing by the RMM agent.
+
+---
+
+## Deploy Layout
+
+| Path | Purpose |
+|------|---------|
+| `C:\GuruScan\` | Base directory — module files, whitelist, state files |
+| `C:\GuruScan\downloads\` | Scanner EXEs (populated by `Download-Scanners.ps1`) |
+| `C:\GuruScan\reports\` | Per-scan zip archives |
+| `C:\ScanLogs\` | Per-scan log folders (`-\`) |
+
+The module files (`GuruScan.psm1`, `GuruScan.psd1`, `scanners.json`) live in the
+same directory as the launcher scripts (`Invoke-GuruScan.ps1`, etc.). This is
+typically `C:\GuruScan\` or the RMM deployment path — wherever the scripts are placed.
+
+---
+
+## Scanner Chain
+
+Scanners run in this order. Each stage hands off to the next regardless of findings.
+
+| # | Scanner | Category | Notes |
+|---|---------|----------|-------|
+| 1 | **RKill** | process-killer | Kills malware-related processes before scanners run. Exit 1 = processes were killed (not a threat indicator). |
+| 2 | **AdwCleaner** | adware | Removes adware, PUPs, and browser hijackers. |
+| 3 | **Emsisoft Command Line Scanner** | antimalware | Two-step: NSIS installer extracts to `C:\EmsisoftCmd\`, then `/update` fetches latest definitions, then scans. |
+| 4 | **HitmanPro** | antimalware | Cloud-assisted second-opinion scanner. Trial registry is reset before each run via `Invoke-HitmanProTrialReset`. |
+| 5 | **ESET Online Scanner** | antimalware | Skipped automatically when running as SYSTEM (requires interactive desktop). |
+
+MSERT (Microsoft Safety Scanner) is excluded from the default chain — it is too slow
+for routine remediation runs. Add it back to `scanners.json` if needed.
+
+---
+
+## Exit Code Semantics
+
+| Scanner | Exit 0 | Exit 1 | Exit 2 | Exit 3 | Other |
+|---------|--------|--------|--------|--------|-------|
+| RKill | Clean run | Processes killed (not a threat) | — | — | — |
+| AdwCleaner | Clean | Cleaned, no reboot needed | Cleaned, reboot required | Found but not cleaned (scan-only) | — |
+| Emsisoft | Clean | Threats found/cleaned | Cleaned, reboot required | — | — |
+| HitmanPro | Clean | Cleaned | Cleaned, reboot required | — | — |
+| ESET | Clean | Threats found | Incomplete removal, reboot may help | — | — |
+| MSERT | Clean | Threats found/cleaned | — | — | Non-zero = threats |
+| TDSSKiller | Clean | Threats found | — | — | — |
+| Stinger | Clean | — | — | — | 13 = threats |
+
+Reboot-required exit codes: AdwCleaner 2, HitmanPro 2, Emsisoft 2, ESET 2.
+
+---
+
+## Autologon / Cleanup Lifecycle
+
+When any scanner exits with a reboot-required code (exit 2), the following sequence runs:
+
+1. `Invoke-RebootCleanupSetup` writes `cleanup-state.json` with the original user, scan ID, and log path.
+2. A hidden `GuruRMM-Temp` administrator account is created with a random password.
+3. One-time autologon (`AutoLogonCount=1`) is configured for `GuruRMM-Temp`. Windows clears the password after the first use.
+4. The account is hidden from the login screen via the `SpecialAccounts\UserList` registry key.
+5. A logon-triggered scheduled task (`GuruRMM-PostRebootCleanup`) is registered for `GuruRMM-Temp`.
+6. The machine reboots after a 15-second warning.
+7. On next boot, Windows auto-logs in as `GuruRMM-Temp`. The WPF splash appears immediately (full-screen, black, cursor hidden).
+8. `Invoke-PostRebootCleanup` runs: verifies pending operations cleared, removes scanner files, writes `logs-ready.json`, restores the original user's login name, clears autologon, removes the cleanup task.
+9. A SYSTEM scheduled task (`GuruRMM-TempUserDelete`) is registered to delete the `GuruRMM-Temp` account 2 minutes later (cannot delete your own account while logged in).
+10. The splash closes, `logoff` is called, and the machine returns to the normal login screen.
+
+---
+
+## Headless / SYSTEM Behavior
+
+- `-Headless` passes `NoNewWindow` to all scanner launches, suppressing UI windows.
+ Use this when dispatching from an RMM agent that has no interactive desktop.
+- ESET is automatically skipped when the script detects it is running as the SYSTEM
+ account (`[System.Security.Principal.WindowsIdentity]::GetCurrent().IsSystem`).
+ Pass `-SkipEset` explicitly to skip it under other accounts.
+
+---
+
+## Licensing
+
+| Scanner | License for MSP use |
+|---------|---------------------|
+| RKill | Free (BleepingComputer) |
+| AdwCleaner | Free for personal and commercial use |
+| Emsisoft Command Line Scanner | Free for personal and MSP remediation use |
+| HitmanPro | Commercial license required. Each scan uses trial mode; `Invoke-HitmanProTrialReset` resets the trial window. Verify current licensing terms at https://www.hitmanpro.com before deploying at scale. |
+| ESET Online Scanner | Free for personal and commercial use |
+
+Always verify current licensing terms with each vendor before large-scale deployment.
+
+---
+
+## Stand-alone Usage
+
+```powershell
+# Run all scanners in clean mode (default)
+.\Invoke-GuruScan.ps1
+
+# Detect only, then auto-remediate if threats found
+.\Invoke-GuruScan.ps1 -ScanOnly -AutoRemediate
+
+# Skip ESET (e.g. unattended run)
+.\Invoke-GuruScan.ps1 -SkipEset
+
+# Suppress scanner windows (RMM dispatch)
+.\Invoke-GuruScan.ps1 -Headless
+
+# View the latest scan results
+.\Get-ScanSummary.ps1
+
+# View with AI analysis (requires Ollama)
+.\Get-ScanSummary.ps1 -AI
+
+# Re-run clean pass against a prior scan
+.\Invoke-Remediation.ps1 -LogRoot "C:\ScanLogs\DESKTOP-20260523-143000"
+
+# Download/refresh scanner EXEs
+.\Download-Scanners.ps1
+```
+
+---
+
+## Module Usage
+
+The launcher scripts are thin wrappers. Import the module directly for
+scripted/pipeline use:
+
+```powershell
+Import-Module .\GuruScan.psd1
+
+# Disk sink (default) — writes results.json + CSV + zip
+Invoke-GuruScan
+
+# RMM sink — returns result object to the pipeline, no disk writes
+$result = Invoke-GuruScan -OutputSink RMM -Headless
+if ($result.total_threats -gt 0) { ... }
+
+# Remediation from a prior scan
+Invoke-Remediation -LogRoot "C:\ScanLogs\DESKTOP-20260523-143000"
+
+# Summary report
+Get-ScanSummary -AI
+
+# Post-reboot cleanup (called by Invoke-PostRebootCleanup.ps1)
+Invoke-PostRebootCleanup -StateFile "C:\GuruScan\cleanup-state.json"
+```
+
+---
+
+## Module Structure
+
+```
+guru-scan\
+ GuruScan.psm1 # Core module — all helpers + exported cmdlets
+ GuruScan.psd1 # Module manifest
+ scanners.json # Scanner definitions (single source of truth)
+ Invoke-GuruScan.ps1 # Thin launcher -> Invoke-GuruScan
+ Invoke-Remediation.ps1 # Thin launcher -> Invoke-Remediation
+ Get-ScanSummary.ps1 # Thin launcher -> Get-ScanSummary
+ Invoke-PostRebootCleanup.ps1 # WPF splash + logoff; delegates cleanup to module
+ Download-Scanners.ps1 # Downloads scanner EXEs from scanners.json URLs
+ downloads\ # Scanner EXEs (gitignored)
+```
diff --git a/projects/msp-tools/guru-scan/scanners.json b/projects/msp-tools/guru-scan/scanners.json
new file mode 100644
index 0000000..04266eb
--- /dev/null
+++ b/projects/msp-tools/guru-scan/scanners.json
@@ -0,0 +1,143 @@
+{
+ "scanners": [
+ {
+ "name": "RKill",
+ "category": "process-killer",
+ "exe": "C:\\GuruScan\\downloads\\rkill.exe",
+ "installer_exe": null,
+ "installer_args": null,
+ "run_update_after_install": false,
+ "download_url": "https://download.bleepingcomputer.com/grinler/rkill.exe",
+ "manual_download": false,
+ "manual_download_note": null,
+ "scan_args": ["-s", "-l \"{LOG_ROOT}\\rkill.log\""],
+ "clean_args": ["-s", "-l \"{LOG_ROOT}\\rkill.log\""],
+ "log_src": "{LOG_ROOT}\\rkill.log",
+ "timeout_min": 10,
+ "randomize_exe": false,
+ "pre_close_processes": [],
+ "pre_clean_paths": [],
+ "post_clean_paths": [],
+ "service_names": [],
+ "hitmanpro_trial_reset": false,
+ "whitelist_arg": null,
+ "wait_on_process": null
+ },
+ {
+ "name": "AdwCleaner",
+ "category": "adware",
+ "exe": "C:\\GuruScan\\downloads\\adwcleaner.exe",
+ "installer_exe": null,
+ "installer_args": null,
+ "run_update_after_install": false,
+ "download_url": "https://adwcleaner.malwarebytes.com/adwcleaner?channel=release",
+ "manual_download": true,
+ "manual_download_note": "Malwarebytes blocks automated downloads; download manually from https://www.malwarebytes.com/adwcleaner",
+ "scan_args": ["/eula", "/scan", "/noreboot"],
+ "clean_args": ["/eula", "/clean", "/noreboot"],
+ "log_src": "C:\\AdwCleaner\\Logs",
+ "timeout_min": 60,
+ "randomize_exe": false,
+ "pre_close_processes": [],
+ "pre_clean_paths": ["C:\\AdwCleaner"],
+ "post_clean_paths": ["C:\\AdwCleaner"],
+ "service_names": ["AdwCleanerSvc"],
+ "hitmanpro_trial_reset": false,
+ "whitelist_arg": null,
+ "wait_on_process": "AdwCleaner"
+ },
+ {
+ "name": "Emsisoft",
+ "category": "antimalware",
+ "exe": "C:\\EmsisoftCmd\\a2cmd.exe",
+ "installer_exe": "C:\\GuruScan\\downloads\\EmsisoftCommandlineScanner64.exe",
+ "installer_args": ["/S"],
+ "run_update_after_install": true,
+ "download_url": "https://dl.emsisoft.com/EmsisoftCommandlineScanner64.exe",
+ "manual_download": false,
+ "manual_download_note": null,
+ "scan_args": [
+ "/f=C:\\",
+ "/deep",
+ "/rk",
+ "/m",
+ "/t",
+ "/pup",
+ "/a",
+ "/n",
+ "/ac",
+ "/d",
+ "/wl=\"C:\\GuruScan\\whitelist.txt\"",
+ "/la=\"{LOG_ROOT}\\a2cmd_deep_log.txt\""
+ ],
+ "clean_args": [
+ "/f=C:\\",
+ "/deep",
+ "/rk",
+ "/m",
+ "/t",
+ "/c",
+ "/pup",
+ "/a",
+ "/n",
+ "/ac",
+ "/d",
+ "/wl=\"C:\\GuruScan\\whitelist.txt\"",
+ "/la=\"{LOG_ROOT}\\a2cmd_deep_log.txt\""
+ ],
+ "log_src": null,
+ "timeout_min": 120,
+ "randomize_exe": false,
+ "pre_close_processes": [],
+ "pre_clean_paths": ["C:\\EmsisoftCmd"],
+ "post_clean_paths": ["C:\\EmsisoftCmd"],
+ "service_names": [],
+ "hitmanpro_trial_reset": false,
+ "whitelist_arg": "emsisoft",
+ "wait_on_process": "a2cmd"
+ },
+ {
+ "name": "HitmanPro",
+ "category": "antimalware",
+ "exe": "C:\\GuruScan\\downloads\\HitmanPro_x64.exe",
+ "installer_exe": null,
+ "installer_args": null,
+ "run_update_after_install": false,
+ "download_url": null,
+ "manual_download": true,
+ "manual_download_note": "Requires a trial/license — download from https://www.hitmanpro.com/en-us/hmp.aspx",
+ "scan_args": [
+ "/noinstall",
+ "/scan",
+ "/log=\"{LOG_ROOT}\\HitmanPro_Scan_Log.txt\"",
+ "/excludelist=\"C:\\GuruScan\\whitelist.txt\""
+ ],
+ "clean_args": [
+ "/noinstall",
+ "/clean",
+ "/log=\"{LOG_ROOT}\\HitmanPro_Scan_Log.txt\"",
+ "/excludelist=\"C:\\GuruScan\\whitelist.txt\""
+ ],
+ "log_src": null,
+ "timeout_min": 60,
+ "randomize_exe": false,
+ "pre_close_processes": ["chrome", "firefox", "msedge", "brave", "opera", "iexplore", "operagx", "MicrosoftEdge"],
+ "pre_clean_paths": [
+ "C:\\ProgramData\\HitmanPro",
+ "C:\\ProgramData\\HitmanPro.Alert",
+ "%LOCALAPPDATA%\\HitmanPro",
+ "%LOCALAPPDATA%\\HitmanPro.Alert"
+ ],
+ "post_clean_paths": [
+ "C:\\ProgramData\\HitmanPro",
+ "C:\\ProgramData\\HitmanPro.Alert",
+ "%LOCALAPPDATA%\\HitmanPro",
+ "%LOCALAPPDATA%\\HitmanPro.Alert"
+ ],
+ "service_names": [],
+ "hitmanpro_trial_reset": true,
+ "whitelist_arg": "hitmanpro",
+ "wait_on_process": "HitmanPro_x64"
+ }
+ ]
+}