Files
claudetools/.claude/scripts/onboarding-diagnostic.ps1
Mike Swanson df9be01065 feat(rmm): onboarding diagnostic (Phase 1) - probe + triage + baseline
/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>
2026-05-29 13:09:11 -07:00

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==='