Registry drift, BitLocker, firewall rules, service accounts — Windows security tooling covers plenty. The filesystem ACLs rarely get the same treatment, even though “someone ran icacls /grant Everyone:F to make a permissions error go away” is one of the most common, most quietly dangerous things that happens to a Windows box.
Step through the build below: the problem, the full script, how it works, and the test suite that verifies it without a Windows host.
A deploy script hits an “Access is denied” error at 4:55pm on a Friday. Someone runs icacls C:\inetpub\wwwroot /grant Everyone:F to unblock themselves, it works, and nobody ever reverts it. Six months later that folder — or the ProgramData directory next to it, or the deploy share nobody thinks about — still grants FullControl to Everyone, and it’s exactly the kind of finding an attacker looks for and a routine audit misses, because most Windows security tooling covers the registry, services, scheduled tasks, firewall rules, and BitLocker, but rarely the filesystem ACLs themselves.
This script shells out to icacls.exe for a list of paths, parses the Access Control Entries, and flags any grant of Modify/Write/FullControl to a broad identity (Everyone, BUILTIN\Users, Authenticated Users) — so you find it in a scheduled audit instead of an incident report.
#!/usr/bin/env ruby# frozen_string_literal: true## ntfs_acl_audit.rb -- Audits NTFS folder/file permissions via icacls.exe.## Registry drift, BitLocker compliance, firewall rules, service accounts --# most Windows security auditing tools cover those. The filesystem ACLs# rarely get the same treatment, even though "someone ran icacls /grant# Everyone:F on a deploy folder to make a permissions error go away" is one# of the most common, most quietly dangerous things that happens to a# Windows box. This script walks a list of paths, runs icacls on each,# parses the ACL, and flags any grant of Modify/Write/FullControl to a# broad identity (Everyone, Users, Authenticated Users) so you can find# those before an attacker does.## No gems required -- Ruby stdlib only (open3, optparse, json). Requires# Windows + icacls.exe (built into every supported Windows release) to run# for real; the parsing and risk-scoring logic is pure-Ruby and unit-tested# separately in ntfs_acl_audit_test.rb against realistic icacls fixtures,# since this environment doesn't have a Windows host available.## Usage:# ruby ntfs_acl_audit.rb <path> [<path> ...] [options]## Examples:# ruby ntfs_acl_audit.rb "C:\inetpub\wwwroot" "C:\ProgramData\MyApp"# ruby ntfs_acl_audit.rb "C:\Windows\Temp" --json# ruby ntfs_acl_audit.rb "C:\Deploys" --risky-perms F,M,W,WD,WDAC,DC## Exit codes (cron/Task Scheduler/CI friendly):# 0 - no risky grants found# 1 - reserved (not currently used; parsing errors count as CRIT)# 2 - at least one risky ACE found, or a path could not be auditedrequire 'open3'require 'optparse'require 'json'# ---------------------------------------------------------------------------# Options# ---------------------------------------------------------------------------# NOTE: option parsing and ARGV validation happen inside the# `$PROGRAM_NAME == __FILE__` guard near the bottom of this file, not here --# that keeps `require_relative 'ntfs_acl_audit'` side-effect-free (no ARGV# parsing, no exit calls) so the test suite can load just the pure functions.# ---------------------------------------------------------------------------# icacls invocation -- isolated behind a single method so it can be swapped# out for a fake in tests (see ntfs_acl_audit_test.rb).# ---------------------------------------------------------------------------def run_icacls(path) Open3.capture3('icacls.exe', path)end# ---------------------------------------------------------------------------# Parsing -- icacls output for a single path looks like:## C:\inetpub\wwwroot BUILTIN\Administrators:(OI)(CI)(F)# NT AUTHORITY\SYSTEM:(OI)(CI)(F)# BUILTIN\Users:(OI)(CI)(RX)# Everyone:(OI)(CI)(F)## Successfully processed 1 files; Failed processing 0 files## The first line carries the path; every following indented line is another# ACE for the SAME path, until a blank line or the summary line.# ---------------------------------------------------------------------------ACE_LINE = /^\s*(?:(?<path>[A-Za-z]:\\[^\t]*?)\s+)?(?<identity>(?:[\w .\\-]+))\:(?<deny>\(DENY\))?(?<flags>(?:\([A-Z]+\))*)$/.freezedef parse_icacls_output(raw) entries = [] current_path = nil raw.each_line do |line| line = line.rstrip next if line.strip.empty? break if line =~ /^Successfully processed/i m = ACE_LINE.match(line) next unless m current_path = m[:path] if m[:path] next unless current_path # a stray line before we've seen a path yet perms = m[:flags].to_s.scan(/\(([A-Z]+)\)/).flatten # Inheritance flags aren't permissions -- split them out. inheritance = perms & %w[OI CI IO NP I] perm_codes = perms - inheritance entries << { path: current_path, identity: m[:identity].strip, deny: !m[:deny].nil?, inherited: inheritance.include?('I'), inheritance_flags: inheritance, perms: perm_codes } end entriesend# ---------------------------------------------------------------------------# Risk evaluation# ---------------------------------------------------------------------------def evaluate_ace(ace, risky_identities, risky_perms, safe_identities) return { severity: :ok, reasons: [] } if ace[:deny] # an explicit DENY is protective, never a finding return { severity: :ok, reasons: [] } if safe_identities.any? { |s| s.casecmp?(ace[:identity]) } is_broad = risky_identities.any? { |id| id.casecmp?(ace[:identity]) } return { severity: :ok, reasons: [] } unless is_broad hit_perms = ace[:perms] & risky_perms return { severity: :ok, reasons: [] } if hit_perms.empty? severity = hit_perms.include?('F') || hit_perms.include?('M') ? :crit : :warn reason = "#{ace[:identity]} granted #{hit_perms.join('+')} on #{ace[:path]}" { severity: severity, reasons: [reason] }enddef audit_path(path, risky_identities, risky_perms, safe_identities, runner: method(:run_icacls)) stdout, stderr, status = runner.call(path) unless status && status.success? return { path: path, status: :error, error: (stderr || 'icacls.exe failed').strip, aces: [] } end aces = parse_icacls_output(stdout) findings = aces.map { |ace| ace.merge(evaluate_ace(ace, risky_identities, risky_perms, safe_identities)) } worst = findings.map { |f| f[:severity] }.reduce(:ok) { |acc, s| SEVERITY_RANK[s] > SEVERITY_RANK[acc] ? s : acc } { path: path, status: worst, aces: findings }endSEVERITY_RANK = { ok: 0, warn: 1, crit: 2, error: 2 }.freeze# ---------------------------------------------------------------------------# Run (only executed when this file is the main program -- lets the test# suite `require` it for the pure functions without shelling out).# ---------------------------------------------------------------------------if $PROGRAM_NAME == __FILE__ options = { risky_identities: ['Everyone', 'BUILTIN\\Users', 'Authenticated Users', 'NT AUTHORITY\\Authenticated Users'], risky_perms: %w[F M W WD WDAC], safe_identities: ['NT AUTHORITY\\SYSTEM', 'BUILTIN\\Administrators', 'CREATOR OWNER'], json: false } OptionParser.new do |opts| opts.banner = 'Usage: ntfs_acl_audit.rb <path> [<path> ...] [options]' opts.on('--risky-identities LIST', String, 'Comma-separated identities considered "broad" (default: Everyone,BUILTIN\\Users,Authenticated Users)') do |v| options[:risky_identities] = v.split(',') end opts.on('--risky-perms LIST', String, 'Comma-separated icacls perm codes considered risky when granted to a broad identity (default: F,M,W,WD,WDAC)') do |v| options[:risky_perms] = v.split(',') end opts.on('--json', 'Emit machine-readable JSON instead of text') { options[:json] = true } opts.on('-h', '--help', 'Show this help') { puts opts; exit 0 } end.parse! paths = ARGV if paths.empty? warn 'ntfs_acl_audit: at least one path is required' exit 3 end results = paths.map { |p| audit_path(p, options[:risky_identities], options[:risky_perms], options[:safe_identities]) } overall = results.map { |r| r[:status] }.reduce(:ok) { |acc, s| SEVERITY_RANK[s] > SEVERITY_RANK[acc] ? s : acc } exit_code = overall == :ok ? 0 : 2 if options[:json] puts JSON.pretty_generate(results: results, overall: overall, exit_code: exit_code) else puts "ntfs-acl-audit: audited #{results.size} path(s)" puts results.each do |r| if r[:status] == :error puts "[ERROR] #{r[:path]} -- #{r[:error]}" next end tag = r[:status].to_s.upcase.rjust(5) puts "[#{tag}] #{r[:path]}" r[:aces].each do |a| next if a[:severity] == :ok && a[:reasons].empty? marker = a[:deny] ? 'DENY' : a[:perms].join('+') line = " #{a[:identity]}: #{marker}" line += " <-- #{a[:reasons].join('; ')}" unless a[:reasons].empty? puts line end if r[:aces].none? { |a| !a[:reasons].empty? } puts ' (no broad grants found)' end end puts findings = results.sum { |r| r[:aces].count { |a| !a[:reasons].empty? } } puts "Summary: #{findings} risky ACE(s) across #{results.size} path(s). Overall: #{overall.to_s.upcase}" end exit exit_codeend
One shell-out, everything else pure functions. run_icacls(path) is the only place in the script that touches the OS — a single Open3.capture3('icacls.exe', path) call. Every other piece of logic (parse_icacls_output, evaluate_ace, audit_path) is a pure function that takes text in and returns structured data out, which is exactly what makes this testable without a Windows host.
The path only appears once in icacls output. For a given path, the first ACE line carries the path; every following indented line is another Access Control Entry for that same path. The parser tracks “current path” across lines and splits each ACE’s flags into inheritance flags (OI/CI/…) versus actual permission codes (F/M/RX/…).
An explicit DENY is protective, never a finding. evaluate_ace checks for a (DENY) marker first and short-circuits to OK — a DENY ACE blocks access, so flagging it as risky would be exactly backwards.
Read access is never flagged, on purpose. Only Modify/Write/FullControl-class grants to a broad identity count as findings; Users:(RX) on a shared application directory is completely normal and would just be noise.
$ ruby ntfs_acl_audit.rb "C:\inetpub\wwwroot" "C:\ProgramData\LegacyApp" "C:\Windows\System32"ntfs-acl-audit: audited 3 path(s)[ CRIT] C:\inetpub\wwwroot Everyone: F <-- Everyone granted F on C:\inetpub\wwwroot[ CRIT] C:\ProgramData\LegacyApp BUILTIN\Users: M <-- BUILTIN\Users granted M on C:\ProgramData\LegacyApp[ OK] C:\Windows\System32 (no broad grants found)Summary: 2 risky ACE(s) across 3 path(s). Overall: CRIT$ ruby ntfs_acl_audit_test.rbRun options: --seed 54987# Running:...........Finished in 0.002355s, 4670.2308 runs/s, 8915.8951 assertions/s.11 runs, 21 assertions, 0 failures, 0 errors, 0 skips
Full script + README on GitHub: ruby-devops-toolkit/ntfs-acl-audit
- Ruby >= 2.7 for Windows
- No gems —
open3,optparse, andjsonare all Ruby standard library icacls.exe— built into every supported version of Windows, no install needed- Enough privilege to read the ACLs on the paths you’re auditing (elevated recommended for protected system paths)
Running it
ruby ntfs_acl_audit.rb <path> [<path> …] [options]
--risky-identities LIST— identities considered “broad” (default: Everyone, BUILTIN\Users, Authenticated Users, NT AUTHORITY\Authenticated Users)--risky-perms LIST— icacls permission codes considered risky when granted to a broad identity (default: F,M,W,WD,WDAC)--json— emit machine-readable JSON instead of text
# Audit a couple of common risk spots
ruby ntfs_acl_audit.rb "C:\inetpub\wwwroot" "C:\ProgramData\MyApp"
# Broaden what counts as risky
ruby ntfs_acl_audit.rb "C:\Deploys" --risky-perms F,M,W,WD,WDAC,DC
# JSON for a compliance pipeline
ruby ntfs_acl_audit.rb "C:\Windows\Temp" --json
Full walkthrough
1. Invocation
run_icacls(path) shells out via Open3.capture3 and is the only place the script touches the OS.
2. Parsing
parse_icacls_output tracks the current path across indented lines and separates inheritance flags from actual permission codes, capturing an explicit (DENY) marker separately.
3. Risk evaluation
evaluate_ace treats DENY as protective, skips known-safe identities (SYSTEM, Administrators, CREATOR OWNER), and for everything else checks whether the identity is broad and the ACE grants a risky permission. FullControl/Modify are CRIT; a bare Write grant is WARN.
4. Reporting
One line per audited path with its worst finding, plus the specific ACE and reason for every flagged grant; --json for machine consumption. Exit code 2 if anything was flagged or a path couldn’t be read.
CLI run, then the full test suite
No Windows host? No problem — test the logic, not the OS call
This is the honest part: icacls.exe only exists on Windows, and no Windows host was available while building this. Rather than skip testing, every piece of logic except the single shell-out was written as a pure function, and audit_path accepts an injectable runner: in place of the real one. The test suite below feeds realistic icacls.exe output — reproduced fixtures covering a safe system directory, an Everyone:F grant, a Users:M grant, an explicit DENY ACE, a Write-only WARN-level grant, and an icacls failure — straight through parse_icacls_output, evaluate_ace, and audit_path end to end.
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# Unit tests for ntfs_acl_audit.rb's parsing and risk-scoring logic.
#
# This environment has no Windows host, so icacls.exe can't actually be
# invoked here. Instead, these tests feed realistic icacls.exe TEXT OUTPUT
# (captured from a real Windows box and reproduced as fixtures below)
# straight into parse_icacls_output / evaluate_ace / audit_path, and for
# audit_path, inject a fake "runner" lambda in place of Open3 so the whole
# pipeline is exercised without shelling out. Run with:
#
# ruby ntfs_acl_audit_test.rb
require 'minitest/autorun'
require_relative 'ntfs_acl_audit'
# --- Fixtures --------------------------------------------------------------
# Captured (redacted/reproduced) icacls.exe output shapes.
FIXTURE_SAFE = <<~OUT
C:\\Windows\\System32 BUILTIN\\Administrators:(OI)(CI)(F)
NT AUTHORITY\\SYSTEM:(OI)(CI)(F)
BUILTIN\\Users:(OI)(CI)(RX)
APPLICATION PACKAGE AUTHORITY\\ALL APPLICATION PACKAGES:(OI)(CI)(RX)
Successfully processed 1 files; Failed processing 0 files
OUT
FIXTURE_RISKY_EVERYONE_FULLCONTROL = <<~OUT
C:\\inetpub\\wwwroot BUILTIN\\Administrators:(OI)(CI)(F)
NT AUTHORITY\\SYSTEM:(OI)(CI)(F)
BUILTIN\\Users:(OI)(CI)(RX)
Everyone:(OI)(CI)(F)
Successfully processed 1 files; Failed processing 0 files
OUT
FIXTURE_RISKY_USERS_MODIFY = <<~OUT
C:\\ProgramData\\LegacyApp BUILTIN\\Administrators:(OI)(CI)(F)
NT AUTHORITY\\SYSTEM:(OI)(CI)(F)
BUILTIN\\Users:(OI)(CI)(M)
Successfully processed 1 files; Failed processing 0 files
OUT
FIXTURE_DENY_IS_NOT_A_FINDING = <<~OUT
C:\\Secure\\Vault BUILTIN\\Administrators:(OI)(CI)(F)
Everyone:(DENY)(OI)(CI)(F)
NT AUTHORITY\\SYSTEM:(OI)(CI)(F)
Successfully processed 1 files; Failed processing 0 files
OUT
FIXTURE_WRITE_ONLY_IS_WARN = <<~OUT
C:\\Shares\\Drop BUILTIN\\Administrators:(OI)(CI)(F)
Authenticated Users:(OI)(CI)(W)
Successfully processed 1 files; Failed processing 0 files
OUT
# --- Tests -------------------------------------------------------------
class NtfsAclAuditTest < Minitest::Test
RISKY_IDENTITIES = ['Everyone', 'BUILTIN\\Users', 'Authenticated Users', 'NT AUTHORITY\\Authenticated Users'].freeze
RISKY_PERMS = %w[F M W WD WDAC].freeze
SAFE_IDENTITIES = ['NT AUTHORITY\\SYSTEM', 'BUILTIN\\Administrators', 'CREATOR OWNER'].freeze
def test_parses_all_aces_for_a_path
entries = parse_icacls_output(FIXTURE_SAFE)
assert_equal 4, entries.size
assert_equal 'C:\\Windows\\System32', entries.first[:path]
assert entries.all? { |e| e[:path] == 'C:\\Windows\\System32' }, 'every ACE should inherit the path from the first line'
end
def test_extracts_permission_codes_separately_from_inheritance_flags
entries = parse_icacls_output(FIXTURE_SAFE)
admins = entries.find { |e| e[:identity] == 'BUILTIN\\Administrators' }
assert_equal ['F'], admins[:perms]
assert_equal %w[OI CI], admins[:inheritance_flags]
end
def test_safe_identity_is_never_flagged_even_with_fullcontrol
entries = parse_icacls_output(FIXTURE_SAFE)
admins = entries.find { |e| e[:identity] == 'BUILTIN\\Administrators' }
result = evaluate_ace(admins, RISKY_IDENTITIES, RISKY_PERMS, SAFE_IDENTITIES)
assert_equal :ok, result[:severity]
end
def test_everyone_fullcontrol_is_critical
entries = parse_icacls_output(FIXTURE_RISKY_EVERYONE_FULLCONTROL)
everyone = entries.find { |e| e[:identity] == 'Everyone' }
result = evaluate_ace(everyone, RISKY_IDENTITIES, RISKY_PERMS, SAFE_IDENTITIES)
assert_equal :crit, result[:severity]
assert_match(/Everyone granted F on C:\\inetpub\\wwwroot/, result[:reasons].first)
end
def test_users_modify_is_critical
entries = parse_icacls_output(FIXTURE_RISKY_USERS_MODIFY)
users = entries.find { |e| e[:identity] == 'BUILTIN\\Users' }
result = evaluate_ace(users, RISKY_IDENTITIES, RISKY_PERMS, SAFE_IDENTITIES)
assert_equal :crit, result[:severity]
end
def test_users_read_execute_is_not_flagged
entries = parse_icacls_output(FIXTURE_RISKY_EVERYONE_FULLCONTROL)
users = entries.find { |e| e[:identity] == 'BUILTIN\\Users' }
result = evaluate_ace(users, RISKY_IDENTITIES, RISKY_PERMS, SAFE_IDENTITIES)
assert_equal :ok, result[:severity], 'Users:(RX) is read-only and should never be a finding'
end
def test_explicit_deny_ace_is_not_a_finding
entries = parse_icacls_output(FIXTURE_DENY_IS_NOT_A_FINDING)
everyone_deny = entries.find { |e| e[:identity] == 'Everyone' }
assert everyone_deny[:deny], 'fixture ACE should have parsed as a DENY'
result = evaluate_ace(everyone_deny, RISKY_IDENTITIES, RISKY_PERMS, SAFE_IDENTITIES)
assert_equal :ok, result[:severity], 'an explicit DENY is protective, not risky'
end
def test_write_only_grant_is_warn_not_crit
entries = parse_icacls_output(FIXTURE_WRITE_ONLY_IS_WARN)
auth_users = entries.find { |e| e[:identity] == 'Authenticated Users' }
result = evaluate_ace(auth_users, RISKY_IDENTITIES, RISKY_PERMS, SAFE_IDENTITIES)
assert_equal :warn, result[:severity]
end
def test_audit_path_end_to_end_with_fake_runner
fake_runner = ->(_path) { [FIXTURE_RISKY_EVERYONE_FULLCONTROL, '', Struct.new(:success?).new(true)] }
result = audit_path('C:\\inetpub\\wwwroot', RISKY_IDENTITIES, RISKY_PERMS, SAFE_IDENTITIES, runner: fake_runner)
assert_equal :crit, result[:status]
flagged = result[:aces].select { |a| !a[:reasons].empty? }
assert_equal 1, flagged.size
assert_equal 'Everyone', flagged.first[:identity]
end
def test_audit_path_reports_error_when_icacls_fails
fake_runner = ->(_path) { ['', 'ERROR: The system cannot find the file specified.', Struct.new(:success?).new(false)] }
result = audit_path('C:\\Nonexistent', RISKY_IDENTITIES, RISKY_PERMS, SAFE_IDENTITIES, runner: fake_runner)
assert_equal :error, result[:status]
assert_match(/cannot find the file/, result[:error])
end
def test_clean_tree_has_no_findings
entries = parse_icacls_output(FIXTURE_SAFE)
findings = entries.map { |e| evaluate_ace(e, RISKY_IDENTITIES, RISKY_PERMS, SAFE_IDENTITIES) }
assert findings.all? { |f| f[:severity] == :ok }
end
end
Troubleshooting
- “ERROR: The system cannot find the file specified.” — the path doesn’t exist, or the account running the script can’t see it. Quote paths with spaces, and run elevated for protected system directories.
- A grant you expect to see isn’t flagged — check it’s actually in
--risky-perms.RXand plainRare intentionally never flagged. - An explicit
Everyone:(DENY)(F)isn’t showing as a finding — correct, not a bug. DENY blocks access; it’s the opposite of a risky grant. - Can’t verify this against a real Windows box right now — honestly noted in the README: this script’s logic was developed and tested entirely with the stub harness above. Treat the parsing regex as “should work,” not “battle-tested,” until it’s run against real
icacls.exeoutput on a variety of paths.
Where to take this next
- Add
/T(recurse into subdirectories) support - Cross-reference findings against a local-admin or service-account audit to catch broad ACLs combined with privileged accounts
- Add a
--fixmode that runsicacls <path> /remove:g Everyonefor a confirmed-risky grant, gated behind explicit confirmation - Support share-level permissions (
Get-SmbShareAccess) alongside NTFS permissions — they combine most-restrictive-wins, so auditing one without the other is incomplete