NoNewWindow caused scanner processes to inherit PowerShell's stdout/stderr pipe handles from the GuruRMM agent. If any scanner hung in Session 0 (e.g. AdwCleaner GUI init), it held the pipe open after PowerShell exited, blocking the GuruRMM command for hours until the server-side reaper fired. WindowStyle=Hidden gives each scanner its own window/console so pipe handles are not inherited. Scanner processes that timeout are still killed by Wait-ProcessWithTimeout; the overall scan completes normally. Verified: full pipeline completes in ~7.5 min on RMM-TEST-MACHINE with EICAR detection, GURUSCAN_RESULT_JSON emitted correctly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1418 lines
57 KiB
PowerShell
1418 lines
57 KiB
PowerShell
#
|
|
# 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 }
|
|
$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 }
|
|
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 = @() }
|
|
|
|
# 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 }
|
|
|
|
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
|
|
}
|
|
# Use WindowStyle=Hidden (not NoNewWindow) so scanner processes get
|
|
# their own window/console and do NOT inherit the PowerShell pipe
|
|
# handles. With NoNewWindow the child shares the parent console and
|
|
# inherits its stdout/stderr pipes; if a scanner hangs the pipe stays
|
|
# open after PowerShell exits, blocking the GuruRMM agent for hours.
|
|
if ($Headless) {
|
|
$startParams['WindowStyle'] = 'Hidden'
|
|
}
|
|
if ($expandedArgs -and $expandedArgs.Count -gt 0) {
|
|
$startParams['ArgumentList'] = $expandedArgs
|
|
}
|
|
|
|
$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) {
|
|
$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 {
|
|
$proc.WaitForExit() | Out-Null # no-arg form guarantees ExitCode is readable
|
|
$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
|
|
|
|
# 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 {
|
|
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
|
|
-Argument "-NoProfile -ExecutionPolicy Bypass -File `"$script:Base\Invoke-ScannerCleanup.ps1`""
|
|
$trigger = New-ScheduledTaskTrigger -AtLogOn
|
|
$trigger.Delay = 'PT30M'
|
|
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -RunLevel Highest
|
|
$settings = New-ScheduledTaskSettingsSet `
|
|
-ExecutionTimeLimit (New-TimeSpan -Minutes 10) `
|
|
-MultipleInstances IgnoreNew `
|
|
-DeleteExpiredTaskAfter (New-TimeSpan -Seconds 0)
|
|
Register-ScheduledTask -TaskName 'GuruRMM-ScannerCleanup' `
|
|
-Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
|
|
Write-Host ' [OK] Scanner cleanup task registered (runs 30 min after next logon)' -ForegroundColor Green
|
|
}
|
|
catch {
|
|
Write-Host " [WARNING] Could not register cleanup task: $_" -ForegroundColor Yellow
|
|
}
|
|
|
|
Write-Host ''
|
|
Write-Host ' [INFO] Please reboot this machine at your convenience to complete removal.' -ForegroundColor Yellow
|
|
Write-Host ' Scanner files will be cleaned up automatically 30 minutes after login.' -ForegroundColor Yellow
|
|
Write-Host ''
|
|
}
|
|
|
|
function Test-RunningAsSystem {
|
|
$id = [System.Security.Principal.WindowsIdentity]::GetCurrent()
|
|
return ($id.IsSystem)
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Private Ollama helper (used by Get-ScanSummary)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
function Invoke-Ollama {
|
|
param(
|
|
[string]$Prompt,
|
|
[string]$Model,
|
|
[string]$BaseUrl
|
|
)
|
|
$body = @{ model = $Model; prompt = $Prompt; stream = $false } | ConvertTo-Json -Depth 3
|
|
try {
|
|
$r = Invoke-RestMethod -Uri "$BaseUrl/api/generate" -Method POST `
|
|
-Body $body -ContentType 'application/json' -TimeoutSec 180
|
|
return ($r.response -replace '</?think[^>]*>', '' -replace '(?s)<think>.*?</think>', '').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
|
|
}
|
|
}
|
|
|
|
# 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 = @{
|
|
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
|
|
}
|
|
|
|
# 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
|
|
}
|
|
}
|
|
|
|
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'
|
|
)
|