From 71c9ddce9ee53e8680d21de00cac4b5089d06ee3 Mon Sep 17 00:00:00 2001 From: Howard Enos Date: Fri, 17 Apr 2026 14:10:21 -0700 Subject: [PATCH] sync: auto-sync from ACG-TECH03L at 2026-04-17 14:10:20 Author: Howard Enos Machine: ACG-TECH03L Timestamp: 2026-04-17 14:10:20 --- .../msp-audit-scripts/workstation_audit.ps1 | 145 ++++++++++++++---- 1 file changed, 116 insertions(+), 29 deletions(-) diff --git a/projects/msp-tools/msp-audit-scripts/workstation_audit.ps1 b/projects/msp-tools/msp-audit-scripts/workstation_audit.ps1 index b19c75d..fbcaa03 100644 --- a/projects/msp-tools/msp-audit-scripts/workstation_audit.ps1 +++ b/projects/msp-tools/msp-audit-scripts/workstation_audit.ps1 @@ -1,8 +1,23 @@ -# ========================================== +# ============================================================ # UNIVERSAL WORKSTATION AUDIT SCRIPT -# Works on Windows 10 / 11 (domain or workgroup) -# Outputs: HOSTNAME_workstation_audit_DATE.json to C:\Temp -# ========================================== +# +# Script Version : 2.0.0 +# Schema Version : 2.0 (output JSON shape; bump on breaking changes) +# Build Date : 2026-04-17 +# Sections : 49 (originals 1-33; security/diag additions 34-49) +# Compatibility : Windows 10 21H2 -> Windows 11 25H2 (admin required) +# +# Outputs : C:\Temp\_workstation_audit_.json +# (UTF-8, ConvertTo-Json depth 10) +# +# Each top-level JSON key is one of: _metadata, _errors, then one +# property per section in execution order. Section names are stable; +# never rename without bumping Schema Version. +# ============================================================ + +$ScriptVersion = "2.0.1" +$ScriptSchemaVersion = "2.0" +$ScriptBuildDate = "2026-04-17" # Auto-relaunch with ExecutionPolicy Bypass if needed if ($MyInvocation.MyCommand.Path) { @@ -27,22 +42,37 @@ $JsonFile = "$OutputDir\${Name}_workstation_audit_$Date.json" if (!(Test-Path $OutputDir)) { New-Item -ItemType Directory -Path $OutputDir | Out-Null } +# Self-SHA (file-on-disk runs only; iex-streamed runs report "iex-streamed") +$ScriptSelfSHA256 = if ($MyInvocation.MyCommand.Path -and (Test-Path $MyInvocation.MyCommand.Path)) { + try { (Get-FileHash -Path $MyInvocation.MyCommand.Path -Algorithm SHA256 -ErrorAction Stop).Hash } + catch { "sha-computation-failed" } +} else { "iex-streamed" } + # Structured data collector $audit = [ordered]@{ _metadata = [ordered]@{ - ScriptVersion = "1.0" - ScriptType = "Workstation" - RunDate = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") - RunBy = "$env:USERDOMAIN\$env:USERNAME" - Hostname = $env:COMPUTERNAME + ScriptVersion = $ScriptVersion + ScriptSchemaVersion = $ScriptSchemaVersion + ScriptBuildDate = $ScriptBuildDate + ScriptSelfSHA256 = $ScriptSelfSHA256 + ScriptType = "Workstation" + SectionCount = 49 + RunDate = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") + RunDateUtc = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") + RunBy = "$env:USERDOMAIN\$env:USERNAME" + Hostname = $env:COMPUTERNAME + OSCaption = (Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction SilentlyContinue).Caption + OSBuild = (Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction SilentlyContinue).BuildNumber + PowerShellVersion = "$($PSVersionTable.PSVersion)" } _errors = @() } -Write-Host "=======================================" -Write-Host " UNIVERSAL WORKSTATION AUDIT v1.0" -Write-Host " $((Get-Date).ToString('yyyy-MM-dd HH:mm:ss'))" -Write-Host "=======================================" +Write-Host "=============================================" +Write-Host " UNIVERSAL WORKSTATION AUDIT v$ScriptVersion (schema $ScriptSchemaVersion)" +Write-Host " Build $ScriptBuildDate - $($audit._metadata.SectionCount) sections" +Write-Host " $($audit._metadata.RunDate)" +Write-Host "=============================================" Write-Host "" # ===================================================== @@ -1292,10 +1322,10 @@ try { NISEngineVersion = "$($mp.NISEngineVersion)" AntispywareSignatureLastUpdated = if ($mp.AntispywareSignatureLastUpdated) { $mp.AntispywareSignatureLastUpdated.ToString("yyyy-MM-dd HH:mm:ss") } else { $null } AntivirusSignatureLastUpdated = if ($mp.AntivirusSignatureLastUpdated) { $mp.AntivirusSignatureLastUpdated.ToString("yyyy-MM-dd HH:mm:ss") } else { $null } - AntivirusSignatureAgeDays = $mp.AntivirusSignatureAge - NISSignatureAgeDays = $mp.NISSignatureAge - FullScanAgeDays = $mp.FullScanAge - QuickScanAgeDays = $mp.QuickScanAge + AntivirusSignatureAgeDays = if ($mp.AntivirusSignatureAge -eq 4294967295) { $null } else { $mp.AntivirusSignatureAge } + NISSignatureAgeDays = if ($mp.NISSignatureAge -eq 4294967295) { $null } else { $mp.NISSignatureAge } + FullScanAgeDays = if ($mp.FullScanAge -eq 4294967295) { $null } else { $mp.FullScanAge } + QuickScanAgeDays = if ($mp.QuickScanAge -eq 4294967295) { $null } else { $mp.QuickScanAge } FullScanEndTime = if ($mp.FullScanEndTime) { $mp.FullScanEndTime.ToString("yyyy-MM-dd HH:mm:ss") } else { $null } QuickScanEndTime = if ($mp.QuickScanEndTime) { $mp.QuickScanEndTime.ToString("yyyy-MM-dd HH:mm:ss") } else { $null } IsTamperProtected = if ($mp.PSObject.Properties.Match('IsTamperProtected').Count) { [bool]$mp.IsTamperProtected } else { $null } @@ -1419,6 +1449,12 @@ try { if ($def.Status.QuickScanAgeDays -ne $null -and $def.Status.QuickScanAgeDays -gt 14) { $audit.SecuritySummary += "Defender quick scan stale ($($def.Status.QuickScanAgeDays)d)" } + if ($def.Status.QuickScanAgeDays -eq $null) { + $audit.SecuritySummary += "Defender quick scan has never run" + } + if ($def.Status.FullScanAgeDays -eq $null) { + $audit.SecuritySummary += "Defender full scan has never run" + } if ($def.ControlledFolderAccess.EnableControlledFolderAccess -eq "0") { $audit.SecuritySummary += "Controlled Folder Access (anti-ransomware) disabled" } @@ -1584,6 +1620,13 @@ Write-Host "=== 38. SERVICES INVENTORY ===" -ForegroundColor Cyan try { $allSvc = @(Get-CimInstance -ClassName Win32_Service -ErrorAction SilentlyContinue) $suspSvc = @() + # Allowlist for known-good Microsoft / vendor paths under ProgramData (auto-updating engines etc.) + $svcAllowlistPatterns = @( + '(?i)^[A-Z]:\\ProgramData\\Microsoft\\Windows Defender\\Platform\\', + '(?i)^[A-Z]:\\ProgramData\\Microsoft\\Windows Defender\\Definition Updates\\', + '(?i)^[A-Z]:\\ProgramData\\Package Cache\\', + '(?i)^[A-Z]:\\ProgramData\\Microsoft\\Click[Tt]o[Rr]un\\' + ) foreach ($s in $allSvc) { $reasons = @() $path = "$($s.PathName)" @@ -1593,16 +1636,20 @@ try { elseif ($path -match '^(\S+\.exe)') { $exe = $matches[1] } else { $exe = ($path -split '\s')[0] } + # Check against allowlist + $isAllowed = $false + foreach ($pat in $svcAllowlistPatterns) { if ($exe -match $pat) { $isAllowed = $true; break } } + # Path with spaces but not quoted (privilege-escalation classic) if ($path -and $path -notmatch '^"' -and $path -match '\s' -and $path -match '^([A-Z]:\\[^"]+\s+[^"]+\.exe)') { $reasons += "Unquoted path with spaces" } - # Binary in user-writable dir - if ($exe -match '(?i)\\(users|programdata|temp|public|appdata)\\') { + # Binary in user-writable dir (skip if allowlisted) + if (-not $isAllowed -and $exe -match '(?i)\\(users|programdata|temp|public|appdata)\\') { $reasons += "Binary in user-writable directory: $exe" } - # Not in standard system paths - if ($exe -and $exe -notmatch '(?i)^[A-Z]:\\(Windows|Program Files|Program Files \(x86\))') { + # Not in standard system paths (skip if allowlisted) + if (-not $isAllowed -and $exe -and $exe -notmatch '(?i)^[A-Z]:\\(Windows|Program Files|Program Files \(x86\))') { if ($exe -notmatch '(?i)^[A-Z]:\\Users') { $reasons += "Binary outside standard system paths: $exe" } @@ -2744,21 +2791,61 @@ try { # ===================================================== # DONE - SAVE JSON +# (Banner moved to AFTER save so completion message reflects truth. +# ConvertTo-Json on PS 5.1 is slow on multi-MB hashtables; we time it, +# use [IO.File]::WriteAllText for fast write, and provide a per-section +# fallback if the monolithic serialize fails.) # ===================================================== Write-Host "" -Write-Host "=======================================" -Write-Host " WORKSTATION AUDIT COMPLETE" -Write-Host "=======================================" +Write-Host "[INFO] All section data collected. Serializing to JSON (can take 30-90s on large profiles)..." -ForegroundColor Cyan +$saveStart = Get-Date +$saveOk = $false +$saveErr = "" +try { + $jsonText = $audit | ConvertTo-Json -Depth 8 -ErrorAction Stop + [System.IO.File]::WriteAllText($JsonFile, $jsonText, [System.Text.UTF8Encoding]::new($false)) + $saveOk = $true + $saveDuration = ((Get-Date) - $saveStart).TotalSeconds + $jsonSizeKB = [math]::Round((Get-Item $JsonFile).Length / 1KB, 1) + Write-Host "[OK] JSON written: $JsonFile ($jsonSizeKB KB in $([math]::Round($saveDuration,1))s)" -ForegroundColor Green +} catch { + $saveErr = $_.Exception.Message + Write-Host "[ERROR] Monolithic JSON serialize/write failed: $saveErr" -ForegroundColor Red + Write-Host "[INFO] Attempting per-section fallback export..." -ForegroundColor Yellow + try { + $fallbackDir = ($JsonFile -replace '\.json$','') + "_partial" + New-Item -ItemType Directory -Path $fallbackDir -Force | Out-Null + $secOk = 0; $secFail = 0 + foreach ($k in @($audit.Keys)) { + $partPath = Join-Path $fallbackDir "$k.json" + try { + $partText = $audit[$k] | ConvertTo-Json -Depth 8 -ErrorAction Stop + [System.IO.File]::WriteAllText($partPath, $partText, [System.Text.UTF8Encoding]::new($false)) + $secOk++ + } catch { + [System.IO.File]::WriteAllText((Join-Path $fallbackDir "$k.ERROR.txt"), "Failed: $($_.Exception.Message)", [System.Text.UTF8Encoding]::new($false)) + $secFail++ + } + } + Write-Host "[OK] Fallback complete: $secOk sections saved, $secFail failed. Folder: $fallbackDir" -ForegroundColor Yellow + } catch { + Write-Host "[ERROR] Per-section fallback also failed: $($_.Exception.Message)" -ForegroundColor Red + } +} +Write-Host "" +Write-Host "=======================================" +if ($saveOk) { + Write-Host " WORKSTATION AUDIT COMPLETE" +} else { + Write-Host " WORKSTATION AUDIT FINISHED (SAVE ISSUE)" +} +Write-Host "=======================================" $errorCount = $audit._errors.Count if ($errorCount -gt 0) { - Write-Host "Completed with $errorCount section errors (see _errors in JSON)" -ForegroundColor Yellow + Write-Host "Section errors: $errorCount (see _errors in JSON)" -ForegroundColor Yellow } else { Write-Host "All sections completed successfully" -ForegroundColor Green } - Write-Host "JSON data: $JsonFile" Write-Host "=======================================" - -# Save JSON -$audit | ConvertTo-Json -Depth 10 | Out-File $JsonFile -Encoding UTF8