Introduction: The Automation Imperative
An MSP managing 2,000 endpoints cannot afford for every routine task to require manual technician intervention. The math does not work. At even one minute of technician time per device per week for routine tasks, you are consuming 33 technician-hours weekly — nearly a full-time role — on work that should be automated.
PowerShell is the automation backbone of Windows environments. It gives you direct access to every OS component, every installed application, Active Directory, Microsoft 365, and thousands of third-party tools. A technician who is fluent in PowerShell can automate in an hour what would otherwise take a week of manual work.
In this guide, I have compiled 25 production-ready scripts that I use in real MSP environments — organized by category, with deployment instructions for RMM platforms, and notes on customization.
Security note: All scripts in this guide should be: (1) stored in a secure script library with version control, (2) deployed only via authenticated RMM channels, (3) code-signed if your environment requires signed scripts, and (4) tested in a non-production environment before deployment. Scripts with SYSTEM-level execution require particular care.
Prerequisites: PowerShell Best Practices
Before running scripts at scale via RMM, establish these practices:
Execution Policy: Most RMM platforms execute scripts as SYSTEM with a -ExecutionPolicy Bypass flag. In environments with security hardening, sign your scripts with a code signing certificate.
Error Handling: All production scripts should include error handling. The pattern:
try {
# Your code here
} catch {
Write-Output "ERROR: $($_.Exception.Message)"
exit 1
}
Output Conventions: Scripts deployed via RMM should produce clean, parseable output. Use Write-Output for normal output and exit 1 for failure. Most RMM platforms capture standard output as the script result.
Idempotency: Scripts should be safe to run multiple times without side effects. A disk cleanup script that runs again on a clean disk should complete successfully and report "Nothing to clean" rather than erroring.
Category 1: System Health and Diagnostics
Script 1: Disk Space Report with Cleanup Trigger
<#
.SYNOPSIS
Reports disk space for all volumes and optionally cleans if below threshold.
.PARAMETER CleanThresholdGB
Trigger cleanup when free space is below this value (GB). Default: 10
#>
param([int]$CleanThresholdGB = 10)
$volumes = Get-PSDrive -PSProvider FileSystem | Where-Object { $_.Used -ne $null }
foreach ($vol in $volumes) {
$freeGB = [math]::Round($vol.Free / 1GB, 2)
$usedGB = [math]::Round($vol.Used / 1GB, 2)
$totalGB = [math]::Round(($vol.Free + $vol.Used) / 1GB, 2)
$pctFree = [math]::Round(($vol.Free / ($vol.Free + $vol.Used)) * 100, 1)
Write-Output "Drive $($vol.Name): $freeGB GB free of $totalGB GB ($pctFree% free)"
if ($freeGB -lt $CleanThresholdGB) {
Write-Output " [WARNING] Below threshold ($CleanThresholdGB GB). Running cleanup..."
# Clear Windows Update cache
Stop-Service wuauserv -Force -ErrorAction SilentlyContinue
Remove-Item "C:\Windows\SoftwareDistribution\Download\*" -Recurse -Force -ErrorAction SilentlyContinue
Start-Service wuauserv -ErrorAction SilentlyContinue
# Clear temp files
Remove-Item "$env:TEMP\*" -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item "C:\Windows\Temp\*" -Recurse -Force -ErrorAction SilentlyContinue
# Empty Recycle Bin
Clear-RecycleBin -Force -ErrorAction SilentlyContinue
# Re-measure
$newFree = [math]::Round((Get-PSDrive $vol.Name).Free / 1GB, 2)
Write-Output " Cleanup complete. Free space now: $newFree GB"
}
}
Deploy via: RMM scheduled task, run weekly on all Windows servers and workstations.
Script 2: Uptime and Recent Reboot Report
$os = Get-CimInstance Win32_OperatingSystem
$lastBoot = $os.LastBootUpTime
$uptime = (Get-Date) - $lastBoot
$days = [math]::Floor($uptime.TotalDays)
$hours = $uptime.Hours
Write-Output "System: $($env:COMPUTERNAME)"
Write-Output "Last boot: $($lastBoot.ToString('yyyy-MM-dd HH:mm:ss'))"
Write-Output "Uptime: $days days, $hours hours"
if ($days -gt 30) {
Write-Output "WARNING: System has not been rebooted in over 30 days. Pending updates may not be applied."
exit 1
} else {
Write-Output "Uptime within normal range."
exit 0
}
Script 3: Windows Service Health Monitor
<#
.SYNOPSIS
Check critical services, attempt restart if stopped, report status.
.PARAMETER Services
Comma-separated list of service names to check.
#>
param([string]$Services = "wuauserv,WinDefend,EventLog,RpcSs")
$serviceList = $Services -split ','
$issues = @()
foreach ($svcName in $serviceList) {
$svc = Get-Service -Name $svcName.Trim() -ErrorAction SilentlyContinue
if (-not $svc) {
Write-Output "[$svcName] NOT FOUND"
$issues += $svcName
continue
}
if ($svc.Status -ne 'Running') {
Write-Output "[$svcName] STOPPED — attempting restart"
try {
Start-Service -Name $svcName -ErrorAction Stop
Start-Sleep -Seconds 5
$svc.Refresh()
if ($svc.Status -eq 'Running') {
Write-Output "[$svcName] Restart SUCCESSFUL"
} else {
Write-Output "[$svcName] Restart FAILED — manual intervention required"
$issues += $svcName
}
} catch {
Write-Output "[$svcName] ERROR: $($_.Exception.Message)"
$issues += $svcName
}
} else {
Write-Output "[$svcName] Running OK"
}
}
if ($issues.Count -gt 0) {
Write-Output "SUMMARY: $($issues.Count) service(s) require attention: $($issues -join ', ')"
exit 1
} else {
Write-Output "SUMMARY: All services healthy."
exit 0
}
Script 4: Event Log Error Summary
<#
.SYNOPSIS
Extracts critical and error events from Windows Event Log in the past N hours.
#>
param(
[int]$HoursBack = 24,
[int]$MaxEvents = 50
)
$cutoff = (Get-Date).AddHours(-$HoursBack)
$logs = @('System','Application')
foreach ($logName in $logs) {
$events = Get-WinEvent -FilterHashtable @{
LogName = $logName
Level = 1,2 # 1=Critical, 2=Error
StartTime = $cutoff
} -MaxEvents $MaxEvents -ErrorAction SilentlyContinue
if ($events) {
Write-Output "`n=== $logName — $($events.Count) errors in past $HoursBack hours ==="
$events | Group-Object Id | Sort-Object Count -Descending | Select-Object -First 10 | ForEach-Object {
Write-Output " EventID $($_.Name): $($_.Count) occurrences — $($_.Group[0].ProviderName)"
}
} else {
Write-Output "$logName: No errors in past $HoursBack hours."
}
}
Script 5: RAM Utilization and Top Processes
$os = Get-CimInstance Win32_OperatingSystem
$total = [math]::Round($os.TotalVisibleMemorySize / 1MB, 2)
$free = [math]::Round($os.FreePhysicalMemory / 1MB, 2)
$used = $total - $free
$pct = [math]::Round(($used / $total) * 100, 1)
Write-Output "Memory: $used GB used of $total GB ($pct% utilization)"
if ($pct -gt 85) {
Write-Output "WARNING: High memory utilization. Top consumers:"
Get-Process | Sort-Object WorkingSet64 -Descending | Select-Object -First 5 |
ForEach-Object {
$memMB = [math]::Round($_.WorkingSet64 / 1MB, 1)
Write-Output " $($_.Name) (PID $($_.Id)): $memMB MB"
}
exit 1
} else {
exit 0
}
Category 2: Security Hardening and Compliance
Script 6: Local Administrator Account Audit
<#
.SYNOPSIS
Lists all local administrator accounts and flags unexpected entries.
#>
param([string]$ExpectedAdmins = "Administrator,YourMSPServiceAccount")
$expected = $ExpectedAdmins -split ',' | ForEach-Object { $_.Trim().ToLower() }
$admins = Get-LocalGroupMember -Group "Administrators" -ErrorAction SilentlyContinue
$unexpected = @()
Write-Output "Local Administrators on $($env:COMPUTERNAME):"
foreach ($admin in $admins) {
$name = $admin.Name.Split('\')[-1].ToLower()
$flag = if ($expected -notcontains $name) { " *** UNEXPECTED ***" } else { "" }
Write-Output " $($admin.Name) [$($admin.ObjectClass)]$flag"
if ($flag) { $unexpected += $admin.Name }
}
Write-Output "`nTotal: $($admins.Count) administrators, $($unexpected.Count) unexpected"
if ($unexpected.Count -gt 0) { exit 1 } else { exit 0 }
Script 7: Windows Firewall Status Check
$profiles = @('Domain', 'Private', 'Public')
$issues = @()
foreach ($profile in $profiles) {
$fw = Get-NetFirewallProfile -Name $profile -ErrorAction SilentlyContinue
if ($fw.Enabled) {
Write-Output "$profile profile: ENABLED (DefaultInbound: $($fw.DefaultInboundAction), DefaultOutbound: $($fw.DefaultOutboundAction))"
} else {
Write-Output "$profile profile: DISABLED *** ACTION REQUIRED ***"
$issues += $profile
}
}
if ($issues.Count -gt 0) {
Write-Output "ALERT: Firewall disabled on profiles: $($issues -join ', ')"
exit 1
} else {
Write-Output "All firewall profiles are enabled."
exit 0
}
Script 8: BitLocker Encryption Status
$volumes = Get-BitLockerVolume -ErrorAction SilentlyContinue
if (-not $volumes) {
Write-Output "BitLocker not available on this system."
exit 0
}
$unencrypted = @()
foreach ($vol in $volumes) {
$status = "$($vol.MountPoint): $($vol.VolumeStatus) — $($vol.EncryptionPercentage)% — ProtectionStatus: $($vol.ProtectionStatus)"
Write-Output $status
if ($vol.ProtectionStatus -ne 'On' -and $vol.VolumeType -eq 'OperatingSystem') {
$unencrypted += $vol.MountPoint
}
}
if ($unencrypted.Count -gt 0) {
Write-Output "WARNING: OS volume encryption is off or suspended on: $($unencrypted -join ', ')"
exit 1
} else {
Write-Output "BitLocker protection is active on OS volume."
exit 0
}
Script 9: Password Policy Compliance Check
# Check local password policy
$policy = net accounts 2>&1
Write-Output "=== Password Policy ==="
$policy | ForEach-Object { Write-Output $_ }
# Parse minimum length
$minLen = ($policy | Select-String "Minimum password length").ToString() -replace '\D',''
if ([int]$minLen -lt 12) {
Write-Output "WARNING: Minimum password length ($minLen) is below recommended minimum (12)."
exit 1
} else {
Write-Output "Password length requirement meets minimum standard."
exit 0
}
Script 10: Inactive User Account Audit
<#
.SYNOPSIS
Lists user accounts that have not logged in within N days.
.PARAMETER DaysInactive
Threshold for inactive accounts. Default: 90 days.
#>
param([int]$DaysInactive = 90)
$cutoff = (Get-Date).AddDays(-$DaysInactive)
$users = Get-LocalUser | Where-Object { $_.Enabled -eq $true }
$inactive = @()
foreach ($user in $users) {
$lastLogin = $user.LastLogon
if (-not $lastLogin -or $lastLogin -lt $cutoff) {
$daysSince = if ($lastLogin) { [math]::Round(((Get-Date) - $lastLogin).TotalDays) } else { "Never" }
Write-Output "INACTIVE: $($user.Name) — Last login: $daysSince days ago"
$inactive += $user.Name
} else {
$daysSince = [math]::Round(((Get-Date) - $lastLogin).TotalDays)
Write-Output "Active: $($user.Name) — Last login: $daysSince days ago"
}
}
Write-Output "`nSummary: $($inactive.Count) inactive accounts of $($users.Count) total enabled accounts."
if ($inactive.Count -gt 0) { exit 1 } else { exit 0 }
Category 3: Patch Management Automation
Script 11: Windows Update Pending Patches Report
# Requires PSWindowsUpdate module or COM object
$updateSession = New-Object -ComObject Microsoft.Update.Session
$updateSearcher = $updateSession.CreateUpdateSearcher()
$results = $updateSearcher.Search("IsInstalled=0 and IsHidden=0")
Write-Output "Pending Windows Updates: $($results.Updates.Count)"
$critical = 0
$important = 0
foreach ($update in $results.Updates) {
Write-Output " [$($update.MsrcSeverity)] $($update.Title)"
if ($update.MsrcSeverity -eq 'Critical') { $critical++ }
elseif ($update.MsrcSeverity -eq 'Important') { $important++ }
}
Write-Output "`nCritical: $critical | Important: $important | Total: $($results.Updates.Count)"
if ($critical -gt 0) { exit 2 }
elseif ($important -gt 0) { exit 1 }
else { exit 0 }
Script 12: Pending Reboot Detection
$rebootRequired = $false
$reasons = @()
# Check Windows Update pending reboot
if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
$rebootRequired = $true
$reasons += "Windows Update"
}
# Check Component Based Servicing pending reboot
if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
$rebootRequired = $true
$reasons += "Component Based Servicing"
}
# Check pending file rename operations
$pfro = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue
if ($pfro -and $pfro.PendingFileRenameOperations) {
$rebootRequired = $true
$reasons += "Pending File Rename"
}
if ($rebootRequired) {
Write-Output "REBOOT PENDING: $($reasons -join ', ')"
$lastBoot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
Write-Output "Last reboot: $($lastBoot.ToString('yyyy-MM-dd HH:mm'))"
exit 1
} else {
Write-Output "No pending reboot."
exit 0
}
Script 13: Installed Software Version Report
<#
.SYNOPSIS
Exports installed software with version numbers for license compliance and patch targeting.
#>
$paths = @(
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
)
$software = $paths | ForEach-Object {
Get-ItemProperty $_ -ErrorAction SilentlyContinue
} | Where-Object { $_.DisplayName } |
Select-Object DisplayName, DisplayVersion, Publisher, InstallDate |
Sort-Object DisplayName
Write-Output "Installed software count: $($software.Count)"
$software | ForEach-Object {
Write-Output "$($_.DisplayName) | $($_.DisplayVersion) | $($_.Publisher)"
}
Category 4: User and Account Management
Script 14: New User Onboarding (Active Directory)
<#
.SYNOPSIS
Creates a new AD user with standard configuration.
Parameters: FirstName, LastName, Department, Title, Manager
#>
param(
[string]$FirstName,
[string]$LastName,
[string]$Department,
[string]$Title,
[string]$Manager = ""
)
Import-Module ActiveDirectory -ErrorAction Stop
$username = "$($FirstName.ToLower().Substring(0,1))$($LastName.ToLower())"
$upn = "$username@$(([adsi]"").distinguishedName.ToString().Replace('DC=','.').Replace(',','').TrimStart('.'))"
$ou = "OU=$Department,OU=Users,$(([adsi]"").distinguishedName)"
# Generate a temporary password (meets typical complexity requirements)
$tempPass = ConvertTo-SecureString "Temp$(Get-Random -Minimum 1000 -Maximum 9999)!" -AsPlainText -Force
$params = @{
Name = "$FirstName $LastName"
GivenName = $FirstName
Surname = $LastName
SamAccountName = $username
UserPrincipalName = $upn
Department = $Department
Title = $Title
Path = $ou
AccountPassword = $tempPass
ChangePasswordAtLogon = $true
Enabled = $true
}
if ($Manager) {
$managerObj = Get-ADUser -Filter "SamAccountName -eq '$Manager'" -ErrorAction SilentlyContinue
if ($managerObj) { $params['Manager'] = $managerObj.DistinguishedName }
}
New-ADUser @params
Write-Output "Created user: $username ($FirstName $LastName)"
Write-Output "Temporary password: Review with manager — must change at first login."
Script 15: User Offboarding Checklist
<#
.SYNOPSIS
Disables user, moves to Disabled OU, clears group memberships (except Domain Users).
#>
param([string]$Username)
Import-Module ActiveDirectory -ErrorAction Stop
$user = Get-ADUser -Identity $Username -Properties MemberOf -ErrorAction Stop
# Disable account
Disable-ADAccount -Identity $Username
Write-Output "Disabled account: $Username"
# Move to Disabled OU (create if needed)
$disabledOU = "OU=Disabled,$(([adsi]"").distinguishedName)"
Move-ADObject -Identity $user.DistinguishedName -TargetPath $disabledOU -ErrorAction SilentlyContinue
Write-Output "Moved to Disabled OU"
# Remove from all groups except Domain Users
$groups = $user.MemberOf | Where-Object { $_ -notlike "*Domain Users*" }
foreach ($group in $groups) {
Remove-ADGroupMember -Identity $group -Members $Username -Confirm:$false
Write-Output "Removed from group: $group"
}
# Append offboarding note to description
$timestamp = Get-Date -Format "yyyy-MM-dd"
Set-ADUser -Identity $Username -Description "OFFBOARDED $timestamp - $(whoami)"
Write-Output "Offboarding complete for $Username"
Script 16: MFA Enrollment Status Report (Microsoft 365)
<#
.SYNOPSIS
Reports MFA enrollment status for all Microsoft 365 users.
Requires: MSOnline module, Connect-MsolService run prior to deployment.
#>
Import-Module MSOnline -ErrorAction Stop
$users = Get-MsolUser -All | Where-Object { $_.IsLicensed -eq $true }
$mfaOff = @()
foreach ($user in $users) {
$mfaState = $user.StrongAuthenticationRequirements.State
if (-not $mfaState -or $mfaState -eq "Disabled") {
Write-Output "NO MFA: $($user.UserPrincipalName)"
$mfaOff += $user.UserPrincipalName
}
}
Write-Output "`nMFA Summary: $($mfaOff.Count) of $($users.Count) licensed users without MFA enabled."
if ($mfaOff.Count -gt 0) { exit 1 } else { exit 0 }
Category 5: Automated Reporting and Monitoring
Script 17: HTML Health Report Generator
<#
.SYNOPSIS
Generates an HTML health report for a Windows server.
Saves to C:\Reports\health-report-[date].html
#>
$date = Get-Date -Format "yyyy-MM-dd"
$hostname = $env:COMPUTERNAME
$os = Get-CimInstance Win32_OperatingSystem
$cpu = (Get-CimInstance Win32_Processor).LoadPercentage
$ramTotal = [math]::Round($os.TotalVisibleMemorySize / 1MB, 2)
$ramFree = [math]::Round($os.FreePhysicalMemory / 1MB, 2)
$ramPct = [math]::Round((($ramTotal - $ramFree) / $ramTotal) * 100, 1)
$diskHTML = ""
Get-PSDrive -PSProvider FileSystem | Where-Object { $_.Used -ne $null } | ForEach-Object {
$freeGB = [math]::Round($_.Free / 1GB, 2)
$totalGB = [math]::Round(($_.Free + $_.Used) / 1GB, 2)
$pct = [math]::Round(($_.Used / ($_.Free + $_.Used)) * 100, 1)
$color = if ($pct -gt 90) { 'red' } elseif ($pct -gt 80) { 'orange' } else { 'green' }
$diskHTML += "<tr><td>$($_.Name)</td><td>$totalGB GB</td><td>$freeGB GB free</td><td style='color:$color'>$pct%</td></tr>"
}
$html = @"
<!DOCTYPE html><html><body style='font-family:Arial;padding:20px'>
<h2>Server Health Report — $hostname — $date</h2>
<table border='1' cellpadding='8' cellspacing='0' style='border-collapse:collapse;width:500px'>
<tr><th>Metric</th><th>Value</th><th>Status</th></tr>
<tr><td>CPU Usage</td><td>$cpu%</td><td style='color:$(if($cpu -gt 80){"red"}else{"green"})'>$(if($cpu -gt 80){"WARNING"}else{"OK"})</td></tr>
<tr><td>RAM Usage</td><td>$ramPct%</td><td style='color:$(if($ramPct -gt 85){"red"}else{"green"})'>$(if($ramPct -gt 85){"WARNING"}else{"OK"})</td></tr>
<tr><td>OS</td><td colspan='2'>$($os.Caption) — Build $($os.BuildNumber)</td></tr>
</table>
<h3 style='margin-top:20px'>Disk Status</h3>
<table border='1' cellpadding='8' cellspacing='0' style='border-collapse:collapse'>
<tr><th>Drive</th><th>Total</th><th>Free</th><th>Used %</th></tr>
$diskHTML
</table>
<p style='color:gray;font-size:12px'>Generated: $(Get-Date)</p>
</body></html>
"@
$reportDir = "C:\Reports"
if (-not (Test-Path $reportDir)) { New-Item -ItemType Directory -Path $reportDir | Out-Null }
$html | Out-File "$reportDir\health-report-$date.html" -Encoding UTF8
Write-Output "Report saved to $reportDir\health-report-$date.html"
Script 18: Patch Compliance Summary
<#
.SYNOPSIS
Generates a patch compliance summary for the local device.
Outputs: OS version, last update date, pending critical/important updates.
#>
$updateSession = New-Object -ComObject Microsoft.Update.Session
$updateSearcher = $updateSession.CreateUpdateSearcher()
# Get last successful update
$history = $updateSearcher.QueryHistory(0, 10)
$lastUpdate = ($history | Where-Object { $_.ResultCode -eq 2 } | Sort-Object Date -Descending)[0]
# Get pending updates
$pending = $updateSearcher.Search("IsInstalled=0 and IsHidden=0").Updates
$critical = ($pending | Where-Object { $_.MsrcSeverity -eq 'Critical' }).Count
$important = ($pending | Where-Object { $_.MsrcSeverity -eq 'Important' }).Count
Write-Output "=== Patch Compliance Report: $env:COMPUTERNAME ==="
Write-Output "OS: $((Get-CimInstance Win32_OperatingSystem).Caption)"
Write-Output "Last successful update: $(if($lastUpdate){$lastUpdate.Date.ToString('yyyy-MM-dd')}else{'Unknown'})"
Write-Output "Pending updates: $($pending.Count) total ($critical critical, $important important)"
$score = if ($critical -gt 0) { "NON-COMPLIANT" } elseif ($important -gt 0) { "ATTENTION NEEDED" } else { "COMPLIANT" }
Write-Output "Compliance status: $score"
if ($critical -gt 0) { exit 2 }
elseif ($important -gt 0) { exit 1 }
else { exit 0 }
Script 19: Network Connectivity Test
<#
.SYNOPSIS
Tests connectivity to critical network targets.
#>
param([string]$Targets = "8.8.8.8,1.1.1.1,google.com,microsoft.com")
$targetList = $Targets -split ','
$failures = @()
foreach ($target in $targetList) {
$result = Test-Connection -ComputerName $target.Trim() -Count 2 -Quiet
if ($result) {
$latency = (Test-Connection -ComputerName $target.Trim() -Count 2 |
Measure-Object ResponseTime -Average).Average
Write-Output "$($target.Trim()): REACHABLE (avg latency: $([math]::Round($latency))ms)"
} else {
Write-Output "$($target.Trim()): UNREACHABLE"
$failures += $target.Trim()
}
}
if ($failures.Count -gt 0) {
Write-Output "NETWORK ISSUE: Cannot reach $($failures -join ', ')"
exit 1
} else {
exit 0
}
Script 20: SSL Certificate Expiry Check
<#
.SYNOPSIS
Checks SSL certificate expiry for specified domains.
Alert when expiry is within WarningDays.
#>
param(
[string]$Domains = "google.com,microsoft.com",
[int]$WarningDays = 30
)
foreach ($domain in ($Domains -split ',')) {
$domain = $domain.Trim()
try {
$request = [System.Net.HttpWebRequest]::Create("https://$domain")
$request.Timeout = 10000
$request.AllowAutoRedirect = $false
$response = $request.GetResponse()
$cert = $request.ServicePoint.Certificate
$expiry = [DateTime]::Parse($cert.GetExpirationDateString())
$daysLeft = ($expiry - (Get-Date)).Days
$status = if ($daysLeft -lt $WarningDays) { "WARNING - EXPIRES SOON" } else { "OK" }
Write-Output "$domain — Expires: $($expiry.ToString('yyyy-MM-dd')) ($daysLeft days) — $status"
$response.Close()
} catch {
Write-Output "$domain — CHECK FAILED: $($_.Exception.Message)"
}
}
Category 5: Advanced Automation
Scripts 21–25: Advanced Scenarios
Script 21 — Bulk Software Deployment via Winget:
param([string]$Apps = "Google.Chrome,Mozilla.Firefox,7zip.7zip")
foreach ($app in ($Apps -split ',')) {
Write-Output "Installing: $app"
winget install --id $app.Trim() --silent --accept-package-agreements --accept-source-agreements
if ($LASTEXITCODE -eq 0) { Write-Output "$app installed successfully" }
else { Write-Output "$app installation failed (exit $LASTEXITCODE)" }
}
Script 22 — Active Directory Password Expiry Report:
Import-Module ActiveDirectory
$users = Get-ADUser -Filter {Enabled -eq $true} -Properties PasswordLastSet, PasswordNeverExpires |
Where-Object { -not $_.PasswordNeverExpires } |
ForEach-Object {
$maxAge = (Get-ADDefaultDomainPasswordPolicy).MaxPasswordAge.Days
$expiry = $_.PasswordLastSet.AddDays($maxAge)
[PSCustomObject]@{ User=$_.Name; Expiry=$expiry; DaysLeft=([int]($expiry-(Get-Date)).TotalDays) }
} | Sort-Object DaysLeft
$expiringSoon = $users | Where-Object { $_.DaysLeft -lt 14 -and $_.DaysLeft -gt 0 }
Write-Output "Passwords expiring within 14 days: $($expiringSoon.Count)"
$expiringSoon | ForEach-Object { Write-Output " $($_.User): $($_.DaysLeft) days" }
Script 23 — SMART Disk Health Report:
$disks = Get-PhysicalDisk
foreach ($disk in $disks) {
$health = Get-StorageReliabilityCounter -PhysicalDisk $disk
Write-Output "Disk: $($disk.FriendlyName) | Health: $($disk.HealthStatus) | Read Errors: $($health.ReadErrorsTotal) | Write Errors: $($health.WriteErrorsTotal)"
if ($disk.HealthStatus -ne 'Healthy') { exit 1 }
}
Script 24 — Scheduled Task Health Check:
$tasks = Get-ScheduledTask | Where-Object { $_.State -ne 'Disabled' }
$failed = @()
foreach ($task in $tasks) {
$info = $task | Get-ScheduledTaskInfo
if ($info.LastTaskResult -ne 0 -and $info.LastTaskResult -ne 267011) {
Write-Output "FAILED TASK: $($task.TaskName) — Result: $($info.LastTaskResult) — Last Run: $($info.LastRunTime)"
$failed += $task.TaskName
}
}
Write-Output "Task summary: $($failed.Count) failed tasks of $($tasks.Count) checked."
if ($failed.Count -gt 0) { exit 1 }
Script 25 — Automated Client Monthly Health Report Compiler:
# Aggregates data from other scripts into a single client-facing summary
$hostname = $env:COMPUTERNAME
$date = Get-Date -Format "MMMM yyyy"
$os = Get-CimInstance Win32_OperatingSystem
$uptime = [math]::Round(((Get-Date) - $os.LastBootUpTime).TotalDays, 1)
$diskOK = (Get-PSDrive -PSProvider FileSystem | Where-Object { $_.Used -ne $null -and (($_.Free / ($_.Free + $_.Used)) * 100) -lt 80 }).Count -eq 0
Write-Output "=== Monthly Health Summary: $hostname — $date ==="
Write-Output "OS: $($os.Caption)"
Write-Output "Uptime since last reboot: $uptime days"
Write-Output "Disk status: $(if($diskOK){'All volumes have >20% free space'}else{'WARNING: Low disk space detected'})"
Write-Output "Report generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm')"
Deploying Scripts via RMM
The value of these scripts multiplies when deployed via your RMM across entire client environments. In NinjaIT's scripting interface:
- Navigate to Automation → Script Library
- Click Add Script → paste the script
- Set Run As: SYSTEM (for most scripts) or Logged-in User (for user profile operations)
- Add to a policy: Automation → Policies → Add Condition/Action
- Configure trigger: Schedule, alert trigger, or on-demand
Best practices for RMM script deployment:
- Test scripts on 2–3 machines before policy-wide deployment
- Review script output via the RMM results dashboard
- Set exit code expectations:
exit 1creates alert,exit 0= success - Use script parameters where possible to make scripts reusable across different configurations
For more on automation strategy and AI-assisted automation, see how AI is transforming IT management. For the security controls these scripts help enforce, see endpoint hardening guide.
Advanced PowerShell: Working with REST APIs
Modern MSP operations involve calling external APIs — your PSA, RMM, cloud providers, security tools. PowerShell's built-in REST client capabilities make this straightforward.
Making API Calls with Invoke-RestMethod
# Basic API call pattern — GET request with Bearer token auth
function Invoke-MspApi {
param(
[Parameter(Mandatory)]
[string]$Endpoint,
[string]$Method = 'GET',
[hashtable]$Body = $null,
[Parameter(Mandatory)]
[string]$ApiKey
)
$headers = @{
'Authorization' = "Bearer $ApiKey"
'Content-Type' = 'application/json'
'Accept' = 'application/json'
}
$params = @{
Uri = $Endpoint
Method = $Method
Headers = $headers
}
if ($Body -and $Method -ne 'GET') {
$params['Body'] = $Body | ConvertTo-Json -Depth 10
}
try {
$response = Invoke-RestMethod @params
return $response
}
catch [System.Net.WebException] {
$statusCode = [int]$_.Exception.Response.StatusCode
Write-Error "API call failed: HTTP $statusCode - $($_.Exception.Message)"
throw
}
}
# Example: Get all devices from NinjaIT API
$ninjaDevices = Invoke-MspApi `
-Endpoint "https://api.ninjarmm.com/v2/devices" `
-Method 'GET' `
-ApiKey $env:NINJAIT_API_KEY
# Example: Create a ticket in your PSA
$ticketBody = @{
subject = "High CPU Alert - PROD-SQL-01"
message = "CPU usage has exceeded 90% for 20 minutes. Investigating."
priority = "High"
clientId = 12345
}
Invoke-MspApi `
-Endpoint "https://your-psa.com/api/tickets" `
-Method 'POST' `
-Body $ticketBody `
-ApiKey $env:PSA_API_KEY
Parallel API Operations with PowerShell Jobs
When you need to query multiple clients or devices simultaneously, serial execution is too slow. PowerShell jobs or ForEach-Object -Parallel (PowerShell 7+) dramatically reduce execution time:
# PowerShell 7+: Parallel execution for bulk API operations
# Example: Get disk space from 200 servers simultaneously
$servers = @(
"server-01.client-a.com",
"server-02.client-a.com",
"server-01.client-b.com"
# ... 200 servers total
)
$results = $servers | ForEach-Object -Parallel {
$server = $_
try {
$disk = Get-PSDrive -PSProvider FileSystem -Name C `
-ComputerName $server `
-ErrorAction Stop
[PSCustomObject]@{
Server = $server
UsedGB = [math]::Round(($disk.Used / 1GB), 2)
FreeGB = [math]::Round(($disk.Free / 1GB), 2)
TotalGB = [math]::Round(($disk.Used + $disk.Free) / 1GB, 2)
PctFree = [math]::Round($disk.Free * 100 / ($disk.Used + $disk.Free), 1)
Status = 'OK'
}
}
catch {
[PSCustomObject]@{
Server = $server
UsedGB = $null
FreeGB = $null
TotalGB = $null
PctFree = $null
Status = "Error: $($_.Exception.Message)"
}
}
} -ThrottleLimit 20 # Maximum 20 concurrent operations
# Find servers with < 15% free space
$results | Where-Object { $_.PctFree -lt 15 -and $_.Status -eq 'OK' } |
Sort-Object PctFree |
Format-Table -AutoSize
PowerShell for Security Incident Response
When an incident occurs, the first 15 minutes of investigation determine how quickly you contain it. These PowerShell scripts help triage rapidly.
Initial Triage Script
# Run on a suspected compromised host for rapid initial triage
# Save as Invoke-InitialTriage.ps1
param(
[Parameter(Mandatory)]
[string]$OutputDir = "C:\Temp\Triage_$(Get-Date -Format 'yyyyMMdd_HHmmss')"
)
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
Write-Host "=== Initial Triage: $timestamp ===" -ForegroundColor Cyan
Write-Host "Output directory: $OutputDir"
# 1. Current network connections (look for C2, unusual outbound)
Write-Host "`n[1/8] Network connections..."
$netConn = Get-NetTCPConnection -State Established,TimeWait,CloseWait |
Select-Object LocalAddress, LocalPort, RemoteAddress, RemotePort, State,
@{N='ProcessName'; E={(Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName}},
@{N='ProcessPath'; E={(Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).Path}}
$netConn | Export-Csv "$OutputDir\network_connections.csv" -NoTypeInformation
# 2. Running processes with unusual characteristics
Write-Host "[2/8] Running processes..."
$processes = Get-Process | Select-Object Id, ProcessName, Path, Company,
CPU, WorkingSet64, StartTime,
@{N='ParentPID'; E={ (Get-CimInstance Win32_Process -Filter "ProcessId=$($_.Id)" -ErrorAction SilentlyContinue).ParentProcessId }}
$processes | Export-Csv "$OutputDir\processes.csv" -NoTypeInformation
# Flag unsigned executables in unusual locations
$processes | Where-Object {
$_.Path -and
$_.Path -notmatch 'Windows|Program Files|SysWow64' -and
$_.Path -match '\\Users\\|\\Temp\\|\\AppData\\|\\ProgramData\\'
} | ForEach-Object { Write-Warning "Suspicious process location: $($_.ProcessName) at $($_.Path)" }
# 3. Recently modified executables (within last 24 hours)
Write-Host "[3/8] Recently modified executables..."
$recentExes = Get-ChildItem -Path C:\ -Include *.exe,*.dll,*.ps1,*.bat,*.cmd -Recurse -ErrorAction SilentlyContinue |
Where-Object { $_.LastWriteTime -gt (Get-Date).AddHours(-24) -and
$_.FullName -notmatch 'Windows\\WinSxS|Windows\\Temp' } |
Select-Object FullName, LastWriteTime, Length
$recentExes | Export-Csv "$OutputDir\recent_executables.csv" -NoTypeInformation
Write-Host " Found $($recentExes.Count) recently modified executables"
# 4. Scheduled tasks (common persistence mechanism)
Write-Host "[4/8] Scheduled tasks..."
$tasks = Get-ScheduledTask | Where-Object { $_.State -ne 'Disabled' } |
Select-Object TaskName, TaskPath, State,
@{N='Execute'; E={$_.Actions.Execute}},
@{N='Arguments'; E={$_.Actions.Arguments}}
$tasks | Export-Csv "$OutputDir\scheduled_tasks.csv" -NoTypeInformation
# 5. Services with unusual executables
Write-Host "[5/8] Services..."
$services = Get-CimInstance Win32_Service |
Where-Object { $_.PathName -match '\\Users\\|\\Temp\\|\\AppData\\' } |
Select-Object Name, DisplayName, State, PathName, StartMode
if ($services) {
Write-Warning "Services with unusual paths found:"
$services | Format-Table -AutoSize
}
$services | Export-Csv "$OutputDir\suspicious_services.csv" -NoTypeInformation
# 6. Recent logins (last 24 hours)
Write-Host "[6/8] Recent logins..."
$logons = Get-WinEvent -LogName Security -FilterXPath "*[System[(EventID=4624)] and EventData[Data[@Name='LogonType']='10' or Data[@Name='LogonType']='3']]" -MaxEvents 100 -ErrorAction SilentlyContinue |
Select-Object TimeCreated,
@{N='Account'; E={$_.Properties[5].Value}},
@{N='Domain'; E={$_.Properties[6].Value}},
@{N='LogonType'; E={$_.Properties[8].Value}},
@{N='SourceIP'; E={$_.Properties[18].Value}}
$logons | Export-Csv "$OutputDir\recent_logons.csv" -NoTypeInformation
# 7. DNS cache (shows recent name resolutions - C2 indicators)
Write-Host "[7/8] DNS cache..."
Get-DnsClientCache |
Where-Object { $_.TTL -gt 0 } |
Select-Object Name, Type, Data, TTL |
Export-Csv "$OutputDir\dns_cache.csv" -NoTypeInformation
# 8. Persistence registry keys
Write-Host "[8/8] Registry persistence keys..."
$regPaths = @(
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run",
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce",
"HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run",
"HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce",
"HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Run"
)
$regEntries = foreach ($path in $regPaths) {
try {
$regData = Get-ItemProperty -Path $path -ErrorAction SilentlyContinue
if ($regData) {
$regData.PSObject.Properties | Where-Object { $_.Name -notmatch '^PS' } |
Select-Object @{N='Path'; E={$path}}, Name, @{N='Value'; E={$_.Value}}
}
}
catch {}
}
$regEntries | Export-Csv "$OutputDir\registry_persistence.csv" -NoTypeInformation
Write-Host "`n=== Triage Complete ===" -ForegroundColor Green
Write-Host "Output files in: $OutputDir"
Write-Host "Review network_connections.csv and recent_executables.csv first."
Write-Host "Zip and send to security team: Compress-Archive -Path $OutputDir -DestinationPath '$OutputDir.zip'"
PowerShell Module Development for MSPs
As your script library grows, organizing scripts into proper PowerShell modules provides reusability, discoverability, and distribution capabilities.
Creating a Basic MSP Module
# Module structure:
# MSPTools/
# MSPTools.psm1 (main module file)
# MSPTools.psd1 (module manifest)
# Public/ (functions exported to users)
# Get-DiskSpaceReport.ps1
# Invoke-PatchCheck.ps1
# Get-OfficeActivation.ps1
# Private/ (internal functions, not exported)
# Invoke-MSPApiCall.ps1
# Write-MSPLog.ps1
# MSPTools.psd1 - Module manifest
@{
ModuleVersion = '1.5.0'
GUID = '550e8400-e29b-41d4-a716-446655440000' # Generate unique GUID: [System.Guid]::NewGuid()
Author = 'Your MSP Name'
CompanyName = 'Your MSP Name'
Description = 'MSP Operations Toolkit - standardized scripts for managed environments'
PowerShellVersion = '5.1'
RootModule = 'MSPTools.psm1'
FunctionsToExport = @(
'Get-DiskSpaceReport',
'Invoke-PatchCheck',
'Get-OfficeActivation',
'Get-SystemHealthSummary'
)
PrivateData = @{
PSData = @{
Tags = @('MSP', 'IT', 'Automation')
ProjectUri = 'https://github.com/yourmsp/MSPTools'
}
}
}
# MSPTools.psm1 - Dot-source all function files
$publicFunctions = Get-ChildItem -Path "$PSScriptRoot\Public\*.ps1" -ErrorAction SilentlyContinue
$privateFunctions = Get-ChildItem -Path "$PSScriptRoot\Private\*.ps1" -ErrorAction SilentlyContinue
foreach ($function in ($publicFunctions + $privateFunctions)) {
. $function.FullName
}
Export-ModuleMember -Function $publicFunctions.BaseName
Deploying Your Module via RMM
# Install MSPTools module from internal source during device setup
# Run this via RMM script during new device onboarding
$moduleSource = "\\fileserver\MSPTools\MSPTools.psm1" # Internal share
$moduleDestination = "$env:ProgramFiles\WindowsPowerShell\Modules\MSPTools"
if (-not (Test-Path $moduleDestination)) {
New-Item -ItemType Directory -Path $moduleDestination -Force | Out-Null
}
Copy-Item -Path $moduleSource -Destination $moduleDestination -Force
# Verify installation
Import-Module MSPTools -Force
if (Get-Module MSPTools) {
Write-Output "MSPTools module installed successfully"
Get-Command -Module MSPTools | Select-Object Name
} else {
Write-Error "MSPTools installation failed"
exit 1
}
PowerShell Security: Writing Scripts That Do Not Introduce Vulnerabilities
MSP scripts run with elevated privileges on client systems. A poorly written script is a security risk.
Script Security Best Practices
Never store credentials in scripts:
# BAD: Hardcoded credentials
$password = "MyPassword123!"
$cred = New-Object PSCredential("admin", (ConvertTo-SecureString $password -AsPlainText -Force))
# BETTER: Use Windows Credential Manager
$cred = Get-StoredCredential -Target "MSP-ServiceAccount"
# BEST: Use the RMM's built-in credential vault (NinjaIT, Hudu, IT Glue integration)
# Retrieve credentials at runtime from encrypted vault, never embedded in script
Validate all inputs:
function Set-UserPassword {
param(
[Parameter(Mandatory)]
[ValidatePattern('^[a-zA-Z0-9._-]+$')] # Only safe characters
[ValidateLength(1, 64)]
[string]$Username,
[Parameter(Mandatory)]
[SecureString]$NewPassword
)
# Never accept user input directly into commands without validation
# SQL injection and command injection apply in PowerShell too
}
Use -WhatIf for dangerous operations:
function Remove-OldUserProfiles {
[CmdletBinding(SupportsShouldProcess)]
param(
[int]$DaysInactive = 180
)
$profiles = Get-CimInstance -ClassName Win32_UserProfile |
Where-Object {
$_.LastUseTime -lt (Get-Date).AddDays(-$DaysInactive) -and
-not $_.Special
}
foreach ($profile in $profiles) {
# -WhatIf support: shows what WOULD happen without actually doing it
if ($PSCmdlet.ShouldProcess($profile.LocalPath, "Remove user profile")) {
$profile | Remove-CimInstance
Write-Output "Removed: $($profile.LocalPath)"
}
}
}
# Run with -WhatIf to preview before executing:
Remove-OldUserProfiles -DaysInactive 180 -WhatIf
Enable logging for all production scripts:
# Standard logging pattern for production scripts
$LogPath = "C:\ProgramData\YourMSP\Logs\$(Split-Path $PSCommandPath -Leaf)-$(Get-Date -Format 'yyyyMMdd').log"
function Write-Log {
param([string]$Message, [string]$Level = 'INFO')
$entry = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [$Level] $Message"
Add-Content -Path $LogPath -Value $entry
if ($Level -eq 'ERROR') { Write-Error $Message }
elseif ($Level -eq 'WARNING') { Write-Warning $Message }
else { Write-Verbose $Message }
}
# Every production script starts by establishing the log context
Write-Log "Script started: $($MyInvocation.MyCommand.Name)"
Write-Log "Running as: $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)"
This logging pattern means every script execution is auditable — critical for compliance-regulated environments and essential for troubleshooting when scripts run unattended in the middle of the night.
Frequently Asked Questions About PowerShell Automation
What PowerShell version should I target for MSP scripts?
Target PowerShell 5.1 for maximum compatibility — it ships with Windows 10/11 and Server 2016/2019/2022 and requires no installation. Use PowerShell 7.x features only where the performance benefits (ForEach-Object -Parallel) or functionality (better JSON handling, cross-platform compatibility) justify requiring it. For scripts that need to run on older Windows versions (Server 2012 R2, Windows 8.1), stick strictly to 5.1-compatible syntax. Include version checks in scripts where this matters: #Requires -Version 5.1.
How do I store and retrieve sensitive credentials securely in RMM scripts?
Never embed credentials in scripts. Options in order of preference: (1) Use the RMM platform's built-in credential vault — NinjaIT and most platforms allow you to define credentials that are injected into scripts at runtime without appearing in the script code or logs. (2) Use Windows Credential Manager via Get-StoredCredential (requires CredentialManager module). (3) Use encrypted variable storage in your PSA's documentation tool (IT Glue, Hudu). Never store passwords in environment variables on shared systems or in plain-text configuration files.
How do I handle scripts that need to run on remote computers?
PowerShell Remoting (WinRM) is the standard mechanism, but requires WinRM to be enabled and configured on the remote host. Alternative for MSPs: most RMM platforms allow you to run scripts remotely via the management agent without requiring WinRM configuration. This is simpler and more reliable for MSP use cases. For environments where you need direct PowerShell remoting without RMM, enable WinRM selectively (Enable-PSRemoting) and restrict access via Windows Firewall to your management IPs only.
What is the best way to document PowerShell scripts for a team?
Use PowerShell comment-based help (the <#...#> block at the top of each function/script). Include: Synopsis (one-line description), Description (what it does and why), Parameters (each parameter with type and description), Examples (2–3 real usage examples), Notes (dependencies, known limitations). Store scripts in a version-controlled repository (GitHub, GitLab, Azure DevOps). Include a CHANGELOG.md that documents what changed between script versions. Review scripts older than 6 months for continued relevance and update for OS/application changes.
AI & Automation Engineer
Elena is a machine learning engineer turned IT operations specialist. She spent 6 years building AIOps platforms at a major observability vendor before pivoting to help MSPs adopt AI-driven monitoring and automation. She writes about practical AI applications — anomaly detection, predictive alerting, and automated remediation — without the hype. MS in Computer Science from Georgia Tech.
Ready to put this into practice?
NinjaIT's all-in-one platform handles everything covered in this guide — monitoring, automation, and management at scale.