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