From f8440548479cd2dbc69727c4fc45544828318894 Mon Sep 17 00:00:00 2001 From: Howard Enos Date: Tue, 26 May 2026 21:58:07 -0700 Subject: [PATCH] sync: auto-sync from HOWARD-HOME at 2026-05-26 21:58:00 Author: Howard Enos Machine: HOWARD-HOME Timestamp: 2026-05-26 21:58:00 --- .../msp-tools/guru-scan/Get-ScanSummary.ps1 | 261 +--- projects/msp-tools/guru-scan/GuruScan.psd1 | 27 + projects/msp-tools/guru-scan/GuruScan.psm1 | 1391 +++++++++++++++++ .../msp-tools/guru-scan/Invoke-GuruScan.ps1 | 1072 +------------ .../guru-scan/Invoke-PostRebootCleanup.ps1 | 218 +-- .../guru-scan/Invoke-Remediation.ps1 | 437 +----- projects/msp-tools/guru-scan/README.md | 169 ++ projects/msp-tools/guru-scan/scanners.json | 143 ++ 8 files changed, 1764 insertions(+), 1954 deletions(-) create mode 100644 projects/msp-tools/guru-scan/GuruScan.psd1 create mode 100644 projects/msp-tools/guru-scan/GuruScan.psm1 create mode 100644 projects/msp-tools/guru-scan/README.md create mode 100644 projects/msp-tools/guru-scan/scanners.json 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 ']*>', '' -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 ']*>', '' -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" + } + ] +}