# # GuruScan.psm1 # Multi-engine malware scan orchestrator for GuruRMM. # PowerShell 5.1 compatible — no ternary, no ??, no ?. operators. # Set-StrictMode -Version Latest $ErrorActionPreference = 'Continue' $ProgressPreference = 'SilentlyContinue' # --------------------------------------------------------------------------- # Module-level constants and scanner definition loading # --------------------------------------------------------------------------- $script:ModuleRoot = $PSScriptRoot $script:Base = 'C:\GuruScan' $script:LogRoot = 'C:\ScanLogs' $script:ScannersJson = Join-Path $script:ModuleRoot 'scanners.json' $script:ScannerDefs = if (Test-Path $script:ScannersJson) { (Get-Content $script:ScannersJson -Raw | ConvertFrom-Json).scanners } else { @() } # --------------------------------------------------------------------------- # Private helpers # --------------------------------------------------------------------------- function Expand-TokenizedArgs { <# .SYNOPSIS Replaces {LOG_ROOT} and {EXE_DIR} tokens in a list of argument strings. #> param( [string[]]$ArgList, [string]$LogRoot, [string]$ExeDir ) $out = foreach ($a in $ArgList) { $a = $a -replace '\{LOG_ROOT\}', $LogRoot $a = $a -replace '\{EXE_DIR\}', $ExeDir $a } return $out } function Expand-EnvPath { <# .SYNOPSIS Expands environment variable references in a path string. #> param([string]$Path) return [System.Environment]::ExpandEnvironmentVariables($Path) } function Remove-PathSilent { <# .SYNOPSIS Removes a file or directory silently if it exists. #> param([string]$Path) $expanded = Expand-EnvPath $Path if (Test-Path $expanded) { Remove-Item -Path $expanded -Recurse -Force -ErrorAction SilentlyContinue } } function Wait-ProcessWithTimeout { <# .SYNOPSIS Waits for a process to exit within a deadline; kills if it exceeds timeout. .OUTPUTS $true if the process exited cleanly within the timeout, $false if it was killed. .NOTES Calls WaitForExit(5000) before returning to flush the exit code — required before the ExitCode property is reliably readable on fast-exit processes. #> param( [System.Diagnostics.Process]$Process, [int]$TimeoutSeconds ) $deadline = (Get-Date).AddSeconds($TimeoutSeconds) while (-not $Process.HasExited) { if ((Get-Date) -gt $deadline) { try { $Process.Kill() } catch {} $Process.WaitForExit(5000) | Out-Null return $false } Start-Sleep -Seconds 5 } # Flush exit code — required before ExitCode property is readable $Process.WaitForExit(5000) | Out-Null return $true } function Wait-ServicesToStop { <# .SYNOPSIS Waits until all named services have stopped (or the deadline passes). #> param( [string[]]$ServiceNames, [int]$TimeoutSeconds = 120 ) if (-not $ServiceNames -or $ServiceNames.Count -eq 0) { return } $deadline = (Get-Date).AddSeconds($TimeoutSeconds) foreach ($svc in $ServiceNames) { while ((Get-Date) -lt $deadline) { $s = Get-Service -Name $svc -ErrorAction SilentlyContinue if (-not $s -or $s.Status -ne 'Running') { break } Start-Sleep -Seconds 3 } } } function Wait-ScannerCompletion { <# .SYNOPSIS Waits for a scanner process to complete, then waits for any named child process and scanner services to stop. .OUTPUTS $true if the main process exited cleanly, $false if it timed out. #> param( [System.Diagnostics.Process]$Process, [string]$WaitOnProcess, [string[]]$ServiceNames, [int]$TimeoutSeconds ) # Wait for the main process $completed = Wait-ProcessWithTimeout -Process $Process -TimeoutSeconds $TimeoutSeconds if (-not $completed) { return $false } # Wait for named child process if specified if ($WaitOnProcess) { $deadline = (Get-Date).AddSeconds(60) while ((Get-Date) -lt $deadline) { $child = Get-Process -Name $WaitOnProcess -ErrorAction SilentlyContinue if (-not $child) { break } Start-Sleep -Seconds 3 } } # Wait for services to stop if ($ServiceNames -and $ServiceNames.Count -gt 0) { Wait-ServicesToStop -ServiceNames $ServiceNames -TimeoutSeconds 120 } return $true } function Invoke-HitmanProTrialReset { <# .SYNOPSIS Wipes and recreates HitmanPro registry keys to reset the trial window. #> Remove-Item -Path 'HKLM:\SOFTWARE\HitmanPro' -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -Path 'HKLM:\SOFTWARE\HitmanPro.Alert' -Recurse -Force -ErrorAction SilentlyContinue New-Item -Path 'HKLM:\SOFTWARE\HitmanPro' -Force | Out-Null New-Item -Path 'HKLM:\SOFTWARE\HitmanPro.Alert' -Force | Out-Null } function Get-ExitCodeThreats { <# .SYNOPSIS Maps a scanner exit code to a threat indicator (0 = clean, 1 = threats found). .NOTES Exit code semantics per scanner: AdwCleaner : 1=cleaned(no reboot), 2=cleaned(reboot needed), 3=found/not cleaned HitmanPro : 1=cleaned, 2=cleaned(reboot needed) Emsisoft : >=1 = threats found or cleaned MSERT : non-zero = threats TDSSKiller : 1=threats Stinger : 13=threats RKill : exit 1 = processes killed (NOT a threat indicator) #> param([string]$ScannerName, [int]$ExitCode) switch ($ScannerName) { 'AdwCleaner' { if ($ExitCode -in @(1, 2, 3)) { return 1 } } 'HitmanPro' { if ($ExitCode -in @(1, 2)) { return 1 } } 'Emsisoft' { if ($ExitCode -ge 1) { return 1 } } 'MSERT' { if ($ExitCode -ne 0) { return 1 } } 'TDSSKiller' { if ($ExitCode -eq 1) { return 1 } } 'Stinger' { if ($ExitCode -eq 13) { return 1 } } # RKill: exit 1 = processes were killed — not a threat count } return 0 } function Get-ExitCodeReboot { <# .SYNOPSIS Returns $true if the scanner exit code signals a reboot is required. #> param([string]$ScannerName, [int]$ExitCode) switch ($ScannerName) { 'AdwCleaner' { if ($ExitCode -eq 2) { return $true } } 'HitmanPro' { if ($ExitCode -eq 2) { return $true } } 'Emsisoft' { if ($ExitCode -eq 2) { return $true } } } return $false } function Invoke-ForceRemoveList { <# .SYNOPSIS Force-removes a list of blacklisted items (paths, registry keys/values, services, scheduled tasks, and processes). .PARAMETER RemoveList Array of hashtables with Type and Value (and Key for RegValue entries). #> param([array]$RemoveList) if (-not $RemoveList -or $RemoveList.Count -eq 0) { return } Write-Host '' Write-Host '------------------------------------------------------------' -ForegroundColor DarkGray Write-Host ' Force-Remove Blacklist' -ForegroundColor Cyan foreach ($entry in $RemoveList) { $type = $entry.Type $value = $entry.Value try { switch ($type) { 'Path' { $expanded = [System.Environment]::ExpandEnvironmentVariables($value) if (Test-Path $expanded) { Remove-Item -Path $expanded -Recurse -Force -ErrorAction Stop Write-Host " [OK] Removed path: $expanded" -ForegroundColor Green } else { Write-Host " [SKIP] Path not found: $expanded" -ForegroundColor Gray } } 'Registry' { if (Test-Path $value) { Remove-Item -Path $value -Recurse -Force -ErrorAction Stop Write-Host " [OK] Removed registry key: $value" -ForegroundColor Green } else { Write-Host " [SKIP] Registry key not found: $value" -ForegroundColor Gray } } 'RegValue' { if (Test-Path $entry.Key) { Remove-ItemProperty -Path $entry.Key -Name $value -Force -ErrorAction Stop Write-Host " [OK] Removed registry value: $($entry.Key)\$value" -ForegroundColor Green } else { Write-Host " [SKIP] Registry key not found: $($entry.Key)" -ForegroundColor Gray } } 'Service' { $svc = Get-Service -Name $value -ErrorAction SilentlyContinue if ($svc) { Stop-Service -Name $value -Force -ErrorAction SilentlyContinue Start-Sleep -Seconds 2 & sc.exe delete $value | Out-Null Write-Host " [OK] Removed service: $value" -ForegroundColor Green } else { Write-Host " [SKIP] Service not found: $value" -ForegroundColor Gray } } 'Task' { $task = Get-ScheduledTask -TaskName $value -ErrorAction SilentlyContinue if ($task) { Unregister-ScheduledTask -TaskName $value -Confirm:$false -ErrorAction Stop Write-Host " [OK] Removed scheduled task: $value" -ForegroundColor Green } else { Write-Host " [SKIP] Scheduled task not found: $value" -ForegroundColor Gray } } 'Process' { $procs = Get-Process -Name $value -ErrorAction SilentlyContinue if ($procs) { $procs | Stop-Process -Force -ErrorAction SilentlyContinue Write-Host " [OK] Killed process: $value ($($procs.Count) instance(s))" -ForegroundColor Green } else { Write-Host " [SKIP] Process not running: $value" -ForegroundColor Gray } } default { Write-Host " [WARNING] Unknown ForceRemove type '$type' for: $value" -ForegroundColor Yellow } } } catch { Write-Host " [ERROR] ForceRemove failed ($type): $value - $_" -ForegroundColor Red } } } function Invoke-ScannerInstallPrep { <# .SYNOPSIS Runs a two-step installer + optional update, then closes any Explorer folder window opened by the NSIS installer. .PARAMETER InstallerExePath Full path to the installer EXE. .PARAMETER MainExePath Full path to the main scanner EXE that appears after install. .PARAMETER InstallerArgs Arguments to pass to the installer. If null or empty, the installer is launched without any ArgumentList parameter. .PARAMETER RunUpdateAfterInstall When $true, runs the main EXE with /update after installation completes. .OUTPUTS $true on success, $false if the installer timed out or the main EXE is missing. #> param( [string]$InstallerExePath, [string]$MainExePath, [string]$ScannerName = '', [string[]]$InstallerArgs = @('/S'), [bool]$RunUpdateAfterInstall = $false ) Write-Host " [INFO] Running installer: $InstallerExePath" -ForegroundColor Cyan try { $startParams = @{ FilePath = $InstallerExePath PassThru = $true WindowStyle = 'Hidden' } if ($InstallerArgs -and $InstallerArgs.Count -gt 0) { $startParams['ArgumentList'] = $InstallerArgs } $instProc = Start-Process @startParams $instCompleted = Wait-ProcessWithTimeout -Process $instProc -TimeoutSeconds 300 # Close any Explorer folder window the NSIS installer opened try { (New-Object -ComObject Shell.Application).Windows() | Where-Object { $_.LocationURL -like '*EmsisoftCmd*' -or $_.LocationName -like '*EmsisoftCmd*' } | ForEach-Object { $_.Quit() } } catch {} if (-not $instCompleted) { Write-Host ' [WARNING] Installer timed out after 5 min - skipping scanner' -ForegroundColor Yellow return $false } } catch { Write-Host " [ERROR] Installer failed: $_" -ForegroundColor Red return $false } if (-not (Test-Path $MainExePath)) { Write-Host " [ERROR] Main EXE not found after install: $MainExePath" -ForegroundColor Red return $false } if ($RunUpdateAfterInstall -eq $true) { Write-Host ' [INFO] Updating scanner definitions...' -ForegroundColor Cyan try { $updateProc = Start-Process -FilePath $MainExePath -ArgumentList '/update' -PassThru -WindowStyle Hidden Wait-ProcessWithTimeout -Process $updateProc -TimeoutSeconds 300 | Out-Null } catch { Write-Host " [WARNING] Update step failed: $_ - continuing with existing definitions" -ForegroundColor Yellow } } return $true } function Invoke-ScanPass { <# .SYNOPSIS Runs one complete pass of the scanner loop. .DESCRIPTION Iterates over the provided scanner list in the given mode, launches each scanner, waits for completion, collects logs, and accumulates results. Sets $script:LastPassRebootRequired and $script:LastPassTotalThreats. .PARAMETER ScannerList The filtered list of scanner definition objects to run. .PARAMETER ScanMode 'scan' for detect-only, 'clean' for remediation. .PARAMETER RunLogRoot The per-scan log directory for this pass. .PARAMETER TimeoutMinOverride If > 0, overrides the per-scanner timeout_min for all scanners. .PARAMETER Headless When set, suppress scanner UI windows (NoNewWindow). .OUTPUTS [System.Collections.Generic.List[pscustomobject]] of result objects. #> param( [object[]]$ScannerList, [string]$ScanMode, [string]$RunLogRoot, [int]$TimeoutMinOverride = 0, [switch]$Headless ) $script:LastPassRebootRequired = $false $script:LastPassTotalThreats = 0 $results = [System.Collections.Generic.List[pscustomobject]]::new() foreach ($s in $ScannerList) { Write-Host '------------------------------------------------------------' -ForegroundColor DarkGray Write-Host " Scanner : $($s.Name)" -ForegroundColor Cyan Write-Host " Mode : $ScanMode" # Normalize property names — JSON objects use snake_case, PS hashtables use PascalCase $sName = if ($s.PSObject.Properties['Name']) { $s.Name } else { $s.name } $sExe = if ($s.PSObject.Properties['Exe']) { $s.Exe } else { $s.exe } $sInstallerExe = if ($s.PSObject.Properties['InstallerExe']) { $s.InstallerExe } else { $s.installer_exe } $sScanArgs = if ($s.PSObject.Properties['ScanArgs']) { $s.ScanArgs } else { $s.scan_args } $sCleanArgs = if ($s.PSObject.Properties['CleanArgs']) { $s.CleanArgs } else { $s.clean_args } $sLogSrc = if ($s.PSObject.Properties['LogSrc']) { $s.LogSrc } else { $s.log_src } $sTimeoutMin = if ($s.PSObject.Properties['TimeoutMin']) { $s.TimeoutMin } else { $s.timeout_min } $sRandomizeExe = if ($s.PSObject.Properties['RandomizeExe']) { $s.RandomizeExe } else { $s.randomize_exe } $sPreClose = if ($s.PSObject.Properties['PreCloseProcesses']) { $s.PreCloseProcesses } else { $s.pre_close_processes } $sPreClean = if ($s.PSObject.Properties['PreCleanPaths']) { $s.PreCleanPaths } else { $s.pre_clean_paths } $sPostClean = if ($s.PSObject.Properties['PostCleanPaths']) { $s.PostCleanPaths } else { $s.post_clean_paths } $sServiceNames = if ($s.PSObject.Properties['ServiceNames']) { $s.ServiceNames } else { $s.service_names } $sHmpReset = if ($s.PSObject.Properties['HitmanProReset']) { $s.HitmanProReset } else { $s.hitmanpro_trial_reset } $sWaitOnProcess = if ($s.PSObject.Properties['WaitOnProcess']) { $s.WaitOnProcess } else { $s.wait_on_process } $sInstallerArgs = if ($s.PSObject.Properties['InstallerArgs']) { $s.InstallerArgs } else { $s.installer_args } $sRunUpdateAfterInstall = if ($s.PSObject.Properties['RunUpdateAfterInstall']) { $s.RunUpdateAfterInstall } else { $s.run_update_after_install } if ($null -eq $sInstallerArgs) { $sInstallerArgs = @('/S') } if ($null -eq $sRunUpdateAfterInstall) { $sRunUpdateAfterInstall = $false } # Coerce null JSON values to empty arrays / nulls if ($null -eq $sInstallerExe) { $sInstallerExe = $null } if ($null -eq $sLogSrc) { $sLogSrc = $null } if ($null -eq $sWaitOnProcess) { $sWaitOnProcess = $null } if ($null -eq $sPreClose) { $sPreClose = @() } if ($null -eq $sPreClean) { $sPreClean = @() } if ($null -eq $sPostClean) { $sPostClean = @() } if ($null -eq $sServiceNames) { $sServiceNames = @() } $mainExePath = $sExe $installerExePath = $sInstallerExe $exeToCheck = if ($installerExePath) { $installerExePath } else { $mainExePath } if (-not (Test-Path $exeToCheck)) { Write-Host " [WARNING] EXE not found - skipping: $exeToCheck" -ForegroundColor Yellow $results.Add([pscustomobject]@{ Scanner = $sName Status = 'SKIPPED (missing)' ExitCode = $null ThreatsFound = 0 Duration = '0 min' LogPath = '' }) continue } $exeDir = Split-Path $mainExePath -Parent if ($ScanMode -eq 'scan') { $rawArgs = @($sScanArgs) } else { $rawArgs = @($sCleanArgs) } $expandedArgs = Expand-TokenizedArgs -ArgList $rawArgs -LogRoot $RunLogRoot -ExeDir $exeDir # HitmanPro trial registry pre-seed if ($sHmpReset -eq $true) { Write-Host ' [INFO] Resetting HitmanPro trial registry...' -ForegroundColor Cyan Invoke-HitmanProTrialReset } # Pre-clean paths foreach ($p in $sPreClean) { Remove-PathSilent $p } # Effective timeout $effectiveTimeoutMin = if ($TimeoutMinOverride -gt 0) { $TimeoutMinOverride } else { $sTimeoutMin } $timeoutSec = $effectiveTimeoutMin * 60 # Two-step install (scanners with an installer_exe defined) if ($installerExePath) { $installOk = Invoke-ScannerInstallPrep -InstallerExePath $installerExePath -MainExePath $mainExePath -ScannerName $sName -InstallerArgs $sInstallerArgs -RunUpdateAfterInstall $sRunUpdateAfterInstall if (-not $installOk) { $results.Add([pscustomobject]@{ Scanner = $sName Status = 'FAILED (installer)' ExitCode = $null ThreatsFound = 0 Duration = '0 min' LogPath = '' }) continue } } # Randomize EXE (TDSSKiller pattern) $randomizedExePath = $null $launchExe = $mainExePath if ($sRandomizeExe -eq $true) { $tempName = [System.IO.Path]::GetRandomFileName() -replace '\..*$', '' $randomizedExePath = Join-Path $env:TEMP "$tempName.exe" Copy-Item -Path $mainExePath -Destination $randomizedExePath -Force $launchExe = $randomizedExePath Write-Host " [INFO] EXE randomized to: $randomizedExePath" -ForegroundColor Cyan } # Kill processes that would block this scanner if ($sPreClose -and $sPreClose.Count -gt 0) { foreach ($proc in $sPreClose) { Get-Process -Name $proc -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue } Start-Sleep -Seconds 2 } # Launch scanner Write-Host " [....] Launching $sName..." -ForegroundColor Cyan $scanStart = Get-Date $proc = $null $status = 'completed' $exitCode = $null try { $startParams = @{ FilePath = $launchExe PassThru = $true NoNewWindow = [bool]$Headless } if ($expandedArgs -and $expandedArgs.Count -gt 0) { $startParams['ArgumentList'] = $expandedArgs } $proc = Start-Process @startParams $serviceArr = @() if ($sServiceNames -and $sServiceNames.Count -gt 0) { $serviceArr = @($sServiceNames) } $wpStr = $null if ($sWaitOnProcess) { $wpStr = [string]$sWaitOnProcess } $completed = Wait-ScannerCompletion -Process $proc -WaitOnProcess $wpStr -ServiceNames $serviceArr -TimeoutSeconds $timeoutSec if (-not $completed) { $status = 'TIMED OUT' Write-Host " [WARNING] $sName timed out after $effectiveTimeoutMin min" -ForegroundColor Yellow } else { $exitCode = $proc.ExitCode } } catch { $status = 'FAILED' Write-Host " [ERROR] $sName failed to launch: $_" -ForegroundColor Red } # Calculate duration $scanEnd = Get-Date $durMin = [math]::Round(($scanEnd - $scanStart).TotalMinutes, 2) # Collect logs from scanner-specific log source $logDestDir = Join-Path $RunLogRoot "$($sName)_Logs" if ($sLogSrc) { $logSrcExpanded = @(Expand-TokenizedArgs -ArgList @($sLogSrc) -LogRoot $RunLogRoot -ExeDir $exeDir) if ($logSrcExpanded.Count -gt 0 -and $logSrcExpanded[0]) { $logSrcPath = Expand-EnvPath $logSrcExpanded[0] if (Test-Path $logSrcPath) { New-Item -ItemType Directory -Path $logDestDir -Force | Out-Null Copy-Item -Path $logSrcPath -Destination $logDestDir -Recurse -Force -ErrorAction SilentlyContinue Write-Host " [INFO] Logs copied to: $logDestDir" -ForegroundColor Cyan } else { Write-Host " [WARNING] Log source not found: $logSrcPath" -ForegroundColor Yellow } } } # Post-clean paths foreach ($p in $sPostClean) { Remove-PathSilent $p } # Clean up randomized EXE copy if ($randomizedExePath -and (Test-Path $randomizedExePath)) { Remove-Item $randomizedExePath -Force -ErrorAction SilentlyContinue } # Threat and reboot detection from exit code $threatsFound = 0 if ($null -ne $exitCode) { $threatsFound = Get-ExitCodeThreats -ScannerName $sName -ExitCode $exitCode if (Get-ExitCodeReboot -ScannerName $sName -ExitCode $exitCode) { $script:LastPassRebootRequired = $true Write-Host " [WARNING] $sName signals REBOOT REQUIRED to complete removal" -ForegroundColor Yellow } } $script:LastPassTotalThreats += $threatsFound # Status reporting $color = if ($status -eq 'completed') { 'Green' } elseif ($status -eq 'TIMED OUT') { 'Yellow' } else { 'Red' } Write-Host " [$status] ExitCode=$exitCode Threats=$threatsFound Duration=${durMin}min" -ForegroundColor $color $results.Add([pscustomobject]@{ Scanner = $sName Status = $status ExitCode = $exitCode ThreatsFound = $threatsFound Duration = "$durMin min" LogPath = $logDestDir }) } return $results } function Register-ScannerCleanupTask { <# .SYNOPSIS Writes cleanup state and registers a SYSTEM scheduled task that fires at next user logon + 30 minutes to remove scanner files and unregister itself. #> param( [string]$ScanId, [string]$LogRoot ) Write-Host '' Write-Host '------------------------------------------------------------' -ForegroundColor Yellow Write-Host ' Reboot recommended — registering post-reboot cleanup task' -ForegroundColor Yellow Write-Host '------------------------------------------------------------' -ForegroundColor Yellow # Write state for the cleanup script to read @{ scan_id = $ScanId log_root = $LogRoot created_at = (Get-Date).ToUniversalTime().ToString('o') } | ConvertTo-Json | Set-Content "$script:Base\cleanup-state.json" -Encoding UTF8 # Write the cleanup script that the scheduled task will run $cleanupScript = @' $Base = 'C:\GuruScan' $state = @{ scan_id = ''; log_root = '' } $stateFile = "$Base\cleanup-state.json" if (Test-Path $stateFile) { try { $state = Get-Content $stateFile -Raw | ConvertFrom-Json } catch {} } # Remove scanner installation files $scannerPaths = @( 'C:\EmsisoftCmd', 'C:\AdwCleaner', 'C:\ProgramData\HitmanPro', 'C:\ProgramData\HitmanPro.Alert' ) foreach ($p in $scannerPaths) { Remove-Item -Path $p -Recurse -Force -ErrorAction SilentlyContinue } # Remove scanner download EXEs Remove-Item -Path "$Base\downloads" -Recurse -Force -ErrorAction SilentlyContinue # Flag logs for GuruRMM to pull @{ scan_id = $state.scan_id log_root = $state.log_root zip_path = "$Base\reports\$($state.scan_id).zip" cleaned_at = (Get-Date).ToUniversalTime().ToString('o') } | ConvertTo-Json | Set-Content "$Base\logs-ready.json" -Encoding UTF8 # Remove state file Remove-Item -Path $stateFile -Force -ErrorAction SilentlyContinue # Unregister this task Unregister-ScheduledTask -TaskName 'GuruRMM-ScannerCleanup' -Confirm:$false -ErrorAction SilentlyContinue '@ $cleanupScript | Set-Content "$script:Base\Invoke-ScannerCleanup.ps1" -Encoding UTF8 # Register as SYSTEM logon task with 30-minute delay try { $action = New-ScheduledTaskAction -Execute 'powershell.exe' ` -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$script:Base\Invoke-ScannerCleanup.ps1`"" $trigger = New-ScheduledTaskTrigger -AtLogOn $trigger.Delay = 'PT30M' $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -RunLevel Highest $settings = New-ScheduledTaskSettingsSet ` -ExecutionTimeLimit (New-TimeSpan -Minutes 10) ` -MultipleInstances IgnoreNew ` -DeleteExpiredTaskAfter (New-TimeSpan -Seconds 0) Register-ScheduledTask -TaskName 'GuruRMM-ScannerCleanup' ` -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null Write-Host ' [OK] Scanner cleanup task registered (runs 30 min after next logon)' -ForegroundColor Green } catch { Write-Host " [WARNING] Could not register cleanup task: $_" -ForegroundColor Yellow } Write-Host '' Write-Host ' [INFO] Please reboot this machine at your convenience to complete removal.' -ForegroundColor Yellow Write-Host ' Scanner files will be cleaned up automatically 30 minutes after login.' -ForegroundColor Yellow Write-Host '' } function Test-RunningAsSystem { $id = [System.Security.Principal.WindowsIdentity]::GetCurrent() return ($id.IsSystem) } # --------------------------------------------------------------------------- # Private Ollama helper (used by Get-ScanSummary) # --------------------------------------------------------------------------- function Invoke-Ollama { param( [string]$Prompt, [string]$Model, [string]$BaseUrl ) $body = @{ model = $Model; prompt = $Prompt; stream = $false } | ConvertTo-Json -Depth 3 try { $r = Invoke-RestMethod -Uri "$BaseUrl/api/generate" -Method POST ` -Body $body -ContentType 'application/json' -TimeoutSec 180 return ($r.response -replace ']*>', '' -replace '(?s).*?', '').Trim() } catch { return $null } } # --------------------------------------------------------------------------- # Exported functions # --------------------------------------------------------------------------- function Invoke-GuruScan { <# .SYNOPSIS GuruScan - multi-engine malware scanning orchestrator. .DESCRIPTION Runs a suite of portable malware scanners in sequence, captures logs, and writes a structured results.json plus a zip archive of all logs. Scanner definitions are loaded from scanners.json in the module directory. By default runs all scanners in clean (remediation) mode. Use -ScanOnly to detect without cleaning. When a scanner signals a reboot is required, GuruScan registers a SYSTEM scheduled task (GuruRMM-ScannerCleanup) that fires 30 minutes after the next user logon to remove scanner files and flag logs for pickup. NOTE: MSERT is not included in the default scanner list because it takes too long for routine runs. .PARAMETER ScanOnly Use scan args (detect only) instead of clean args for every scanner. .PARAMETER AutoRemediate After a scan-only pass, if threats are found, automatically re-run all scanners in clean mode. .PARAMETER Scanners Run only the named scanners (comma-separated or multiple values). Names must match the Name field in scanners.json exactly. .PARAMETER TimeoutMin Override the per-scanner timeout (in minutes) for all scanners. .PARAMETER SkipScanners Skip one or more named scanners by name. .PARAMETER Headless Suppress scanner windows (used when dispatching via RMM). .PARAMETER OutputSink 'Disk' (default) writes results.json, CSV, and zip to C:\ScanLogs and C:\GuruScan\reports. 'RMM' returns the result object to the pipeline instead and skips writing to disk. .EXAMPLE Invoke-GuruScan Invoke-GuruScan -ScanOnly -AutoRemediate Invoke-GuruScan -OutputSink RMM #> [CmdletBinding()] param( [switch]$ScanOnly, [switch]$AutoRemediate, [string[]]$Scanners, [int]$TimeoutMin = 0, [string[]]$SkipScanners = @(), [switch]$Headless, [ValidateSet('Disk', 'RMM')] [string]$OutputSink = 'Disk' ) # Whitelist — written to C:\GuruScan\whitelist.txt before any scanner runs. # Emsisoft and HitmanPro honour this; RKill and AdwCleaner do not. $whitelist = @('C:\GuruScan') # ForceRemove blacklist — items removed after all scanners complete. $forceRemove = @() $scanStamp = "$env:COMPUTERNAME-$(Get-Date -Format yyyyMMdd-HHmmss)" $runLogRoot = "$script:LogRoot\$scanStamp" $scanMode = if ($ScanOnly) { 'scan' } else { 'clean' } $runningAsSystem = Test-RunningAsSystem # Create required directories New-Item -ItemType Directory -Path $script:Base -Force | Out-Null New-Item -ItemType Directory -Path "$script:Base\downloads" -Force | Out-Null New-Item -ItemType Directory -Path $runLogRoot -Force | Out-Null New-Item -ItemType Directory -Path "$script:Base\reports" -Force | Out-Null # Write whitelist file $whitelist | Set-Content -Path "$script:Base\whitelist.txt" -Encoding UTF8 Write-Host "[INFO] Whitelist written to $script:Base\whitelist.txt ($($whitelist.Count) entries)" -ForegroundColor Cyan Write-Host '' Write-Host '=== GuruScan ===' -ForegroundColor Cyan Write-Host " Machine : $env:COMPUTERNAME" Write-Host " Scan ID : $scanStamp" Write-Host " Log root : $runLogRoot" Write-Host " Mode : $scanMode" Write-Host " As SYSTEM : $runningAsSystem" Write-Host '' # Load scanner defs from module (already loaded at module scope, but refresh # the reference so callers who import the module later get current data) $allDefs = $script:ScannerDefs # Filter scanners $scannerList = @($allDefs) if ($SkipScanners -and $SkipScanners.Count -gt 0) { $scannerList = @($scannerList | Where-Object { $n = if ($_.PSObject.Properties['Name']) { $_.Name } else { $_.name } $SkipScanners -notcontains $n }) } if ($Scanners -and $Scanners.Count -gt 0) { $scannerList = @($scannerList | Where-Object { $n = if ($_.PSObject.Properties['Name']) { $_.Name } else { $_.name } $Scanners -contains $n }) if ($scannerList.Count -eq 0) { Write-Host "[ERROR] No scanners matched the provided names: $($Scanners -join ', ')" -ForegroundColor Red if ($OutputSink -eq 'RMM') { return $null } exit 1 } } # First pass $startedAt = Get-Date $passParams = @{ ScannerList = $scannerList ScanMode = $scanMode RunLogRoot = $runLogRoot TimeoutMinOverride = $TimeoutMin Headless = $Headless } $results = Invoke-ScanPass @passParams $totalThreats = $script:LastPassTotalThreats $rebootRequired = $script:LastPassRebootRequired # AutoRemediate: re-run in clean mode if scan-only found threats if ($AutoRemediate -and $ScanOnly -and $totalThreats -gt 0) { Write-Host '' Write-Host ' [INFO] -AutoRemediate: threats found - re-running all scanners in clean mode...' -ForegroundColor Cyan Write-Host '' # Rebuild scanner list for the clean pass (same filters honored) $remList = @($allDefs) if ($SkipScanners -and $SkipScanners.Count -gt 0) { $remList = @($remList | Where-Object { $n = if ($_.PSObject.Properties['Name']) { $_.Name } else { $_.name } $SkipScanners -notcontains $n }) } if ($Scanners -and $Scanners.Count -gt 0) { $remList = @($remList | Where-Object { $n = if ($_.PSObject.Properties['Name']) { $_.Name } else { $_.name } $Scanners -contains $n }) } $cleanPassParams = @{ ScannerList = $remList ScanMode = 'clean' RunLogRoot = $runLogRoot TimeoutMinOverride = $TimeoutMin Headless = $Headless } $results = Invoke-ScanPass @cleanPassParams $totalThreats = $script:LastPassTotalThreats $rebootRequired = $script:LastPassRebootRequired $scanMode = 'clean' } # Force-remove blacklist stage Invoke-ForceRemoveList -RemoveList $forceRemove # Build results object $completedAt = Get-Date $scannerResults = foreach ($r in $results) { $durValue = 0 if ($r.Duration -match '^([\d.]+)') { $durValue = [double]$Matches[1] } [ordered]@{ name = $r.Scanner status = $r.Status exit_code = $r.ExitCode threats_found = $r.ThreatsFound duration_min = $durValue log_path = $r.LogPath } } $resultsObj = [ordered]@{ scan_id = $scanStamp machine = $env:COMPUTERNAME started_at = $startedAt.ToUniversalTime().ToString('o') completed_at = $completedAt.ToUniversalTime().ToString('o') total_threats = $totalThreats reboot_required = $rebootRequired scan_mode = $scanMode scanners = @($scannerResults) } # Write to disk (Disk sink) $resultsJsonPath = '' $csvPath = '' $zipPath = '' if ($OutputSink -eq 'Disk') { $resultsJsonPath = Join-Path $runLogRoot 'results.json' $resultsObj | ConvertTo-Json -Depth 10 | Set-Content -Path $resultsJsonPath -Encoding UTF8 $csvPath = Join-Path $runLogRoot '_summary.csv' $results | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8 $reportsDir = "$script:Base\reports" New-Item -ItemType Directory -Path $reportsDir -Force | Out-Null $zipPath = "$reportsDir\$scanStamp.zip" try { Compress-Archive -Path "$runLogRoot\*" -DestinationPath $zipPath -Force Write-Host "[INFO] Log archive: $zipPath" -ForegroundColor Cyan } catch { Write-Host "[WARNING] Failed to create log archive: $_" -ForegroundColor Yellow } } # Print summary Write-Host '' Write-Host '============================================================' -ForegroundColor Cyan Write-Host " SCAN COMPLETE - $scanStamp" -ForegroundColor Cyan Write-Host '============================================================' -ForegroundColor Cyan Write-Host '' $results | Format-Table -AutoSize Write-Host '' Write-Host " Total threats detected : $totalThreats" if ($OutputSink -eq 'Disk') { Write-Host " Results JSON : $resultsJsonPath" Write-Host " Summary CSV : $csvPath" Write-Host " Log archive : $zipPath" } Write-Host '' if ($totalThreats -gt 0) { Write-Host " [WARNING] THREATS DETECTED - review logs in: $runLogRoot" -ForegroundColor Yellow } # Reboot handling if ($rebootRequired) { Register-ScannerCleanupTask -ScanId $scanStamp -LogRoot $runLogRoot } # Return result object (always returned; caller uses it for RMM sink) $resultRecord = [pscustomobject]$resultsObj if ($OutputSink -eq 'RMM') { return $resultRecord } } function Invoke-Remediation { <# .SYNOPSIS Re-runs GuruScan scanners in clean mode against a previous scan's log folder. .DESCRIPTION Reads the results.json from a prior scan run, then re-launches each scanner that completed (or any subset via -Scanners) using clean args to remove detected threats. Writes remediation-results.json to the same log folder. .PARAMETER LogRoot Path to the scan results folder produced by Invoke-GuruScan. This folder must contain a results.json file. .PARAMETER Scanners Run only the named scanners. Names must match the "name" field in scanners.json exactly. If omitted, all scanners that previously ran successfully are re-run. .EXAMPLE Invoke-Remediation -LogRoot "C:\ScanLogs\DESKTOP-20260523-143000" Invoke-Remediation -LogRoot "C:\ScanLogs\DESKTOP-20260523-143000" -Scanners AdwCleaner,MSERT #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$LogRoot, [string[]]$Scanners ) $priorResultsPath = Join-Path $LogRoot 'results.json' if (-not (Test-Path $LogRoot)) { Write-Host "[ERROR] LogRoot not found: $LogRoot" -ForegroundColor Red return } if (-not (Test-Path $priorResultsPath)) { Write-Host "[ERROR] results.json not found in: $LogRoot" -ForegroundColor Red return } if (-not (Test-Path $script:ScannersJson)) { Write-Host "[ERROR] scanners.json not found: $script:ScannersJson" -ForegroundColor Red return } $priorData = Get-Content $priorResultsPath -Raw | ConvertFrom-Json $priorScannerNames = $priorData.scanners | Where-Object { $_.status -eq 'completed' } | ForEach-Object { $_.name } $allDefs = $script:ScannerDefs $scannerList = @($allDefs | Where-Object { $priorScannerNames -contains $_.name }) if ($Scanners -and $Scanners.Count -gt 0) { $scannerList = @($scannerList | Where-Object { $Scanners -contains $_.name }) } if ($scannerList.Count -eq 0) { Write-Host "[WARNING] No eligible scanners to remediate." -ForegroundColor Yellow return } Write-Host "" Write-Host "=== GuruScan Remediation ===" -ForegroundColor Cyan Write-Host " Log root : $LogRoot" Write-Host " Scanners : $($scannerList.name -join ', ')" Write-Host "" $startedAt = Get-Date $passParams = @{ ScannerList = $scannerList ScanMode = 'clean' RunLogRoot = $LogRoot TimeoutMinOverride = 0 Headless = $false } $results = Invoke-ScanPass @passParams $totalThreats = $script:LastPassTotalThreats # Write remediation-results.json $completedAt = Get-Date $scannerResults = foreach ($r in $results) { $durValue = 0 if ($r.Duration -match '^([\d.]+)') { $durValue = [double]$Matches[1] } [ordered]@{ name = $r.Scanner status = $r.Status exit_code = $r.ExitCode threats_found = $r.ThreatsFound duration_min = $durValue log_path = $r.LogPath } } $remObj = [ordered]@{ scan_id = $priorData.scan_id machine = $env:COMPUTERNAME started_at = $startedAt.ToUniversalTime().ToString('o') completed_at = $completedAt.ToUniversalTime().ToString('o') total_threats = $totalThreats reboot_required = $script:LastPassRebootRequired scan_mode = 'clean' scanners = @($scannerResults) } $remResultsPath = Join-Path $LogRoot 'remediation-results.json' $remObj | ConvertTo-Json -Depth 10 | Set-Content -Path $remResultsPath -Encoding UTF8 # Summary Write-Host "" Write-Host "============================================================" -ForegroundColor Cyan Write-Host " REMEDIATION COMPLETE" -ForegroundColor Cyan Write-Host "============================================================" -ForegroundColor Cyan Write-Host "" $results | Format-Table -AutoSize Write-Host "" if ($totalThreats -gt 0) { Write-Host " [WARNING] $totalThreats threat indicator(s) still detected after clean pass." -ForegroundColor Yellow Write-Host " Review logs carefully — some threats may require a reboot before" -ForegroundColor Yellow Write-Host " they can be fully removed. Reboot and re-scan." -ForegroundColor Yellow } else { Write-Host " [OK] Clean pass complete — no threats detected in output." -ForegroundColor Green } Write-Host "" Write-Host " Remediation results: $remResultsPath" -ForegroundColor Gray Write-Host "" } function Get-ScanSummary { <# .SYNOPSIS Parses a GuruScan results.json into a human-readable report. .DESCRIPTION Locates a results.json (defaults to the most recent scan in C:\ScanLogs\), prints a formatted summary with per-scanner results, threat counts, and a remediation recommendation if threats were found. Use -AI to send log content to a local Ollama model for threat analysis and AI-generated remediation recommendations. .PARAMETER ResultsFile Full path to a specific results.json. If omitted, the latest file in C:\ScanLogs\ is used automatically. .PARAMETER ShowAll Show every scanner including those that completed clean (no threats). .PARAMETER AI Send scan logs to local Ollama for AI-powered threat analysis and prioritized remediation recommendations. .PARAMETER OllamaUrl Ollama base URL. Defaults to http://localhost:11434. .PARAMETER OllamaModel Ollama model to use. Defaults to qwen3.6:latest. .EXAMPLE Get-ScanSummary Get-ScanSummary -AI Get-ScanSummary -ResultsFile "C:\ScanLogs\DESKTOP-20260523-143000\results.json" -AI #> [CmdletBinding()] param( [string]$ResultsFile = '', [switch]$ShowAll, [switch]$AI, [string]$OllamaUrl = 'http://localhost:11434', [string]$OllamaModel = 'qwen3.6:latest' ) # Locate results.json if (-not $ResultsFile) { $scanLogsRoot = $script:LogRoot if (-not (Test-Path $scanLogsRoot)) { Write-Host "[ERROR] No results file specified and $scanLogsRoot does not exist." -ForegroundColor Red return } $latestJson = Get-ChildItem -Path $scanLogsRoot -Recurse -Filter 'results.json' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if (-not $latestJson) { Write-Host "[ERROR] No results.json found under $scanLogsRoot\" -ForegroundColor Red return } $ResultsFile = $latestJson.FullName Write-Host "[INFO] Using latest results: $ResultsFile" -ForegroundColor Cyan } if (-not (Test-Path $ResultsFile)) { Write-Host "[ERROR] Results file not found: $ResultsFile" -ForegroundColor Red return } $data = Get-Content $ResultsFile -Raw | ConvertFrom-Json # Header $startDt = [datetime]::Parse($data.started_at).ToLocalTime() $endDt = [datetime]::Parse($data.completed_at).ToLocalTime() $totalDurMin = [math]::Round(($endDt - $startDt).TotalMinutes, 1) Write-Host "" Write-Host "================================================================" -ForegroundColor Cyan Write-Host " GuruScan Report" -ForegroundColor Cyan Write-Host "================================================================" -ForegroundColor Cyan Write-Host " Machine : $($data.machine)" Write-Host " Scan ID : $($data.scan_id)" Write-Host " Mode : $($data.scan_mode)" Write-Host " Started : $($startDt.ToString('yyyy-MM-dd HH:mm:ss'))" Write-Host " Completed : $($endDt.ToString('yyyy-MM-dd HH:mm:ss'))" Write-Host " Duration : $totalDurMin min total" Write-Host "================================================================" -ForegroundColor Cyan Write-Host "" # Per-scanner table $rows = foreach ($s in $data.scanners) { $threatStr = if ($s.threats_found -gt 0) { "YES ($($s.threats_found))" } else { 'Clean' } $statusColor = switch ($s.status) { 'completed' { 'Green' } { $_ -like 'TIMED*' } { 'Yellow' } default { 'Red' } } [pscustomobject]@{ Scanner = $s.name Status = $s.status ExitCode = $s.exit_code Duration = "$($s.duration_min) min" Threats = $threatStr '_Color' = $statusColor } } Write-Host " Scanner Results:" -ForegroundColor Cyan Write-Host "" $header = ' {0,-18} {1,-28} {2,-10} {3,-12} {4}' -f 'Scanner', 'Status', 'ExitCode', 'Duration', 'Threats' Write-Host $header -ForegroundColor Gray Write-Host (' ' + ('-' * 78)) -ForegroundColor DarkGray foreach ($row in $rows) { $line = ' {0,-18} {1,-28} {2,-10} {3,-12} {4}' -f $row.Scanner, $row.Status, $row.ExitCode, $row.Duration, $row.Threats Write-Host $line -ForegroundColor $row.'_Color' } Write-Host "" # Threat summary $threatScanners = $data.scanners | Where-Object { $_.threats_found -gt 0 } $nonClean = $data.scanners | Where-Object { $_.status -ne 'completed' } if ($data.total_threats -gt 0) { Write-Host "================================================================" -ForegroundColor Yellow Write-Host " [WARNING] THREATS DETECTED" -ForegroundColor Yellow Write-Host "================================================================" -ForegroundColor Yellow Write-Host "" Write-Host " Scanners that detected threats:" -ForegroundColor Yellow foreach ($ts in $threatScanners) { Write-Host " - $($ts.name) (exit code $($ts.exit_code), log: $($ts.log_path))" -ForegroundColor Yellow } Write-Host "" } else { Write-Host " [OK] No threats detected across all scanners." -ForegroundColor Green Write-Host "" } if ($nonClean) { Write-Host " [WARNING] Scanners with non-completed status:" -ForegroundColor Yellow foreach ($nc in $nonClean) { Write-Host " - $($nc.name): $($nc.status)" -ForegroundColor Yellow } Write-Host "" } # Recommendation Write-Host "================================================================" -ForegroundColor Cyan Write-Host " Recommendation" -ForegroundColor Cyan Write-Host "================================================================" -ForegroundColor Cyan if ($data.reboot_required) { Write-Host " [WARNING] A REBOOT IS REQUIRED to complete remediation." -ForegroundColor Yellow Write-Host " Reboot the machine, then re-run GuruScan to verify." -ForegroundColor Yellow } elseif ($data.total_threats -gt 0 -and $data.scan_mode -eq 'scan') { Write-Host " [INFO] Threats were detected in scan-only mode." -ForegroundColor Cyan Write-Host " Run: .\Invoke-GuruScan.ps1 (without -ScanOnly)" -ForegroundColor Cyan Write-Host " Or: .\Invoke-Remediation.ps1 -LogRoot `"$(Split-Path $ResultsFile)`"" -ForegroundColor Cyan } elseif ($data.total_threats -gt 0) { Write-Host " [INFO] Clean mode was run. Review logs to confirm threats removed." -ForegroundColor Cyan Write-Host " Log folder: $(Split-Path $ResultsFile)" -ForegroundColor Cyan } else { Write-Host " [OK] System appears clean. No further action required." -ForegroundColor Green } Write-Host "" Write-Host " Full logs: $(Split-Path $ResultsFile)" -ForegroundColor Gray Write-Host "" # Ollama AI analysis (optional — requires -AI switch) if ($AI) { Write-Host "================================================================" -ForegroundColor Cyan Write-Host " AI Analysis (Ollama)" -ForegroundColor Cyan Write-Host "================================================================" -ForegroundColor Cyan Write-Host " Model : $OllamaModel" Write-Host " Server : $OllamaUrl" Write-Host "" # Collect log content from all scanner log folders (cap at 8KB each) $logFolder = Split-Path $ResultsFile $logContent = [System.Text.StringBuilder]::new() Get-ChildItem -Path $logFolder -Recurse -Include '*.log', '*.txt', '*.csv' -ErrorAction SilentlyContinue | Where-Object { $_.Length -gt 0 -and $_.Length -lt 200KB } | ForEach-Object { $snippet = Get-Content $_.FullName -Raw -ErrorAction SilentlyContinue if ($snippet) { $snippet = if ($snippet.Length -gt 8192) { $snippet.Substring(0, 8192) + "`n[TRUNCATED]" } else { $snippet } [void]$logContent.AppendLine("=== $($_.Name) ===") [void]$logContent.AppendLine($snippet) [void]$logContent.AppendLine() } } $logText = if ($logContent.Length -gt 0) { $logContent.ToString() } else { 'No log files found.' } $scanJson = $data | ConvertTo-Json -Depth 5 # Step 1: Extract specific threat names / findings Write-Host " [....] Extracting threat details..." -ForegroundColor Cyan $threatPrompt = @" You are a malware analyst. Below is a JSON scan summary and raw log excerpts from a multi-engine malware scan. SCAN SUMMARY JSON: $scanJson RAW LOG EXCERPTS: $logText Task: Extract a concise list of specific threat names, file paths, registry keys, or other findings from the logs. Format your response as a plain numbered list. If no specific threats are named in the logs, say "No named threats found in logs." Do not add commentary — only the list. "@ $threatDetails = Invoke-Ollama -Prompt $threatPrompt -Model $OllamaModel -BaseUrl $OllamaUrl if ($threatDetails) { Write-Host "" Write-Host " Identified Threats / Findings:" -ForegroundColor Yellow $threatDetails -split "`n" | ForEach-Object { Write-Host " $_" -ForegroundColor Yellow } } else { Write-Host " [WARNING] Ollama unavailable — skipping threat extraction." -ForegroundColor Yellow } # Step 2: Prioritized remediation recommendations Write-Host "" Write-Host " [....] Generating remediation recommendations..." -ForegroundColor Cyan $remediationPrompt = @" You are an MSP technician. A multi-engine malware scan produced these results: $scanJson Threat details extracted from logs: $threatDetails Task: Write a short, prioritized remediation checklist (max 8 steps) for the technician. Include: immediate actions, follow-up verification steps, and whether a reboot is needed. Plain numbered list, no markdown headers, no padding text. Be specific. "@ $recommendations = Invoke-Ollama -Prompt $remediationPrompt -Model $OllamaModel -BaseUrl $OllamaUrl if ($recommendations) { Write-Host "" Write-Host " Remediation Checklist:" -ForegroundColor Cyan $recommendations -split "`n" | ForEach-Object { Write-Host " $_" -ForegroundColor Cyan } } else { Write-Host " [WARNING] Ollama unavailable — skipping recommendations." -ForegroundColor Yellow } Write-Host "" Write-Host "================================================================" -ForegroundColor DarkGray Write-Host " NOTE: AI output is advisory. Review logs before acting." -ForegroundColor DarkGray Write-Host "================================================================" -ForegroundColor DarkGray Write-Host "" } } function Invoke-PostRebootCleanup { <# .SYNOPSIS Manually triggers GuruScan post-scan cleanup (removes scanner files). Normally this runs automatically via the GuruRMM-ScannerCleanup scheduled task. .PARAMETER StateFile Path to cleanup-state.json. Defaults to C:\GuruScan\cleanup-state.json. (Parameter kept for backward compatibility — the cleanup script reads it directly.) #> [CmdletBinding()] param( [string]$StateFile = 'C:\GuruScan\cleanup-state.json' ) # Manually run the cleanup script if the scheduled task was missed $cleanupScript = 'C:\GuruScan\Invoke-ScannerCleanup.ps1' if (Test-Path $cleanupScript) { Write-Host '[INFO] Running scanner cleanup...' -ForegroundColor Cyan & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $cleanupScript } else { Write-Host '[INFO] No cleanup script found at C:\GuruScan\Invoke-ScannerCleanup.ps1' -ForegroundColor Yellow } } # --------------------------------------------------------------------------- # Export declarations # --------------------------------------------------------------------------- Export-ModuleMember -Function @( 'Invoke-GuruScan', 'Invoke-Remediation', 'Get-ScanSummary', 'Invoke-PostRebootCleanup' )