~/2026/09/02/windows-management-building-a-discovery-inventory-nobody-has-to-maintain-by-hand.md
Windows Management: A Discovery Inventory Nobody Maintains by Hand
--- author: Tom Lasswell date: read: 5 min in: [engineering, strategy] tags: [discovery, windows, active-directory] ---
$ grep -n '^#' post.md
Every Windows shop I have worked in has had at least one asset spreadsheet that was accurate for about a month after someone built it and has been slowly lying ever since. The problem was never a lack of effort, it was that the inventory depended on a human remembering to update it every time a machine was imaged, retired, or moved to a different subnet. The inventories that have actually stayed useful for me are the ones nobody has to remember to touch.
This post is the design and the scripts I use to get there: a scheduled sweep that asks Active Directory what should exist, asks each machine over CIM what it actually is, merges the answers into a file that remembers when each machine was last seen, and reports on the ones that have gone quiet.
The spreadsheet fails the moment it requires a human step
A manually maintained inventory has exactly one job and one failure mode: someone forgets to add a row, or forgets to remove one, and from that moment the document is actively wrong rather than merely incomplete. Wrong is worse than incomplete, because an incomplete inventory gets treated with appropriate suspicion while a wrong one gets trusted right up until it causes a bad decision, like decommissioning a server the spreadsheet says is unused. Any inventory design that depends on someone remembering a manual step will eventually fail exactly that way, no matter how well-intentioned the team maintaining it is.
Query the machines instead of asking people to report them
The fix is to make discovery something the machines answer, not something a person types. There are two sources, and they answer different questions:
- Active Directory says what should exist.
Get-ADComputerreturns every enabled computer account, and with-Propertiesit addsOperatingSystem,OperatingSystemVersion,LastLogonDateandIPv4Address(resolved from DNS) without touching the machine. - CIM says what the machine actually is.
Win32_OperatingSystemhasCaption,BuildNumberandLastBootUpTime;Win32_ComputerSystemhas manufacturer, model and memory;Win32_BIOShas the serial number your hardware vendor and your warranty lookups want.
New-CimSession uses WSMan by default when you give it a computer name, so the same WinRM configuration you already need for PowerShell remoting covers it. For the handful of old servers where WinRM isn't available, New-CimSessionOption -Protocol Dcom gets you a DCOM session instead. Pass an array of sessions to Get-CimInstance and it queries them all at once, tagging each result with PSComputerName.
One thing AD can't tell you is whether a machine is alive right now. LastLogonDate is derived from lastLogonTimestamp, and Microsoft documents that by default that attribute is only updated when its current value is 9 to 14 days older than the logon, to keep replication load down. It's fine for "hasn't been seen in 90 days" and useless for "is it on this week". That's why the sweep keeps its own LastSeen.
Here is the sweep. It needs the Active Directory module (RSAT), WinRM reachable on the targets, and an account with admin rights on them (the default WinRM endpoint only admits Administrators and, if you configure it, Remote Management Users):
<#
.SYNOPSIS
Discovers Windows computers from Active Directory, collects OS and hardware facts
over CIM, and merges them into an inventory CSV that tracks when each was last seen.
.DESCRIPTION
Reads enabled computer accounts from AD, opens CIM sessions (WSMan) in bulk, queries
Win32_OperatingSystem, Win32_ComputerSystem and Win32_BIOS, and merges the results
into the existing inventory. Reachable machines get fresh facts and a new LastSeen;
unreachable ones keep their last known facts and their old LastSeen, so they age
into the staleness report instead of disappearing.
.PARAMETER SearchBase
Distinguished name of the OU to discover. Defaults to the whole domain.
.PARAMETER InventoryPath
Inventory CSV to read and rewrite.
.PARAMETER TimeoutSec
CIM operation timeout per computer, in seconds.
.EXAMPLE
.\Invoke-DiscoverySweep.ps1 -SearchBase "OU=Workstations,DC=corp,DC=example,DC=com" -InventoryPath D:\Inventory\inventory.csv
.NOTES
Author : Thomas Lasswell (https://www.techcolumnist.com)
Version : 1.0 (2026-09-02)
Requires: ActiveDirectory module (RSAT); WinRM on targets; admin rights on targets
#>
[CmdletBinding()]
param (
[string]$SearchBase,
[string]$InventoryPath = ".\inventory.csv",
[int]$TimeoutSec = 30
)
Import-Module -Name ActiveDirectory -ErrorAction Stop
$now = Get-Date
$adParams = @{
Filter = 'Enabled -eq $true'
Properties = "OperatingSystem", "OperatingSystemVersion", "LastLogonDate", "IPv4Address"
}
if ($SearchBase) {
$adParams.SearchBase = $SearchBase
}
$adComputers = @(Get-ADComputer @adParams)
Write-Host "AD returned $($adComputers.Count) enabled computer account(s)."
# Load the previous inventory so unreachable machines keep their last known facts.
$inventory = @{}
if (Test-Path -Path $InventoryPath) {
foreach ($row in Import-Csv -Path $InventoryPath) {
$inventory[$row.Name] = $row
}
}
# Open CIM sessions in bulk; failures land in $sessionErrors instead of stopping the run.
$sessionOption = New-CimSessionOption -Protocol Wsman
$targets = @($adComputers | Where-Object { $_.DNSHostName } | Select-Object -ExpandProperty DNSHostName)
$sessions = @(New-CimSession -ComputerName $targets -SessionOption $sessionOption -OperationTimeoutSec $TimeoutSec -ErrorAction SilentlyContinue -ErrorVariable sessionErrors)
Write-Host "Opened $($sessions.Count) CIM session(s); $(@($sessionErrors).Count) computer(s) unreachable."
$facts = @{}
if ($sessions.Count -gt 0) {
$cimParams = @{ CimSession = $sessions; OperationTimeoutSec = $TimeoutSec; ErrorAction = "SilentlyContinue" }
foreach ($os in Get-CimInstance @cimParams -ClassName Win32_OperatingSystem -Property Caption, BuildNumber, LastBootUpTime) {
$facts[$os.PSComputerName] = @{ OS = $os }
}
foreach ($cs in Get-CimInstance @cimParams -ClassName Win32_ComputerSystem -Property Manufacturer, Model, TotalPhysicalMemory) {
if ($facts.ContainsKey($cs.PSComputerName)) { $facts[$cs.PSComputerName].CS = $cs }
}
foreach ($bios in Get-CimInstance @cimParams -ClassName Win32_BIOS -Property SerialNumber) {
if ($facts.ContainsKey($bios.PSComputerName)) { $facts[$bios.PSComputerName].BIOS = $bios }
}
$sessions | Remove-CimSession
}
foreach ($computer in $adComputers) {
$previous = $inventory[$computer.Name]
$live = if ($computer.DNSHostName) { $facts[$computer.DNSHostName] } else { $null }
if ($live) {
$inventory[$computer.Name] = [pscustomobject]@{
Name = $computer.Name
DNSHostName = $computer.DNSHostName
IPv4Address = $computer.IPv4Address
OSCaption = $live.OS.Caption
OSBuild = $live.OS.BuildNumber
LastBoot = $live.OS.LastBootUpTime.ToString("s")
Manufacturer = $live.CS.Manufacturer
Model = $live.CS.Model
MemoryGB = [math]::Round($live.CS.TotalPhysicalMemory / 1GB, 1)
SerialNumber = $live.BIOS.SerialNumber
ADLastLogon = if ($computer.LastLogonDate) { $computer.LastLogonDate.ToString("s") } else { "" }
LastSeen = $now.ToString("s")
InAD = $true
}
} elseif ($previous) {
# Unreachable this run: keep the old facts and the old LastSeen, refresh the AD fields.
$previous.IPv4Address = $computer.IPv4Address
$previous.ADLastLogon = if ($computer.LastLogonDate) { $computer.LastLogonDate.ToString("s") } else { "" }
$previous.InAD = $true
} else {
# Known to AD but never reached: record it so it shows up as never seen.
$inventory[$computer.Name] = [pscustomobject]@{
Name = $computer.Name
DNSHostName = $computer.DNSHostName
IPv4Address = $computer.IPv4Address
OSCaption = $computer.OperatingSystem
OSBuild = $computer.OperatingSystemVersion
LastBoot = ""
Manufacturer = ""
Model = ""
MemoryGB = ""
SerialNumber = ""
ADLastLogon = if ($computer.LastLogonDate) { $computer.LastLogonDate.ToString("s") } else { "" }
LastSeen = ""
InAD = $true
}
}
}
# Rows whose computer account is gone or disabled stay in the file, flagged.
$adNames = @($adComputers.Name)
foreach ($name in @($inventory.Keys)) {
if ($adNames -notcontains $name) {
$inventory[$name].InAD = $false
}
}
$inventory.Values | Sort-Object -Property Name | Export-Csv -Path $InventoryPath -NoTypeInformation
Write-Host "Inventory written to $InventoryPath ($($inventory.Count) rows, $($facts.Count) refreshed this run)."
Sample console output from a run against a workstation OU:
AD returned 412 enabled computer account(s).
Opened 377 CIM session(s); 35 computer(s) unreachable.
Inventory written to D:\Inventory\inventory.csv (419 rows, 377 refreshed this run).
Notice the row count is higher than the AD count. The extra rows are computers whose accounts were disabled or deleted since the last sweep. They stay in the file with InAD = False until someone confirms the hardware is really gone, which is exactly the conversation a spreadsheet never forces.
Staleness is a feature, not a bug, if you surface it
An automated inventory should record when each entry was last refreshed, and that timestamp is more valuable than most of the other fields combined. A machine that has not reported in three weeks is telling you something: it is off the network, decommissioned without the paperwork catching up, or broken in a way worth investigating. I treat "last seen" as the primary health signal of the whole inventory system, and the stale-entries report has turned up orphaned assets that no human review of a spreadsheet ever caught.
The report is deliberately small. It reads the inventory and sorts machines into the buckets that need different follow-ups:
<#
.SYNOPSIS
Reports inventory rows that have not been seen recently.
.PARAMETER InventoryPath
Inventory CSV written by Invoke-DiscoverySweep.ps1.
.PARAMETER StaleDays
Days since LastSeen after which a machine counts as stale.
.EXAMPLE
.\Get-StaleInventory.ps1 -InventoryPath D:\Inventory\inventory.csv -StaleDays 21
.NOTES
Author : Thomas Lasswell (https://www.techcolumnist.com)
Version : 1.0 (2026-09-02)
#>
[CmdletBinding()]
param (
[string]$InventoryPath = ".\inventory.csv",
[int]$StaleDays = 21
)
$cutoff = (Get-Date).AddDays(-$StaleDays)
Import-Csv -Path $InventoryPath | ForEach-Object {
$lastSeen = if ($_.LastSeen) { [datetime]$_.LastSeen } else { $null }
$bucket = if ($_.InAD -eq "False") {
"Removed from AD: confirm disposal"
} elseif (-not $lastSeen) {
"Never reached: check WinRM, firewall or whether it exists"
} elseif ($lastSeen -lt $cutoff) {
"Stale: not seen in $StaleDays+ days"
} else {
$null
}
if ($bucket) {
[pscustomobject]@{
Name = $_.Name
Bucket = $bucket
LastSeen = $_.LastSeen
ADLastLogon = $_.ADLastLogon
Model = $_.Model
Serial = $_.SerialNumber
}
}
} | Sort-Object -Property Bucket, LastSeen | Format-Table -AutoSize
Name Bucket LastSeen ADLastLogon Model Serial
---- ------ -------- ----------- ----- ------
WKS-0091 Never reached: check WinRM, firewall or whether it exists 2026-05-02T08:14:51
WKS-0310 Removed from AD: confirm disposal 2026-07-28T02:00:12 2026-07-21T09:33:02 Latitude 5440 <serial>
WKS-0142 Stale: not seen in 21+ days 2026-08-03T02:00:09 2026-08-01T16:20:45 OptiPlex 7010 <serial>
Reading ADLastLogon next to LastSeen is where the insight is. Stale in the sweep but recently logged on in AD usually means the machine is alive and the sweep can't reach it (a firewall rule, a VPN-only laptop). Stale in both is a machine that's genuinely gone somewhere.
Store it somewhere that answers questions, not just holds rows
A spreadsheet is a poor home for this kind of data past a few hundred rows, because nobody can ask it a real question without exporting it somewhere else first. Landing the discovery data somewhere Power BI (or an equivalent) can query directly, even a CSV on a file share to start with, turns the inventory into something that answers "which machines are still running an unsupported OS build" in seconds instead of a manual filter-and-scroll exercise:
Import-Csv -Path D:\Inventory\inventory.csv |
Where-Object { $_.OSCaption -like "*Windows 11*" } |
Group-Object -Property OSBuild |
Sort-Object -Property Name |
Format-Table Name, Count -AutoSize
The format matters less than the principle: the inventory should be queryable by the people who need answers, not just readable by the person who happens to have it open. The same file joins cleanly with the per-machine CSVs from the software inventory script and the local admin audit, because all three are keyed by computer name.
Schedule it, or it's just another spreadsheet
The last step is the one that makes it maintenance-free: run the sweep on a schedule from a management server, as a service account that has admin rights on the targets, so nobody has to remember to run it either.
$credential = Get-Credential -Message "Discovery service account (for example CORP\svc-discovery)"
$action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument '-NoProfile -ExecutionPolicy Bypass -File "D:\Inventory\Invoke-DiscoverySweep.ps1" -InventoryPath "D:\Inventory\inventory.csv"'
$trigger = New-ScheduledTaskTrigger -Daily -At "02:00"
Register-ScheduledTask -TaskName "Discovery Sweep" -TaskPath "\Inventory\" -Action $action -Trigger $trigger -User $credential.UserName -Password $credential.GetNetworkCredential().Password -RunLevel Highest
Keep the collection script simpler than the reporting layer
The temptation once this is running is to keep adding fields to the collection step until it is fetching everything CIM can possibly return. I have found the opposite discipline pays off better: keep what each sweep collects narrow and fast so the collection never becomes fragile or slow enough that people start disabling it, and push the complexity into the reporting queries instead, where it can change without touching a thousand endpoints. The sweep above asks three CIM classes for eight properties, and uses -Property so the remote side only returns those. An inventory that nobody has to maintain by hand only stays that way if the thing collecting it stays simple enough that it never needs hand maintenance either.