用 PowerShell 盘点文件服务器 ── 容量调查与访问权限(ACL)审计

· · PowerShell, Windows, 文件服务器, ACL, 访问权限, 运维改善, 安全, 脚本

「文件服务器的可用空间只剩不到一成了」── 从这句警告开始的工作,通常都令人心情沉重。不知道是哪里在占用容量,也无法判断那些十年没人碰过的文件夹能不能删,甚至连「谁能访问哪里」「离职者的权限是否还残留着」这类问题,被问到时也答不上来。靠资源管理器逐个打开属性去调查,面对数 TB 级别的共享盘根本做不完。

每次接手这类工作我都会想到:文件服务器的整理,考验的不是「删除的技术」,而是「盘点的技术」。没有数字和清单,光问一句「可以删了吗」,部门是绝对不会点头的。反过来,如果能拿出「仅这个文件夹就有 800GB,其中 3 年以上未更新的文件有 620GB」这样的数据,事情就能一下子往前推进。

本文面向中小企业的信息系统 / 运维负责人,整理用 PowerShell 盘点文件服务器的容量与访问权限(ACL),并最终落地为 CSV 报表的实务步骤。方针始终一致:先只读取,变更放到最后,并配合备份与 -WhatIf

1. 先说结论

  • 容量调查的基本套路是 Get-ChildItem -Recurse + Measure-Object -Sum。不要一上来就汇总整体,而是按第1层文件夹分别汇总,找出「体积偏大的位置」。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」这句话,把第1项和第2项对照起来就能得出。后半段的数字,只需重新读取 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 = @()

# 按第1层文件夹逐个汇总 ── 与其一次性汇总整体,不如先「找出重点」
# 为了让根目录自身的列举失败也进入 $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 结果是对象,便于筛选与生成 CSV7
变更前备份 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 变更时: 取得 → 编辑规则 → 应用 这3个阶段
$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 与服务器本地都做同样的测量,也能用数字说明「是否应该在服务器上运行」。
# 选一个有代表性的部门文件夹,实测扫描速度后再估算整体
# (选一个1个文件都没有的文件夹会导致最后的除法出错,请选有实际数据的位置)
$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. 实务定式(判断表)

论点 选项 判断依据
容量调查的范围 整体一次性 / 按第1层逐个 先在浅层找出重点,只对体积大的位置深入调查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 制作第1层容量排行、旧文件清单、重复候选、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 求和。比起一次性汇总整体,实务上更推荐先按共享根目录下的第1层文件夹分别汇总,找出「体积偏大的位置」。此时应同时使用 -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 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表