the shed // ruby for devops

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.

ntfs_acl_audit.rb

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
Get the code

Full script + README on GitHub: ruby-devops-toolkit/ntfs-acl-audit

Prerequisites
  • Ruby >= 2.7 for Windows
  • No gemsopen3, optparse, and json are 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)
usage

Running it

ruby ntfs_acl_audit.rb <path> [<path> …] [options]

Key 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
examples.ps1powershell
# 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
ntfs-acl-audit pipeline diagram

One real shell-out feeds pure parsing + risk-evaluation functions, which the test suite exercises directly
how it works

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.

example output

CLI run, then the full test suite

ntfs_acl_audit.rb + ntfs_acl_audit_test.rb
$ 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.rb
Run 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
how it was actually tested

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.

ntfs_acl_audit_test.rbruby
#!/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

Troubleshooting

Common issues
  • “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. RX and plain R are 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.exe output on a variety of paths.
extending it

Where to take this next

Ideas
  • 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 --fix mode that runs icacls <path> /remove:g Everyone for 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