/rmm diagnose: dispatches a Windows security/health probe to a newly onboarded agent, grades RED/AMBER/GREEN, writes an immutable per-client baseline (clients/<slug>/onboarding-baselines/), diffs vs prior, and alerts CRITICALs to #dev-alerts. Probe is PS5.1/ASCII/SYSTEM-safe, never-abort, base64 chunked upload around the agent command-size cap. Code-reviewed (no blockers); folded in immutability guard, severity-independent finding ids, Defender-unknown sentinel, expanded competitor/backup detection. First baselines captured: Rednour FRONTDESKRECEPT + LEGALASST (both RED - prior MSP ScreenConnect/Splashtop/Syncro still live; LEGALASST OS EOL). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1276 lines
61 KiB
PowerShell
1276 lines
61 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
GuruRMM onboarding diagnostic probe (Phase 1).
|
|
|
|
.DESCRIPTION
|
|
Security + health + inventory probe for a newly onboarded Windows agent.
|
|
Dispatched via the GuruRMM RMM API and run as SYSTEM by the agent.
|
|
|
|
Design constraints (do not relax):
|
|
- Windows PowerShell 5.1 compatible (no PS7-only syntax, no ternary, no ??).
|
|
- ASCII only. No unicode, em-dashes, smart quotes. They break the
|
|
Discord alert path and can break PS5.1 parsing of this file.
|
|
- Non-interactive. No Read-Host / pause / prompts.
|
|
- EVERY check wrapped in try/catch so one failure cannot abort the probe.
|
|
A failed check records a finding with severity "unknown".
|
|
- Emits a SINGLE JSON object to stdout, fenced by literal marker lines:
|
|
===DIAG-JSON-START===
|
|
{ ... }
|
|
===DIAG-JSON-END===
|
|
so the runner can extract it cleanly from surrounding noise.
|
|
|
|
Finding schema:
|
|
{ id, category, severity, title, detail, evidence }
|
|
severity in: critical | warning | info | unknown
|
|
|
|
Top-level schema:
|
|
{ host, collected_at_utc, os{}, facts{}, findings[] }
|
|
#>
|
|
|
|
# Be permissive: non-terminating errors should not kill the probe. Each check
|
|
# uses its own try/catch with -ErrorAction Stop where it needs hard failures.
|
|
$ErrorActionPreference = 'Continue'
|
|
$ProgressPreference = 'SilentlyContinue'
|
|
Set-StrictMode -Off
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Collectors
|
|
# ---------------------------------------------------------------------------
|
|
$Findings = New-Object System.Collections.ArrayList
|
|
$Facts = @{}
|
|
|
|
function Add-Finding {
|
|
param(
|
|
[string]$Id,
|
|
[string]$Category,
|
|
[string]$Severity, # critical | warning | info | unknown
|
|
[string]$Title,
|
|
[string]$Detail,
|
|
$Evidence
|
|
)
|
|
$ev = ''
|
|
try {
|
|
if ($null -ne $Evidence) {
|
|
if ($Evidence -is [string]) {
|
|
$ev = $Evidence
|
|
} else {
|
|
$ev = ($Evidence | Out-String).Trim()
|
|
}
|
|
}
|
|
} catch { $ev = '' }
|
|
# Keep evidence bounded so a single finding can't bloat the JSON.
|
|
if ($ev.Length -gt 4000) { $ev = $ev.Substring(0, 4000) + ' ...[truncated]' }
|
|
|
|
$null = $Findings.Add([ordered]@{
|
|
id = $Id
|
|
category = $Category
|
|
severity = $Severity
|
|
title = $Title
|
|
detail = $Detail
|
|
evidence = $ev
|
|
})
|
|
}
|
|
|
|
# Wrap a check. If the scriptblock throws, record an "unknown" finding instead
|
|
# of letting the exception propagate and abort the whole probe.
|
|
function Invoke-Check {
|
|
param(
|
|
[string]$Id,
|
|
[string]$Category,
|
|
[string]$Title,
|
|
[scriptblock]$Body
|
|
)
|
|
try {
|
|
& $Body
|
|
} catch {
|
|
$msg = ''
|
|
try { $msg = $_.Exception.Message } catch { $msg = 'unknown error' }
|
|
Add-Finding -Id "$Id.error" -Category $Category -Severity 'unknown' `
|
|
-Title "Check failed: $Title" `
|
|
-Detail "The probe could not complete this check. Manual review recommended." `
|
|
-Evidence $msg
|
|
}
|
|
}
|
|
|
|
# Safe fact setter (never throws).
|
|
function Set-Fact {
|
|
param([string]$Key, $Value)
|
|
try { $Facts[$Key] = $Value } catch { }
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Basic host / OS facts (best-effort; never aborts)
|
|
# ---------------------------------------------------------------------------
|
|
$HostName = $env:COMPUTERNAME
|
|
try { if ([string]::IsNullOrEmpty($HostName)) { $HostName = [System.Net.Dns]::GetHostName() } } catch { }
|
|
|
|
$CollectedAtUtc = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
|
|
|
|
$OsInfo = [ordered]@{
|
|
caption = ''
|
|
version = ''
|
|
build = ''
|
|
install_date = ''
|
|
last_boot_utc = ''
|
|
architecture = ''
|
|
}
|
|
|
|
# Chassis / laptop detection result is needed by multiple checks.
|
|
$IsLaptop = $false
|
|
|
|
Invoke-Check -Id 'os.info' -Category 'inventory' -Title 'OS information' -Body {
|
|
$os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop
|
|
$script:OsInfo.caption = [string]$os.Caption
|
|
$script:OsInfo.version = [string]$os.Version
|
|
$script:OsInfo.build = [string]$os.BuildNumber
|
|
$script:OsInfo.architecture = [string]$os.OSArchitecture
|
|
try { $script:OsInfo.install_date = $os.InstallDate.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') } catch { }
|
|
try { $script:OsInfo.last_boot_utc = $os.LastBootUpTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') } catch { }
|
|
}
|
|
|
|
Invoke-Check -Id 'chassis.detect' -Category 'inventory' -Title 'Chassis type (laptop detection)' -Body {
|
|
$enc = Get-CimInstance -ClassName Win32_SystemEnclosure -ErrorAction Stop
|
|
$types = @()
|
|
foreach ($e in $enc) { if ($e.ChassisTypes) { $types += $e.ChassisTypes } }
|
|
Set-Fact 'chassis_types' $types
|
|
# 8=Portable 9=Laptop 10=Notebook 11=Hand Held 12=Docking 14=Sub Notebook
|
|
foreach ($t in $types) {
|
|
if ($t -eq 8 -or $t -eq 9 -or $t -eq 10 -or $t -eq 11 -or $t -eq 14) { $script:IsLaptop = $true }
|
|
}
|
|
Set-Fact 'is_laptop' $script:IsLaptop
|
|
}
|
|
|
|
# ===========================================================================
|
|
# SECURITY CHECKS
|
|
# ===========================================================================
|
|
|
|
# --- Microsoft Defender ---
|
|
Invoke-Check -Id 'sec.defender' -Category 'security' -Title 'Microsoft Defender status' -Body {
|
|
$mp = $null
|
|
try { $mp = Get-MpComputerStatus -ErrorAction Stop } catch { $mp = $null }
|
|
if ($null -eq $mp) {
|
|
Add-Finding -Id 'sec.defender.unavailable' -Category 'security' -Severity 'warning' `
|
|
-Title 'Defender status unavailable' `
|
|
-Detail 'Get-MpComputerStatus returned nothing. Defender may be disabled, replaced by a 3rd-party AV, or the cmdlet is unavailable. Confirm an active AV exists (see security-center check).' `
|
|
-Evidence 'Get-MpComputerStatus returned null'
|
|
Set-Fact 'defender' @{ available = $false }
|
|
return
|
|
}
|
|
|
|
$rtp = [bool]$mp.RealTimeProtectionEnabled
|
|
$amsvc = [bool]$mp.AMServiceEnabled
|
|
$sigAge = 0
|
|
try { $sigAge = [int]$mp.AntispywareSignatureAge } catch { $sigAge = -1 }
|
|
$tamper = $false
|
|
try { $tamper = [bool]$mp.IsTamperProtected } catch { $tamper = $false }
|
|
|
|
Set-Fact 'defender' @{
|
|
available = $true
|
|
real_time_protection = $rtp
|
|
am_service_enabled = $amsvc
|
|
antispyware_signature_age = $sigAge
|
|
tamper_protected = $tamper
|
|
antivirus_enabled = [bool]$mp.AntivirusEnabled
|
|
nis_enabled = [bool]$mp.NISEnabled
|
|
}
|
|
|
|
$ev = "RealTimeProtectionEnabled=$rtp; AMServiceEnabled=$amsvc; AntispywareSignatureAge=$sigAge days; IsTamperProtected=$tamper"
|
|
|
|
if (-not $rtp) {
|
|
Add-Finding -Id 'sec.defender.rtp_off' -Category 'security' -Severity 'critical' `
|
|
-Title 'Defender real-time protection is OFF' `
|
|
-Detail 'Real-time protection is disabled. The endpoint is unprotected against active threats. Re-enable immediately or confirm a managed 3rd-party AV is providing real-time protection.' `
|
|
-Evidence $ev
|
|
}
|
|
if (-not $amsvc) {
|
|
Add-Finding -Id 'sec.defender.amservice_off' -Category 'security' -Severity 'critical' `
|
|
-Title 'Defender antimalware service is not running' `
|
|
-Detail 'The Defender antimalware service is not active. If no 3rd-party AV is present, this endpoint has no antivirus protection.' `
|
|
-Evidence $ev
|
|
}
|
|
if ($sigAge -lt 0) {
|
|
# Sentinel: AntispywareSignatureAge could not be read. Never let this
|
|
# vanish silently - emit an explicit unknown so currency is flagged for
|
|
# manual review instead of being treated as "fine".
|
|
Add-Finding -Id 'sec.defender.sig_unknown' -Category 'security' -Severity 'unknown' `
|
|
-Title 'Defender signature currency could not be determined' `
|
|
-Detail 'AntispywareSignatureAge could not be read from Get-MpComputerStatus. Signature freshness is unknown. Verify manually (Get-MpComputerStatus, or Update-MpSignature to force a refresh).' `
|
|
-Evidence $ev
|
|
} elseif ($sigAge -gt 7) {
|
|
Add-Finding -Id 'sec.defender.sig_stale' -Category 'security' -Severity 'warning' `
|
|
-Title "Defender signatures are $sigAge days old" `
|
|
-Detail 'Antispyware signatures are more than 7 days old. The endpoint may not detect recent threats. Force a signature update (Update-MpSignature).' `
|
|
-Evidence $ev
|
|
}
|
|
if (-not $tamper) {
|
|
Add-Finding -Id 'sec.defender.tamper_off' -Category 'security' -Severity 'warning' `
|
|
-Title 'Defender tamper protection is OFF' `
|
|
-Detail 'Tamper protection is disabled, so malware or a local admin can silently disable Defender. Enable tamper protection (typically via Intune / Security Center).' `
|
|
-Evidence $ev
|
|
}
|
|
if ($rtp -and $amsvc -and $sigAge -ge 0 -and $sigAge -le 7) {
|
|
Add-Finding -Id 'sec.defender.ok' -Category 'security' -Severity 'info' `
|
|
-Title 'Defender active and current' `
|
|
-Detail 'Real-time protection on, service running, signatures current.' `
|
|
-Evidence $ev
|
|
}
|
|
}
|
|
|
|
# --- 3rd-party AV via SecurityCenter2 ---
|
|
Invoke-Check -Id 'sec.av_products' -Category 'security' -Title 'Registered antivirus products' -Body {
|
|
$avList = @()
|
|
try {
|
|
$av = Get-CimInstance -Namespace 'root\SecurityCenter2' -ClassName AntiVirusProduct -ErrorAction Stop
|
|
} catch {
|
|
# SecurityCenter2 does not exist on Server SKUs; that is expected, not an error.
|
|
$av = $null
|
|
}
|
|
if ($null -eq $av) {
|
|
Set-Fact 'antivirus_products' @()
|
|
Add-Finding -Id 'sec.av_products.none_registered' -Category 'security' -Severity 'info' `
|
|
-Title 'No AV products registered in Security Center' `
|
|
-Detail 'SecurityCenter2 returned no AntiVirusProduct entries. This is normal on Windows Server SKUs (Security Center is a client feature). On a workstation, confirm Defender or a managed AV is active.' `
|
|
-Evidence 'root\SecurityCenter2 AntiVirusProduct: none'
|
|
return
|
|
}
|
|
|
|
$nonDefender = @()
|
|
foreach ($p in $av) {
|
|
$name = [string]$p.displayName
|
|
$avList += $name
|
|
if ($name -notmatch 'Windows Defender' -and $name -notmatch 'Microsoft Defender') {
|
|
$nonDefender += $name
|
|
}
|
|
}
|
|
Set-Fact 'antivirus_products' $avList
|
|
|
|
if ($nonDefender.Count -gt 0) {
|
|
$names = ($nonDefender -join ', ')
|
|
Add-Finding -Id 'sec.av_products.third_party' -Category 'security' -Severity 'warning' `
|
|
-Title "Third-party AV present: $names" `
|
|
-Detail 'A non-Defender antivirus is registered. Running two real-time AV engines causes conflicts, performance loss, and detection gaps. Confirm the intended AV and ensure only one provides real-time protection.' `
|
|
-Evidence ("Registered AV: " + ($avList -join ', '))
|
|
} else {
|
|
Add-Finding -Id 'sec.av_products.defender_only' -Category 'security' -Severity 'info' `
|
|
-Title 'Defender is the only registered AV' `
|
|
-Detail 'Only Microsoft/Windows Defender is registered in Security Center.' `
|
|
-Evidence ($avList -join ', ')
|
|
}
|
|
}
|
|
|
|
# --- Leftover / competitor management agents (CRITICAL at onboarding) ---
|
|
Invoke-Check -Id 'sec.foreign_agents' -Category 'security' -Title 'Competitor / leftover management agents' -Body {
|
|
# name regex -> friendly label.
|
|
# IMPORTANT: anchor short/ambiguous tokens with \b word boundaries. Bare
|
|
# substrings cause false positives (e.g. 'cove' matches 'Recovery'/'Discovery',
|
|
# 'ltsvc' matches 'VaultSvc'). \b requires a word boundary on each side.
|
|
$patterns = @(
|
|
@{ rx = 'screenconnect|connectwise control|connectwisecontrol'; label = 'ScreenConnect / ConnectWise Control' },
|
|
@{ rx = 'ninjarmm|ninja rmm'; label = 'NinjaRMM' },
|
|
@{ rx = 'datto rmm|\bcagservice\b|\baemagent\b|centrastage'; label = 'Datto RMM' },
|
|
@{ rx = '\batera\b|ateraagent'; label = 'Atera' },
|
|
@{ rx = '\bkaseya\b|\bagentmon\b'; label = 'Kaseya' },
|
|
@{ rx = '\bteamviewer\b'; label = 'TeamViewer' },
|
|
@{ rx = '\blogmein\b'; label = 'LogMeIn' },
|
|
@{ rx = '\banydesk\b'; label = 'AnyDesk' },
|
|
@{ rx = '\bsplashtop\b'; label = 'Splashtop (SOS/Streamer)' },
|
|
@{ rx = '\bn-able\b|\bnableagent\b|take control|\bbasupport\b'; label = 'N-able / Take Control' },
|
|
@{ rx = '\bsyncro\b|\bkabuto\b'; label = 'Syncro / Kabuto' },
|
|
@{ rx = '\baction1\b'; label = 'Action1' },
|
|
@{ rx = '\blabtech\b|connectwise automate|\bltservice\b|\bltsvcmon\b'; label = 'ConnectWise Automate / LabTech' },
|
|
@{ rx = '\bbeyondtrust\b|\bbomgar\b'; label = 'BeyondTrust / Bomgar' },
|
|
@{ rx = '\bgotoassist\b|goto assist|goto resolve|\bgotoresolve\b'; label = 'GoToAssist / GoTo Resolve' },
|
|
@{ rx = '\brustdesk\b'; label = 'RustDesk' },
|
|
@{ rx = '\bpulseway\b'; label = 'Pulseway' },
|
|
@{ rx = '\blevel\.io\b|\blevelio\b|level rmm'; label = 'Level.io' },
|
|
@{ rx = '\bmanageengine\b|zoho assist|\bzohoassist\b'; label = 'ManageEngine / Zoho Assist' }
|
|
)
|
|
|
|
# Collect installed programs from both 64-bit and 32-bit uninstall hives.
|
|
$installed = @()
|
|
$uninstallPaths = @(
|
|
'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
|
|
'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
|
|
)
|
|
foreach ($up in $uninstallPaths) {
|
|
try {
|
|
$items = Get-ItemProperty $up -ErrorAction SilentlyContinue
|
|
foreach ($it in $items) {
|
|
if ($it.DisplayName) {
|
|
$installed += [pscustomobject]@{
|
|
Name = [string]$it.DisplayName
|
|
Version = [string]$it.DisplayVersion
|
|
Publisher = [string]$it.Publisher
|
|
}
|
|
}
|
|
}
|
|
} catch { }
|
|
}
|
|
|
|
# Collect services (name + display name).
|
|
$svcRows = @()
|
|
try {
|
|
$svcRows = Get-CimInstance -ClassName Win32_Service -ErrorAction Stop |
|
|
Select-Object Name, DisplayName, State, StartMode
|
|
} catch {
|
|
try { $svcRows = Get-Service -ErrorAction SilentlyContinue | Select-Object Name, DisplayName } catch { $svcRows = @() }
|
|
}
|
|
|
|
$hits = @()
|
|
foreach ($pat in $patterns) {
|
|
$rx = $pat.rx
|
|
$matchEvidence = @()
|
|
|
|
foreach ($prog in $installed) {
|
|
if ($prog.Name -match $rx) {
|
|
$matchEvidence += ("program: " + $prog.Name + " " + $prog.Version)
|
|
}
|
|
}
|
|
foreach ($s in $svcRows) {
|
|
$sn = [string]$s.Name
|
|
$sd = [string]$s.DisplayName
|
|
if ($sn -match $rx -or $sd -match $rx) {
|
|
$st = ''
|
|
try { $st = [string]$s.State } catch { }
|
|
$matchEvidence += ("service: " + $sn + " (" + $sd + ") " + $st)
|
|
}
|
|
}
|
|
|
|
if ($matchEvidence.Count -gt 0) {
|
|
$hits += [pscustomobject]@{ Label = $pat.label; Evidence = ($matchEvidence | Select-Object -Unique) }
|
|
}
|
|
}
|
|
|
|
Set-Fact 'foreign_agents' ($hits | ForEach-Object { $_.Label })
|
|
|
|
if ($hits.Count -gt 0) {
|
|
foreach ($h in $hits) {
|
|
Add-Finding -Id ('sec.foreign_agents.' + ($h.Label -replace '[^A-Za-z0-9]+','_').ToLower()) `
|
|
-Category 'security' -Severity 'critical' `
|
|
-Title ('Foreign management/remote-access agent: ' + $h.Label) `
|
|
-Detail 'A competitor RMM or unmanaged remote-access tool is present. At onboarding this is a security and control risk (a prior MSP or attacker may retain remote access). Verify it is authorized; if not, remove it.' `
|
|
-Evidence ($h.Evidence -join "`n")
|
|
}
|
|
} else {
|
|
Add-Finding -Id 'sec.foreign_agents.none' -Category 'security' -Severity 'info' `
|
|
-Title 'No competitor/leftover management agents detected' `
|
|
-Detail 'No known competitor RMM or unmanaged remote-access agents found in installed programs or services.' `
|
|
-Evidence 'Scanned uninstall hives (HKLM + WOW6432Node) and Win32_Service'
|
|
}
|
|
}
|
|
|
|
# --- Firewall profiles ---
|
|
Invoke-Check -Id 'sec.firewall' -Category 'security' -Title 'Windows Firewall profiles' -Body {
|
|
$profiles = Get-NetFirewallProfile -ErrorAction Stop
|
|
$state = @{}
|
|
$disabled = @()
|
|
foreach ($p in $profiles) {
|
|
$en = [bool]$p.Enabled
|
|
$state[[string]$p.Name] = $en
|
|
if (-not $en) { $disabled += [string]$p.Name }
|
|
}
|
|
Set-Fact 'firewall_profiles' $state
|
|
|
|
if ($disabled.Count -gt 0) {
|
|
Add-Finding -Id 'sec.firewall.disabled' -Category 'security' -Severity 'critical' `
|
|
-Title ('Firewall disabled on profile(s): ' + ($disabled -join ', ')) `
|
|
-Detail 'One or more firewall profiles are OFF. The endpoint is exposed to lateral movement and inbound attacks on those networks. Re-enable all profiles.' `
|
|
-Evidence ("Profile states: " + (($state.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join '; '))
|
|
} else {
|
|
Add-Finding -Id 'sec.firewall.ok' -Category 'security' -Severity 'info' `
|
|
-Title 'All firewall profiles enabled' `
|
|
-Detail 'Domain, Private, and Public firewall profiles are all enabled.' `
|
|
-Evidence (($state.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join '; ')
|
|
}
|
|
}
|
|
|
|
# --- BitLocker ---
|
|
Invoke-Check -Id 'sec.bitlocker' -Category 'security' -Title 'BitLocker on OS volume' -Body {
|
|
$sysDrive = $env:SystemDrive
|
|
if ([string]::IsNullOrEmpty($sysDrive)) { $sysDrive = 'C:' }
|
|
|
|
$vol = $null
|
|
try { $vol = Get-BitLockerVolume -MountPoint $sysDrive -ErrorAction Stop } catch { $vol = $null }
|
|
if ($null -eq $vol) {
|
|
Add-Finding -Id 'sec.bitlocker.unavailable' -Category 'security' -Severity 'unknown' `
|
|
-Title 'BitLocker status unavailable' `
|
|
-Detail 'Get-BitLockerVolume failed for the OS volume. BitLocker may not be installed (Home edition) or the cmdlet is unavailable. Verify encryption manually (manage-bde -status).' `
|
|
-Evidence "MountPoint=$sysDrive, Get-BitLockerVolume returned null"
|
|
Set-Fact 'bitlocker' @{ os_volume = $sysDrive; available = $false }
|
|
return
|
|
}
|
|
|
|
$protection = [string]$vol.ProtectionStatus # On / Off
|
|
$pct = 0
|
|
try { $pct = [int]$vol.EncryptionPercentage } catch { $pct = -1 }
|
|
$hasRecovery = $false
|
|
$protTypes = @()
|
|
try {
|
|
foreach ($kp in $vol.KeyProtector) {
|
|
$protTypes += [string]$kp.KeyProtectorType
|
|
if ([string]$kp.KeyProtectorType -match 'RecoveryPassword') { $hasRecovery = $true }
|
|
}
|
|
} catch { }
|
|
|
|
Set-Fact 'bitlocker' @{
|
|
os_volume = $sysDrive
|
|
available = $true
|
|
protection_status = $protection
|
|
encryption_percent = $pct
|
|
recovery_key_present = $hasRecovery
|
|
key_protectors = $protTypes
|
|
}
|
|
|
|
$ev = "Volume=$sysDrive; ProtectionStatus=$protection; EncryptionPercentage=$pct; KeyProtectors=$($protTypes -join ',')"
|
|
|
|
$encrypted = ($protection -eq 'On')
|
|
if (-not $encrypted) {
|
|
$sev = 'warning'
|
|
$extra = ''
|
|
if ($script:IsLaptop) { $sev = 'critical'; $extra = ' This is a laptop (portable chassis), so the data-at-rest risk if lost or stolen is high.' }
|
|
Add-Finding -Id 'sec.bitlocker.unencrypted' -Category 'security' -Severity $sev `
|
|
-Title 'OS volume is NOT encrypted with BitLocker' `
|
|
-Detail ('The operating system volume is unencrypted. Data is exposed if the disk is removed or the device is lost.' + $extra + ' Enable BitLocker and escrow the recovery key.') `
|
|
-Evidence $ev
|
|
} else {
|
|
if (-not $hasRecovery) {
|
|
Add-Finding -Id 'sec.bitlocker.no_recovery' -Category 'security' -Severity 'warning' `
|
|
-Title 'BitLocker on, but no recovery password protector' `
|
|
-Detail 'The OS volume is encrypted but has no RecoveryPassword key protector. Without an escrowed recovery key, a TPM/boot change can permanently lock out the data. Add a recovery password and escrow it.' `
|
|
-Evidence $ev
|
|
} else {
|
|
Add-Finding -Id 'sec.bitlocker.ok' -Category 'security' -Severity 'info' `
|
|
-Title 'OS volume encrypted with recovery protector present' `
|
|
-Detail 'BitLocker is on for the OS volume and a recovery password protector exists.' `
|
|
-Evidence $ev
|
|
}
|
|
}
|
|
}
|
|
|
|
# --- Local administrators / privileged accounts ---
|
|
Invoke-Check -Id 'sec.local_admins' -Category 'security' -Title 'Local administrators and privileged accounts' -Body {
|
|
$adminMembers = @()
|
|
try {
|
|
$members = Get-LocalGroupMember -Group 'Administrators' -ErrorAction Stop
|
|
foreach ($m in $members) { $adminMembers += [string]$m.Name }
|
|
} catch {
|
|
# Fallback via net localgroup if the cmdlet is unavailable.
|
|
try {
|
|
$raw = (net localgroup Administrators) 2>$null
|
|
$capture = $false
|
|
foreach ($line in $raw) {
|
|
if ($line -match '^----') { $capture = $true; continue }
|
|
if ($line -match 'completed successfully') { $capture = $false; continue }
|
|
if ($capture -and $line.Trim().Length -gt 0) { $adminMembers += $line.Trim() }
|
|
}
|
|
} catch { }
|
|
}
|
|
Set-Fact 'local_administrators' $adminMembers
|
|
|
|
# Built-in Administrator (RID 500) enabled?
|
|
$builtinAdminEnabled = $null
|
|
$neverExpire = @()
|
|
try {
|
|
$users = Get-LocalUser -ErrorAction Stop
|
|
foreach ($u in $users) {
|
|
try {
|
|
if ([string]$u.SID -match '-500$') { $builtinAdminEnabled = [bool]$u.Enabled }
|
|
} catch { }
|
|
try {
|
|
if ($u.Enabled -and $u.PasswordNeverExpires) { $neverExpire += [string]$u.Name }
|
|
} catch { }
|
|
}
|
|
} catch { }
|
|
|
|
Set-Fact 'builtin_admin_enabled' $builtinAdminEnabled
|
|
Set-Fact 'accounts_password_never_expires' $neverExpire
|
|
|
|
Add-Finding -Id 'sec.local_admins.list' -Category 'security' -Severity 'info' `
|
|
-Title ('Local administrators (' + $adminMembers.Count + ')') `
|
|
-Detail 'Members of the local Administrators group. Review for unexpected or unknown accounts (especially leftover MSP/vendor accounts from a prior provider).' `
|
|
-Evidence ($adminMembers -join "`n")
|
|
|
|
if ($builtinAdminEnabled -eq $true) {
|
|
Add-Finding -Id 'sec.local_admins.builtin_enabled' -Category 'security' -Severity 'warning' `
|
|
-Title 'Built-in Administrator account is enabled' `
|
|
-Detail 'The built-in Administrator (RID 500) is enabled. It is a well-known target for brute force and lateral movement. Disable it or ensure it is managed by LAPS with a strong unique password.' `
|
|
-Evidence 'Get-LocalUser SID ...-500 Enabled=True'
|
|
}
|
|
|
|
if ($neverExpire.Count -gt 0) {
|
|
Add-Finding -Id 'sec.local_admins.never_expire' -Category 'security' -Severity 'warning' `
|
|
-Title ('Enabled accounts with non-expiring passwords: ' + ($neverExpire -join ', ')) `
|
|
-Detail 'These enabled local accounts have PasswordNeverExpires set. Non-expiring passwords increase the window of exposure if credentials leak. Review and apply a rotation policy or LAPS.' `
|
|
-Evidence ($neverExpire -join "`n")
|
|
}
|
|
}
|
|
|
|
# --- Patch posture ---
|
|
Invoke-Check -Id 'sec.patch' -Category 'security' -Title 'Patch posture and OS support' -Body {
|
|
# Last installed hotfix
|
|
$lastHotfix = $null
|
|
try {
|
|
$hf = Get-HotFix -ErrorAction Stop | Where-Object { $_.InstalledOn } | Sort-Object InstalledOn -Descending | Select-Object -First 1
|
|
if ($hf) {
|
|
$lastHotfix = @{
|
|
hotfix_id = [string]$hf.HotFixID
|
|
installed_on = $hf.InstalledOn.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
|
|
}
|
|
}
|
|
} catch { }
|
|
Set-Fact 'last_hotfix' $lastHotfix
|
|
|
|
# Pending Windows Update count (COM). Best-effort; can be slow or blocked.
|
|
$pendingCount = $null
|
|
try {
|
|
$session = New-Object -ComObject Microsoft.Update.Session
|
|
$searcher = $session.CreateUpdateSearcher()
|
|
$result = $searcher.Search("IsInstalled=0 and IsHidden=0")
|
|
$pendingCount = [int]$result.Updates.Count
|
|
} catch { $pendingCount = $null }
|
|
Set-Fact 'pending_updates' $pendingCount
|
|
|
|
# OS build + EOL map
|
|
$build = $script:OsInfo.build
|
|
$caption = $script:OsInfo.caption
|
|
Set-Fact 'os_build' $build
|
|
|
|
# EOL map keyed by build number (consumer/Pro mainstream support cutoffs).
|
|
# Dates are end-of-servicing for that build. Kept small and explicit.
|
|
$eolMap = @{
|
|
# Windows 10 builds
|
|
'10240' = @{ name='Win10 1507'; eol='2017-05-09' }
|
|
'10586' = @{ name='Win10 1511'; eol='2017-10-10' }
|
|
'14393' = @{ name='Win10 1607'; eol='2018-04-10' }
|
|
'15063' = @{ name='Win10 1703'; eol='2018-10-09' }
|
|
'16299' = @{ name='Win10 1709'; eol='2019-04-09' }
|
|
'17134' = @{ name='Win10 1803'; eol='2019-11-12' }
|
|
'17763' = @{ name='Win10 1809'; eol='2020-11-10' }
|
|
'18362' = @{ name='Win10 1903'; eol='2019-12-08' }
|
|
'18363' = @{ name='Win10 1909'; eol='2021-05-11' }
|
|
'19041' = @{ name='Win10 2004'; eol='2021-12-14' }
|
|
'19042' = @{ name='Win10 20H2'; eol='2022-05-10' }
|
|
'19043' = @{ name='Win10 21H1'; eol='2022-12-13' }
|
|
'19044' = @{ name='Win10 21H2'; eol='2023-06-13' }
|
|
'19045' = @{ name='Win10 22H2'; eol='2025-10-14' }
|
|
# Windows 11 builds
|
|
'22000' = @{ name='Win11 21H2'; eol='2023-10-10' }
|
|
'22621' = @{ name='Win11 22H2'; eol='2024-10-08' }
|
|
'22631' = @{ name='Win11 23H2'; eol='2025-11-11' }
|
|
'26100' = @{ name='Win11 24H2'; eol='2026-10-13' }
|
|
'26200' = @{ name='Win11 25H2'; eol='2027-10-12' }
|
|
}
|
|
|
|
$now = (Get-Date).ToUniversalTime()
|
|
$eolEntry = $null
|
|
if ($eolMap.ContainsKey([string]$build)) { $eolEntry = $eolMap[[string]$build] }
|
|
|
|
if ($eolEntry) {
|
|
$eolDate = $null
|
|
try { $eolDate = [datetime]::ParseExact($eolEntry.eol, 'yyyy-MM-dd', $null) } catch { }
|
|
Set-Fact 'os_eol' @{ release=$eolEntry.name; eol_date=$eolEntry.eol }
|
|
if ($eolDate -and $now -gt $eolDate) {
|
|
Add-Finding -Id 'sec.patch.os_eol' -Category 'security' -Severity 'critical' `
|
|
-Title ('OS build is end-of-life: ' + $eolEntry.name) `
|
|
-Detail ('This OS build (' + $build + ', ' + $eolEntry.name + ') passed end-of-servicing on ' + $eolEntry.eol + '. It no longer receives security updates. Plan a feature update or OS upgrade.') `
|
|
-Evidence "$caption build $build; EOL $($eolEntry.eol)"
|
|
} elseif ($eolDate -and $now -gt $eolDate.AddDays(-90)) {
|
|
Add-Finding -Id 'sec.patch.os_eol_soon' -Category 'security' -Severity 'warning' `
|
|
-Title ('OS build nearing end-of-life: ' + $eolEntry.name) `
|
|
-Detail ('This OS build reaches end-of-servicing on ' + $eolEntry.eol + ' (within 90 days). Schedule a feature update.') `
|
|
-Evidence "$caption build $build; EOL $($eolEntry.eol)"
|
|
} else {
|
|
Add-Finding -Id 'sec.patch.os_supported' -Category 'security' -Severity 'info' `
|
|
-Title ('OS build supported: ' + $eolEntry.name) `
|
|
-Detail ('Build ' + $build + ' (' + $eolEntry.name + ') is in support until ' + $eolEntry.eol + '.') `
|
|
-Evidence "$caption build $build"
|
|
}
|
|
} else {
|
|
Add-Finding -Id 'sec.patch.os_build_unknown' -Category 'security' -Severity 'unknown' `
|
|
-Title ('OS build not in EOL map: ' + $build) `
|
|
-Detail 'The build number is not in the local EOL reference map. Verify support status manually. This may be a Server SKU or a build newer than the map.' `
|
|
-Evidence "$caption build $build"
|
|
}
|
|
|
|
if ($pendingCount -ne $null -and $pendingCount -gt 0) {
|
|
$sev = 'warning'
|
|
Add-Finding -Id 'sec.patch.pending' -Category 'security' -Severity $sev `
|
|
-Title ($pendingCount.ToString() + ' pending Windows updates') `
|
|
-Detail 'Windows Update reports pending (not installed, not hidden) updates. Some may be security updates. Approve/install on the next maintenance window.' `
|
|
-Evidence "Microsoft.Update.Session search IsInstalled=0 and IsHidden=0 -> $pendingCount"
|
|
}
|
|
|
|
if ($lastHotfix) {
|
|
Add-Finding -Id 'sec.patch.last_hotfix' -Category 'security' -Severity 'info' `
|
|
-Title ('Last hotfix: ' + $lastHotfix.hotfix_id) `
|
|
-Detail 'Most recently installed update (from Get-HotFix; reflects CBS/MSU packages, not all cumulative metadata).' `
|
|
-Evidence ("$($lastHotfix.hotfix_id) installed $($lastHotfix.installed_on)")
|
|
}
|
|
}
|
|
|
|
# --- Exposure surface: RDP/NLA, SMBv1, UAC, LAPS ---
|
|
Invoke-Check -Id 'sec.exposure' -Category 'security' -Title 'Exposure surface (RDP, SMBv1, UAC, LAPS)' -Body {
|
|
$exposure = @{}
|
|
|
|
# RDP enabled? fDenyTSConnections = 0 means RDP enabled.
|
|
$rdpEnabled = $null
|
|
try {
|
|
$deny = (Get-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server' -Name 'fDenyTSConnections' -ErrorAction Stop).fDenyTSConnections
|
|
$rdpEnabled = ($deny -eq 0)
|
|
} catch { $rdpEnabled = $null }
|
|
$exposure['rdp_enabled'] = $rdpEnabled
|
|
|
|
# NLA (UserAuthentication = 1 means NLA required)
|
|
$nla = $null
|
|
try {
|
|
$ua = (Get-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' -Name 'UserAuthentication' -ErrorAction Stop).UserAuthentication
|
|
$nla = ($ua -eq 1)
|
|
} catch { $nla = $null }
|
|
$exposure['rdp_nla'] = $nla
|
|
|
|
if ($rdpEnabled -eq $true) {
|
|
if ($nla -eq $false) {
|
|
Add-Finding -Id 'sec.exposure.rdp_no_nla' -Category 'security' -Severity 'critical' `
|
|
-Title 'RDP enabled WITHOUT Network Level Authentication' `
|
|
-Detail 'RDP is on and NLA is not required. This exposes the logon screen pre-auth and is vulnerable to pre-auth exploits and brute force. Require NLA, restrict RDP to VPN/allow-listed IPs, or disable RDP.' `
|
|
-Evidence "fDenyTSConnections=0; UserAuthentication=0"
|
|
} else {
|
|
Add-Finding -Id 'sec.exposure.rdp_on' -Category 'security' -Severity 'warning' `
|
|
-Title 'RDP is enabled' `
|
|
-Detail 'Remote Desktop is enabled (NLA required). Confirm it is restricted to VPN or specific source IPs and not exposed to the internet.' `
|
|
-Evidence "fDenyTSConnections=0; UserAuthentication=$([int]($nla -eq $true))"
|
|
}
|
|
}
|
|
|
|
# SMBv1
|
|
$smb1 = $null
|
|
try {
|
|
$cfg = Get-SmbServerConfiguration -ErrorAction Stop
|
|
$smb1 = [bool]$cfg.EnableSMB1Protocol
|
|
} catch { $smb1 = $null }
|
|
$exposure['smb1_enabled'] = $smb1
|
|
if ($smb1 -eq $true) {
|
|
Add-Finding -Id 'sec.exposure.smb1' -Category 'security' -Severity 'critical' `
|
|
-Title 'SMBv1 is ENABLED' `
|
|
-Detail 'SMBv1 is an obsolete, insecure protocol (WannaCry/EternalBlue vector). Disable it: Set-SmbServerConfiguration -EnableSMB1Protocol $false and remove the SMB1 feature.' `
|
|
-Evidence 'Get-SmbServerConfiguration EnableSMB1Protocol=True'
|
|
} elseif ($smb1 -eq $false) {
|
|
Add-Finding -Id 'sec.exposure.smb1_off' -Category 'security' -Severity 'info' `
|
|
-Title 'SMBv1 disabled' `
|
|
-Detail 'SMBv1 server protocol is disabled.' `
|
|
-Evidence 'EnableSMB1Protocol=False'
|
|
}
|
|
|
|
# UAC (EnableLUA = 1 means UAC on)
|
|
$uac = $null
|
|
try {
|
|
$lua = (Get-ItemProperty -Path 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Policies\System' -Name 'EnableLUA' -ErrorAction Stop).EnableLUA
|
|
$uac = ($lua -eq 1)
|
|
} catch { $uac = $null }
|
|
$exposure['uac_enabled'] = $uac
|
|
if ($uac -eq $false) {
|
|
Add-Finding -Id 'sec.exposure.uac_off' -Category 'security' -Severity 'critical' `
|
|
-Title 'UAC is disabled' `
|
|
-Detail 'User Account Control is turned off (EnableLUA=0). All processes run elevated silently, removing a key barrier against malware. Re-enable UAC.' `
|
|
-Evidence 'EnableLUA=0'
|
|
}
|
|
|
|
# LAPS presence (legacy AdmPwd or Windows LAPS)
|
|
$lapsPresent = $false
|
|
$lapsEvidence = @()
|
|
try {
|
|
if (Test-Path 'HKLM:\Software\Microsoft\Windows\CurrentVersion\LAPS') { $lapsPresent = $true; $lapsEvidence += 'Windows LAPS reg key' }
|
|
} catch { }
|
|
try {
|
|
if (Test-Path 'HKLM:\Software\Policies\Microsoft Services\AdmPwd') { $lapsPresent = $true; $lapsEvidence += 'Legacy AdmPwd policy' }
|
|
} catch { }
|
|
try {
|
|
$lapsDll = "$env:ProgramFiles\LAPS\CSE\AdmPwd.dll"
|
|
if (Test-Path $lapsDll) { $lapsPresent = $true; $lapsEvidence += 'AdmPwd CSE installed' }
|
|
} catch { }
|
|
try {
|
|
$lapsSvc = Get-Service -Name 'LAPS' -ErrorAction SilentlyContinue
|
|
if ($lapsSvc) { $lapsPresent = $true; $lapsEvidence += 'LAPS service' }
|
|
} catch { }
|
|
$exposure['laps_present'] = $lapsPresent
|
|
|
|
if (-not $lapsPresent) {
|
|
Add-Finding -Id 'sec.exposure.no_laps' -Category 'security' -Severity 'info' `
|
|
-Title 'LAPS not detected' `
|
|
-Detail 'No LAPS (Windows LAPS or legacy AdmPwd) detected. Without LAPS, the local admin password is likely static/shared across the fleet. Consider deploying LAPS to randomize and escrow local admin passwords.' `
|
|
-Evidence 'No LAPS registry keys, CSE, or service found'
|
|
} else {
|
|
Add-Finding -Id 'sec.exposure.laps_present' -Category 'security' -Severity 'info' `
|
|
-Title 'LAPS detected' `
|
|
-Detail 'A LAPS mechanism is present.' `
|
|
-Evidence ($lapsEvidence -join '; ')
|
|
}
|
|
|
|
Set-Fact 'exposure' $exposure
|
|
}
|
|
|
|
# ===========================================================================
|
|
# HEALTH CHECKS
|
|
# ===========================================================================
|
|
|
|
# --- Disks: free space + SMART/reliability ---
|
|
Invoke-Check -Id 'health.disk_space' -Category 'health' -Title 'Disk free space' -Body {
|
|
$volRows = @()
|
|
$vols = $null
|
|
try { $vols = Get-Volume -ErrorAction Stop | Where-Object { $_.DriveType -eq 'Fixed' -and $_.Size -gt 0 } } catch { $vols = $null }
|
|
if ($null -eq $vols) {
|
|
# Fallback to Win32_LogicalDisk (DriveType 3 = fixed)
|
|
$ld = Get-CimInstance -ClassName Win32_LogicalDisk -Filter 'DriveType=3' -ErrorAction SilentlyContinue
|
|
foreach ($d in $ld) {
|
|
$size = [double]$d.Size
|
|
if ($size -le 0) { continue }
|
|
$free = [double]$d.FreeSpace
|
|
$pct = [math]::Round(($free / $size) * 100, 1)
|
|
$volRows += [pscustomobject]@{ Drive=[string]$d.DeviceID; FreeGB=[math]::Round($free/1GB,1); SizeGB=[math]::Round($size/1GB,1); FreePct=$pct }
|
|
}
|
|
} else {
|
|
foreach ($v in $vols) {
|
|
$size = [double]$v.Size
|
|
if ($size -le 0) { continue }
|
|
$free = [double]$v.SizeRemaining
|
|
$pct = [math]::Round(($free / $size) * 100, 1)
|
|
$letter = [string]$v.DriveLetter
|
|
$hasLetter = -not [string]::IsNullOrEmpty($letter)
|
|
if ($hasLetter) { $drv = $letter + ':' }
|
|
elseif (-not [string]::IsNullOrEmpty([string]$v.FileSystemLabel)) { $drv = '[' + [string]$v.FileSystemLabel + ']' }
|
|
else { $drv = '[unlabeled]' }
|
|
$volRows += [pscustomobject]@{ Drive=$drv; HasLetter=$hasLetter; FreeGB=[math]::Round($free/1GB,1); SizeGB=[math]::Round($size/1GB,1); FreePct=$pct }
|
|
}
|
|
}
|
|
|
|
Set-Fact 'volumes' ($volRows | ForEach-Object { @{ drive=$_.Drive; free_gb=$_.FreeGB; size_gb=$_.SizeGB; free_pct=$_.FreePct } })
|
|
|
|
foreach ($r in $volRows) {
|
|
# Only alert on lettered (user/data) volumes. Unlettered partitions
|
|
# (EFI, recovery, WinRE) are near-full by design and not actionable.
|
|
$rHasLetter = $true
|
|
try { $rHasLetter = [bool]$r.HasLetter } catch { $rHasLetter = $true }
|
|
if (-not $rHasLetter) { continue }
|
|
|
|
$ev = "$($r.Drive) free $($r.FreeGB) GB of $($r.SizeGB) GB ($($r.FreePct)%)"
|
|
# Finding id MUST be severity-independent (id keyed only on the drive, not
|
|
# the tier) so the runner's diff can see a warn->crit transition on the
|
|
# same drive as a REGRESSION rather than a resolved+new pair. The tier
|
|
# lives only in the -Severity field.
|
|
$driveKey = ($r.Drive -replace '[^A-Za-z0-9]','')
|
|
if ($r.FreePct -lt 8) {
|
|
Add-Finding -Id ("health.disk_space." + $driveKey) -Category 'health' -Severity 'critical' `
|
|
-Title ("Disk critically low: $($r.Drive) at $($r.FreePct)% free") `
|
|
-Detail 'Less than 8 percent free. Risk of failed updates, crashes, and corruption. Free space or expand the volume urgently.' `
|
|
-Evidence $ev
|
|
} elseif ($r.FreePct -lt 15) {
|
|
Add-Finding -Id ("health.disk_space." + $driveKey) -Category 'health' -Severity 'warning' `
|
|
-Title ("Disk low: $($r.Drive) at $($r.FreePct)% free") `
|
|
-Detail 'Less than 15 percent free. Plan cleanup or expansion.' `
|
|
-Evidence $ev
|
|
}
|
|
}
|
|
}
|
|
|
|
Invoke-Check -Id 'health.disk_smart' -Category 'health' -Title 'Physical disk health (SMART)' -Body {
|
|
$disks = $null
|
|
try { $disks = Get-PhysicalDisk -ErrorAction Stop } catch { $disks = $null }
|
|
if ($null -eq $disks) {
|
|
Add-Finding -Id 'health.disk_smart.unavailable' -Category 'health' -Severity 'unknown' `
|
|
-Title 'Physical disk health unavailable' `
|
|
-Detail 'Get-PhysicalDisk is unavailable (older OS / RAID controller hiding disks). Verify drive health via vendor tools.' `
|
|
-Evidence 'Get-PhysicalDisk returned null'
|
|
return
|
|
}
|
|
$diskFacts = @()
|
|
foreach ($d in $disks) {
|
|
$hs = [string]$d.HealthStatus
|
|
$model = [string]$d.FriendlyName
|
|
$diskFacts += @{ model=$model; health=$hs; media_type=[string]$d.MediaType }
|
|
|
|
# Reliability counters (best-effort; not all disks expose them)
|
|
$relEv = ''
|
|
try {
|
|
$rel = $d | Get-StorageReliabilityCounter -ErrorAction Stop
|
|
if ($rel) {
|
|
$relEv = "Wear=$($rel.Wear); ReadErrorsTotal=$($rel.ReadErrorsTotal); Temperature=$($rel.Temperature)"
|
|
}
|
|
} catch { }
|
|
|
|
if ($hs -and $hs -ne 'Healthy') {
|
|
# Severity-independent id (keyed on disk model only, not the health
|
|
# tier) so the runner diff treats a health change on the same disk as
|
|
# a regression, not new+resolved. Tier lives only in -Severity.
|
|
Add-Finding -Id ("health.disk_smart." + ($model -replace '[^A-Za-z0-9]+','_').ToLower()) -Category 'health' -Severity 'critical' `
|
|
-Title ("Disk not healthy: $model ($hs)") `
|
|
-Detail 'A physical disk reports a non-Healthy SMART/health status. Imminent failure risk. Back up immediately and plan replacement.' `
|
|
-Evidence (("HealthStatus=$hs; " + $relEv).Trim())
|
|
}
|
|
}
|
|
Set-Fact 'physical_disks' $diskFacts
|
|
}
|
|
|
|
# --- Stability: unexpected shutdowns, BSOD, disk errors (last 14 days) ---
|
|
Invoke-Check -Id 'health.stability' -Category 'health' -Title 'Stability events (14 days)' -Body {
|
|
$start = (Get-Date).AddDays(-14)
|
|
|
|
function Count-Events {
|
|
param([string]$LogName, [int[]]$Ids, [string]$Source)
|
|
$filter = @{ LogName = $LogName; StartTime = $start; Id = $Ids }
|
|
$cnt = 0
|
|
try {
|
|
$evts = Get-WinEvent -FilterHashtable $filter -ErrorAction Stop
|
|
if ($Source) { $evts = $evts | Where-Object { $_.ProviderName -eq $Source } }
|
|
$cnt = @($evts).Count
|
|
} catch {
|
|
# "No events found" throws on PS5.1; treat as zero.
|
|
$cnt = 0
|
|
}
|
|
return $cnt
|
|
}
|
|
|
|
$unexpected = Count-Events -LogName 'System' -Ids @(41)
|
|
$bugcheck = Count-Events -LogName 'System' -Ids @(1001) -Source 'Microsoft-Windows-WER-SystemErrorReporting'
|
|
if ($bugcheck -eq 0) { $bugcheck = Count-Events -LogName 'System' -Ids @(1001) -Source 'BugCheck' }
|
|
$diskErr = Count-Events -LogName 'System' -Ids @(7,51,153) -Source 'disk'
|
|
if ($diskErr -eq 0) { $diskErr = Count-Events -LogName 'System' -Ids @(7,51,153) }
|
|
|
|
Set-Fact 'stability_14d' @{
|
|
unexpected_shutdowns = $unexpected
|
|
bugchecks = $bugcheck
|
|
disk_errors = $diskErr
|
|
}
|
|
|
|
$report = "Unexpected shutdowns (id 41)=$unexpected; Bugchecks/BSOD (id 1001)=$bugcheck; Disk errors (id 7/51/153)=$diskErr"
|
|
|
|
if ($unexpected -ge 3 -or $bugcheck -ge 3 -or $diskErr -ge 3) {
|
|
Add-Finding -Id 'health.stability.recurring' -Category 'health' -Severity 'critical' `
|
|
-Title 'Recurring stability events in the last 14 days' `
|
|
-Detail 'Three or more of one event class (unexpected shutdown, BSOD, or disk error) in 14 days indicates a hardware or driver problem. Investigate memory, disk, PSU, and drivers.' `
|
|
-Evidence $report
|
|
} elseif ($unexpected -gt 0 -or $bugcheck -gt 0 -or $diskErr -gt 0) {
|
|
Add-Finding -Id 'health.stability.some' -Category 'health' -Severity 'warning' `
|
|
-Title 'Stability events present in the last 14 days' `
|
|
-Detail 'One or more unexpected shutdowns, BSODs, or disk errors occurred recently. Monitor and correlate with user reports.' `
|
|
-Evidence $report
|
|
} else {
|
|
Add-Finding -Id 'health.stability.clean' -Category 'health' -Severity 'info' `
|
|
-Title 'No stability events in the last 14 days' `
|
|
-Detail 'No unexpected shutdowns, BSODs, or disk errors logged.' `
|
|
-Evidence $report
|
|
}
|
|
}
|
|
|
|
# --- Pending reboot + uptime + failed auto-start services ---
|
|
Invoke-Check -Id 'health.reboot_uptime' -Category 'health' -Title 'Pending reboot and uptime' -Body {
|
|
$pending = $false
|
|
$reasons = @()
|
|
try { if (Test-Path 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') { $pending = $true; $reasons += 'CBS RebootPending' } } catch { }
|
|
try { if (Test-Path 'HKLM:\Software\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') { $pending = $true; $reasons += 'WU RebootRequired' } } catch { }
|
|
try {
|
|
$pfro = (Get-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Session Manager' -Name 'PendingFileRenameOperations' -ErrorAction Stop).PendingFileRenameOperations
|
|
if ($pfro) { $pending = $true; $reasons += 'PendingFileRenameOperations' }
|
|
} catch { }
|
|
Set-Fact 'pending_reboot' $pending
|
|
|
|
if ($pending) {
|
|
Add-Finding -Id 'health.reboot_uptime.pending' -Category 'health' -Severity 'warning' `
|
|
-Title 'Reboot pending' `
|
|
-Detail 'A reboot is pending. Pending reboots can block patches and leave the system in a half-updated state. Schedule a restart.' `
|
|
-Evidence ($reasons -join '; ')
|
|
}
|
|
|
|
# Uptime
|
|
try {
|
|
$lastBoot = (Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop).LastBootUpTime
|
|
$uptimeDays = [math]::Round(((Get-Date) - $lastBoot).TotalDays, 1)
|
|
Set-Fact 'uptime_days' $uptimeDays
|
|
if ($uptimeDays -gt 30) {
|
|
Add-Finding -Id 'health.reboot_uptime.long_uptime' -Category 'health' -Severity 'warning' `
|
|
-Title ("Uptime is $uptimeDays days") `
|
|
-Detail 'Uptime exceeds 30 days. Long uptime usually means pending updates have not been applied (reboots deferred). Schedule maintenance.' `
|
|
-Evidence ("LastBootUpTime=" + $lastBoot.ToString('u'))
|
|
}
|
|
} catch { }
|
|
}
|
|
|
|
Invoke-Check -Id 'health.failed_services' -Category 'health' -Title 'Failed auto-start services' -Body {
|
|
$failed = @()
|
|
try {
|
|
$svcs = Get-CimInstance -ClassName Win32_Service -ErrorAction Stop |
|
|
Where-Object { $_.StartMode -eq 'Auto' -and $_.State -ne 'Running' }
|
|
foreach ($s in $svcs) {
|
|
# Ignore services with delayed start that are simply not yet started? Keep them; they should be running.
|
|
$failed += [pscustomobject]@{ Name=[string]$s.Name; Display=[string]$s.DisplayName; State=[string]$s.State }
|
|
}
|
|
} catch { }
|
|
|
|
# Filter out known benign trigger-start services masquerading as Auto in some images.
|
|
$benign = 'gupdate|gupdatem|MapsBroker|RemoteRegistry|sppsvc|edgeupdate|edgeupdatem|cbdhsvc|WbioSrvc'
|
|
$real = @()
|
|
foreach ($f in $failed) {
|
|
if ($f.Name -notmatch $benign) { $real += $f }
|
|
}
|
|
Set-Fact 'failed_autostart_services' ($real | ForEach-Object { @{ name=$_.Name; display=$_.Display; state=$_.State } })
|
|
|
|
if ($real.Count -gt 0) {
|
|
$list = ($real | ForEach-Object { "$($_.Name) ($($_.Display)) = $($_.State)" }) -join "`n"
|
|
Add-Finding -Id 'health.failed_services.stopped' -Category 'health' -Severity 'warning' `
|
|
-Title ($real.Count.ToString() + ' auto-start service(s) not running') `
|
|
-Detail 'These services are set to start automatically but are not running. Some may be benign; review for security agents, backup agents, or AV that should be running.' `
|
|
-Evidence $list
|
|
} else {
|
|
Add-Finding -Id 'health.failed_services.ok' -Category 'health' -Severity 'info' `
|
|
-Title 'All auto-start services running' `
|
|
-Detail 'No automatic-start services found stopped (excluding known trigger-start/update services).' `
|
|
-Evidence 'Win32_Service StartMode=Auto State!=Running -> none significant'
|
|
}
|
|
}
|
|
|
|
# --- Domain secure channel + time skew ---
|
|
Invoke-Check -Id 'health.domain' -Category 'health' -Title 'Domain membership and secure channel' -Body {
|
|
$cs = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop
|
|
$partOfDomain = [bool]$cs.PartOfDomain
|
|
$domain = [string]$cs.Domain
|
|
Set-Fact 'domain_joined' $partOfDomain
|
|
Set-Fact 'domain' $domain
|
|
|
|
if ($partOfDomain) {
|
|
$scOk = $null
|
|
try { $scOk = Test-ComputerSecureChannel -ErrorAction Stop } catch { $scOk = $null }
|
|
Set-Fact 'secure_channel_ok' $scOk
|
|
if ($scOk -eq $false) {
|
|
Add-Finding -Id 'health.domain.secure_channel_broken' -Category 'health' -Severity 'critical' `
|
|
-Title 'Domain secure channel is BROKEN' `
|
|
-Detail 'Test-ComputerSecureChannel returned false. The machine trust relationship with the domain is broken (Group Policy, Kerberos, and domain logon will fail). Repair with Test-ComputerSecureChannel -Repair or rejoin.' `
|
|
-Evidence "PartOfDomain=True; Test-ComputerSecureChannel=False; Domain=$domain"
|
|
} elseif ($scOk -eq $true) {
|
|
Add-Finding -Id 'health.domain.secure_channel_ok' -Category 'health' -Severity 'info' `
|
|
-Title 'Domain secure channel healthy' `
|
|
-Detail 'Machine trust relationship with the domain is intact.' `
|
|
-Evidence "Domain=$domain"
|
|
}
|
|
} else {
|
|
Add-Finding -Id 'health.domain.workgroup' -Category 'health' -Severity 'info' `
|
|
-Title 'Not domain-joined (workgroup)' `
|
|
-Detail ('This machine is in workgroup/Azure AD only mode (Domain=' + $domain + '). No on-prem AD secure channel applies.') `
|
|
-Evidence "PartOfDomain=False; Domain=$domain"
|
|
}
|
|
}
|
|
|
|
Invoke-Check -Id 'health.time' -Category 'health' -Title 'Time service status' -Body {
|
|
# w32tm /query /status is non-blocking (unlike /stripchart). Parse leniently.
|
|
$statusText = ''
|
|
try { $statusText = (w32tm /query /status 2>&1 | Out-String) } catch { $statusText = '' }
|
|
$source = ''
|
|
try {
|
|
$src = (w32tm /query /source 2>&1 | Out-String).Trim()
|
|
if ($src) { $source = $src }
|
|
} catch { }
|
|
Set-Fact 'time_source' $source
|
|
|
|
if ($statusText -match 'Local CMOS Clock' -or $source -match 'Local CMOS Clock') {
|
|
Add-Finding -Id 'health.time.local_cmos' -Category 'health' -Severity 'warning' `
|
|
-Title 'Time source is local CMOS clock (not NTP)' `
|
|
-Detail 'The system is not syncing time from an NTP source. Clock drift breaks Kerberos and certificate validation. Configure a reliable time source (domain hierarchy or pool.ntp.org).' `
|
|
-Evidence ("Source=$source")
|
|
} else {
|
|
Add-Finding -Id 'health.time.source' -Category 'health' -Severity 'info' `
|
|
-Title 'Time service source' `
|
|
-Detail 'Current Windows Time service source.' `
|
|
-Evidence ("Source=$source")
|
|
}
|
|
}
|
|
|
|
# --- Battery health (laptops) ---
|
|
Invoke-Check -Id 'health.battery' -Category 'health' -Title 'Battery health' -Body {
|
|
if (-not $script:IsLaptop) {
|
|
Set-Fact 'battery' @{ present = $false }
|
|
return
|
|
}
|
|
$bat = Get-CimInstance -ClassName Win32_Battery -ErrorAction SilentlyContinue
|
|
if ($null -eq $bat) {
|
|
Set-Fact 'battery' @{ present = $false }
|
|
Add-Finding -Id 'health.battery.none' -Category 'health' -Severity 'info' `
|
|
-Title 'Laptop chassis but no battery reported' `
|
|
-Detail 'Chassis indicates a portable device but Win32_Battery returned nothing (battery removed, or detection failed).' `
|
|
-Evidence 'Win32_Battery returned null'
|
|
return
|
|
}
|
|
$b = $bat | Select-Object -First 1
|
|
$charge = ''
|
|
try { $charge = [string]$b.EstimatedChargeRemaining } catch { }
|
|
Set-Fact 'battery' @{ present = $true; estimated_charge_remaining = $charge; status = [string]$b.BatteryStatus }
|
|
Add-Finding -Id 'health.battery.present' -Category 'health' -Severity 'info' `
|
|
-Title 'Battery present' `
|
|
-Detail 'Battery detected. (Wear-level / design-vs-full-capacity requires a powercfg battery report, not collected here.)' `
|
|
-Evidence ("EstimatedChargeRemaining=$charge%; BatteryStatus=$($b.BatteryStatus)")
|
|
}
|
|
|
|
# --- Backup agent presence ---
|
|
Invoke-Check -Id 'health.backup' -Category 'health' -Title 'Backup agent presence' -Body {
|
|
# Anchor with \b to avoid substring false positives (e.g. bare 'cove'
|
|
# matched 'Recovery'/'Discovery' service display names).
|
|
$patterns = @(
|
|
@{ rx='datto.*workplace|datto.*backup'; label='Datto Workplace' },
|
|
@{ rx='\bmsp360\b|\bcloudberry\b'; label='MSP360 / CloudBerry' },
|
|
@{ rx='\bveeam\b'; label='Veeam' },
|
|
@{ rx='\bacronis\b'; label='Acronis' },
|
|
@{ rx='\bcarbonite\b'; label='Carbonite' },
|
|
@{ rx='\bbackblaze\b'; label='Backblaze' },
|
|
@{ rx='shadowprotect|storagecraft'; label='ShadowProtect / StorageCraft' },
|
|
@{ rx='\baxcient\b|\breplibit\b|\bx360\b'; label='Axcient' },
|
|
@{ rx='\bcove\b|n-able backup'; label='Cove / N-able Backup' },
|
|
@{ rx='\bveritas\b|backup exec|\bbackupexec\b'; label='Veritas / Backup Exec' },
|
|
@{ rx='datto.*siris|datto.*alto|\bsiris\b'; label='Datto SIRIS/ALTO agent' },
|
|
@{ rx='comet backup|\bcometbackup\b|\bcomet-backup\b'; label='Comet Backup' },
|
|
@{ rx='\bdruva\b|\binsync\b'; label='Druva' }
|
|
)
|
|
$svcRows = @()
|
|
try { $svcRows = Get-CimInstance -ClassName Win32_Service -ErrorAction Stop | Select-Object Name, DisplayName, State } catch { }
|
|
|
|
$found = @()
|
|
foreach ($pat in $patterns) {
|
|
foreach ($s in $svcRows) {
|
|
$sn = [string]$s.Name; $sd = [string]$s.DisplayName
|
|
if ($sn -match $pat.rx -or $sd -match $pat.rx) {
|
|
$found += [pscustomobject]@{ Label=$pat.label; Service=$sn; Display=$sd; State=[string]$s.State }
|
|
}
|
|
}
|
|
}
|
|
Set-Fact 'backup_agents' ($found | ForEach-Object { @{ label=$_.Label; service=$_.Service; state=$_.State } })
|
|
|
|
if (@($found).Count -gt 0) {
|
|
$running = @($found | Where-Object { $_.State -eq 'Running' })
|
|
$list = ($found | ForEach-Object { "$($_.Label): $($_.Service) = $($_.State)" }) -join "`n"
|
|
if ($running.Count -gt 0) {
|
|
Add-Finding -Id 'health.backup.present' -Category 'health' -Severity 'info' `
|
|
-Title 'Backup agent installed and running' `
|
|
-Detail 'A backup agent service is present and running. Confirm the backup is actually configured and reporting successful jobs (presence != working backup).' `
|
|
-Evidence $list
|
|
} else {
|
|
Add-Finding -Id 'health.backup.stopped' -Category 'health' -Severity 'warning' `
|
|
-Title 'Backup agent installed but NOT running' `
|
|
-Detail 'A backup agent service exists but is not running. Backups may not be occurring. Investigate.' `
|
|
-Evidence $list
|
|
}
|
|
} else {
|
|
Add-Finding -Id 'health.backup.none' -Category 'health' -Severity 'info' `
|
|
-Title 'No backup agent detected' `
|
|
-Detail 'No known backup agent service found. Backup expectation varies by endpoint; confirm whether this machine is supposed to have local/cloud backup and whether server-side or M365 backup covers it.' `
|
|
-Evidence 'No matching backup service in Win32_Service'
|
|
}
|
|
}
|
|
|
|
# ===========================================================================
|
|
# INVENTORY BASELINE (facts, info-level)
|
|
# ===========================================================================
|
|
|
|
Invoke-Check -Id 'inv.hardware' -Category 'inventory' -Title 'Hardware inventory' -Body {
|
|
$cs = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction SilentlyContinue
|
|
$bios = Get-CimInstance -ClassName Win32_BIOS -ErrorAction SilentlyContinue
|
|
$cpu = Get-CimInstance -ClassName Win32_Processor -ErrorAction SilentlyContinue | Select-Object -First 1
|
|
|
|
$hw = @{}
|
|
if ($cs) {
|
|
$hw['manufacturer'] = [string]$cs.Manufacturer
|
|
$hw['model'] = [string]$cs.Model
|
|
$hw['ram_gb'] = [math]::Round(([double]$cs.TotalPhysicalMemory)/1GB, 1)
|
|
}
|
|
if ($bios) {
|
|
$hw['serial'] = [string]$bios.SerialNumber
|
|
$hw['bios_version'] = [string]($bios.SMBIOSBIOSVersion)
|
|
try { $hw['bios_date'] = $bios.ReleaseDate.ToUniversalTime().ToString('yyyy-MM-dd') } catch { }
|
|
}
|
|
if ($cpu) {
|
|
$hw['cpu'] = [string]$cpu.Name
|
|
$hw['cpu_cores'] = [int]$cpu.NumberOfCores
|
|
$hw['cpu_logical']= [int]$cpu.NumberOfLogicalProcessors
|
|
}
|
|
Set-Fact 'hardware' $hw
|
|
}
|
|
|
|
Invoke-Check -Id 'inv.tpm_secureboot' -Category 'inventory' -Title 'TPM and Secure Boot' -Body {
|
|
$tpm = @{}
|
|
try {
|
|
$t = Get-Tpm -ErrorAction Stop
|
|
$tpm['present'] = [bool]$t.TpmPresent
|
|
$tpm['ready'] = [bool]$t.TpmReady
|
|
$tpm['enabled'] = [bool]$t.TpmEnabled
|
|
} catch { $tpm['present'] = $null; $tpm['error'] = 'Get-Tpm unavailable' }
|
|
Set-Fact 'tpm' $tpm
|
|
|
|
$sb = $null
|
|
try { $sb = Confirm-SecureBootUEFI -ErrorAction Stop } catch { $sb = $null }
|
|
Set-Fact 'secure_boot' $sb
|
|
}
|
|
|
|
Invoke-Check -Id 'inv.activation' -Category 'inventory' -Title 'OS edition and activation' -Body {
|
|
$lic = $null
|
|
try {
|
|
$lic = Get-CimInstance -ClassName SoftwareLicensingProduct -ErrorAction Stop |
|
|
Where-Object { $_.PartialProductKey -and $_.ApplicationID -eq '55c92734-d682-4d71-983e-d6ec3f16059f' } |
|
|
Select-Object -First 1
|
|
} catch { $lic = $null }
|
|
$act = @{}
|
|
if ($lic) {
|
|
$st = [int]$lic.LicenseStatus # 1 = licensed
|
|
$act['license_status_code'] = $st
|
|
$act['licensed'] = ($st -eq 1)
|
|
$act['description'] = [string]$lic.Description
|
|
} else {
|
|
$act['licensed'] = $null
|
|
}
|
|
$act['edition'] = $script:OsInfo.caption
|
|
Set-Fact 'activation' $act
|
|
}
|
|
|
|
Invoke-Check -Id 'inv.software' -Category 'inventory' -Title 'Installed software' -Body {
|
|
$sw = @()
|
|
$paths = @(
|
|
'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
|
|
'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
|
|
)
|
|
foreach ($p in $paths) {
|
|
try {
|
|
$items = Get-ItemProperty $p -ErrorAction SilentlyContinue
|
|
foreach ($it in $items) {
|
|
if ($it.DisplayName) {
|
|
$sw += [pscustomobject]@{
|
|
name = [string]$it.DisplayName
|
|
version = [string]$it.DisplayVersion
|
|
publisher = [string]$it.Publisher
|
|
}
|
|
}
|
|
}
|
|
} catch { }
|
|
}
|
|
# De-dup by name+version, sort by name.
|
|
$uniq = $sw | Sort-Object name, version -Unique
|
|
Set-Fact 'installed_software' ($uniq | ForEach-Object { @{ name=$_.name; version=$_.version; publisher=$_.publisher } })
|
|
Set-Fact 'installed_software_count' @($uniq).Count
|
|
}
|
|
|
|
Invoke-Check -Id 'inv.users' -Category 'inventory' -Title 'Local users and groups' -Body {
|
|
$users = @()
|
|
try {
|
|
foreach ($u in (Get-LocalUser -ErrorAction Stop)) {
|
|
$ll = ''
|
|
try { if ($u.LastLogon) { $ll = $u.LastLogon.ToString('yyyy-MM-dd') } } catch { }
|
|
$users += @{
|
|
name = [string]$u.Name
|
|
enabled = [bool]$u.Enabled
|
|
password_never_expires= [bool]$u.PasswordNeverExpires
|
|
last_logon = $ll
|
|
}
|
|
}
|
|
} catch { }
|
|
Set-Fact 'local_users' $users
|
|
|
|
$groups = @()
|
|
try {
|
|
foreach ($g in (Get-LocalGroup -ErrorAction Stop)) { $groups += [string]$g.Name }
|
|
} catch { }
|
|
Set-Fact 'local_groups' $groups
|
|
}
|
|
|
|
Invoke-Check -Id 'inv.network' -Category 'inventory' -Title 'Network configuration' -Body {
|
|
$net = @()
|
|
try {
|
|
$cfgs = Get-CimInstance -ClassName Win32_NetworkAdapterConfiguration -Filter 'IPEnabled=True' -ErrorAction Stop
|
|
foreach ($c in $cfgs) {
|
|
$net += @{
|
|
description = [string]$c.Description
|
|
mac = [string]$c.MACAddress
|
|
ip = @($c.IPAddress)
|
|
gateway = @($c.DefaultIPGateway)
|
|
dns = @($c.DNSServerSearchOrder)
|
|
dhcp = [bool]$c.DHCPEnabled
|
|
}
|
|
}
|
|
} catch { }
|
|
Set-Fact 'network_adapters' $net
|
|
}
|
|
|
|
Invoke-Check -Id 'inv.autoruns' -Category 'inventory' -Title 'Autoruns (Run keys) and scheduled tasks' -Body {
|
|
# Run keys (HKLM + HKCU + WOW6432Node)
|
|
$runEntries = @()
|
|
$runKeys = @(
|
|
'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run',
|
|
'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Run',
|
|
'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run',
|
|
'HKLM:\Software\Microsoft\Windows\CurrentVersion\RunOnce'
|
|
)
|
|
foreach ($rk in $runKeys) {
|
|
try {
|
|
if (Test-Path $rk) {
|
|
$props = Get-ItemProperty -Path $rk -ErrorAction SilentlyContinue
|
|
foreach ($prop in $props.PSObject.Properties) {
|
|
if ($prop.Name -notmatch '^PS') {
|
|
$runEntries += @{ key=$rk; name=$prop.Name; value=[string]$prop.Value }
|
|
}
|
|
}
|
|
}
|
|
} catch { }
|
|
}
|
|
Set-Fact 'autoruns_run_keys' $runEntries
|
|
|
|
# Scheduled tasks (non-Microsoft, enabled) snapshot
|
|
$taskList = @()
|
|
try {
|
|
$tasks = Get-ScheduledTask -ErrorAction Stop |
|
|
Where-Object { $_.State -ne 'Disabled' -and $_.TaskPath -notmatch '^\\Microsoft\\' }
|
|
foreach ($t in $tasks) {
|
|
$taskList += @{ name=[string]$t.TaskName; path=[string]$t.TaskPath; state=[string]$t.State }
|
|
}
|
|
} catch { }
|
|
Set-Fact 'scheduled_tasks' $taskList
|
|
Set-Fact 'scheduled_tasks_count' @($taskList).Count
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Assemble and emit
|
|
# ---------------------------------------------------------------------------
|
|
$report = [ordered]@{
|
|
host = $HostName
|
|
collected_at_utc = $CollectedAtUtc
|
|
os = $OsInfo
|
|
facts = $Facts
|
|
findings = @($Findings)
|
|
}
|
|
|
|
$json = ''
|
|
try {
|
|
$json = $report | ConvertTo-Json -Depth 12 -Compress
|
|
} catch {
|
|
# Last-ditch fallback: emit a minimal valid object so the runner never gets garbage.
|
|
$errMsg = ''
|
|
try { $errMsg = $_.Exception.Message } catch { }
|
|
$fallback = [ordered]@{
|
|
host = $HostName
|
|
collected_at_utc = $CollectedAtUtc
|
|
os = $OsInfo
|
|
facts = @{}
|
|
findings = @(
|
|
[ordered]@{ id='probe.serialize_error'; category='probe'; severity='unknown';
|
|
title='JSON serialization failed';
|
|
detail='The probe ran but the result could not be serialized to JSON. Re-run.';
|
|
evidence=$errMsg }
|
|
)
|
|
}
|
|
$json = $fallback | ConvertTo-Json -Depth 6 -Compress
|
|
}
|
|
|
|
# Strip any non-ASCII that may have crept in (defensive; replace with '?').
|
|
$sb = New-Object System.Text.StringBuilder
|
|
foreach ($ch in $json.ToCharArray()) {
|
|
if ([int]$ch -ge 32 -and [int]$ch -le 126) { [void]$sb.Append($ch) }
|
|
elseif ([int]$ch -eq 9 -or [int]$ch -eq 10 -or [int]$ch -eq 13) { [void]$sb.Append($ch) }
|
|
else { [void]$sb.Append('?') }
|
|
}
|
|
$json = $sb.ToString()
|
|
|
|
Write-Output '===DIAG-JSON-START==='
|
|
Write-Output $json
|
|
Write-Output '===DIAG-JSON-END==='
|