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:
5
projects/msp-tools/guru-scan/.gitignore
vendored
Normal file
5
projects/msp-tools/guru-scan/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Scanner binaries — downloaded at runtime, not committed
|
||||||
|
downloads/
|
||||||
|
|
||||||
|
# Scan output — machine-local, can be large
|
||||||
|
C:\ScanLogs\
|
||||||
115
projects/msp-tools/guru-scan/Download-Scanners.ps1
Normal file
115
projects/msp-tools/guru-scan/Download-Scanners.ps1
Normal 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
|
||||||
294
projects/msp-tools/guru-scan/Get-ScanSummary.ps1
Normal file
294
projects/msp-tools/guru-scan/Get-ScanSummary.ps1
Normal 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
|
||||||
1094
projects/msp-tools/guru-scan/Invoke-GuruScan.ps1
Normal file
1094
projects/msp-tools/guru-scan/Invoke-GuruScan.ps1
Normal file
File diff suppressed because it is too large
Load Diff
215
projects/msp-tools/guru-scan/Invoke-PostRebootCleanup.ps1
Normal file
215
projects/msp-tools/guru-scan/Invoke-PostRebootCleanup.ps1
Normal 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
|
||||||
460
projects/msp-tools/guru-scan/Invoke-Remediation.ps1
Normal file
460
projects/msp-tools/guru-scan/Invoke-Remediation.ps1
Normal 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
|
||||||
Reference in New Issue
Block a user