~/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: 
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-ADComputer returns every enabled computer account, and with -Properties it adds OperatingSystem, OperatingSystemVersion, LastLogonDate and IPv4Address (resolved from DNS) without touching the machine.
  • CIM says what the machine actually is. Win32_OperatingSystem has Caption, BuildNumber and LastBootUpTime; Win32_ComputerSystem has manufacturer, model and memory; Win32_BIOS has 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):

powershell
<#
.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:

text
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:

powershell
<#
.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
text
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:

powershell
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.

powershell
$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.

References