feat(guru-scan): fix exit code capture, add GURUSCAN_RESULT_JSON reporting, pre-scan hardening

Exit code fix: add $proc.Handle caching after Start-Process -PassThru to prevent
the handle from being released before ExitCode is readable (known PS5.1 bug).

GuruRMM reporting: launcher now finds results.json after each scan and emits
GURUSCAN_RESULT_JSON:<compressed> to stdout. Agent CommandResult captures it;
server stores it in commands.stdout for retrieval via GET /api/commands/:id.

Pre-scan hardening:
- Pre-flight EXE check: warns about missing scanner binaries before run starts
- Windows Defender exclusions added for scanner/log paths before scan, removed after

AdwCleaner: add /path {LOG_ROOT} arg so logs write directly to scan log root;
update log_src to {LOG_ROOT}\Logs to match.

HitmanPro: add /quiet to scan and clean args to suppress GUI in headless runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 00:13:16 -07:00
parent a8ee927db0
commit 40e090c95a
4 changed files with 113 additions and 64 deletions

View File

@@ -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(

View File

@@ -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"
}
}

View File

@@ -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)
```

View File

@@ -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
}
]
}