PowerShell Quick Guide: Creating Your First Security Audit Script

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-CimInstance so 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

Authoritative reference for the script structure: PowerShell Cmdlet Overview on Microsoft Learn.

0 comments:

Post a Comment