用 PowerShell 為檔案伺服器盤點 ── 容量調查與存取權限(ACL)稽核

· · PowerShell, Windows, 檔案伺服器, ACL, 存取權限, 維運改善, 資訊安全, 腳本

「檔案伺服器的可用空間已經不到一成了」── 從這句警告開始的工作,通常都很讓人憂鬱。不知道容量被什麼佔用了。也判斷不出那個十年前、沒有人碰過的資料夾能不能刪除。甚至連誰能存取哪裡、離職者的權限是否還殘留著,被問到時也無法立刻回答。用檔案總管一個一個開啟屬性視窗調查,面對數 TB 的共用資料夾根本查不完。

每次動手處理這類委託時都會想到,整理檔案伺服器不是「刪除的技術」,而是「盤點的技術」。若沒有數字和清單,只問「可以刪除嗎」,部門絕對不會點頭。反過來,若有一張表寫著「光是這個資料夾就有 800GB,其中 3 年以上未更新的檔案佔 620GB」,事情就會一口氣往前推進。

本文以中小企業的資訊系統・維運人員為對象,整理用 PowerShell 盤點檔案伺服器的容量與存取權限(ACL),並落實成 CSV 報表的實務步驟。方針一貫是「先讀取,變更放到最後,並附上備份與 -WhatIf」。

1. 先講結論

  • 容量調查的基本形是 Get-ChildItem -Recurse + Measure-Object -Sum。不要一開始就彙總整體,先以第一層資料夾為單位,鎖定「肥大的位置」在哪裡。12
  • 存取拒絕不要吞掉,務必記錄下來。用 -ErrorAction SilentlyContinue 讓掃描不中斷,同時務必用 -ErrorVariable 記下被拒絕的位置。無法計數的位置代表的是「不明」,而不是「零」。3
  • 判斷舊檔案以 LastWriteTime 為基準。NTFS 的最終存取日期時間在許多環境中預設會停用更新(或改由系統管理),不能當作可靠的判斷依據。45
  • 重複檔案用 Get-FileHash(預設 SHA256)擷取「候選」。先用容量篩選、再計算雜湊,是節省 I/O 的標準做法。6
  • ACL 的盤點使用 Get-Acl 的 Access 屬性。把 IdentityReference(誰)、FileSystemRights(做什麼)、IsInherited(繼承或直接授予)輸出成 CSV,再用 AreAccessRulesProtected 偵測繼承中斷的位置。78
  • 本文盤點的對象是 NTFS 的存取權限。透過共用(SMB)存取時的實際有效權限,是由共用端的存取權限與 NTFS ACL 組合決定的。共用端請另外用 Get-SmbShareAccess 列出清單,與 NTFS 的台帳一起檢視。9
  • CSV 報表的文字編碼要明確指定。Windows PowerShell 5.1 的 Export-Csv 預設是 ASCII,中文會亂碼;PowerShell 7 的預設是不含 BOM 的 UTF-8,有時和 Excel 的預設行為不相容。1011
  • 變更(Set-Acl)要等盤點與部門確認完成之後才進行。先用 icacls /save 取得備份,再用 -WhatIf 確認對象後才套用。1213

依本文步驟走完一輪之後,手邊會留下以下 4 項成果物。先在這裡呈現完成的樣貌。

成果物 製作章節 欄位 能看出什麼
依資料夾分類的容量排行 第 2 章 Folder / SizeGB / FileCount 哪個資料夾比較肥大。「共用整體 ◯GB,其中業務部佔 ◯GB」這類說法所需的數字都能從這裡取得
old-files.csv 第 3 章 FullName / LastWriteTime / SizeMB 3 年以上未更新的檔案清單。依 SizeMB 降冪排序,前幾十列就能看出「有效的容量」
duplicate-candidates.csv 第 3 章 Hash / Path 內容相同的檔案群組。依 Hash 欄排序後,相同內容的檔案會排在一起
acl-report.csv 第 4 章 Path / Identity / Rights / Type / IsInherited / Inheritance / InheritanceBroken 誰在哪裡擁有什麼權限的台帳,一筆 ACE 對應一列

除此之外,還會產生記錄無法掃描位置的文字檔(denied-paths.txt、old-files-uninspected.txt、hash-uninspected.txt、acl-uninspected.txt)。如果這些檔案不是空的,上述 4 項成果物就是「有破洞的報表」。交付時請務必附上這項說明。

開頭寫的「光是這個資料夾就有 800GB,其中 3 年以上未更新的檔案佔 620GB」這句話,只要把第一項和第二項對照起來就能寫出來。後半的數字,只需要重新讀取 old-files.csv 並加總即可。

# 從 old-files.csv 算出「3 年以上未更新的檔案總容量」
$old   = Import-Csv .\old-files.csv
$sumMB = ($old | ForEach-Object { [double]$_.SizeMB } | Measure-Object -Sum).Sum
'3 年以上未更新: {0} 筆 / {1:N1} GB' -f $old.Count, ($sumMB / 1024)

2. 容量調查 ── 把哪裡肥大變成數字

第一步要做的,是共用根目錄下每個資料夾的總容量。用 Get-ChildItem 遞迴列舉檔案,再用 Measure-Object 加總 Length。12

$root = 'D:\share'   # 預期在檔案伺服器上執行。透過 UNC 執行時的注意事項見第 6 章
$denied = @()

# 以第一層資料夾為單位彙總 ── 比起一次彙總整體,先「鎖定範圍」比較優先
# 為了讓根目錄本身的列舉失敗也記入 $denied,外層的 Get-ChildItem 也加上相同的指定
$report = foreach ($dir in Get-ChildItem -LiteralPath $root -Directory `
    -ErrorAction SilentlyContinue -ErrorVariable +denied) {
    # 用 SilentlyContinue 避免因存取拒絕而中斷,但拒絕的記錄會累積到 $denied
    # 列舉直接用管線送進 Measure-Object(若先存進變數,會把全部 FileInfo 保留在記憶體中,
    # 對檔案數達數百萬等級的資料夾會很吃力)
    $stats = Get-ChildItem -LiteralPath $dir.FullName -Recurse -File `
        -ErrorAction SilentlyContinue -ErrorVariable +denied |
        Measure-Object -Property Length -Sum
    [PSCustomObject]@{
        Folder    = $dir.Name
        SizeGB    = [math]::Round([double]$stats.Sum / 1GB, 2)
        FileCount = $stats.Count
    }
}
# 直接放在根目錄下的檔案也算成一列(否則會從資料夾單位的彙總中漏掉)
$rootStats = Get-ChildItem -LiteralPath $root -File `
    -ErrorAction SilentlyContinue -ErrorVariable +denied |
    Measure-Object -Property Length -Sum
if ($rootStats.Count -gt 0) {
    $report += [PSCustomObject]@{
        Folder    = '(根目錄下)'
        SizeGB    = [math]::Round([double]$rootStats.Sum / 1GB, 2)
        FileCount = $rootStats.Count
    }
}

$report | Sort-Object SizeGB -Descending | Format-Table -AutoSize

# 務必保留「無法計數的位置」── 若這裡不是空的,代表彙總並不完整
$denied | ForEach-Object { $_.TargetObject } | Sort-Object -Unique |
    Set-Content -Path .\denied-paths.txt

重點有兩個。第一,-ErrorAction SilentlyContinue 並不是「當作沒看到錯誤」的開關。它只是抑制顯示並繼續執行,錯誤本身確實有發生。3 所以要用 -ErrorVariable 來接住它。在變數名稱前面加上 +,就會變成追加而非覆寫,可以把整個迴圈的拒絕記錄集中到同一個變數裡。3 被拒絕存取的資料夾會從彙總中漏掉,報表上看起來會比實際容量小。若 denied-paths.txt 不是空的,請務必在報表中附上這項說明。若想把錯誤處理的設計再深入一步,同期發布的「PowerShell 的錯誤處理與重試設計」可以參考。

第二個重點是深層路徑的問題。經年累月運作的共用資料夾,幾乎必定存在路徑超過 260 個字元(MAX_PATH)的位置,依工具不同,列舉可能會在那裡失敗。14 若掃描結果出現不自然的缺漏,請先懷疑是路徑長度的問題。這項限制的全貌與因應方式,整理在「MAX_PATH 與 Windows 路徑・檔案名稱的陷阱」。

3. 舊檔案與重複候選 ── 從列表化開始,而非刪除

3.1. 把「3 年沒動過的檔案」列成清單

找出肥大的位置之後,接下來要收集「可刪除候選」的材料。使用的基準是 LastWriteTime(最終更新日期時間)

$cutoff = (Get-Date).AddYears(-3)

# 3 年以上未更新的檔案清單。不做刪除 ── 先做出給部門看的材料
# 列舉失敗的位置之後也要公開,所以用 -ErrorVariable 記錄下來
Get-ChildItem -LiteralPath $root -Recurse -File `
    -ErrorAction SilentlyContinue -ErrorVariable oldEnumErrors |
    Where-Object LastWriteTime -lt $cutoff |
    Select-Object FullName, LastWriteTime,
        @{ Name = 'SizeMB'; Expression = { [math]::Round($_.Length / 1MB, 2) } } |
    Sort-Object SizeMB -Descending |
    Export-Csv -Path .\old-files.csv -NoTypeInformation -Encoding utf8BOM   # 5.1 的話用 UTF8(兩者都含 BOM)

# 為避免清單看起來「完整無缺」,無法列舉的位置也務必公開
$oldEnumErrors | ForEach-Object { $_.TargetObject } |
    Set-Content -Path .\old-files-uninspected.txt

或許會想「用最終存取日期時間(LastAccessTime)不就能看出『連讀取都沒被讀取過』了嗎」,但這其實是個陷阱。NTFS 因為最終存取日期時間的更新會影響效能,所以透過 fsutil behavior 命令與登錄機碼(NtfsDisableLastAccessUpdate)控制更新的啟用/停用4,Windows Vista 之後預設為停用(近年 Windows 10 之後改由系統管理,伺服器則一律停用)。5 也就是說,明明實際上有被開啟,日期時間卻仍是舊的這種檔案很常見。盤點的說明資料應使用代表「內容最後一次變更的日期」的 LastWriteTime,並明確寫出它的意義,這樣和部門溝通時才不會產生糾紛。

3.2. 列出重複檔案的「候選」

共用資料夾裡總會堆積大量的「最終版」「最終版_修正」「複製 ~」。內容是否相同可以用 Get-FileHash 判斷。預設演算法是 SHA256,雜湊值一致就能判斷檔案內容相同。6

# 對全部檔案計算雜湊,I/O 負擔很重。先只篩選出「大小相同的檔案」
# 列舉失敗的位置(存取拒絕等)之後也要公開,所以用 -ErrorVariable 記錄下來
$candidates = Get-ChildItem -LiteralPath $root -Recurse -File `
    -ErrorAction SilentlyContinue -ErrorVariable enumErrors |
    Group-Object -Property Length |
    Where-Object { $_.Count -ge 2 -and [long]$_.Name -gt 0 } |   # 排除大小為 0 的
    ForEach-Object { $_.Group }

# 只針對篩選後的候選計算雜湊,把一致的群組當作重複「候選」輸出
$candidates |
    Get-FileHash -ErrorAction SilentlyContinue -ErrorVariable hashErrors |   # 預設是 SHA256
    Group-Object -Property Hash |
    Where-Object Count -ge 2 |
    ForEach-Object { $_.Group } |
    Select-Object Hash, Path |
    Export-Csv -Path .\duplicate-candidates.csv -NoTypeInformation -Encoding utf8BOM   # 5.1 的話用 UTF8

# 無法列舉的資料夾,以及因鎖定中・沒有讀取權限等原因無法計算雜湊的檔案,
# 都要以「無法調查」的身分留在清單裡
# (悄悄捨棄的話會和「重複為零」無法區分)
@($enumErrors) + @($hashErrors) | ForEach-Object { $_.TargetObject } |
    Set-Content -Path .\hash-uninspected.txt

之所以刻意稱之為「候選」,是因為要留下哪一份是業務層面的判斷,而不是技術問題。即使內容相同,「部門 A 的正本」和「部門 B 的參考副本」有時候意義並不一樣。不機械式地刪除,而是帶著清單和相關人員討論,這就是這支腳本的守備範圍。雜湊這項工具本身的性質(保證了什麼、又不保證什麼),整理在「從雜湊值的字串表示判斷雜湊方式」。

3.3. 刪除之前先隔離 ── 「隔離 → 觀察 → 刪除」的步驟

即使已經請部門確認過清單,也不會直接進入刪除。第 7 章判斷表中寫的「隔離→觀察→刪除」,具體步驟如下。

  1. 決定隔離目的地。在同一台伺服器上建立一個帶日期的資料夾。盡量放在與原始位置相同的磁碟區。(理由後述)。權限只限縮給系統管理員與負責人。不過,光是限縮資料夾的權限,對裡面的檔案不會生效。因為在同一磁碟區內移動時,原本的權限會原封不動地跟過來,所以必須連同把每個移動過去的檔案還原為繼承預設值的步驟一起做,才算完整(這點也在後面說明)。
  2. 保持原本的階層結構移動。若攤平移動,同名檔案會互相衝突,要復原時也搞不清楚原本的位置。
  3. 留下移動的台帳。若沒有原始路徑與目的地路徑的對應表,就無法回答「那個檔案跑去哪了?」。台帳只能寫實際有被移動的項目。試跑的結果,以及無法移動的項目,請分到不同的檔案。混在一起的話,復原時就會跑去查看根本不存在的位置。
  4. 決定觀察期間。與其定「1 個月」,不如像「跨過一次季度結算」這樣配合業務週期,比較實務。有人詢問時就查台帳還原。
  5. 過了期間就刪除。刪除前,再把台帳分享給相關部門一次。
# 把部門確認過的清單(從 old-files.csv 中只留下對象列的檔案)移到隔離資料夾
$root       = 'D:\share'
$quarantine = 'D:\quarantine\2026-07'
$list       = Import-Csv .\old-files-approved.csv   # 欄位與 old-files.csv 相同
$dryRun     = $true       # 先設為 $true 確認對象,沒問題後再改成 $false 正式執行
$planned    = @()   # 試跑的結果。輸出到與正式執行台帳不同的檔案
$moved      = @()   # 實際成功移動的項目(用於統計件數。台帳的實體是下面的檔案)
$failed     = @()   # 未能移動的項目。仍在原本的位置
$needsAcl   = @()   # 移動成功但未能限縮權限的項目

# 輸出檔案每次執行都用不同的名稱。若用固定名稱,下一批次一跑,
# 上一次的台帳就會被覆寫,當時移動過的檔案就再也無法還原
$runId    = Get-Date -Format 'yyyyMMdd-HHmmss'
$ledger   = ".\quarantine-log-$runId.csv"        # 用於復原的台帳。每移動一筆就追加寫入
$planFile = ".\quarantine-plan-$runId.csv"
$failFile = ".\quarantine-failed-$runId.csv"
$aclFile  = ".\quarantine-needs-acl-$runId.csv"
$enc      = 'utf8BOM'                            # 5.1 的話用 'UTF8'

# 用於判斷範圍的絕對路徑。統一在結尾加上 \,
# 可以避免 D:\share2 被誤判為在 D:\share 內側
$rootFull       = [System.IO.Path]::GetFullPath($root).TrimEnd('\') + '\'
$quarantineFull = [System.IO.Path]::GetFullPath($quarantine).TrimEnd('\') + '\'

foreach ($row in $list) {
    $src = $row.FullName
    if (-not (Test-Path -LiteralPath $src)) { continue }   # 已經不存在的就跳過

    # 這份 CSV 是人工編輯的,可能混入指向 $root 之外的列。
    # 下面的 Substring 在這種情況下也會悄悄執行,把完全無關位置的檔案拿去隔離。
    # 因此先轉成實體路徑,確認位於稽核對象內側之後才繼續
    $srcFull = (Resolve-Path -LiteralPath $src).ProviderPath
    if (-not $srcFull.StartsWith($rootFull, [StringComparison]::OrdinalIgnoreCase)) {
        $failed += [PSCustomObject]@{ Source = $src; Destination = ''
                                      Error  = "指向稽核對象($root)之外" }
        continue
    }

    # 保持原本的階層結構移動(避免同名檔案衝突與「無法還原」的問題)
    $relative = $srcFull.Substring($rootFull.Length)
    # Join-Path 不會解析 '..'。先用 GetFullPath 摺疊,
    # 再確認是否收在隔離目的地內側
    $dest     = [System.IO.Path]::GetFullPath((Join-Path $quarantine $relative))
    if (-not $dest.StartsWith($quarantineFull, [StringComparison]::OrdinalIgnoreCase)) {
        $failed += [PSCustomObject]@{ Source = $src; Destination = $dest
                                      Error  = "會跑到隔離目的地($quarantine)之外" }
        continue
    }

    if ($dryRun) {
        # 不可以把試跑的列混進 $moved。若寫進同一份台帳,
        # 正式執行之後只要再試跑一次,有效的台帳
        # 就會被「實際不存在的目的地」清單覆蓋掉
        $planned += [PSCustomObject]@{ Source = $src; Destination = $dest }
        continue
    }

    try {
        New-Item -ItemType Directory -Path (Split-Path -Parent $dest) -Force -ErrorAction Stop | Out-Null

        # Move-Item 的錯誤預設是非終止性的。若不加上 -ErrorAction Stop,
        # 即使因鎖定・存取拒絕・衝突而失敗,也會繼續處理下一列,
        # 導致「明明留在原本位置,台帳卻寫成已經移動」
        Move-Item -LiteralPath $srcFull -Destination $dest -ErrorAction Stop
    }
    catch {
        # 在這裡失敗的檔案並沒有被移動,仍在原本的位置
        $failed += [PSCustomObject]@{ Source = $src; Destination = $dest; Error = $_.Exception.Message }
        continue
    }

    # 走到這裡之後,檔案已經在 $dest。無論之後發生什麼失敗,
    # 「檔案在 $dest」這件事都必須留在台帳裡。若把這裡歸到 failed 那一邊,
    # 記錄上看起來是「在原本的位置」,但實物卻在隔離目的地,這是最糟的不一致,
    # 而且再執行一次時 Test-Path $src 會是假,也再也不會被撿到。
    #
    # 而且,不能等跳出迴圈之後才一次寫出去。若中途斷電,
    # 已經移動的檔案就會一筆都沒有被記錄下來。所以要一筆一筆追加寫入
    $record = [PSCustomObject]@{ Source = $srcFull; Destination = $dest; MovedAt = Get-Date }
    $record | Export-Csv -Path $ledger -NoTypeInformation -Encoding $enc -Append
    $moved += $record

    # 在同一個磁碟區內移動時,原本的權限會原封不動地跟過來(後述)。
    # 還原為繼承預設值,讓隔離資料夾的權限單獨生效
    try {
        & icacls $dest /reset /q
        if ($LASTEXITCODE -ne 0) { throw "icacls /reset 失敗 (exit $LASTEXITCODE)" }
    }
    catch {
        # 移動雖然成功,但未能限縮權限。隔離目的地仍維持原本較寬鬆的權限。
        # 這個也要一筆一筆追加寫入
        $aclRow = [PSCustomObject]@{ Destination = $dest; Error = $_.Exception.Message }
        $aclRow | Export-Csv -Path $aclFile -NoTypeInformation -Encoding $enc -Append
        $needsAcl += $aclRow
    }
}

if ($dryRun) {
    $planned | Export-Csv -Path $planFile -NoTypeInformation -Encoding $enc
    Write-Host "試跑: 對象共 $($planned.Count) 筆。尚未實際移動 ($planFile)"
    return
}

$failed | Export-Csv -Path $failFile -NoTypeInformation -Encoding $enc

# 未能限縮權限的部分不要悄悄放過。在這個數字歸零之前,不要開始觀察期間
if ($needsAcl.Count -gt 0) {
    Write-Warning "有 $($needsAcl.Count) 筆隔離檔案未能限縮權限。請查看 $aclFile 並處理"
}
Write-Host "移動 $($moved.Count) 筆 / 失敗 $($failed.Count) 筆 / 權限未處理 $($needsAcl.Count) 筆"
Write-Host "台帳: $ledger"

移動之前,請先確認那個路徑是否真的在稽核對象內側。這支腳本讀取的是 old-files-approved.csv ── 一份由人打開、刪除列,有時還會手動追加內容的檔案。就算裡面混入了一行指向 $root 之外的 FullName,$src.Substring($root.Length) 也不會擲出例外。它只是單純計算字元數並砍掉開頭,因此會若無其事地為完全無關的檔案組出移動目的地。沿用舊的 CSV、複製路徑時漏掉、把對象「順便」加進去 ── 這些都可能發生。基於同樣的理由,Join-Path 不會解析 ..,所以含有相對路徑分隔符的列,可能會產生指向隔離資料夾外部的 $dest

因此,腳本先用 Resolve-Path 轉成實體路徑,確認它位於 $root 內側,$dest 也用 GetFullPath 摺疊之後,檢查是否收在 $quarantine 內側。在比較用的路徑結尾加上 \,是為了避免 D:\share2 被判定成在 D:\share 內側。範圍外的列不移動,直接記到 $failed,請人修正 CSV。這兩項確認,是為了讓「先決定對象再行動」這件事,在程式碼層面也有所保障。

腳本中間會插入 icacls /reset,是出於權限的考量。正如官方文件明確記載的,物件預設會繼承移動目的地父層的權限,但唯一的例外是「移動到同一個磁碟區內的另一個資料夾」,只有這種情況會保留原本的權限。15

也就是說,步驟 1 建議的「移到與原本相同的磁碟區」這種做法,同時也是隔離資料夾的權限對裡面的檔案不會生效的做法。原本開放給部門共用群組的檔案,即使把隔離資料夾限縮成只有系統管理員能存取,移動之後那個群組依然讀得到。「雖然沒有刪除,卻沒有真正做到隔離」── 這項作業中最糟糕的失敗,就是這樣發生的。icacls <path> /reset用預設的繼承 ACL 取代原本的 ACL,只要在移動之後立刻執行,隔離資料夾的權限就會單獨生效。13

若移動到不同的磁碟區(不同磁碟機・不同伺服器),那時就會繼承移動目的地資料夾的權限。15 這種情況不需要 /reset,但前提是要先限縮好隔離資料夾本身的權限。無論走哪一條路徑,移動後都抽幾筆用 Get-Acl 檢查一下,確認權限是否如預期,會比較保險。

移動成功但 /reset 失敗的部分,處理方式也要留意。這種情況下,檔案已經移到隔離目的地,只有權限還維持原本較寬鬆的狀態。若把這種情況當作「失敗」踢出台帳,記錄上就會顯示還在原本的位置,而且再執行一次也因為原始路徑不存在而不會被撿到。結果就是留在隔離目的地、維持寬鬆的權限、卻不在任何台帳上,這是最糟糕的一種形態。上面的腳本之所以在移動完成的當下就先寫進台帳,再把未能限縮權限的部分輸出到另一份 CSV,原因就在這裡。若件數不是 0,請務必先處理完那部分,再開始觀察期間。

請不要把台帳的檔案名稱固定下來。隔離不會一次就結束,而是會依部門、依季度反覆執行多次。若寫成固定名稱 quarantine-log.csv 的架構,第二次批次一跑,第一次的台帳就會消失。當時移動的檔案明明在隔離目的地,卻沒有任何記錄能知道它從哪裡來 ── 無法復原。上面的腳本之所以在每次執行時加上 -$runId,原因就在這裡。要還原時,用 Import-Csv .\quarantine-log-*.csv 把全部讀進來即可。

台帳要一筆一筆追加寫入。若寫成跳出迴圈之後才一次性 Export-Csv 的架構,一旦中途工作階段中斷、或伺服器重新啟動,就會變成已經移動的檔案一筆記錄都沒有的狀態。移動實際上已經發生了,所以這不是「作業前」,而是「只是沒有記錄」的狀態。用 -Append 一筆一筆寫入,無論在哪裡中斷,都能還原到那個時間點為止。雖然因為要重新開啟檔案而變慢,但台帳這種東西,確實性比速度更重要

腳本輸出的檔案依角色區分如下($runId 是像 20260718-143052 這樣的執行時刻)。

檔案 內容 什麼時候輸出
quarantine-plan-$runId.csv 接下來預計要移動的清單 試跑($dryRun = $true)時
quarantine-log-$runId.csv 實際移動的項目。還原時使用的台帳,一筆一筆追加寫入 正式執行時
quarantine-failed-$runId.csv 未能移動的項目,仍在原本的位置 正式執行時
quarantine-needs-acl-$runId.csv 已移動但未能限縮權限的項目 只有在有這種情況時才會輸出

4. 存取權限(ACL)的盤點 ── 把誰能做什麼整理成表格

與容量並列的另一個盤點對象是權限。從這裡開始術語會一口氣增加,先把彼此的關係整理一下。

相同內容的另一種表示法安全性描述元每個檔案或資料夾都有一個Get-Acl 取得的就是這個擁有者以 SID 記錄DACL記錄誰被允許或拒絕做什麼的清單Get-Acl 的 Access 屬性看到的就是這裡SACL稽核設定,本文不討論ACE ── DACL 的一行IdentityReference - 誰,SID 或已解析的名稱FileSystemRights - 能做什麼AccessControlType - 允許還是拒絕IsInherited - 繼承還是直接授予ACEACE ...SDDL把整個描述元濃縮成一串字串的格式

用文字說明的話,就是安全性描述元裡有 DACL,DACL 裡排列著多個 ACE,而 ACE 所指的對象是 SID,這樣一層層的巢狀結構。實務上所說的「ACL」,幾乎都是指這個 DACL。之後出現的表格與 CSV,請把它們想成是把最內層的 ACE 一列一列寫出來的結果。SDDL 是相同內容的字串表示法,在盤點階段不需要去讀懂它。

在這個前提下進入正題。Get-Acl 會取得檔案或資料夾的安全性描述元,可以從 Access 屬性讀出 DACL 的存取控制項目(ACE)清單。7

# 盤點從共用根目錄起算 2 層資料夾的 ACL
# 權限設計的「主幹」集中在淺層,先從那裡開始確認
$aclErrors = @()   # 加了 + 的 -ErrorVariable 是為了追加寫入,每次執行前都要初始化,避免帶入上次的結果
$targets = @(Get-Item -LiteralPath $root `
                 -ErrorAction SilentlyContinue -ErrorVariable +aclErrors) +
           @(Get-ChildItem -LiteralPath $root -Directory -Recurse -Depth 1 `
                 -ErrorAction SilentlyContinue -ErrorVariable +aclErrors)

$aclReport = foreach ($t in $targets) {
    # 取得失敗(無法讀取・掃描過程中被刪除等)也要記錄下來,以便之後公開
    $acl = Get-Acl -LiteralPath $t.FullName -ErrorAction SilentlyContinue -ErrorVariable +aclErrors
    if (-not $acl) { continue }
    if (@($acl.Access).Count -eq 0) {
        # DACL 為空(明確項目為零)的資料夾也要保留成一列,避免繼承中斷的資訊從台帳中消失
        [PSCustomObject]@{
            Path = $t.FullName; Identity = '(無項目)'; Rights = $null; Type = $null
            IsInherited = $null; Inheritance = $null
            InheritanceBroken = $acl.AreAccessRulesProtected
        }
        continue
    }
    foreach ($ace in $acl.Access) {
        [PSCustomObject]@{
            Path        = $t.FullName
            Identity    = $ace.IdentityReference    # 誰(使用者/群組)
            Rights      = $ace.FileSystemRights     # 能做什麼
            Type        = $ace.AccessControlType    # Allow / Deny
            IsInherited = $ace.IsInherited          # 是繼承自上層,還是直接授予
            # 即使是相同的權限,也要區別是「只限這個資料夾」還是「連底下的檔案/資料夾也涵蓋」
            Inheritance = "$($ace.InheritanceFlags)/$($ace.PropagationFlags)"
            InheritanceBroken = $acl.AreAccessRulesProtected  # 這個資料夾是否切斷了繼承
        }
    }
}
$aclReport | Export-Csv -Path .\acl-report.csv -NoTypeInformation -Encoding utf8BOM   # 5.1 的話用 UTF8

# 無法取得 ACL 的對象,也要當作台帳的破洞務必公開(與其他調查採用相同的方針)
$aclErrors | ForEach-Object { $_.TargetObject } |
    Set-Content -Path .\acl-uninspected.txt

打開這份 CSV 之後,用以下觀點篩選,找出異常。

應查看的欄位 異常訊號 典型背景
Identity 直接列出個人使用者名稱 「先給這個人權限」不斷累積的結果。因異動・離職而腐化
Identity 顯示成 S-1-5-21-... 這樣未解析的 SID 已刪除帳戶的權限還殘留著(清理的第一候選)
IsInherited = False 深層資料夾散落著直接授予的 ACE 權宜之計的個別授予,是脫離設計的例外
InheritanceBroken = True 繼承被切斷的資料夾 過去「不想讓其他人看到這個資料夾」的處理措施,是盤點的重點對象
Type = Deny 存在拒絕 ACE 條件相同時,拒絕的優先順序高於允許。不過明確 ACE 會比繼承 ACE 先被評估,因此也有明確允許勝過繼承拒絕的排列方式。影響必須連 ACE 的來源一併確認,最終要以實際有效存取來驗證

IsInherited 為 False 的項目,代表「有人在那個資料夾手動加了權限」;AreAccessRulesProtected 為 True 的資料夾,則是「親層權限異動傳不到的孤島」。78 權限相關的糾紛大多集中在這兩者上,所以先從這裡開始台帳化。這裡也明確寫出一個限制。這個範例是 -Depth 1 的淺層掃描,因此比這更深層、切斷繼承的資料夾不會被納入對象,也不會產生錯誤。若想全面調查繼承中斷的情況,雖然要花時間,但可以拿掉 -Depth 的限制執行同一支腳本,或是把懷疑有問題的部門資料夾指定為根目錄再重新執行。另外,Get-Acl 也能以 SDDL 這種字串格式顯示安全性描述元,7但若目的是盤點,Access 屬性的表格就足夠了。等到遇到這張表格解釋不了的現象時,再深入研究 SDDL 也不遲。

再提醒一個實務上的注意事項。CSV 的文字編碼務必要明確指定。Windows PowerShell 5.1 的 Export-Csv 預設輸出 ASCII,中文會亂碼。10 PowerShell 7 系列的預設是 utf8NoBOM(不含 BOM 的 UTF-8)11,在某些環境下用 Excel 直接開啟會出現亂碼。若是要交給部門的檔案,像 -Encoding UTF8BOM(5.1 則用 -Encoding UTF8)這樣統一使用含 BOM 的 UTF-8,會比較保險。

5. 該如何變更 ── icacls 與 Set-Acl 的分工、備份與 -WhatIf

盤點與部門確認結束之後,才會進入變更階段。這裡有兩套工具。

用途 工具 理由
調查・報表 Get-Acl 結果是物件,篩選・轉成 CSV 都很容易7
變更前備份 icacls /save 把 ACL 存到檔案,用 /restore 就能直接還原13
定型的權限授予・移除 icacls /grant, /remove 一行就能完成,用 /t 遞迴套用,也能重新啟用繼承(/inheritancelevel)13
複雜的條件式變更 Set-Acl 可以用程式碼組出規則,支援 -WhatIf12
重建損壞的 ACL icacls /reset 置換為預設的繼承 ACL(影響很大,是最後手段)13

無論使用哪一種,順序都是固定的。備份 → -WhatIf(或確認對象) → 套用 → 事後確認。

# 1. 變更前先把 ACL 存到檔案(/t 表示連同底下全部,/c 表示即使有錯誤也繼續)
icacls "D:\share\sales" /save "C:\aclbackup\sales-acl.txt" /t /c
# 備份沒成功就不要進入變更。icacls 的成敗要看結束代碼來確認
if ($LASTEXITCODE -ne 0) {
    throw "ACL 備份失敗 (icacls ExitCode=$LASTEXITCODE)。中止變更作業"
}

# 2. 用 Set-Acl 變更時: 取得 → 編輯規則 → 套用 這三個階段
$path = 'D:\share\sales\estimate'
$acl  = Get-Acl -LiteralPath $path

# 授予業務群組修改權限的規則(繼承到子資料夾・檔案)
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
    'CONTOSO\SalesTeam', 'Modify', 'ContainerInherit,ObjectInherit', 'None', 'Allow')
# 追加授予要用 AddAccessRule。SetAccessRule 會置換同一個使用者/群組現有的 Allow 規則,
# 該群組原本已設定的細部權限可能會在不知不覺間消失
$acl.AddAccessRule($rule)

# 3. 先用 -WhatIf 確認「會套用到哪裡」,沒問題後再正式執行
Set-Acl -LiteralPath $path -AclObject $acl -WhatIf
# 沒問題的話: Set-Acl -LiteralPath $path -AclObject $acl

Set-Acl 的動作是「把 Get-Acl 取得的安全性描述元當作模型套用上去」12,因此若在取得與套用之間搞錯了對象,就會變成整份 ACL 遭到非預期的置換。像上面這樣用同一個變數保存取得路徑與套用路徑、在套用前先加上 -WhatIf,這些不起眼的習慣可以防止事故。切斷繼承的操作(SetAccessRuleProtection)也能在同一個框架下進行,12但正如第 4 章所見,繼承中斷會成為未來的管理成本,新增時請限定在真正必要的地方。

要把這一連串作業養成定期執行的腳本時,其設計方式(SupportsShouldProcess、留存證據的做法、註冊到工作排程器)可以直接沿用「PowerShell 腳本應用 ── 安全地自動化日誌調查、封存與報表化」中處理過的型式。

6. 該在哪裡執行 ── 透過 UNC 掃描與執行帳戶

最後,談談意外會左右結果的「執行位置與執行者」的問題。

  • 大規模掃描原則上要在伺服器上執行。透過 UNC 路徑(\\fs01\share)進行遞迴列舉時,每一個檔案的中繼資料取得都要往返網路一趟,在數十萬檔案的規模下,所需時間會有數量級的差異。若想從管理端執行,實務上的做法是用 PowerShell Remoting 讓腳本在伺服器端執行,只把結果 CSV 收回來。做法請參考同期發布的「PowerShell Remoting(WinRM)入門」。
  • 結果只是執行帳戶權限下的一張快照。無法存取的位置,無論是列舉還是 Get-Acl 都會漏掉。調查請用具備系統管理員權限的帳戶執行,即便如此仍被拒絕的位置(第 2 章的 denied-paths.txt),要以「未能調查的位置」列入報告中。
  • 若透過 UNC 執行,也要留意認證資訊與工作階段的陷阱。磁碟機代號是以登入工作階段為單位,所以在互動式登入期間對應的 Z:,工作排程器的工作是看不到的。此外,錯誤 1219(ERROR_SESSION_CREDENTIAL_CONFLICT)是同一個使用者不允許用多個使用者名稱連接同一台伺服器(或共用)這種規格層級的錯誤。16 若打算「只有調查用的共用改用系統管理員帳戶,其他還是用自己的帳戶」,想以兩種認證資訊連到同一台檔案伺服器,第二個連線就會因此失敗。若要透過 UNC 調查,請在執行之前,先把既有的連線統一成同一組認證資訊。這類陷阱的全貌整理在「網路磁碟機與 UNC 路徑的陷阱」。
  • 所需時間不要用「別人的數字」,要在自己的環境中實測。磁碟種類、檔案數量、防毒軟體的即時掃描、階層深度都會讓結果大幅改變,因此在跑整體之前,先挑一個具代表性的部門資料夾實測,再外插到整個共用的檔案數量,是比較可靠的做法。若在 UNC 與伺服器上都做同樣的測量,也能用數字說明「是否該在伺服器上執行」。
# 挑一個具代表性的部門資料夾,實測掃描速度後再估算整體
# (若挑到一個檔案都沒有的資料夾,最後的除法會失敗,請挑有實際資料的位置)
$sample = 'D:\share\sales'
$start  = Get-Date
$count  = (Get-ChildItem -LiteralPath $sample -Recurse -File `
    -ErrorAction SilentlyContinue | Measure-Object).Count
$sec    = ((Get-Date) - $start).TotalSeconds
'列舉 {0} 個檔案花了 {1:N1} 秒 → 每萬個檔案約 {2:N1} 秒' -f `
    $count, $sec, ($sec / $count * 10000)

這種估算方式,適用於以列舉為主的處理(第 2 章的容量彙總、第 3 章的舊檔案清單、第 4 章的 ACL 取得)。唯獨第 3 章的雜湊計算,所需時間取決的不是檔案數量,而是總位元組數,請另外估算。

7. 實務上的標準做法(判斷表)

議題 選項 判斷標準
容量調查的範圍 整體一次彙總 / 依第一層逐一彙總 先在淺層鎖定範圍,只對肥大的位置深入挖掘12
錯誤的處理方式 中斷 / 忽略 / 記錄後繼續 用 SilentlyContinue + ErrorVariable 做到「繼續執行同時全部記錄」。拒絕的區域要明記在報表中3
舊檔案的基準 LastAccessTime / LastWriteTime 存取日期時間在許多環境中停用更新,不採用。以更新日期時間 + 期間與部門達成共識45
重複偵測 對全部檔案算雜湊 / 先用容量篩選再算雜湊 雜湊對 I/O 負擔很重,只計算大小相同的群組6
ACL 調查的深度 全部資料夾 / 淺層 + 繼承中斷的位置 若繼承仍生效,看親層就能得知。以 IsInherited=False 與 Protected=True 為重點78
權限變更的工具 icacls / Set-Acl 定型變更與備份用 icacls。伴隨條件分支的批次處理用 Set-Acl + -WhatIf1312
刪除的執行方式 立即刪除 / 隔離→觀察→刪除 清單經部門確認後,先移到隔離資料夾,經過一段時間沒問題再刪除

8. 總結

  • 整理檔案伺服器不是從刪除開始,而是從盤點開始。用 CSV 做出第一層容量排行、舊檔案清單、重複候選、ACL 台帳這 4 項套組,以此為材料和部門溝通。
  • 掃描用 SilentlyContinue 不中斷,同時務必用 ErrorVariable 記錄被拒絕的位置。無法計數的位置代表的是「不明」,而不是「零」。
  • 判斷新舊以 LastWriteTime 為基準。NTFS 的最終存取日期時間在許多環境中停用更新,不可信賴。
  • 重複先用容量篩選,再用 Get-FileHash(預設 SHA256)。一致只代表「候選」,留下哪一份要由業務端來判斷。
  • ACL 把 Get-Acl 的 Access 輸出成 CSV,重點確認直接授予個人・僅顯示 SID 的項目・繼承中斷(AreAccessRulesProtected)。不需要深入研究 SDDL。
  • 變更的順序是:icacls /save 備份 → -WhatIf 或確認對象 → 套用 → 事後確認。大規模掃描要在伺服器上(或透過 Remoting)以系統管理員權限執行。

相關文章

相關諮詢領域

合同會社小村軟體提供檔案伺服器容量・權限盤點腳本的製作支援、伴隨權限設計檢討而來的調查,以及定期報表自動化(含工作排程器運維)的服務。從「想先把現狀變成數字」這個階段開始的諮詢也歡迎洽詢。

參考連結

  1. Microsoft Learn, Get-ChildItem。關於用 Get-ChildItem 列舉項目、用 -Recurse 遞迴與用 -Depth 限制深度,以及使用 -Recurse 時為了避免萬用字元被解析,建議用 -LiteralPath 指定對象的說明。  2 3

  2. Microsoft Learn, Measure-Object。關於 Measure-Object 可以用 -Property Length -Sum 等計算檔案大小的總和・最大・最小・平均值,以及搭配 Get-ChildItem 彙總目錄內檔案的範例說明。  2 3

  3. Microsoft Learn, about_CommonParameters。關於 -ErrorAction SilentlyContinue 會抑制錯誤顯示並繼續執行、-ErrorVariable 會把錯誤記錄存進指定的變數,以及在變數名稱前加上 + 會變成追加而非覆寫的說明。  2 3 4

  4. Microsoft Learn, fsutil behavior。關於 NTFS 最終存取日期時間(Last Access Time)更新的啟用/停用,是由 fsutil behavior 的 disablelastaccess 參數與 NtfsDisableLastAccessUpdate 登錄值控制,以及停用更新是為了提升檔案/目錄存取速度而設的說明。  2 3

  5. Microsoft Learn, [MS-FSA]: Appendix A: Product Behavior。關於 Windows Vista 之後 NTFS/ReFS 的最終存取日期時間更新預設為停用,Windows 10 v1803 之後改由系統管理,伺服器系統則一律停用最終存取日期時間更新的說明。  2 3

  6. Microsoft Learn, Get-FileHash。關於 Get-FileHash 的預設演算法是 SHA256、雜湊值一致的兩個檔案可以判斷內容相同,以及即使更改檔案名稱或副檔名雜湊值也不會改變的說明。  2 3

  7. Microsoft Learn, Get-Acl。關於 Get-Acl 會取得檔案或資源的安全性描述元、預設會顯示 DACL 的存取控制項目清單(Access),以及也能以 SDDL 格式(Sddl 屬性)取得的說明。  2 3 4 5 6

  8. Microsoft Learn, ObjectSecurity.AreAccessRulesProtected Property。關於 AreAccessRulesProtected 屬性會傳回安全性描述元的 DACL 是否受保護(不接受來自親層的繼承)的說明。  2 3

  9. Microsoft Learn, Get-SmbShareAccess。關於 Get-SmbShareAccess 是用來取得 SMB 共用 ACL(被授予共用存取權限的安全性主體與允許/拒絕・權利)的 Cmdlet 的說明。 

  10. Microsoft Learn, about_Character_Encoding。關於 Windows PowerShell(5.1)中各個 Cmdlet 的預設編碼不統一,Export-Csv 會以 ASCII 建立檔案,以及 PowerShell 6 之後預設統一為 utf8NoBOM 的說明。  2

  11. Microsoft Learn, Export-Csv。關於 Export-Csv 會把物件的各個屬性做成 CSV 檔案的欄位、PowerShell 7 系列中 -Encoding 的預設值是 UTF8NoBOM,以及可以明確指定 UTF8BOM 等編碼的說明。  2

  12. Microsoft Learn, Set-Acl。關於 Set-Acl 會以 AclObject 傳入的安全性描述元為模型變更對象的 ACL、支援 -WhatIf/-Confirm、建立 FileSystemAccessRule 並用 SetAccessRule() 新增的步驟,以及用 SetAccessRuleProtection() 停用繼承(可選擇是否保留既有繼承規則)的範例說明。  2 3 4 5

  13. Microsoft Learn, icacls。關於 /reset 會「把對象檔案的 ACL 置換成預設的繼承 ACL」,以及 /q 會抑制成功訊息的說明。  2 3 4 5 6

  14. Microsoft Learn, Maximum Path Length Limitation。關於 Windows API 的路徑長度上限 MAX_PATH 為 260 個字元,以及只有在登錄的 LongPathsEnabled 與應用程式端的 longPathAware 宣告兩者都具備時,許多 Win32 函式的限制才會放寬的說明。 

  15. Microsoft Learn, Permissions on copying and moving files and folders。關於物件預設會繼承移動目的地・建立目的地的父層存取權限,唯一的例外是移動到同一磁碟區內的另一個資料夾,這種情況會保留原本的存取權限,而複製或移動到不同磁碟區時則會繼承移動目的地資料夾存取權限的說明。  2

  16. Microsoft Learn, System Error Codes (1000-1299)。關於錯誤 1219(ERROR_SESSION_CREDENTIAL_CONFLICT)是「不允許同一使用者用多個使用者名稱,對同一台伺服器或共用資源建立多個連線」這項錯誤的說明。 

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

常見問題

整理諮詢這個主題時常見的問題。

用 PowerShell 該如何調查每個資料夾的總容量?
用 Get-ChildItem 搭配 -Recurse 列舉檔案,再用 Measure-Object 的 -Property Length -Sum 加總。實務上不建議一次彙總整個共用,而是先以共用根目錄下的第一層資料夾為單位彙總,鎖定「肥大的位置」在哪裡。這時請同時併用 -ErrorAction SilentlyContinue 與 -ErrorVariable,務必記錄因存取拒絕而無法計數的位置。若把錯誤悄悄吞掉,漏算的位置在報表上就會看起來像「容量為零」。
判斷舊檔案時可以使用 LastAccessTime(最終存取日期時間)嗎?
不建議。NTFS 基於效能考量,許多環境預設會停用(或改由系統管理)最終存取日期時間的更新,實際上即使檔案有被讀取,日期時間也可能不會更新。盤點的基準應使用 LastWriteTime(最終更新日期時間),並以「距上次內容變更以來經過的時間」這種說法呈現給部門,會比較安全。歸檔的判斷不應機械式地進行,請務必先讓相關人員確認清單後再繼續。
Get-Acl 可以看出什麼?需要讀懂 SDDL 嗎?
Get-Acl 會取得檔案或資料夾的安全性描述元,可以從 Access 屬性讀出「是誰(IdentityReference)」「做了什麼(FileSystemRights)」「允許還是拒絕(AccessControlType)」「是繼承還是直接授予(IsInherited)」。查看 AreAccessRulesProtected 還能知道該資料夾是否切斷了繼承。雖然也有 SDDL 這種字串格式,但以盤點為目的的話,把 Access 屬性輸出成 CSV 更方便與相關人員分享,不需要深入研究 SDDL。
變更 ACL 應該用 PowerShell 的 Set-Acl 還是 icacls?
分工的原則是:調查・報表用可以當作物件處理的 Get-Acl,變更的實務工作則以 icacls 為第一選擇。icacls 可用 /save 把 ACL 存到檔案,再用 /restore 直接還原,變更前的備份與復原都很簡單。若使用 Set-Acl,則會是取得(Get-Acl)→編輯規則→套用這三個階段,並在套用前先用 -WhatIf 確認對象後再執行。無論哪一種,原則上都要等盤點與相關部門確認完成後,才進入變更階段。
檔案伺服器的調查可以透過 UNC 路徑執行嗎?
少量的話沒問題,但數十萬檔案規模的全面掃描,透過網路執行速度會大幅變慢。可以的話,直接在檔案伺服器上執行(或透過 PowerShell Remoting),只把結果 CSV 帶回來,這樣的架構比較有效率。此外,掃描結果會受限於執行帳戶的權限。無法存取的資料夾會從列舉中漏掉,因此請以系統管理員帳戶執行,即便如此仍被拒絕的位置,也要記錄下來,並在報告中列為「未能調查的位置」。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽