feat: add GuruScan standalone multi-scanner security suite

Adds a complete PowerShell-based malware scanning toolkit:

- Invoke-GuruScan.ps1: main orchestrator running RKill, AdwCleaner,
  Emsisoft, HitmanPro, and ESET in sequence with pre/post cleanup,
  whitelist support, ForceRemove blacklist, and -Headless switch
- Invoke-PostRebootCleanup.ps1: post-reboot temp-user session that
  shows a fullscreen splash, verifies boot-time cleanup completed,
  removes scanner files, and restores the original user login name
- Download-Scanners.ps1: downloads/refreshes scanner EXEs
- Get-ScanSummary.ps1: parses results.json with optional Ollama AI analysis
- Invoke-Remediation.ps1: re-runs scanners in clean mode

Key features: exit-code-based reboot detection, whoami-based user
capture (SYSTEM-safe via quser fallback), domain\user and local
MACHINE\user restore on login screen after cleanup reboot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 12:51:29 -07:00
parent d9ab515463
commit 3a0c83dd42
6 changed files with 2183 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
# Scanner binaries — downloaded at runtime, not committed
downloads/
# Scan output — machine-local, can be large
C:\ScanLogs\

View File

@@ -0,0 +1,115 @@
<#
.SYNOPSIS
Downloads or refreshes all scanner executables defined in scanners.json.
.DESCRIPTION
Reads scanner definitions from scanners.json and downloads each scanner EXE
to the downloads\ subdirectory. Skips scanners with null download URLs.
.PARAMETER Force
Re-download all scanners even if they already exist locally.
.EXAMPLE
.\Download-Scanners.ps1
.\Download-Scanners.ps1 -Force
#>
[CmdletBinding()]
param(
[switch]$Force
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
$ConfigPath = Join-Path $PSScriptRoot 'scanners.json'
$DownloadsDir = Join-Path $PSScriptRoot 'downloads'
if (-not (Test-Path $ConfigPath)) {
Write-Host "[ERROR] scanners.json not found at: $ConfigPath" -ForegroundColor Red
exit 1
}
$config = Get-Content $ConfigPath -Raw | ConvertFrom-Json
if (-not (Test-Path $DownloadsDir)) {
New-Item -ItemType Directory -Path $DownloadsDir -Force | Out-Null
Write-Host "[INFO] Created downloads directory: $DownloadsDir" -ForegroundColor Cyan
}
$results = [System.Collections.Generic.List[pscustomobject]]::new()
foreach ($scanner in $config.scanners) {
$urlToUse = $null
$fileToSave = $null
# Manual-download entries: print instructions and skip
if ($scanner.PSObject.Properties['manual_download'] -and $scanner.manual_download -eq $true) {
$note = if ($scanner.PSObject.Properties['manual_download_note']) { $scanner.manual_download_note } else { 'Place EXE manually in downloads\' }
Write-Host " [MANUAL] $($scanner.name)$note" -ForegroundColor Yellow
$results.Add([pscustomobject]@{ Scanner = $scanner.name; Status = 'MANUAL'; File = ''; Size = 0 })
continue
}
if ($scanner.installer_exe -and $scanner.download_url) {
$urlToUse = $scanner.download_url
$fileToSave = Join-Path $DownloadsDir (Split-Path $scanner.installer_exe -Leaf)
}
elseif ($scanner.download_url) {
$urlToUse = $scanner.download_url
$leafName = Split-Path $scanner.exe -Leaf
$fileToSave = Join-Path $DownloadsDir $leafName
}
else {
Write-Host " [SKIP] $($scanner.name) — no download URL configured" -ForegroundColor Yellow
$results.Add([pscustomobject]@{ Scanner = $scanner.name; Status = 'SKIPPED'; File = ''; Size = 0 })
continue
}
if ((Test-Path $fileToSave) -and -not $Force) {
$existingSize = (Get-Item $fileToSave).Length
Write-Host " [OK] $($scanner.name) already exists ($([math]::Round($existingSize / 1MB, 1)) MB)" -ForegroundColor Green
$results.Add([pscustomobject]@{ Scanner = $scanner.name; Status = 'EXISTS'; File = $fileToSave; Size = $existingSize })
continue
}
Write-Host " [....] $($scanner.name) — downloading from $urlToUse" -ForegroundColor Cyan
try {
$wc = New-Object System.Net.WebClient
$wc.Headers.Add('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)')
$wc.DownloadFile($urlToUse, $fileToSave)
$wc.Dispose()
if (-not (Test-Path $fileToSave)) {
throw "File not found after download attempt"
}
$downloadedSize = (Get-Item $fileToSave).Length
if ($downloadedSize -eq 0) {
throw "Downloaded file is 0 bytes — URL may be invalid or redirected"
}
Write-Host " [OK] $($scanner.name) downloaded ($([math]::Round($downloadedSize / 1MB, 1)) MB) -> $fileToSave" -ForegroundColor Green
$results.Add([pscustomobject]@{ Scanner = $scanner.name; Status = 'DOWNLOADED'; File = $fileToSave; Size = $downloadedSize })
}
catch {
Write-Host " [ERROR] $($scanner.name)$($_.Exception.Message)" -ForegroundColor Red
if (Test-Path $fileToSave) {
Remove-Item $fileToSave -Force -ErrorAction SilentlyContinue
}
$results.Add([pscustomobject]@{ Scanner = $scanner.name; Status = 'FAILED'; File = $fileToSave; Size = 0 })
}
}
Write-Host ""
Write-Host "=== Download Summary ===" -ForegroundColor Cyan
$results | Format-Table -AutoSize
$manual = $results | Where-Object { $_.Status -eq 'MANUAL' }
if ($manual) {
Write-Host "[INFO] $($manual.Count) scanner(s) require manual download — see notes above." -ForegroundColor Yellow
}
$failed = $results | Where-Object { $_.Status -eq 'FAILED' }
if ($failed) {
Write-Host "[WARNING] $($failed.Count) scanner(s) failed to download. Scan coverage will be incomplete." -ForegroundColor Yellow
exit 1
}
exit 0

View File

@@ -0,0 +1,294 @@
<#
.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 (http://localhost:11434) 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.ps1
.\Get-ScanSummary.ps1 -AI
.\Get-ScanSummary.ps1 -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'
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# ---------------------------------------------------------------------------
# Ollama helper
# ---------------------------------------------------------------------------
function Invoke-Ollama {
param([string]$Prompt, [string]$Model = $OllamaModel, [string]$BaseUrl = $OllamaUrl)
$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
}
}
# ---------------------------------------------------------------------------
# Locate results.json
# ---------------------------------------------------------------------------
if (-not $ResultsFile) {
$scanLogsRoot = 'C:\ScanLogs'
if (-not (Test-Path $scanLogsRoot)) {
Write-Host "[ERROR] No results file specified and C:\ScanLogs does not exist." -ForegroundColor Red
exit 1
}
$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 C:\ScanLogs\" -ForegroundColor Red
exit 1
}
$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
exit 1
}
# ---------------------------------------------------------------------------
# Parse JSON
# ---------------------------------------------------------------------------
$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 " Consider running ESET Online Scanner for a second opinion." -ForegroundColor Gray
}
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 to avoid huge prompts)
$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
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
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 ""
}
exit 0

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,215 @@
#Requires -RunAsAdministrator
<#
.SYNOPSIS
Post-reboot cleanup agent for GuruScan. Runs automatically as GuruRMM-Temp
after a reboot triggered by scanner exit code 2 (pending removal required).
Shows a full-screen splash, verifies cleanup completed, removes scanner files,
restores the original user's login name, then logs off.
#>
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$Base = 'C:\GuruScan'
$StateFile = "$Base\cleanup-state.json"
$TempUser = 'GuruRMM-Temp'
$WlKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon'
$SpecialKey= 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\SpecialAccounts\UserList'
# ---------------------------------------------------------------------------
# Kill Explorer so we have a clean screen before showing splash
# ---------------------------------------------------------------------------
Get-Process -Name explorer -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 1
# ---------------------------------------------------------------------------
# Full-screen splash in a STA runspace (non-blocking)
# ---------------------------------------------------------------------------
$sync = [hashtable]::Synchronized(@{ Close = $false })
$uiRS = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
$uiRS.ApartmentState = 'STA'
$uiRS.ThreadOptions = 'ReuseThread'
$uiRS.Open()
$uiPS = [System.Management.Automation.PowerShell]::Create()
$uiPS.Runspace = $uiRS
[void]$uiPS.AddScript({
param($s)
Add-Type -AssemblyName PresentationFramework, PresentationCore, WindowsBase
$win = New-Object System.Windows.Window
$win.WindowStyle = 'None'
$win.WindowState = 'Maximized'
$win.Background = [System.Windows.Media.Brushes]::Black
$win.Topmost = $true
$win.Title = 'GuruRMM'
$win.Cursor = [System.Windows.Input.Cursors]::None
$panel = New-Object System.Windows.Controls.StackPanel
$panel.VerticalAlignment = 'Center'
$panel.HorizontalAlignment = 'Center'
$title = New-Object System.Windows.Controls.TextBlock
$title.Text = 'GuruRMM'
$title.Foreground = [System.Windows.Media.Brushes]::White
$title.FontSize = 42
$title.FontFamily = New-Object System.Windows.Media.FontFamily('Segoe UI')
$title.FontWeight = [System.Windows.FontWeights]::Light
$title.TextAlignment = 'Center'
$title.Margin = New-Object System.Windows.Thickness(0,0,0,24)
$body = New-Object System.Windows.Controls.TextBlock
$body.Text = "Security cleanup is being completed on this machine.`n`nPlease do not power off this computer.`n`nThis process will finish shortly."
$body.Foreground = [System.Windows.Media.SolidColorBrush][System.Windows.Media.Color]::FromRgb(180,180,180)
$body.FontSize = 22
$body.FontFamily = New-Object System.Windows.Media.FontFamily('Segoe UI')
$body.TextAlignment = 'Center'
$body.LineHeight = 38
[void]$panel.Children.Add($title)
[void]$panel.Children.Add($body)
$win.Content = $panel
$timer = New-Object System.Windows.Threading.DispatcherTimer
$timer.Interval = [TimeSpan]::FromMilliseconds(500)
$timer.Add_Tick({ if ($s.Close) { $win.Close(); $timer.Stop() } })
$timer.Start()
[void]$win.ShowDialog()
}).AddArgument($sync)
$uiHandle = $uiPS.BeginInvoke()
# ---------------------------------------------------------------------------
# Read state file
# ---------------------------------------------------------------------------
$state = @{ original_user = ''; scan_id = ''; log_root = '' }
if (Test-Path $StateFile) {
try { $state = Get-Content $StateFile -Raw | ConvertFrom-Json } catch {}
}
$logRoot = $state.log_root
$scanId = $state.scan_id
$origUser = $state.original_user
# ---------------------------------------------------------------------------
# Wait for boot-time cleanup to settle
# ---------------------------------------------------------------------------
Start-Sleep -Seconds 60
# ---------------------------------------------------------------------------
# Verify: check PendingFileRenameOperations (empty = boot cleanup completed)
# ---------------------------------------------------------------------------
$pendingKey = 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager'
$pendingOps = (Get-ItemProperty -Path $pendingKey -Name PendingFileRenameOperations -ErrorAction SilentlyContinue).PendingFileRenameOperations
$pendingDone = (-not $pendingOps -or $pendingOps.Count -eq 0)
# Check scanner post-reboot logs
$adwLog = Get-ChildItem 'C:\AdwCleaner\Logs' -Filter '*.txt' -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending | Select-Object -First 1
$hitLog = if ($logRoot -and (Test-Path "$logRoot\HitmanPro_Scan_Log.txt")) {
Get-Item "$logRoot\HitmanPro_Scan_Log.txt"
} else { $null }
$verification = [ordered]@{
checked_at = (Get-Date).ToUniversalTime().ToString('o')
pending_ops_cleared = $pendingDone
adwcleaner_log = if ($adwLog) { $adwLog.FullName } else { 'not found' }
hitmanpro_log = if ($hitLog) { $hitLog.FullName } else { 'not found' }
result = if ($pendingDone) { 'clean' } else { 'pending_items_remain' }
}
# Write verification result alongside original results.json
if ($logRoot -and (Test-Path $logRoot)) {
$verification | ConvertTo-Json | Set-Content "$logRoot\post_reboot_verification.json" -Encoding UTF8
}
# ---------------------------------------------------------------------------
# Remove scanner installation files from this machine
# ---------------------------------------------------------------------------
$scannerPaths = @(
'C:\EmsisoftCmd',
'C:\AdwCleaner',
'C:\ProgramData\HitmanPro',
'C:\ProgramData\HitmanPro.Alert'
)
foreach ($p in $scannerPaths) {
Remove-Item -Path $p -Recurse -Force -ErrorAction SilentlyContinue
}
# ---------------------------------------------------------------------------
# Flag logs for GuruRMM to pull (agent reads this file)
# ---------------------------------------------------------------------------
@{
scan_id = $scanId
log_root = $logRoot
zip_path = "$Base\reports\$scanId.zip"
verified = $verification.result
flagged_at = (Get-Date).ToUniversalTime().ToString('o')
} | ConvertTo-Json | Set-Content "$Base\logs-ready.json" -Encoding UTF8
# ---------------------------------------------------------------------------
# Restore original user's name in the login screen.
# Win32_ComputerSystem.UserName returns "DOMAIN\user" or "MACHINE\user".
# Split so the login screen pre-fills both fields correctly.
# ---------------------------------------------------------------------------
if ($origUser) {
try {
if ($origUser -match '^(.+)\\(.+)$') {
$restoreDomain = $Matches[1]
$restoreUser = $Matches[2]
} else {
$restoreDomain = $env:COMPUTERNAME
$restoreUser = $origUser
}
Set-ItemProperty -Path $WlKey -Name 'DefaultUserName' -Value $restoreUser
Set-ItemProperty -Path $WlKey -Name 'DefaultDomainName' -Value $restoreDomain
} catch {}
}
# ---------------------------------------------------------------------------
# Clear autologon settings
# ---------------------------------------------------------------------------
try {
Set-ItemProperty -Path $WlKey -Name 'AutoAdminLogon' -Value '0'
Remove-ItemProperty -Path $WlKey -Name 'DefaultPassword' -ErrorAction SilentlyContinue
} catch {}
# ---------------------------------------------------------------------------
# Remove our scheduled logon task
# ---------------------------------------------------------------------------
Unregister-ScheduledTask -TaskName 'GuruRMM-PostRebootCleanup' -Confirm:$false -ErrorAction SilentlyContinue
# ---------------------------------------------------------------------------
# Schedule SYSTEM task to delete GuruRMM-Temp 2 minutes from now
# (can't delete the account we're currently logged into)
# ---------------------------------------------------------------------------
try {
$deleteScript = @"
Remove-LocalUser -Name '$TempUser' -ErrorAction SilentlyContinue
Remove-ItemProperty -Path '$SpecialKey' -Name '$TempUser' -ErrorAction SilentlyContinue
Remove-Item -Path 'C:\Users\$TempUser' -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -Path '$StateFile' -Force -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName 'GuruRMM-TempUserDelete' -Confirm:`$false -ErrorAction SilentlyContinue
"@
$deleteScript | Set-Content "$Base\delete-temp-user.ps1" -Encoding UTF8
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument "-NoProfile -ExecutionPolicy Bypass -File `"$Base\delete-temp-user.ps1`""
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(2)
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 5)
Register-ScheduledTask -TaskName 'GuruRMM-TempUserDelete' -Action $action `
-Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
} catch {}
# ---------------------------------------------------------------------------
# Close splash and log off
# ---------------------------------------------------------------------------
$sync.Close = $true
Start-Sleep -Seconds 3
$uiPS.Stop()
$uiRS.Close()
& logoff

View File

@@ -0,0 +1,460 @@
#Requires -RunAsAdministrator
<#
.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.ps1.
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.ps1 -LogRoot "C:\ScanLogs\DESKTOP-20260523-143000"
.\Invoke-Remediation.ps1 -LogRoot "C:\ScanLogs\DESKTOP-20260523-143000" -Scanners AdwCleaner,MSERT
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$LogRoot,
[string[]]$Scanners
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Continue'
$ProgressPreference = 'SilentlyContinue'
# ---------------------------------------------------------------------------
# Helpers (duplicated here so this script is self-contained)
# ---------------------------------------------------------------------------
function Expand-TokenizedArgs {
param([string[]]$Args, [string]$LogRoot, [string]$ExeDir)
$out = foreach ($a in $Args) {
$a = $a -replace '\{LOG_ROOT\}', $LogRoot
$a = $a -replace '\{EXE_DIR\}', $ExeDir
$a
}
return $out
}
function Expand-EnvPath {
param([string]$Path)
return [System.Environment]::ExpandEnvironmentVariables($Path)
}
function Remove-PathSilent {
param([string]$Path)
$expanded = Expand-EnvPath $Path
if (Test-Path $expanded) {
Remove-Item -Path $expanded -Recurse -Force -ErrorAction SilentlyContinue
}
}
function Wait-ProcessWithTimeout {
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 { }
return $false
}
Start-Sleep -Seconds 5
}
return $true
}
function Wait-ServicesToStop {
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 Invoke-HitmanProTrialReset {
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 {
param([string]$ScannerName, [int]$ExitCode)
switch ($ScannerName) {
'AdwCleaner' { if ($ExitCode -eq 1) { return 1 } }
'MSERT' { if ($ExitCode -ne 0) { return 1 } }
'TDSSKiller' { if ($ExitCode -eq 1) { return 1 } }
'HitmanPro' { if ($ExitCode -eq 1) { return 1 } }
'Emsisoft' { if ($ExitCode -ge 1) { return 1 } }
'Stinger' { if ($ExitCode -eq 13) { return 1 } }
'ESET' { if ($ExitCode -ne 0) { return 1 } }
}
return 0
}
# ---------------------------------------------------------------------------
# Validate inputs
# ---------------------------------------------------------------------------
$ScriptRoot = $PSScriptRoot
$ConfigPath = Join-Path $ScriptRoot 'scanners.json'
$PriorResults = Join-Path $LogRoot 'results.json'
if (-not (Test-Path $LogRoot)) {
Write-Host "[ERROR] LogRoot not found: $LogRoot" -ForegroundColor Red
exit 1
}
if (-not (Test-Path $PriorResults)) {
Write-Host "[ERROR] results.json not found in: $LogRoot" -ForegroundColor Red
exit 1
}
if (-not (Test-Path $ConfigPath)) {
Write-Host "[ERROR] scanners.json not found: $ConfigPath" -ForegroundColor Red
exit 1
}
# ---------------------------------------------------------------------------
# Load config and prior results
# ---------------------------------------------------------------------------
$config = Get-Content $ConfigPath -Raw | ConvertFrom-Json
$priorData = Get-Content $PriorResults -Raw | ConvertFrom-Json
$priorScannerNames = $priorData.scanners |
Where-Object { $_.status -eq 'completed' } |
ForEach-Object { $_.name }
$scannerList = $config.scanners | Where-Object { $priorScannerNames -contains $_.name }
if ($Scanners -and $Scanners.Count -gt 0) {
$scannerList = $scannerList | Where-Object { $Scanners -contains $_.name }
}
if (-not $scannerList) {
Write-Host "[WARNING] No eligible scanners to remediate." -ForegroundColor Yellow
exit 0
}
Write-Host ""
Write-Host "=== GuruScan Remediation ===" -ForegroundColor Cyan
Write-Host " Log root : $LogRoot"
Write-Host " Scanners : $($scannerList.name -join ', ')"
Write-Host ""
# ---------------------------------------------------------------------------
# Run scanners in clean mode
# ---------------------------------------------------------------------------
$results = [System.Collections.Generic.List[pscustomobject]]::new()
$startedAt = Get-Date
$totalThreats = 0
foreach ($s in $scannerList) {
Write-Host "------------------------------------------------------------" -ForegroundColor DarkGray
Write-Host " Scanner : $($s.name) [CLEAN MODE]" -ForegroundColor Cyan
# ------------------------------------------------------------------
# Resolve EXE paths
# ------------------------------------------------------------------
$mainExePath = $null
$installerExePath = $null
if ($s.installer_exe) {
$installerExePath = if ([System.IO.Path]::IsPathRooted($s.installer_exe)) {
$s.installer_exe
} else {
Join-Path $ScriptRoot $s.installer_exe
}
}
$rawExe = $s.exe
if ([System.IO.Path]::IsPathRooted($rawExe)) {
$mainExePath = $rawExe
} else {
$mainExePath = Join-Path $ScriptRoot $rawExe
}
$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 = $s.name
Status = 'SKIPPED (missing)'
ExitCode = $null
ThreatsFound = 0
Duration = '0 min'
LogPath = ''
})
continue
}
# ------------------------------------------------------------------
# Resolve args (always clean_args)
# ------------------------------------------------------------------
$exeDir = Split-Path $mainExePath -Parent
$expandedArgs = Expand-TokenizedArgs -Args @($s.clean_args) -LogRoot $LogRoot -ExeDir $exeDir
# ------------------------------------------------------------------
# HitmanPro trial reset
# ------------------------------------------------------------------
if ($s.hitmanpro_trial_reset -eq $true) {
Write-Host " [INFO] Resetting HitmanPro trial registry..." -ForegroundColor Cyan
Invoke-HitmanProTrialReset
}
# ------------------------------------------------------------------
# Pre-clean
# ------------------------------------------------------------------
foreach ($p in $s.pre_clean_paths) {
Remove-PathSilent $p
}
# ------------------------------------------------------------------
# Timeout
# ------------------------------------------------------------------
$timeoutSec = $s.timeout_min * 60
# ------------------------------------------------------------------
# Two-step install (Emsisoft)
# ------------------------------------------------------------------
if ($installerExePath) {
Write-Host " [INFO] Running installer: $installerExePath" -ForegroundColor Cyan
try {
$instProc = Start-Process -FilePath $installerExePath -ArgumentList '/S' -PassThru -NoNewWindow
$instCompleted = Wait-ProcessWithTimeout -Process $instProc -TimeoutSeconds 300
if (-not $instCompleted) {
Write-Host " [WARNING] Installer timed out — skipping" -ForegroundColor Yellow
$results.Add([pscustomobject]@{
Scanner = $s.name
Status = 'FAILED (installer timeout)'
ExitCode = $null
ThreatsFound = 0
Duration = '0 min'
LogPath = ''
})
continue
}
}
catch {
Write-Host " [ERROR] Installer failed: $_" -ForegroundColor Red
$results.Add([pscustomobject]@{
Scanner = $s.name
Status = "FAILED (installer error)"
ExitCode = $null
ThreatsFound = 0
Duration = '0 min'
LogPath = ''
})
continue
}
if (-not (Test-Path $mainExePath)) {
Write-Host " [ERROR] Main EXE missing after install: $mainExePath" -ForegroundColor Red
$results.Add([pscustomobject]@{
Scanner = $s.name
Status = 'FAILED (post-install EXE missing)'
ExitCode = $null
ThreatsFound = 0
Duration = '0 min'
LogPath = ''
})
continue
}
Write-Host " [INFO] Updating Emsisoft definitions..." -ForegroundColor Cyan
try {
$updateProc = Start-Process -FilePath $mainExePath -ArgumentList '/update' -PassThru -NoNewWindow
Wait-ProcessWithTimeout -Process $updateProc -TimeoutSeconds 300 | Out-Null
}
catch {
Write-Host " [WARNING] Update step failed: $_ — continuing with existing definitions" -ForegroundColor Yellow
}
}
# ------------------------------------------------------------------
# Randomize EXE (TDSSKiller)
# ------------------------------------------------------------------
$randomizedExePath = $null
$launchExe = $mainExePath
if ($s.randomize_exe -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] TDSSKiller randomized to: $randomizedExePath" -ForegroundColor Cyan
}
# ------------------------------------------------------------------
# Launch
# ------------------------------------------------------------------
Write-Host " [....] Launching $($s.name) in clean mode..." -ForegroundColor Cyan
$scanStart = Get-Date
$proc = $null
$status = 'completed'
$exitCode = $null
try {
$startParams = @{
FilePath = $launchExe
PassThru = $true
NoNewWindow = $true
}
if ($expandedArgs -and $expandedArgs.Count -gt 0) {
$startParams['ArgumentList'] = $expandedArgs
}
$proc = Start-Process @startParams
$completed = Wait-ProcessWithTimeout -Process $proc -TimeoutSeconds $timeoutSec
if (-not $completed) {
$status = 'TIMED OUT'
Write-Host " [WARNING] $($s.name) timed out after $($s.timeout_min) min" -ForegroundColor Yellow
} else {
$exitCode = $proc.ExitCode
}
}
catch {
$status = 'FAILED'
Write-Host " [ERROR] $($s.name) failed: $_" -ForegroundColor Red
}
# ------------------------------------------------------------------
# Wait for services
# ------------------------------------------------------------------
if ($s.service_names -and $s.service_names.Count -gt 0) {
Wait-ServicesToStop -ServiceNames $s.service_names -TimeoutSeconds 180
}
# ------------------------------------------------------------------
# Duration
# ------------------------------------------------------------------
$durMin = [math]::Round(((Get-Date) - $scanStart).TotalMinutes, 2)
# ------------------------------------------------------------------
# Collect logs
# ------------------------------------------------------------------
$logDestDir = Join-Path $LogRoot "$($s.name)_Remediation_Logs"
if ($s.log_src) {
$expandedLogSrc = Expand-TokenizedArgs -Args @($s.log_src) -LogRoot $LogRoot -ExeDir $exeDir
$expandedLogSrc = Expand-EnvPath $expandedLogSrc[0]
if (Test-Path $expandedLogSrc) {
New-Item -ItemType Directory -Path $logDestDir -Force | Out-Null
Copy-Item -Path $expandedLogSrc -Destination $logDestDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
# ------------------------------------------------------------------
# Post-clean
# ------------------------------------------------------------------
foreach ($p in $s.post_clean_paths) {
Remove-PathSilent $p
}
# ------------------------------------------------------------------
# Randomized EXE cleanup
# ------------------------------------------------------------------
if ($randomizedExePath -and (Test-Path $randomizedExePath)) {
Remove-Item $randomizedExePath -Force -ErrorAction SilentlyContinue
}
# ------------------------------------------------------------------
# Threats
# ------------------------------------------------------------------
$threatsFound = 0
if ($null -ne $exitCode) {
$threatsFound = Get-ExitCodeThreats -ScannerName $s.name -ExitCode $exitCode
}
$totalThreats += $threatsFound
$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 = $s.name
Status = $status
ExitCode = $exitCode
ThreatsFound = $threatsFound
Duration = "$durMin min"
LogPath = $logDestDir
})
}
# ---------------------------------------------------------------------------
# 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 = $false
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 ""
exit 0