Shore Up
WindowsSecurity

Automate a Weekly NTFS Share Permission Drift Audit

Ketan Aagja10 min read
No ratings yet

Share permissions drift. Someone grants a contractor "just temporary" access, a helpdesk ticket adds a group to a folder, an app installer rewrites an ACL — and six months later nobody remembers why Everyone can write to the finance share. This guide builds a simple, read-only baseline of your NTFS share ACLs and a weekly job that tells you exactly what has changed since.

Before you run this

These scripts read the NTFS ACLs of your SMB shares, save a baseline, and once a week compare the live permissions against that baseline and write a report of every added or removed access entry. They are read-only with respect to permissions — they never change an ACL. The only things they write are their own files under C:\ShareAudit and one scheduled task.

  • Privileges: run from an elevated PowerShell session (Run as Administrator). Get-Acl can only read the full security descriptor of every share if you have administrative rights on the server. The scheduled task is registered to run as SYSTEM, which can read local NTFS ACLs. If any share path is on remote storage, SYSTEM won't have rights across the network — use a dedicated service account (ideally a gMSA) with read access instead, and say so to yourself before you deploy it.
  • Test first: read both scripts, then run them on a non-production file server or a throwaway test share before you point them at live data. Deliberately change one ACL on a test share and confirm the audit catches it (there's a step for this at the end).
  • What it changes on the system: it creates the C:\ShareAudit folder and its files, and registers one scheduled task named Weekly NTFS Share Drift Audit. Both are trivially reversible — the undo section shows how. It makes no change to any share, folder, or permission.

Assumptions

  • Windows Server 2022 (the same code works on 2016/2019), domain-joined, acting as a file server.
  • Windows PowerShell 5.1 — the version that ships in the box. The SmbShare and ScheduledTasks modules are present by default.
  • You run everything from an elevated prompt, and all three script files live in C:\ShareAudit.

Replace C:\ShareAudit and the paths below with whatever suits your environment.

Step 1 — The shared ACL reader

Both the baseline and the audit need to read ACLs the same way, so put the logic in one file and dot-source it. Save this as C:\ShareAudit\Get-ShareAce.ps1:

function Get-ShareAce {
    # Enumerate every non-administrative share (skip C$, ADMIN$, IPC$, etc.)
    Get-SmbShare | Where-Object { $_.Name -notlike '*$' } | ForEach-Object {
        $share = $_
        $acl   = Get-Acl -Path $share.Path
        foreach ($ace in $acl.Access) {
            # A single comparable key per access control entry
            $key = '{0}|{1}|{2}|{3}|{4}|{5}|{6}' -f `
                $share.Name, $ace.IdentityReference.Value, $ace.FileSystemRights,
                $ace.AccessControlType, $ace.IsInherited,
                $ace.InheritanceFlags, $ace.PropagationFlags

            [pscustomobject]@{
                Key      = $key
                Share    = $share.Name
                Path     = $share.Path
                Identity = $ace.IdentityReference.Value
                Rights   = $ace.FileSystemRights.ToString()
                Type     = $ace.AccessControlType.ToString()
            }
        }
    }
}

One honest caveat: FileSystemRights occasionally renders as a raw number rather than a friendly name when an ACE uses an unusual rights combination. That's a known cosmetic quirk of the .NET enum — it doesn't break the audit, because both the baseline and the live read use the identical representation, so they still compare correctly.

Step 2 — Capture the baseline

Run this once, after you've confirmed the current permissions are actually correct. Save as C:\ShareAudit\Capture-ShareBaseline.ps1:

#Requires -Version 5.1
#Requires -RunAsAdministrator

$BaselinePath = 'C:\ShareAudit\baseline.json'   # change to suit

. "$PSScriptRoot\Get-ShareAce.ps1"              # load the reader function

New-Item -ItemType Directory -Path (Split-Path $BaselinePath) -Force | Out-Null

Get-ShareAce |
    Sort-Object Share, Identity, Rights |
    ConvertTo-Json -Depth 4 |
    Set-Content -Path $BaselinePath -Encoding UTF8

Write-Host "Baseline written to $BaselinePath"

Open baseline.json and read it. This is your source of truth — if it contains an entry you don't like, fix the real permission first, then re-run this script so the baseline reflects the correct state, not the current one.

Step 3 — The weekly audit

Save as C:\ShareAudit\Audit-ShareDrift.ps1:

#Requires -Version 5.1
#Requires -RunAsAdministrator

$BaselinePath = 'C:\ShareAudit\baseline.json'
$ReportDir    = 'C:\ShareAudit\reports'

. "$PSScriptRoot\Get-ShareAce.ps1"

if (-not (Test-Path $BaselinePath)) {
    throw "Baseline not found at $BaselinePath. Run Capture-ShareBaseline.ps1 first."
}

New-Item -ItemType Directory -Path $ReportDir -Force | Out-Null

$baseline = Get-Content $BaselinePath -Raw | ConvertFrom-Json
$current  = Get-ShareAce

# Compare the two sets of ACEs on their Key property
$diff = Compare-Object -ReferenceObject $baseline -DifferenceObject $current `
                       -Property Key -PassThru

$stamp      = Get-Date -Format 'yyyy-MM-dd'
$reportPath = Join-Path $ReportDir "drift-$stamp.txt"

if ($diff) {
    $lines = foreach ($d in $diff) {
        # => exists now but not in baseline; <= was in baseline but is gone
        $state = if ($d.SideIndicator -eq '=>') { 'ADDED   (not in baseline)' }
                 else                            { 'REMOVED (missing vs baseline)' }
        '{0} : {1} | {2} | {3} | {4}' -f $state, $d.Share, $d.Identity, $d.Rights, $d.Type
    }
    $lines | Set-Content -Path $reportPath -Encoding UTF8
    Write-Host "DRIFT DETECTED - see $reportPath"
    $lines
}
else {
    "No drift from baseline as of $stamp" | Set-Content -Path $reportPath -Encoding UTF8
    Write-Host "No drift detected."
}

Each run drops a dated report in C:\ShareAudit\reports. ADDED means an access entry exists now that wasn't in your baseline (the common case for drift). REMOVED means something you expected is gone.

If you want the report mailed to you, note that the old Send-MailMessage cmdlet still works in PowerShell 5.1 but Microsoft has formally deprecated it. I'd rather write to a file and let your monitoring pick it up, or write to the event log with a registered source — check Microsoft Learn for New-EventLog / Write-EventLog for the exact source-registration syntax.

Step 4 — Schedule it weekly

Run this once, from an elevated prompt, to register the task:

$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
    -Argument '-NoProfile -ExecutionPolicy Bypass -File "C:\ShareAudit\Audit-ShareDrift.ps1"'

$trigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Monday -At 6am

# SYSTEM can read local NTFS. Use a service account if shares are remote.
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' `
    -LogonType ServiceAccount -RunLevel Highest

Register-ScheduledTask -TaskName 'Weekly NTFS Share Drift Audit' `
    -Action $action -Trigger $trigger -Principal $principal `
    -Description 'Compares live NTFS share ACLs against baseline.json'

Verify it worked

First, prove the task exists and can run:

Get-ScheduledTask -TaskName 'Weekly NTFS Share Drift Audit'
Start-ScheduledTask -TaskName 'Weekly NTFS Share Drift Audit'
Get-ScheduledTaskInfo -TaskName 'Weekly NTFS Share Drift Audit'   # LastRunResult 0 = success

Then prove the detection logic actually works, on a test share only:

  1. Grant a harmless test principal read access to a test share — for example, add example\TestAuditGroup (your placeholder) to a share path with the Security tab or Set-Acl.
  2. Run .\Audit-ShareDrift.ps1 and open the newest file in C:\ShareAudit\reports. You should see an ADDED line naming that identity.
  3. Remove the test grant you just made and re-run — the report should return to "No drift".

That round trip confirms both halves: it catches a new entry, and it stops reporting once the entry is reverted.

Undo

Nothing here touches your permissions, so "undo" only means removing the tooling:

Unregister-ScheduledTask -TaskName 'Weekly NTFS Share Drift Audit' -Confirm:$false
Remove-Item -Path 'C:\ShareAudit' -Recurse   # deletes scripts, baseline, and reports

When a permission change is legitimate and you want it to stop showing as drift, don't edit baseline.json by hand — just re-run Capture-ShareBaseline.ps1 so the new baseline is a clean snapshot of the known-good state. Keep the old baseline file somewhere if you want an audit trail of how your share permissions have evolved over time.

Written by
Ketan Aagja

Runs enterprise networks and security for a living, and writes Shore Up to turn two decades of hands-on Linux, Windows and mail-server work into guides you can actually use.

More about the author →

Was this article helpful?

Tap a star — no sign-in needed.

Be the first to rate this article.

Automate NTFS Permission Audits and Detect Oversharing on File Servers

This guide gives you a PowerShell script that walks a share tree, reads the NTFS access control list (ACL) on each folder, and reports every place where a broad identity — Everyone , Authenticated Users , Domain Users , BUILTIN\Users — has been granted Write, Modify, or Full Control. That combination is the classic definition of "oversharing," and this script's only job is to find it and write it to a CSV. It reads permissions. It does not change a single ACE.

9 min read

Automate a Network Share Permissions Audit with PowerShell

This guide builds a read-only report . The script enumerates the SMB shares on a Windows file server, then lists two things for each one: the share-level permissions (the "who can connect" layer) and the NTFS permissions on the folder behind it (the "who can touch the files" layer). It writes both to CSV so you can review access in a spreadsheet instead of clicking through the Security tab share by share.

9 min read

Find Disabled AD Users That Still Hold Group Membership

A disabled account is not a harmless account. If it still holds membership in security groups, it still carries the access rights those groups grant — file shares, application roles, delegated permissions. If that account is ever re-enabled (by mistake, or by an attacker who compromised a helpdesk process), it lights up with all its old access instantly. Periodically finding disabled users that still sit in security groups is a basic hygiene task, and it is easy to script.

8 min read

Detect and Quarantine Suspicious File Extensions in Shared Folders

This guide builds a PowerShell script that scans an SMB shared folder for files whose extensions are commonly used to carry malware ( .exe , .scr , .js , .vbs , .bat , and so on), and moves any it finds into a locked-down quarantine folder outside the share, writing every action to a log. It is a crude, extension-based screen — a tripwire, not an antivirus engine. It does not inspect file contents and will not catch a malicious .docm or a renamed payload. Treat it as one layer, not the layer.

9 min read