diff --git a/projects/msp-tools/guru-scan/GuruScan.psm1 b/projects/msp-tools/guru-scan/GuruScan.psm1 index c01cd39..88accc5 100644 --- a/projects/msp-tools/guru-scan/GuruScan.psm1 +++ b/projects/msp-tools/guru-scan/GuruScan.psm1 @@ -1,7 +1,7 @@ # # GuruScan.psm1 # Multi-engine malware scan orchestrator for GuruRMM. -# PowerShell 5.1 compatible — no ternary, no ??, no ?. operators. +# PowerShell 5.1 compatible -- no ternary, no ??, no ?. operators. # Set-StrictMode -Version Latest @@ -73,7 +73,7 @@ function Wait-ProcessWithTimeout { .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 + Calls WaitForExit(5000) before returning to flush the exit code -- required before the ExitCode property is reliably readable on fast-exit processes. #> param( @@ -89,7 +89,7 @@ function Wait-ProcessWithTimeout { } Start-Sleep -Seconds 5 } - # Flush exit code — required before ExitCode property is readable + # Flush exit code -- required before ExitCode property is readable $Process.WaitForExit(5000) | Out-Null return $true } @@ -184,7 +184,7 @@ function Get-ExitCodeThreats { '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 + # RKill: exit 1 = processes were killed -- not a threat count } return 0 } @@ -399,7 +399,7 @@ function Invoke-ScanPass { Write-Host " Scanner : $($s.Name)" -ForegroundColor Cyan Write-Host " Mode : $ScanMode" - # Normalize property names — JSON objects use snake_case, PS hashtables use PascalCase + # 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 } @@ -416,8 +416,10 @@ function Invoke-ScanPass { $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 } + $sSession0Compatible = if ($s.PSObject.Properties['Session0Compatible']) { $s.Session0Compatible } else { $s.session0_compatible } if ($null -eq $sInstallerArgs) { $sInstallerArgs = @('/S') } if ($null -eq $sRunUpdateAfterInstall) { $sRunUpdateAfterInstall = $false } + if ($null -eq $sSession0Compatible) { $sSession0Compatible = $true } # Coerce null JSON values to empty arrays / nulls if ($null -eq $sInstallerExe) { $sInstallerExe = $null } @@ -428,6 +430,20 @@ function Invoke-ScanPass { if ($null -eq $sPostClean) { $sPostClean = @() } if ($null -eq $sServiceNames) { $sServiceNames = @() } + # Skip GUI-only scanners when running as SYSTEM in Session 0 (no interactive desktop) + if (-not $sSession0Compatible -and (Test-RunningAsSystem)) { + Write-Host " [WARNING] $sName requires an interactive user session -- skipping (running as SYSTEM)" -ForegroundColor Yellow + $results.Add([pscustomobject]@{ + Scanner = $sName + Status = 'SKIPPED (requires user session)' + ExitCode = $null + ThreatsFound = 0 + Duration = '0 min' + LogPath = '' + }) + continue + } + $mainExePath = $sExe $installerExePath = $sInstallerExe $exeToCheck = if ($installerExePath) { $installerExePath } else { $mainExePath } @@ -524,6 +540,7 @@ function Invoke-ScanPass { } $proc = Start-Process @startParams + $null = $proc.Handle # force handle caching -- Start-Process -PassThru can lose the handle before exit, making ExitCode return null $serviceArr = @() if ($sServiceNames -and $sServiceNames.Count -gt 0) { @@ -539,6 +556,7 @@ function Invoke-ScanPass { $status = 'TIMED OUT' Write-Host " [WARNING] $sName timed out after $effectiveTimeoutMin min" -ForegroundColor Yellow } else { + $proc.WaitForExit() | Out-Null # no-arg form guarantees ExitCode is readable $exitCode = $proc.ExitCode } } @@ -619,7 +637,7 @@ function Register-ScannerCleanupTask { Write-Host '' Write-Host '------------------------------------------------------------' -ForegroundColor Yellow - Write-Host ' Reboot recommended — registering post-reboot cleanup task' -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 @@ -629,45 +647,14 @@ function Register-ScannerCleanupTask { 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 + # Copy the static cleanup script from the module directory to C:\GuruScan\ + $cleanupSrc = Join-Path $script:ModuleRoot 'Invoke-ScannerCleanup.ps1' + if (Test-Path $cleanupSrc) { + Copy-Item -Path $cleanupSrc -Destination "$script:Base\Invoke-ScannerCleanup.ps1" -Force + } else { + Write-Host " [WARNING] Invoke-ScannerCleanup.ps1 not found at $cleanupSrc -- cleanup task will not run." -ForegroundColor Yellow + return + } # Register as SYSTEM logon task with 30-minute delay try { @@ -776,11 +763,11 @@ function Invoke-GuruScan { [string]$OutputSink = 'Disk' ) - # Whitelist — written to C:\GuruScan\whitelist.txt before any scanner runs. + # 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 blacklist -- items removed after all scanners complete. $forceRemove = @() $scanStamp = "$env:COMPUTERNAME-$(Get-Date -Format yyyyMMdd-HHmmss)" @@ -836,6 +823,31 @@ function Invoke-GuruScan { } } + # Pre-flight: warn about missing scanner EXEs so missing scanners are + # visible up front rather than silently skipped mid-run. + $missingExes = @() + foreach ($s in $scannerList) { + $sName = if ($s.PSObject.Properties['Name']) { $s.Name } else { $s.name } + $sExe = if ($s.PSObject.Properties['Exe']) { $s.Exe } else { $s.exe } + $sInstExe = if ($s.PSObject.Properties['InstallerExe']) { $s.InstallerExe } else { $s.installer_exe } + if (-not (Test-Path $sExe) -and (-not $sInstExe -or -not (Test-Path $sInstExe))) { + $missingExes += $sName + } + } + if ($missingExes.Count -gt 0) { + Write-Host "[WARNING] Missing scanner EXEs -- these will be skipped: $($missingExes -join ', ')" -ForegroundColor Yellow + Write-Host " Run .\Download-Scanners.ps1 to download them." -ForegroundColor Yellow + Write-Host '' + } + + # Add Windows Defender exclusions for scanner paths so Defender does not + # quarantine scanner EXEs or log files mid-run. + $defenderExclusions = @($script:Base, $script:LogRoot, 'C:\EmsisoftCmd', 'C:\AdwCleaner') + try { + Add-MpPreference -ExclusionPath $defenderExclusions -ErrorAction SilentlyContinue + Write-Host "[INFO] Windows Defender exclusions added for scanner paths" -ForegroundColor Cyan + } catch {} + # First pass $startedAt = Get-Date $passParams = @{ @@ -964,7 +976,14 @@ function Invoke-GuruScan { Register-ScannerCleanupTask -ScanId $scanStamp -LogRoot $runLogRoot } - # Return result object (always returned; caller uses it for RMM sink) + # Remove the Defender exclusions added at scan start. + try { + Remove-MpPreference -ExclusionPath $defenderExclusions -ErrorAction SilentlyContinue + Write-Host "[INFO] Windows Defender exclusions removed" -ForegroundColor Cyan + } catch {} + + # Return result object (RMM sink) or suppress it (Disk sink lets the + # launcher read results.json from disk for structured reporting). $resultRecord = [pscustomobject]$resultsObj if ($OutputSink -eq 'RMM') { return $resultRecord @@ -1093,10 +1112,10 @@ function Invoke-Remediation { 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 " 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 " [OK] Clean pass complete -- no threats detected in output." -ForegroundColor Green } Write-Host "" @@ -1271,7 +1290,7 @@ function Get-ScanSummary { Write-Host " Full logs: $(Split-Path $ResultsFile)" -ForegroundColor Gray Write-Host "" - # Ollama AI analysis (optional — requires -AI switch) + # Ollama AI analysis (optional -- requires -AI switch) if ($AI) { Write-Host "================================================================" -ForegroundColor Cyan Write-Host " AI Analysis (Ollama)" -ForegroundColor Cyan @@ -1311,7 +1330,7 @@ $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. +Do not add commentary -- only the list. "@ $threatDetails = Invoke-Ollama -Prompt $threatPrompt -Model $OllamaModel -BaseUrl $OllamaUrl @@ -1320,7 +1339,7 @@ Do not add commentary — only the list. 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 + Write-Host " [WARNING] Ollama unavailable -- skipping threat extraction." -ForegroundColor Yellow } # Step 2: Prioritized remediation recommendations @@ -1345,7 +1364,7 @@ Plain numbered list, no markdown headers, no padding text. Be specific. 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 " [WARNING] Ollama unavailable -- skipping recommendations." -ForegroundColor Yellow } Write-Host "" @@ -1363,7 +1382,7 @@ function Invoke-PostRebootCleanup { 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.) + (Parameter kept for backward compatibility -- the cleanup script reads it directly.) #> [CmdletBinding()] param( diff --git a/projects/msp-tools/guru-scan/Invoke-GuruScan.ps1 b/projects/msp-tools/guru-scan/Invoke-GuruScan.ps1 index 0f4f2f3..e50dc8e 100644 --- a/projects/msp-tools/guru-scan/Invoke-GuruScan.ps1 +++ b/projects/msp-tools/guru-scan/Invoke-GuruScan.ps1 @@ -52,4 +52,22 @@ if (-not (Test-Path $moduleManifest)) { } Import-Module $moduleManifest -Force +$scanStart = Get-Date Invoke-GuruScan @PSBoundParameters -OutputSink Disk + +# Emit structured JSON to stdout for GuruRMM CommandResult capture. +# Read from results.json written during this run (newer than $scanStart). +$resultsFile = Get-ChildItem -Path 'C:\ScanLogs' -Recurse -Filter 'results.json' -ErrorAction SilentlyContinue | + Where-Object { $_.LastWriteTime -gt $scanStart } | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + +if ($resultsFile) { + $json = Get-Content -Path $resultsFile.FullName -Raw -Encoding UTF8 -ErrorAction SilentlyContinue + if ($json) { + # Compress to single line so the agent's stdout parser sees it as one line + $compressed = ($json | ConvertFrom-Json | ConvertTo-Json -Depth 10 -Compress) + Write-Output '' + Write-Output "GURUSCAN_RESULT_JSON:$compressed" + } +} diff --git a/projects/msp-tools/guru-scan/README.md b/projects/msp-tools/guru-scan/README.md index eef25fe..bd5cdc7 100644 --- a/projects/msp-tools/guru-scan/README.md +++ b/projects/msp-tools/guru-scan/README.md @@ -28,7 +28,7 @@ Scanners run in this order. Each stage hands off to the next regardless of findi | # | 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. | +| 2 | **AdwCleaner** | adware | Removes adware, PUPs, and browser hijackers. **Requires an interactive user session** (GUI app; no headless/SYSTEM mode). Skipped automatically when running as SYSTEM with no desktop. To include AdwCleaner, dispatch via GuruRMM with `context: user_session`. | | 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`. | @@ -75,6 +75,11 @@ To run cleanup immediately without waiting (e.g. if the task was missed): - `-Headless` passes `NoNewWindow` to all scanner launches, suppressing UI windows. Use this when dispatching from an RMM agent that has no interactive desktop. +- Scanners with `session0_compatible: false` in `scanners.json` are automatically skipped + when the module detects it is running as SYSTEM (Session 0). Currently: **AdwCleaner**. + The result record shows `SKIPPED (requires user session)` rather than a failure. +- To run AdwCleaner via GuruRMM, dispatch with `context: user_session` so it runs in + the active user's desktop session (requires a logged-in user on the target machine). --- @@ -156,6 +161,7 @@ guru-scan\ Invoke-Remediation.ps1 # Thin launcher -> Invoke-Remediation Get-ScanSummary.ps1 # Thin launcher -> Get-ScanSummary Invoke-PostRebootCleanup.ps1 # Thin launcher -> Invoke-PostRebootCleanup (manual cleanup trigger) + Invoke-ScannerCleanup.ps1 # Post-reboot cleanup script; copied to C:\GuruScan\ when reboot is needed 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 index 04266eb..65bcbf9 100644 --- a/projects/msp-tools/guru-scan/scanners.json +++ b/projects/msp-tools/guru-scan/scanners.json @@ -21,7 +21,8 @@ "service_names": [], "hitmanpro_trial_reset": false, "whitelist_arg": null, - "wait_on_process": null + "wait_on_process": null, + "session0_compatible": true }, { "name": "AdwCleaner", @@ -33,18 +34,19 @@ "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", + "scan_args": ["/eula", "/scan", "/noreboot", "/path", "{LOG_ROOT}"], + "clean_args": ["/eula", "/clean", "/noreboot", "/path", "{LOG_ROOT}"], + "log_src": "{LOG_ROOT}\\Logs", "timeout_min": 60, "randomize_exe": false, "pre_close_processes": [], "pre_clean_paths": ["C:\\AdwCleaner"], - "post_clean_paths": ["C:\\AdwCleaner"], + "post_clean_paths": [], "service_names": ["AdwCleanerSvc"], "hitmanpro_trial_reset": false, "whitelist_arg": null, - "wait_on_process": "AdwCleaner" + "wait_on_process": "AdwCleaner", + "session0_compatible": false }, { "name": "Emsisoft", @@ -94,7 +96,8 @@ "service_names": [], "hitmanpro_trial_reset": false, "whitelist_arg": "emsisoft", - "wait_on_process": "a2cmd" + "wait_on_process": "a2cmd", + "session0_compatible": true }, { "name": "HitmanPro", @@ -109,12 +112,14 @@ "scan_args": [ "/noinstall", "/scan", + "/quiet", "/log=\"{LOG_ROOT}\\HitmanPro_Scan_Log.txt\"", "/excludelist=\"C:\\GuruScan\\whitelist.txt\"" ], "clean_args": [ "/noinstall", "/clean", + "/quiet", "/log=\"{LOG_ROOT}\\HitmanPro_Scan_Log.txt\"", "/excludelist=\"C:\\GuruScan\\whitelist.txt\"" ], @@ -137,7 +142,8 @@ "service_names": [], "hitmanpro_trial_reset": true, "whitelist_arg": "hitmanpro", - "wait_on_process": "HitmanPro_x64" + "wait_on_process": "HitmanPro_x64", + "session0_compatible": true } ] }