Day 13 我們正式開始用 PowerShell 查詢 Active Directory。
目前已經可以取得:
Get-ADUser
Get-ADComputer
Get-ADOrganizationalUnit
也開始把 AD 裡的:
Name
SamAccountName
Enabled
LastLogonDate
DistinguishedName
整理成 CSV。
到了 Day 14,我們開始處理一個企業 IT 很常遇到的需求:
哪些 AD 帳號已經很久沒有登入了?
這個需求看起來很簡單。
例如:
超過 180 天沒登入
→ 找出來
但真的開始做之後,很快會遇到很多問題:
LastLogonDate 到底準不準?
空白代表什麼?
從未登入跟很久沒登入一樣嗎?
Service Account 怎麼辦?
找到之後可以直接 Disable 嗎?
所以今天不會直接做:
Disable-ADAccount
而是先完成一套比較安全的:
Inactive Account Review Report
也就是:
AD
↓
找出疑似閒置帳號
↓
分類
↓
輸出 CSV
↓
人工確認
先定義什麼叫 Inactive
如果沒有先定義標準,「長期未登入」其實沒有意義。
今天先用一個簡單的範例:
Inactive
PowerShell 可以先算出 180 天前:
$CutoffDate = (Get-Date).AddDays(-180)
查看:
$CutoffDate
假設今天執行,可能得到:
2026/03/26 ...
意思就是:
最後登入時間早於這個日期,就列入 Review。
正式環境裡,180 天只是範例。
公司可能使用:
90 Days
180 Days
365 Days
實際標準應該跟公司的帳號生命週期、稽核與資訊安全規範一致。
先把 Enabled User 抓出來
如果帳號本來就已經:
Enabled = False
通常它跟「目前仍啟用但長期沒使用」是不同的盤點議題。
所以今天先鎖定:
Get-ADUser `
-Filter 'Enabled -eq $true'
接著取得我們需要的 Property:
$Users = Get-ADUser -Filter 'Enabled -eq $true'
-Properties `
LastLogonDate,
Department,
Description,
Created,
PasswordLastSet
現在:
$Users.Count
就可以知道目前查到多少個 Enabled User。
先看看 LastLogonDate
例如:
$Users |
Select-Object `
Name,
SamAccountName,
Enabled,
LastLogonDate
可能得到:
Name SamAccountName Enabled LastLogonDate
User A usera True 2026/09/20 08:31
User B userb True 2026/02/10 13:22
User C userc True
User D userd True 2025/11/05 09:10
這裡立刻出現兩種值得注意的帳號。
第一種:
User B
User D
LastLogonDate 很久以前
第二種:
User C
LastLogonDate 空白
這兩種情況最好分開處理。
LastLogonDate 到底是什麼?
在使用:
Get-ADUser -Properties LastLogonDate
時,LastLogonDate 是 PowerShell Active Directory Module 提供的一個比較容易閱讀的屬性表示。
它主要是根據:
lastLogonTimestamp
轉換成 DateTime。
也就是說,它適合拿來做:
大範圍的 Inactive Account 盤點。
但它不是「精確到最後一次登入那一刻」的欄位。
AD 裡跟登入有關的三個名稱很容易搞混
這邊最好先釐清。
屬性 特性 適合用途
lastLogon 每台 DC 各自保存,不複寫 需要較精確的最後登入時間
lastLogonTimestamp 會在 DC 間複寫,但更新不是即時 Inactive Account 稽核
LastLogonDate PowerShell 對 lastLogonTimestamp 的易讀表示 報表、盤點
其中:
lastLogon
如果 Domain 有:
DC01
DC02
DC03
某個 User 最後一次登入 DC02:
DC01 lastLogon = 舊資料
DC02 lastLogon = 最新
DC03 lastLogon = 舊資料
因為 lastLogon 不會在 DC 之間複寫。
如果真的要精確查:
User 最後一次到底在哪個時間登入?
就需要查詢所有 DC,再取最大的 lastLogon。
今天的目標不是做到這麼精細。
今天要做的是:
定期找出可能長期沒使用的帳號。
所以使用 LastLogonDate 比較符合需求。
LastLogonDate 不是即時值
這一點一定要注意。
lastLogonTimestamp 的設計目的,本來就比較偏:
找長期未使用帳號
而不是:
確認 User 10 分鐘前有沒有登入
它的複寫更新可能有數天的延遲;在預設 AD 行為下,常見會落在大約 9~14 天的更新區間。
所以:
LastLogonDate = 2026/09/01
不一定代表:
這個人從 9/1 後完全沒登入過。
這也是為什麼:
7 天未登入
這種非常短的條件,不適合單純靠 LastLogonDate 做精確判定。
但如果是:
180 天
365 天
這種長期 Inactive Account 盤點,這個時間差通常就比較可以接受。
找出 180 天以前登入的帳號
現在有:
$CutoffDate = (Get-Date).AddDays(-180)
就可以:
$InactiveUsers = $Users |
Where-Object {
$_.LastLogonDate -ne $null -and
$_.LastLogonDate -lt $CutoffDate
}
這裡特別加:
$_.LastLogonDate -ne $null
因為我們目前只想找:
有登入紀錄,但很久沒有登入。
而不是把從未登入的帳號混進來。
結果可以:
$InactiveUsers |
Select-Object `
Name,
SamAccountName,
LastLogonDate
例如:
Name SamAccountName LastLogonDate
User B userb 2026/02/10
User D userd 2025/11/05
再找「從未登入」的帳號
接著處理:
LastLogonDate = 空白
可以:
$NeverLoggedOnUsers = $Users |
Where-Object {
$null -eq $_.LastLogonDate
}
例如:
$NeverLoggedOnUsers |
Select-Object `
Name,
SamAccountName,
Created,
Enabled
可能:
Name SamAccountName Created Enabled
User C userc 2026/01/10 True
NewUser01 newuser01 2026/09/20 True
但現在馬上出現新的問題。
從未登入,不代表一定是垃圾帳號
上面的:
User C
Created = 2026/01/10
已經建立很久,卻從未登入。
確實值得確認。
但:
NewUser01
Created = 2026/09/20
可能昨天才建立。
今天沒有登入非常正常。
所以「從未登入」最好再搭配:
Created
一起判斷。
例如:
建立超過 30 天,而且從未登入。
可以:
$NewAccountCutoff =
(Get-Date).AddDays(-30)
接著:
$NeverLoggedOnUsers = $Users |
Where-Object {
$null -eq $_.LastLogonDate -and
$_.Created -lt $NewAccountCutoff
}
這樣就不會把昨天剛建立的新進人員帳號直接列成異常。
把兩種類型分清楚
現在可以把帳號分成:
Inactive
→ 曾經登入過
→ 但最後登入早於 Cutoff Date
NeverLoggedOn
→ 沒有 LastLogonDate
→ 而且建立超過一定天數
這比全部叫:
Inactive
更容易 Review。
因為後續確認方式可能不一樣。
建立 ReviewReason
我們可以替每一筆資料增加:
ReviewReason
例如 Inactive:
$InactiveReport = $InactiveUsers |
Select-Object `
Name,
SamAccountName,
Enabled,
Department,
Description,
Created,
LastLogonDate,
PasswordLastSet,
@{Name="ReviewReason"; Expression={
"LastLogonDate older than 180 days"
}},
DistinguishedName
Never Logged On:
$NeverLogonReport = $NeverLoggedOnUsers |
Select-Object `
Name,
SamAccountName,
Enabled,
Department,
Description,
Created,
LastLogonDate,
PasswordLastSet,
@{Name="ReviewReason"; Expression={
"Never logged on and account older than 30 days"
}},
DistinguishedName
現在報表本身就會告訴你:
為什麼這個帳號被列出來?
最好再算「距離最後登入幾天」
比起只看:
2025/11/05
有時候:
321 Days
更容易理解。
可以建立:
@{
Name = "InactiveDays"
Expression = {
if ($null -ne $_.LastLogonDate) {
(
(Get-Date) -
$_.LastLogonDate
).Days
}
else {
$null
}
}
}
例如:
$InactiveReport = $InactiveUsers |
Select-Object `
Name,
SamAccountName,
Department,
LastLogonDate,
@{Name="InactiveDays"; Expression={
((Get-Date) - $_.LastLogonDate).Days
}}
結果:
Name SamAccountName LastLogonDate InactiveDays
User B userb 2026/02/10 223
User D userd 2025/11/05 320
這在 Review 時非常直覺。
OU 也應該放進報表
我們目前有:
DistinguishedName
例如:
CN=User A,OU=IT,OU=Taipei,DC=contoso,DC=com
但如果報表直接給主管看 DN,可能不太好讀。
今天可以先保留:
DistinguishedName
因為它非常適合後續工程師確認。
例如可以知道:
這是一般 User OU?
Service Account OU?
Admin OU?
Disabled OU?
後面我們再做:
從 DistinguishedName 自動取得第一層 OU。
為什麼 OU 很重要?
假設找出:
svc_backup
LastLogonDate = 300 天以前
如果看到:
OU=ServiceAccounts
就要非常小心。
因為這可能不是給人互動登入使用的帳號。
Service Account 有可能:
不需要 Interactive Logon
拿來跑 Scheduled Task
執行 Windows Service
Application Authentication
所以:
LastLogonDate 很久
不代表:
這個 Service Account 沒有人用。
這是 AD Inactive Account 清理裡很常見的誤判。
可以排除特定 OU
假設公司已經有:
OU=ServiceAccounts
第一版可以先排除。
例如:
$Users = Get-ADUser -Filter 'Enabled -eq $true'
-Properties `
LastLogonDate,
Department,
Description,
Created,
PasswordLastSet |
Where-Object {
$_.DistinguishedName -notlike `
"*OU=ServiceAccounts,*"
}
也可以排除:
OU=Admins
OU=SystemAccounts
不過這種條件最好依公司的 AD 架構調整。
不要直接照範例拿去 Production。
更好的方式:指定 SearchBase
如果公司的正常 User 都放在:
OU=Users,
OU=Taipei,
DC=contoso,
DC=com
與其:
查全 Domain
↓
再排除 Service Account
我反而比較喜歡:
一開始就只查 User OU
例如:
$SearchBase =
"OU=Users,OU=Taipei,DC=contoso,DC=com"
然後:
$Users = Get-ADUser -Filter 'Enabled -eq $true'
-SearchBase $SearchBase -SearchScope Subtree
-Properties `
LastLogonDate,
Department,
Description,
Created,
PasswordLastSet
這樣 Query 的範圍本身就比較精準。
Service Account 不一定只能靠 OU 判斷
真實環境可能沒有整理得那麼漂亮。
例如:
一般 User
Service Account
Admin Account
全部混在同一個 OU。
這時候可能需要靠:
命名規則
Description
Group
EmployeeID
Account Type
CMDB / HR 資料
交叉判斷。
例如公司 Service Account 都是:
svc_
開頭。
可以:
Where-Object {
$_.SamAccountName -notlike "svc_*"
}
但這種規則一定要先確認公司真的有遵循命名規範。
不能看到:
svc_
就當成全世界通用標準。
找到帳號後可以直接 Disable 嗎?
我不建議。
例如今天找到:
user01
InactiveDays = 250
還需要確認很多事情。
可能:
人在留職停薪
長期海外派駐
特殊主管帳號
共用帳號
應用程式帳號
備援帳號
離職流程漏處理
所以今天的 Script 只做到:
Query
↓
Review Report
而不是:
Query
↓
Disable
我希望這個系列後面即使真的做到帳號停用,也要變成:
Query
↓
Report
↓
Review / Approval
↓
Action
↓
Log
而不是全自動亂停。
建立完整 Review Report
現在把兩種帳號合在一起。
首先:
$InactiveDays = 180
$NewAccountGraceDays = 30
$CutoffDate =
(Get-Date).AddDays(-$InactiveDays)
$NewAccountCutoff =
(Get-Date).AddDays(-$NewAccountGraceDays)
取得 User:
$Users = Get-ADUser -Filter 'Enabled -eq $true'
-Properties `
LastLogonDate,
Department,
Description,
Created,
PasswordLastSet
建立 Inactive Account Object
$InactiveUsers = $Users |
Where-Object {
$null -ne $_.LastLogonDate -and
$_.LastLogonDate -lt $CutoffDate
} |
ForEach-Object {
[PSCustomObject]@{
Name =
$_.Name
SamAccountName =
$_.SamAccountName
Enabled =
$_.Enabled
Department =
$_.Department
Created =
$_.Created
LastLogonDate =
$_.LastLogonDate
InactiveDays =
(
(Get-Date) -
$_.LastLogonDate
).Days
PasswordLastSet =
$_.PasswordLastSet
ReviewType =
"Inactive"
ReviewReason =
"Last logon older than $InactiveDays days"
DistinguishedName =
$_.DistinguishedName
}
}
建立 Never Logged On Object
$NeverLoggedOnUsers = $Users |
Where-Object {
$null -eq $_.LastLogonDate -and
$_.Created -lt $NewAccountCutoff
} |
ForEach-Object {
[PSCustomObject]@{
Name =
$_.Name
SamAccountName =
$_.SamAccountName
Enabled =
$_.Enabled
Department =
$_.Department
Created =
$_.Created
LastLogonDate =
$null
InactiveDays =
$null
PasswordLastSet =
$_.PasswordLastSet
ReviewType =
"NeverLoggedOn"
ReviewReason =
"Never logged on and account older than $NewAccountGraceDays days"
DistinguishedName =
$_.DistinguishedName
}
}
合併兩份資料
PowerShell 可以直接:
$ReviewUsers =
@($InactiveUsers) +
@($NeverLoggedOnUsers)
為什麼加:
@()
因為這樣即使結果只有一筆或零筆,後面操作會比較一致。
接著:
$ReviewUsers |
Sort-Object `
ReviewType,
InactiveDays -Descending
就會得到:
Inactive
Inactive
Inactive
NeverLoggedOn
NeverLoggedOn
輸出 CSV
建立:
$ReportFolder =
"C:\Temp\ADReports"
if (-not (Test-Path $ReportFolder)) {
New-Item `
-Path $ReportFolder `
-ItemType Directory |
Out-Null
}
$Date =
Get-Date -Format "yyyyMMdd"
輸出:
$ReviewUsers |
Sort-Object ReviewType, InactiveDays -Descending | Export-Csv
-Path "$ReportFolder\Inactive_AD_Users_$Date.csv" -NoTypeInformation
-Encoding UTF8
最後:
C:\Temp\ADReports
└── Inactive_AD_Users_20260922.csv
報表會長什麼樣?
可能:
Name Account Type LastLogon InactiveDays Created
User A usera Inactive 2025/08/01 417 2022/05/01
User B userb Inactive 2026/01/15 250 2024/01/10
User C userc NeverLoggedOn 2025/11/01
這時候我們已經把原本:
幾千個 AD User
縮小成:
幾十個需要人工確認的 User
這才是 Automation 真正想做到的事情。
做一份 Summary
除了詳細報表,也可以產生簡單統計:
$Summary = [PSCustomObject]@{
CheckTime =
Get-Date
InactiveThresholdDays =
$InactiveDays
EnabledUsersChecked =
@($Users).Count
InactiveUsers =
@($InactiveUsers).Count
NeverLoggedOnUsers =
@($NeverLoggedOnUsers).Count
TotalReviewUsers =
@($ReviewUsers).Count
}
例如:
CheckTime : 2026/09/22
InactiveThresholdDays : 180
EnabledUsersChecked : 1850
InactiveUsers : 73
NeverLoggedOnUsers : 12
TotalReviewUsers : 85
主管如果只是想知道:
這次盤點有多少筆?
就不需要先打開 85 筆資料慢慢數。
把 Summary 也輸出
$Summary |
Export-Csv -Path "$ReportFolder\Inactive_AD_Summary_$Date.csv"
-NoTypeInformation `
-Encoding UTF8
最後:
ADReports
│
├── Inactive_AD_Users_20260922.csv
└── Inactive_AD_Summary_20260922.csv
一份看 Summary。
一份做 Review。
今天完整 Script
把 Day 14 主要內容整合起來:
Import-Module ActiveDirectory
$InactiveDays = 180
$NewAccountGraceDays = 30
$ReportFolder =
"C:\Temp\ADReports"
$Date =
Get-Date -Format "yyyyMMdd"
$CutoffDate =
(Get-Date).AddDays(
-$InactiveDays
)
$NewAccountCutoff =
(Get-Date).AddDays(
-$NewAccountGraceDays
)
if (-not (Test-Path $ReportFolder)) {
New-Item `
-Path $ReportFolder `
-ItemType Directory |
Out-Null
}
$Users = Get-ADUser -Filter 'Enabled -eq $true'
-Properties `
LastLogonDate,
Department,
Description,
Created,
PasswordLastSet
$InactiveUsers = $Users |
Where-Object {
$null -ne $_.LastLogonDate -and
$_.LastLogonDate -lt $CutoffDate
} |
ForEach-Object {
[PSCustomObject]@{
Name =
$_.Name
SamAccountName =
$_.SamAccountName
Enabled =
$_.Enabled
Department =
$_.Department
Description =
$_.Description
Created =
$_.Created
LastLogonDate =
$_.LastLogonDate
InactiveDays =
(
(Get-Date) -
$_.LastLogonDate
).Days
PasswordLastSet =
$_.PasswordLastSet
ReviewType =
"Inactive"
ReviewReason =
"Last logon older than $InactiveDays days"
DistinguishedName =
$_.DistinguishedName
}
}
$NeverLoggedOnUsers = $Users |
Where-Object {
$null -eq $_.LastLogonDate -and
$_.Created -lt $NewAccountCutoff
} |
ForEach-Object {
[PSCustomObject]@{
Name =
$_.Name
SamAccountName =
$_.SamAccountName
Enabled =
$_.Enabled
Department =
$_.Department
Description =
$_.Description
Created =
$_.Created
LastLogonDate =
$null
InactiveDays =
$null
PasswordLastSet =
$_.PasswordLastSet
ReviewType =
"NeverLoggedOn"
ReviewReason =
"Never logged on and account older than $NewAccountGraceDays days"
DistinguishedName =
$_.DistinguishedName
}
}
$ReviewUsers =
@($InactiveUsers) +
@($NeverLoggedOnUsers)
$Summary = [PSCustomObject]@{
CheckTime =
Get-Date
InactiveThresholdDays =
$InactiveDays
EnabledUsersChecked =
@($Users).Count
InactiveUsers =
@($InactiveUsers).Count
NeverLoggedOnUsers =
@($NeverLoggedOnUsers).Count
TotalReviewUsers =
@($ReviewUsers).Count
}
$ReviewUsers |
Sort-Object ReviewType, InactiveDays -Descending | Export-Csv
-Path "$ReportFolder\Inactive_AD_Users_$Date.csv" -NoTypeInformation
-Encoding UTF8
$Summary |
Export-Csv -Path "$ReportFolder\Inactive_AD_Summary_$Date.csv"
-NoTypeInformation `
-Encoding UTF8
Write-Host ""
Write-Host "===== AD Inactive Account Review ====="
Write-Host ""
$Summary
Write-Host ""
Write-Host "Report:"
Write-Host "$ReportFolder\Inactive_AD_Users_$Date.csv"
這支 Script 還故意沒有做一件事情
你會發現整支程式完全沒有:
Disable-ADAccount
也沒有:
Remove-ADUser
這是故意的。
因為目前我們掌握的只是:
登入時間
帳號狀態
建立時間
OU
Description
Department
這些資料足夠:
找出值得 Review 的帳號。
但還不足以:
自動決定帳號可以被停用。
真正企業流程應該再多一層
我比較希望後面做到:
PowerShell 找出 Inactive Account
↓
Review CSV
↓
帳號負責人確認
↓
主管 / IT 確認
↓
Approved List
↓
PowerShell Action
↓
Log
也就是:
Detection 跟 Action 分開。
這個概念其實不只適用 AD。
前面 Server Health Check 也是一樣:
Disk > 90%
我們先:
Report / Alert
而不是看到:
Disk > 90%
就直接:
自動刪檔案
Automation 並不是:
看到異常就什麼都自動做。
真正重要的是:
哪些步驟適合自動、哪些步驟必須保留人工判斷。
Day 14 小結
今天第一次做出真正比較接近企業 AD 稽核的功能:
Enabled User
↓
LastLogonDate
↓
Cutoff Date
↓
├── 曾登入但長期未登入
│
└── 從未登入且建立已久
↓
Review Report
也開始釐清:
lastLogon
lastLogonTimestamp
LastLogonDate
不能全部當成同一回事。
今天最重要的幾個觀念是:
LastLogonDate 適合長期 Inactive Review
但不是精確的即時登入記錄。
LastLogonDate 空白
也不能直接等同垃圾帳號。
Service Account
不能只靠 User Logon 判斷使用狀態。
找到 Inactive
不等於可以直接 Disable。
所以目前我們做的是:
Inactive Account Detection。
而不是:
Inactive Account Deletion。
Day 15 預告
Day 15|AD Computer 物件盤點:找出長期未登入與可能已淘汰的電腦
今天處理的是:
User Account
但 Active Directory 裡另外一種非常容易累積的東西就是:
Computer Object
例如電腦:
已報廢
已重灌
換新機
離職設備
Hostname 改名
長期沒有連回公司網路
但 AD Object 一直留著。
所以 Day 15 我們會把今天的邏輯搬到:
Get-ADComputer
做:
Enabled Computer
↓
LastLogonDate
↓
Operating System
↓
Computer Age
↓
Inactive Days
↓
OU
↓
Review Report
最後產生:
Inactive AD Computer Review Report
並開始討論另一個實務問題:
「Computer 180 天沒登入,是不是就代表這台電腦可以從 AD 刪掉?」
答案一樣不會這麼簡單。