A first PowerShell security audit script is the single most valuable thing a new Windows admin can build. Not the polished, branded compliance scanner — the messy two-hundred-line file you keep refining for a year that collects exactly the signals your environment cares about. This guide walks through the structure we use for that script, in a shape you can paste into a new project and grow from.
Key Takeaways
- A useful audit script is modular: one function per signal area (system info, security settings, accounts, services, network), called from one top-level loop.
- The collection format should be a list of
PSCustomObject, which trivially exports to CSV, JSON, or HTML at the end. - The script is for systems you administer. For production-scale monitoring, integrate the same signals into Microsoft Defender for Endpoint or a SIEM rather than scheduling a script everywhere.
- Run elevated. Most of the interesting properties (Security log, process command lines, signature paths) require local administrator rights.
- Output to a timestamped file so you can diff today's audit against last week's and see what changed.
Environment
- Windows 10/11 or Windows Server 2019/2022.
- Windows PowerShell 5.1 or PowerShell 7.4. The script below uses
Get-CimInstanceso it works on both. - Local administrator rights on every target machine.
- Optional: an existing CSV-friendly central share for collected reports.
The Problem
Most "first audit script" examples run a single Get-* cmdlet, format the output, and call it a day. That gives you a snapshot, not an audit. A useful script does three things: collects a defined set of signals, structures the output so it can be compared over time, and stays small enough to read in one sitting. The structure below grew out of running the same audit on three hundred machines a week for over a year.
The Solution
Step 1 — Define a script signature and a single output path
Start with parameters that make the script schedule-friendly and a single timestamped output path so multiple runs do not collide:
[CmdletBinding()]
param(
[string]$OutputDirectory = "$env:ProgramData\SecurityAudit",
[ValidateSet('Csv','Json','Both')] [string]$Format = 'Both'
)
$ErrorActionPreference = 'Stop'
$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss'
$hostName = $env:COMPUTERNAME
$reportPath = Join-Path $OutputDirectory "$hostName`_$timestamp"
New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null
[CmdletBinding()] turns the script into an advanced function and lights up -Verbose output for free. Worth it for almost zero effort.
Step 2 — One function per signal area
Splitting the collection into named functions keeps each one short and lets you reuse them in other scripts. Five areas cover most of what we care about:
function Get-SystemSignal {
$os = Get-CimInstance Win32_OperatingSystem
$cs = Get-CimInstance Win32_ComputerSystem
[PSCustomObject]@{
Hostname = $env:COMPUTERNAME
OS = $os.Caption
Build = $os.Version
Architecture = $os.OSArchitecture
LastBoot = $os.LastBootUpTime
Domain = $cs.Domain
Manufacturer = $cs.Manufacturer
Model = $cs.Model
MemoryGB = [math]::Round($cs.TotalPhysicalMemory/1GB,1)
UptimeDays = [math]::Round(((Get-Date) - $os.LastBootUpTime).TotalDays,1)
}
}
function Get-SecuritySignal {
[PSCustomObject]@{
FirewallProfiles = (Get-NetFirewallProfile |
ForEach-Object { "$($_.Name)=$($_.Enabled)" }) -join '; '
Antivirus = (Get-CimInstance -Namespace root\SecurityCenter2 -ClassName AntiVirusProduct -ErrorAction SilentlyContinue).DisplayName -join '; '
UACEnabled = ((Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System').EnableLUA -eq 1)
SecureBoot = (Confirm-SecureBootUEFI -ErrorAction SilentlyContinue)
BitLockerC = (Get-BitLockerVolume -MountPoint 'C:' -ErrorAction SilentlyContinue).ProtectionStatus
PSVersion = $PSVersionTable.PSVersion.ToString()
}
}
function Get-AccountSignal {
[PSCustomObject]@{
EnabledLocalUsers = (Get-LocalUser |
Where-Object Enabled).Name -join ', '
LocalAdministrators = (Get-LocalGroupMember -Group Administrators |
Select-Object -ExpandProperty Name) -join ', '
AccountsNeverExpire = (Get-LocalUser |
Where-Object { $_.Enabled -and $_.PasswordNeverExpires }).Name -join ', '
}
}
function Get-ServiceSignal {
$suspect = Get-CimInstance Win32_Service |
Where-Object { $_.PathName -match 'temp|appdata|users\\public|programdata' }
[PSCustomObject]@{
SuspectPathServices = ($suspect | Select-Object -ExpandProperty Name) -join ', '
AutoStoppedServices = (Get-Service |
Where-Object { $_.StartType -eq 'Automatic' -and $_.Status -eq 'Stopped' } |
Select-Object -ExpandProperty Name) -join ', '
}
}
function Get-NetworkSignal {
$public = Get-NetTCPConnection -State Established |
Where-Object { $_.RemoteAddress -notmatch '^10\.|^172\.(1[6-9]|2[0-9]|3[01])\.|^192\.168\.|^127\.|^::1' }
[PSCustomObject]@{
EstablishedConnections = (Get-NetTCPConnection -State Established | Measure-Object).Count
ExternalConnectionCount = ($public | Measure-Object).Count
ListeningPorts = ((Get-NetTCPConnection -State Listen).LocalPort | Sort-Object -Unique) -join ', '
}
}
Each function returns a single object with the same shape every run. That stability is what makes day-over-day diffs trivial.
Step 3 — Run the collection and assemble one record
The top-level loop wraps each signal function in a try/catch so a single broken section does not lose the whole audit:
function Invoke-Signal {
param([scriptblock]$Block, [string]$Name)
try { & $Block }
catch {
Write-Warning "$Name failed: $($_.Exception.Message)"
[PSCustomObject]@{ Error = $_.Exception.Message }
}
}
$record = [ordered]@{
Timestamp = Get-Date
Host = $hostName
System = Invoke-Signal { Get-SystemSignal } 'System'
Security = Invoke-Signal { Get-SecuritySignal } 'Security'
Accounts = Invoke-Signal { Get-AccountSignal } 'Accounts'
Services = Invoke-Signal { Get-ServiceSignal } 'Services'
Network = Invoke-Signal { Get-NetworkSignal } 'Network'
}
The [ordered] hashtable keeps the section order stable, which matters once you start visually diffing reports.
Step 4 — Persist in the formats you actually consume
CSV is for analysts who want to filter in Excel; JSON is for downstream tooling. Write both unless you have a reason not to:
if ($Format -in 'Json','Both') {
$record | ConvertTo-Json -Depth 6 |
Set-Content -Path "$reportPath.json" -Encoding UTF8
}
if ($Format -in 'Csv','Both') {
# Flatten the nested structure into a single row per signal area
foreach ($section in 'System','Security','Accounts','Services','Network') {
$row = $record[$section] |
Select-Object @{Name='Section';Expression={$section}}, * -ExcludeProperty PS*
$row | Export-Csv -Path "$reportPath.csv" -NoTypeInformation -Encoding UTF8BOM -Append
}
}
UTF8BOM keeps Excel from mojibaking non-ASCII characters; see the CSV guide linked below for the long version.
Step 5 — Summarise on the console, write detail to disk
Interactive runs benefit from a short summary block. Keep the detail in the file:
Write-Host ""
Write-Host "Audit complete on $hostName at $(Get-Date)" -ForegroundColor Green
Write-Host " OS: $($record.System.OS)"
Write-Host " Uptime (days): $($record.System.UptimeDays)"
Write-Host " Local admins: $($record.Accounts.LocalAdministrators)"
Write-Host " Ext. connections:$($record.Network.ExternalConnectionCount)"
Write-Host " Report: $reportPath"
Console output is just for the operator at the keyboard. The actual record on disk is what feeds the comparison.
Step 6 — Schedule the script and diff successive runs
Register the script as a scheduled task running daily under SYSTEM. Point the output path at a writable share or sync it to one. The day-over-day diff is where the value actually lives — new local admin, new auto-stopped service, new external listening port, new unsigned service binary are all signals worth chasing:
Compare-Object -ReferenceObject (Get-Content .\HOST_yesterday.json | ConvertFrom-Json) `
-DifferenceObject (Get-Content .\HOST_today.json | ConvertFrom-Json) `
-Property Accounts.LocalAdministrators
Frequently Asked Questions
Why not use a real compliance scanner like Microsoft Defender or CIS-CAT?
You should, in production. The point of a hand-written script is to teach you what the platform actually exposes, to capture environment-specific signals that off-the-shelf tools miss, and to have something runnable in five seconds during an incident on a box that does not have an agent installed yet.
Should I run this on a Domain Controller?
Yes, but extend the signal list. DCs have additional auditable surfaces — replication health, FSMO role placement, AD recycle bin state, KDS root key, gMSA password retrievers. The same five-function structure scales naturally.
How do I run this across a fleet?
Wrap the body in Invoke-Command -ComputerName $list -ScriptBlock { ... } or push the script via Group Policy and have each host write to a central share. The Remote Management guide linked below covers the patterns.
Why use PSCustomObject instead of a hashtable?
PSCustomObject renders predictably through Export-Csv and ConvertTo-Json, preserves property order in PowerShell 7, and behaves like a real object in the pipeline. Hashtables are fine for keyed lookups, not for tabular output.
What signals should I add next?
The four we add second are: scheduled tasks created in the last 14 days, Authenticode signature status on every service executable, registry persistence locations, and current Group Policy applied. Each is a few lines; together they more than double the value of the audit.
Conclusion
An audit script is not a one-off deliverable; it is a long-running record of what "normal" looks like on the systems you administer. Start with the five signal areas above, keep the structure stable, and add a function whenever a new question turns out to be one you ask twice. Within a quarter you will have something more useful for your specific environment than any out-of-the-box scanner.
Related Posts
- PowerShell Quick Guide: Exporting Data to CSV Files — the encoding and delimiter rules that make audit CSVs survive Excel.
- PowerShell Quick Guide: Remote Management Basics — running the audit across a fleet rather than one host at a time.
- Windows Security: Best Practices for Securing Windows Services — the hardening guidance the audit script effectively measures against.
Authoritative reference for the script structure: PowerShell Cmdlet Overview on Microsoft Learn.
0 comments:
Post a Comment