Files
claudetools/projects/msp-tools/guru-scan/GuruScan.psm1
Howard Enos f844054847 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
2026-05-26 21:58:09 -07:00

1392 lines
55 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 }
if ($null -eq $sInstallerArgs) { $sInstallerArgs = @('/S') }
if ($null -eq $sRunUpdateAfterInstall) { $sRunUpdateAfterInstall = $false }
# Coerce null JSON values to empty arrays / nulls
if ($null -eq $sInstallerExe) { $sInstallerExe = $null }
if ($null -eq $sLogSrc) { $sLogSrc = $null }
if ($null -eq $sWaitOnProcess) { $sWaitOnProcess = $null }
if ($null -eq $sPreClose) { $sPreClose = @() }
if ($null -eq $sPreClean) { $sPreClean = @() }
if ($null -eq $sPostClean) { $sPostClean = @() }
if ($null -eq $sServiceNames) { $sServiceNames = @() }
$mainExePath = $sExe
$installerExePath = $sInstallerExe
$exeToCheck = if ($installerExePath) { $installerExePath } else { $mainExePath }
if (-not (Test-Path $exeToCheck)) {
Write-Host " [WARNING] EXE not found - skipping: $exeToCheck" -ForegroundColor Yellow
$results.Add([pscustomobject]@{
Scanner = $sName
Status = 'SKIPPED (missing)'
ExitCode = $null
ThreatsFound = 0
Duration = '0 min'
LogPath = ''
})
continue
}
$exeDir = Split-Path $mainExePath -Parent
if ($ScanMode -eq 'scan') {
$rawArgs = @($sScanArgs)
} else {
$rawArgs = @($sCleanArgs)
}
$expandedArgs = Expand-TokenizedArgs -ArgList $rawArgs -LogRoot $RunLogRoot -ExeDir $exeDir
# HitmanPro trial registry pre-seed
if ($sHmpReset -eq $true) {
Write-Host ' [INFO] Resetting HitmanPro trial registry...' -ForegroundColor Cyan
Invoke-HitmanProTrialReset
}
# Pre-clean paths
foreach ($p in $sPreClean) {
Remove-PathSilent $p
}
# Effective timeout
$effectiveTimeoutMin = if ($TimeoutMinOverride -gt 0) { $TimeoutMinOverride } else { $sTimeoutMin }
$timeoutSec = $effectiveTimeoutMin * 60
# Two-step install (scanners with an installer_exe defined)
if ($installerExePath) {
$installOk = Invoke-ScannerInstallPrep -InstallerExePath $installerExePath -MainExePath $mainExePath -ScannerName $sName -InstallerArgs $sInstallerArgs -RunUpdateAfterInstall $sRunUpdateAfterInstall
if (-not $installOk) {
$results.Add([pscustomobject]@{
Scanner = $sName
Status = 'FAILED (installer)'
ExitCode = $null
ThreatsFound = 0
Duration = '0 min'
LogPath = ''
})
continue
}
}
# Randomize EXE (TDSSKiller pattern)
$randomizedExePath = $null
$launchExe = $mainExePath
if ($sRandomizeExe -eq $true) {
$tempName = [System.IO.Path]::GetRandomFileName() -replace '\..*$', ''
$randomizedExePath = Join-Path $env:TEMP "$tempName.exe"
Copy-Item -Path $mainExePath -Destination $randomizedExePath -Force
$launchExe = $randomizedExePath
Write-Host " [INFO] EXE randomized to: $randomizedExePath" -ForegroundColor Cyan
}
# Kill processes that would block this scanner
if ($sPreClose -and $sPreClose.Count -gt 0) {
foreach ($proc in $sPreClose) {
Get-Process -Name $proc -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
}
Start-Sleep -Seconds 2
}
# Launch scanner
Write-Host " [....] Launching $sName..." -ForegroundColor Cyan
$scanStart = Get-Date
$proc = $null
$status = 'completed'
$exitCode = $null
try {
$startParams = @{
FilePath = $launchExe
PassThru = $true
NoNewWindow = [bool]$Headless
}
if ($expandedArgs -and $expandedArgs.Count -gt 0) {
$startParams['ArgumentList'] = $expandedArgs
}
$proc = Start-Process @startParams
$serviceArr = @()
if ($sServiceNames -and $sServiceNames.Count -gt 0) {
$serviceArr = @($sServiceNames)
}
$wpStr = $null
if ($sWaitOnProcess) { $wpStr = [string]$sWaitOnProcess }
$completed = Wait-ScannerCompletion -Process $proc -WaitOnProcess $wpStr -ServiceNames $serviceArr -TimeoutSeconds $timeoutSec
if (-not $completed) {
$status = 'TIMED OUT'
Write-Host " [WARNING] $sName timed out after $effectiveTimeoutMin min" -ForegroundColor Yellow
} else {
$exitCode = $proc.ExitCode
}
}
catch {
$status = 'FAILED'
Write-Host " [ERROR] $sName failed to launch: $_" -ForegroundColor Red
}
# Calculate duration
$scanEnd = Get-Date
$durMin = [math]::Round(($scanEnd - $scanStart).TotalMinutes, 2)
# Collect logs from scanner-specific log source
$logDestDir = Join-Path $RunLogRoot "$($sName)_Logs"
if ($sLogSrc) {
$logSrcExpanded = @(Expand-TokenizedArgs -ArgList @($sLogSrc) -LogRoot $RunLogRoot -ExeDir $exeDir)
if ($logSrcExpanded.Count -gt 0 -and $logSrcExpanded[0]) {
$logSrcPath = Expand-EnvPath $logSrcExpanded[0]
if (Test-Path $logSrcPath) {
New-Item -ItemType Directory -Path $logDestDir -Force | Out-Null
Copy-Item -Path $logSrcPath -Destination $logDestDir -Recurse -Force -ErrorAction SilentlyContinue
Write-Host " [INFO] Logs copied to: $logDestDir" -ForegroundColor Cyan
} else {
Write-Host " [WARNING] Log source not found: $logSrcPath" -ForegroundColor Yellow
}
}
}
# Post-clean paths
foreach ($p in $sPostClean) {
Remove-PathSilent $p
}
# Clean up randomized EXE copy
if ($randomizedExePath -and (Test-Path $randomizedExePath)) {
Remove-Item $randomizedExePath -Force -ErrorAction SilentlyContinue
}
# Threat and reboot detection from exit code
$threatsFound = 0
if ($null -ne $exitCode) {
$threatsFound = Get-ExitCodeThreats -ScannerName $sName -ExitCode $exitCode
if (Get-ExitCodeReboot -ScannerName $sName -ExitCode $exitCode) {
$script:LastPassRebootRequired = $true
Write-Host " [WARNING] $sName signals REBOOT REQUIRED to complete removal" -ForegroundColor Yellow
}
}
$script:LastPassTotalThreats += $threatsFound
# Status reporting
$color = if ($status -eq 'completed') { 'Green' } elseif ($status -eq 'TIMED OUT') { 'Yellow' } else { 'Red' }
Write-Host " [$status] ExitCode=$exitCode Threats=$threatsFound Duration=${durMin}min" -ForegroundColor $color
$results.Add([pscustomobject]@{
Scanner = $sName
Status = $status
ExitCode = $exitCode
ThreatsFound = $threatsFound
Duration = "$durMin min"
LogPath = $logDestDir
})
}
return $results
}
function Register-ScannerCleanupTask {
<#
.SYNOPSIS
Writes cleanup state and registers a SYSTEM scheduled task that fires at
next user logon + 30 minutes to remove scanner files and unregister itself.
#>
param(
[string]$ScanId,
[string]$LogRoot
)
Write-Host ''
Write-Host '------------------------------------------------------------' -ForegroundColor Yellow
Write-Host ' Reboot recommended — registering post-reboot cleanup task' -ForegroundColor Yellow
Write-Host '------------------------------------------------------------' -ForegroundColor Yellow
# Write state for the cleanup script to read
@{
scan_id = $ScanId
log_root = $LogRoot
created_at = (Get-Date).ToUniversalTime().ToString('o')
} | ConvertTo-Json | Set-Content "$script:Base\cleanup-state.json" -Encoding UTF8
# Write the cleanup script that the scheduled task will run
$cleanupScript = @'
$Base = 'C:\GuruScan'
$state = @{ scan_id = ''; log_root = '' }
$stateFile = "$Base\cleanup-state.json"
if (Test-Path $stateFile) {
try { $state = Get-Content $stateFile -Raw | ConvertFrom-Json } catch {}
}
# Remove scanner installation files
$scannerPaths = @(
'C:\EmsisoftCmd',
'C:\AdwCleaner',
'C:\ProgramData\HitmanPro',
'C:\ProgramData\HitmanPro.Alert'
)
foreach ($p in $scannerPaths) {
Remove-Item -Path $p -Recurse -Force -ErrorAction SilentlyContinue
}
# Remove scanner download EXEs
Remove-Item -Path "$Base\downloads" -Recurse -Force -ErrorAction SilentlyContinue
# Flag logs for GuruRMM to pull
@{
scan_id = $state.scan_id
log_root = $state.log_root
zip_path = "$Base\reports\$($state.scan_id).zip"
cleaned_at = (Get-Date).ToUniversalTime().ToString('o')
} | ConvertTo-Json | Set-Content "$Base\logs-ready.json" -Encoding UTF8
# Remove state file
Remove-Item -Path $stateFile -Force -ErrorAction SilentlyContinue
# Unregister this task
Unregister-ScheduledTask -TaskName 'GuruRMM-ScannerCleanup' -Confirm:$false -ErrorAction SilentlyContinue
'@
$cleanupScript | Set-Content "$script:Base\Invoke-ScannerCleanup.ps1" -Encoding UTF8
# Register as SYSTEM logon task with 30-minute delay
try {
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument "-NoProfile -ExecutionPolicy Bypass -File `"$script:Base\Invoke-ScannerCleanup.ps1`""
$trigger = New-ScheduledTaskTrigger -AtLogOn
$trigger.Delay = 'PT30M'
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet `
-ExecutionTimeLimit (New-TimeSpan -Minutes 10) `
-MultipleInstances IgnoreNew `
-DeleteExpiredTaskAfter (New-TimeSpan -Seconds 0)
Register-ScheduledTask -TaskName 'GuruRMM-ScannerCleanup' `
-Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
Write-Host ' [OK] Scanner cleanup task registered (runs 30 min after next logon)' -ForegroundColor Green
}
catch {
Write-Host " [WARNING] Could not register cleanup task: $_" -ForegroundColor Yellow
}
Write-Host ''
Write-Host ' [INFO] Please reboot this machine at your convenience to complete removal.' -ForegroundColor Yellow
Write-Host ' Scanner files will be cleaned up automatically 30 minutes after login.' -ForegroundColor Yellow
Write-Host ''
}
function Test-RunningAsSystem {
$id = [System.Security.Principal.WindowsIdentity]::GetCurrent()
return ($id.IsSystem)
}
# ---------------------------------------------------------------------------
# Private Ollama helper (used by Get-ScanSummary)
# ---------------------------------------------------------------------------
function Invoke-Ollama {
param(
[string]$Prompt,
[string]$Model,
[string]$BaseUrl
)
$body = @{ model = $Model; prompt = $Prompt; stream = $false } | ConvertTo-Json -Depth 3
try {
$r = Invoke-RestMethod -Uri "$BaseUrl/api/generate" -Method POST `
-Body $body -ContentType 'application/json' -TimeoutSec 180
return ($r.response -replace '</?think[^>]*>', '' -replace '(?s)<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
}
}
# 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'
)