Building a PowerShell Security Audit Pipeline

A first PowerShell security audit script is the single most valuable thing a new Windows admin can build — but a script that runs on one machine and prints to the console is only a third of the job. The useful version is a pipeline: it collects a defined set of signals into stable, structured objects, exports them in a format that survives Excel, and runs across a whole fleet rather than one host at a time. This guide builds that pipeline end to end, from the modular script through clean CSV export to fleet-wide collection over PowerShell remoting.

Key Takeaways

  • A useful PowerShell security audit is modular: one function per signal area (system, security, accounts, services, network), each returning a stable PSCustomObject.
  • Structured objects export trivially to CSV or JSON, but only if you project, encode, and flatten them correctly — the boring details are what make a CSV open cleanly in Excel.
  • Use -Encoding UTF8BOM (PowerShell 7) or UTF8 (5.1) for non-ASCII data, and set the delimiter from the culture so European Excel does not glue every row into column A.
  • Invoke-Command fans the audit out across a fleet in parallel; persistent sessions and clean credential handling keep that safe and fast.
  • The value lives in the day-over-day diff — a new local admin, a new auto-stopped service, a new external listener — so output to a timestamped file and compare runs.

Environment

  • Windows 10/11 or Windows Server 2019/2022, domain-joined for the fleet-collection section.
  • Windows PowerShell 5.1 or PowerShell 7.4. The script uses Get-CimInstance so it works on both; encoding flags differ between editions, noted inline.
  • Local administrator rights on every target — most interesting properties (Security log, command lines, signature paths) require elevation.
  • A writable central share or collector for the reports, and WinRM reachable for remote collection.

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, and certainly not something you can run across three hundred machines a week. A real pipeline does four things: collects a defined set of signals, structures the output so it can be compared over time, persists it in a format other tools actually consume, and executes everywhere at once. The structure below grew out of running exactly that on a fleet for over a year.

Part 1 — Build the Modular Audit Script

Define a signature and a single output path

Start with parameters that make the script schedule-friendly and a 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'
$reportPath = Join-Path $OutputDirectory "$env:COMPUTERNAME`_$timestamp"
New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null

[CmdletBinding()] turns the script into an advanced function and lights up -Verbose for free — worth it for almost zero effort.

One function per signal area

Splitting collection into named functions keeps each one short and reusable. Each returns a single PSCustomObject with the same shape every run, which is what makes day-over-day diffs trivial. Two representative functions — the rest (accounts, services, network) follow the identical pattern:

function Get-SystemSignal {
    $os = Get-CimInstance Win32_OperatingSystem
    $cs = Get-CimInstance Win32_ComputerSystem
    [PSCustomObject]@{
        Hostname   = $env:COMPUTERNAME
        OS         = $os.Caption
        Build      = $os.Version
        LastBoot   = $os.LastBootUpTime
        Domain     = $cs.Domain
        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)
        BitLockerC = (Get-BitLockerVolume -MountPoint 'C:' -ErrorAction SilentlyContinue).ProtectionStatus
        PSVersion  = $PSVersionTable.PSVersion.ToString()
    }
}

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, and an [ordered] hashtable keeps the section order stable for visual diffing:

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      = $env:COMPUTERNAME
    System    = Invoke-Signal { Get-SystemSignal   } 'System'
    Security  = Invoke-Signal { Get-SecuritySignal } 'Security'
    # Accounts, Services, Network follow the same pattern
}

The same five-function structure scales to a Domain Controller — just extend the signal list with replication health, FSMO placement, and the AD recycle bin state. The hardening this audit effectively measures against is covered in best practices for securing Windows services.

Part 2 — Export Cleanly to CSV

Export-Csv looks trivial until you hit Excel: wrong encoding, mangled accents, columns full of System.Object[], or every row glued into column A on a European locale. Four rules cover almost all of it.

Project, encode, and set the delimiter

Always pass -NoTypeInformation (a no-op default on 7, required on 5.1), project only the columns you want, choose an Excel-safe encoding, and match the delimiter to the consuming locale:

# UTF8BOM on PS7 / UTF8 on 5.1 both write a BOM Excel can read;
# (Get-Culture).TextInfo.ListSeparator matches what Excel expects locally
$record.Security |
    Select-Object * -ExcludeProperty PS* |
    Export-Csv -Path "$reportPath.csv" -NoTypeInformation `
               -Encoding UTF8BOM -Delimiter ((Get-Culture).TextInfo.ListSeparator)

Note that in PowerShell 7 the value UTF8 writes without a BOM — the opposite of 5.1 — so use UTF8BOM on 7 and UTF8 on 5.1 if you share scripts across editions.

Flatten collections and append for long runs

Properties holding arrays export as their type name, not their contents. Flatten them with -join in a calculated property, and use -Append for fleet-scale collection rather than buffering everything in memory:

foreach ($computer in $allComputers) {
    Get-CimInstance Win32_OperatingSystem -ComputerName $computer -ErrorAction SilentlyContinue |
        Select-Object PSComputerName, Caption, Version, LastBootUpTime |
        Export-Csv -Path .\os_inventory.csv -NoTypeInformation -Append -Encoding UTF8BOM
}

-Append only checks that the file exists, not that the schema matches — later objects with different properties align by position, not name. Project to a fixed column set with Select-Object on every iteration so the schema stays stable. For JSON, $record | ConvertTo-Json -Depth 6 | Set-Content "$reportPath.json" -Encoding UTF8 preserves the nested structure the flat CSV cannot.

Part 3 — Run It Across a Fleet with Remoting

PowerShell remoting is built on WinRM. Invoke-Command accepts an array of computer names and fans the call out in parallel — the natural way to run the audit everywhere at once:

$servers = (Get-ADComputer -Filter 'OperatingSystem -like "*Server*"').DNSHostName

$results = Invoke-Command -ComputerName $servers -ScriptBlock {
    # the audit collection from Part 1 runs here, on each host
    [PSCustomObject]@{
        Host       = $env:COMPUTERNAME
        LocalAdmins = (Get-LocalGroupMember Administrators |
            Select-Object -ExpandProperty Name) -join '; '
        AutoStopped = (Get-Service |
            Where-Object { $_.StartType -eq 'Automatic' -and $_.Status -eq 'Stopped' }).Name -join '; '
    }
}
$results | Export-Csv .\fleet_audit.csv -NoTypeInformation -Encoding UTF8BOM

Output comes back deserialised, so downstream Where-Object and Sort-Object work but object methods do not — call methods inside the script block. Inside a domain Kerberos handles authentication automatically; when you need a different identity, prompt once with Get-Credential and pass the resulting PSCredential rather than ever putting a password in a plain string. For unattended runs, use a Group Managed Service Account instead of serialising credentials to disk.

Schedule the run and diff successive collections

Register the script as a scheduled task running daily under SYSTEM, pointing the output at a central share. The day-over-day diff is where the value actually lives:

$yesterday = Import-Csv .\fleet_audit_2026-06-26.csv
$today     = Import-Csv .\fleet_audit_2026-06-27.csv
Compare-Object $yesterday $today -Property Host, LocalAdmins |
    Where-Object SideIndicator -eq '=>'

A new local admin, a newly auto-stopped defensive service, or a new external listener that appears between runs is exactly the kind of signal worth chasing. One caution: remoting is also a lateral-movement primitive, so restrict the WinRM firewall rule to management subnets and audit session usage via PowerShell script block logging with Event ID 4104.

Frequently Asked Questions

Why use PSCustomObject instead of a hashtable for audit output?

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 is the difference between UTF8 and UTF8BOM in PowerShell?

In Windows PowerShell 5.1, UTF8 writes a BOM. In PowerShell 7, UTF8 writes without a BOM and UTF8BOM is the variant Excel needs. The defaults differ, so the same script can produce different files on the two editions.

Why does my remote script work locally but fail when it touches a file share?

That is the classic double-hop problem: the first Kerberos hop authenticates you but does not delegate to a second remote server by default. Fix it with resource-based Kerberos constrained delegation (narrow, recommended) rather than CredSSP (broad, less safe).

How do I run the audit against non-domain machines?

Outside a domain there is no Kerberos, so add each target to the client's TrustedHosts list with Set-Item WSMan:\localhost\Client\TrustedHosts, and prefer an HTTPS/5986 listener with a real certificate for anything crossing an untrusted network.

Why not just use a real compliance scanner like Defender or CIS-CAT?

You should in production. The point of a hand-written pipeline is to teach you what the platform actually exposes, to capture environment-specific signals off-the-shelf tools miss, and to have something runnable in seconds during an incident on a host without an agent.

Conclusion

An audit script is not a one-off deliverable; it is a long-running record of what "normal" looks like across the systems you administer. Build it modular so each signal is a few lines, export it with the encoding and delimiter that survive Excel, fan it out over remoting so it covers the whole fleet, and schedule it so the day-over-day diff does the detection for you. Within a quarter you will have something more useful for your specific environment than any out-of-the-box scanner — and the pipeline shape transfers to almost any other collection task you throw at it.

Related Posts

Authoritative references: Export-Csv on Microsoft Learn and Running Remote Commands on Microsoft Learn.

Editorial note: posts on this blog are drafted with AI assistance and then reviewed, edited, and tested against a real environment before publishing. Commands, output, and screenshots come from systems I actually ran the work on.

Automation PowerShell Scripting Sysadmin Windows Security
SecurityScriptographer author

About the author

SecurityScriptographer is written and maintained by one person — a defender who builds and tests the detections, scripts, and Microsoft 365 workflows here before publishing them. More about me · @twi_nox

0 comments:

Post a Comment