A firewall that’s locked down and a password policy that lets anyone set a 1-character password with unlimited login attempts protect nothing. This tutorial wraps Windows’ built-in net accounts command in a real pass/fail audit — no WMI, no extra modules, just the command that already ships on every Windows box.
Full script, tests, and README on GitHub: ruby-devops-toolkit/winpolicy-audit
Step through the build below:
net accounts already tells you the live values — but only as human-readable text on one box at a time, with no pass/fail judgment and no way to compare it against what your org’s baseline actually requires. This script turns that text dump into a real audit: run it, get CRIT/WARN/OK, drop it into a scheduled task next to your other compliance checks.#!/usr/bin/env ruby
# frozen_string_literal: true
#
# winpolicy_audit.rb
#
# Runs the built-in `net accounts` command (no extra modules, no WMI,
# ships on every Windows box) and audits the local password/lockout
# policy it reports against a configurable baseline: minimum password
# length, maximum password age, and account lockout threshold/duration.
# A firewall that's locked down and a password policy that lets anyone
# set a 1-character password with unlimited login attempts protect
# nothing -- this is the other half of "the box is actually secure."
#
# No gems required -- `optparse`, `json`, and `open3` are stdlib.
#
# Usage (on Windows):
# ruby winpolicy_audit.rb
# ruby winpolicy_audit.rb --domain
# ruby winpolicy_audit.rb --min-length 14 --max-age-days 90 --json
#
# Usage anywhere (Linux/macOS/CI), against captured `net accounts`
# output -- also what the test suite uses:
# ruby winpolicy_audit.rb --test-fixtures fixtures/net_accounts_weak.txt
#
# Exit codes (cron/monitoring friendly):
# 0 = policy meets every threshold
# 1 = WARN-level findings
# 2 = CRIT-level findings, or `net accounts` couldn't be run
require 'optparse'
require 'json'
require 'open3'
# ---------------------------------------------------------------------------
# Parsing -- `net accounts` prints one "Label: Value" line per
# setting. This maps the (English-locale) labels it uses to short keys;
# anything not recognized is ignored rather than raising, since output
# formatting has drifted slightly across Windows versions.
# ---------------------------------------------------------------------------
LABEL_MAP = {
/force user logoff/i => :force_logoff,
/minimum password age/i => :min_password_age_days,
/maximum password age/i => :max_password_age_days,
/minimum password length/i => :min_password_length,
/length of password history/i => :password_history,
/lockout threshold/i => :lockout_threshold,
/lockout duration/i => :lockout_duration_minutes,
/lockout observation window/i => :lockout_window_minutes,
/computer role/i => :computer_role
}.freeze
# Converts a raw string value from `net accounts` into a normalized
# Ruby value: "Never"/"None" become nil (meaning "no limit set" --
# which is itself often the finding), pure integers become Integer,
# everything else stays a String.
def normalize_value(raw)
v = raw.strip
return nil if v.match?(/\A(never|none|unlimited)\z/i)
return v.to_i if v.match?(/\A\d+\z/)
v
end
def parse_net_accounts(text)
policy = {}
text.each_line do |line|
next unless line.include?(':')
label, value = line.split(':', 2)
next unless value
key = LABEL_MAP.find { |pattern, _| label.match?(pattern) }&.last
next unless key
policy[key] = normalize_value(value)
end
policy
end
def run_net_accounts(domain)
cmd = domain ? %w[net accounts /domain] : %w[net accounts]
out, status = Open3.capture2e(*cmd)
raise "net accounts exited #{status.exitstatus}: #{out}" unless status.success?
parse_net_accounts(out)
end
# ---------------------------------------------------------------------------
# Risk logic -- pure function over the normalized policy hash + a
# baseline hash, no Open3/subprocess involved.
# ---------------------------------------------------------------------------
DEFAULT_BASELINE = {
min_password_length: 14,
max_password_age_days: 90,
min_password_history: 5,
max_lockout_threshold: 10
}.freeze
def evaluate_policy(policy, baseline)
findings = []
len = policy[:min_password_length]
if len.nil? || len.zero?
findings << { severity: 'CRIT', reason: 'minimum password length is 0/unset -- any password, including empty, is accepted' }
elsif len < baseline[:min_password_length]
sev = len < 8 ? 'CRIT' : 'WARN'
findings << { severity: sev, reason: "minimum password length is #{len}, below baseline of #{baseline[:min_password_length]}" }
end
max_age = policy[:max_password_age_days]
if max_age.nil?
findings << { severity: 'WARN', reason: 'maximum password age is "Never" -- passwords do not expire; confirm this is an intentional NIST-800-63B-style policy and not an oversight' }
elsif max_age > baseline[:max_password_age_days]
findings << { severity: 'WARN', reason: "maximum password age is #{max_age} days, above baseline of #{baseline[:max_password_age_days]}" }
end
hist = policy[:password_history]
if hist.nil? || hist.zero?
findings << { severity: 'WARN', reason: 'password history is "None" -- users can immediately reuse their previous password' }
elsif hist < baseline[:min_password_history]
findings << { severity: 'WARN', reason: "password history remembers only #{hist} password(s), below baseline of #{baseline[:min_password_history]}" }
end
threshold = policy[:lockout_threshold]
if threshold.nil?
findings << { severity: 'CRIT', reason: 'lockout threshold is "Never" -- unlimited password attempts, no brute-force protection' }
elsif threshold > baseline[:max_lockout_threshold]
findings << { severity: 'WARN', reason: "lockout threshold is #{threshold} attempts, above baseline of #{baseline[:max_lockout_threshold]}" }
end
findings
end
# ---------------------------------------------------------------------------
# Run
# ---------------------------------------------------------------------------
if __FILE__ == $PROGRAM_NAME
options = {
domain: false,
json: false,
test_fixtures: nil,
baseline: DEFAULT_BASELINE.dup
}
parser = OptionParser.new do |opts|
opts.banner = 'Usage: winpolicy_audit.rb [options]'
opts.on('--domain', 'Audit domain policy (net accounts /domain) instead of local') { options[:domain] = true }
opts.on('--min-length N', Integer, "Baseline minimum password length (default: #{DEFAULT_BASELINE[:min_password_length]})") { |v| options[:baseline][:min_password_length] = v }
opts.on('--max-age-days N', Integer, "Baseline maximum password age (default: #{DEFAULT_BASELINE[:max_password_age_days]})") { |v| options[:baseline][:max_password_age_days] = v }
opts.on('--min-history N', Integer, "Baseline minimum password history (default: #{DEFAULT_BASELINE[:min_password_history]})") { |v| options[:baseline][:min_password_history] = v }
opts.on('--max-lockout-threshold N', Integer, "Baseline max lockout threshold (default: #{DEFAULT_BASELINE[:max_lockout_threshold]})") { |v| options[:baseline][:max_lockout_threshold] = v }
opts.on('--json', 'Emit machine-readable JSON instead of text') { options[:json] = true }
opts.on('--test-fixtures PATH',
'Read captured `net accounts` text output from a file instead of ' \
'running it live (works on any OS -- see fixtures/ and the test suite)') { |v| options[:test_fixtures] = v }
opts.on('-h', '--help', 'Show this help') { puts opts; exit 0 }
end
parser.parse!
policy =
begin
if options[:test_fixtures]
parse_net_accounts(File.read(options[:test_fixtures]))
else
run_net_accounts(options[:domain])
end
rescue Errno::ENOENT
warn '`net` command not found -- this script audits live policy on Windows only. Use --test-fixtures PATH to audit captured output on any OS.'
exit 2
rescue StandardError => e
warn "failed to read policy: #{e.class}: #{e.message}"
exit 2
end
findings = evaluate_policy(policy, options[:baseline])
if options[:json]
puts JSON.pretty_generate(policy: policy, baseline: options[:baseline], findings: findings)
else
puts "winpolicy_audit: #{options[:domain] ? 'domain' : 'local'} account policy"
policy.each { |k, v| puts " #{k}: #{v.nil? ? '(never/none)' : v}" }
puts ''
if findings.empty?
puts 'no findings -- policy meets every baseline threshold'
else
findings.sort_by { |f| f[:severity] == 'CRIT' ? 0 : 1 }.each { |f| puts "[#{f[:severity]}] #{f[:reason]}" }
crit = findings.count { |f| f[:severity] == 'CRIT' }
warn_n = findings.count { |f| f[:severity] == 'WARN' }
puts "\n#{crit} CRIT, #{warn_n} WARN"
end
end
exit_code =
if findings.any? { |f| f[:severity] == 'CRIT' }
2
elsif findings.any? { |f| f[:severity] == 'WARN' }
1
else
0
end
exit exit_code
end
run_net_accounts shells out to net accounts live on Windows; --test-fixtures reads previously captured text on any OS. Both paths funnel into the exact same parse_net_accounts.
normalize_value does the heavy lifting. "Never"/"None"/"Unlimited" all become nil — itself frequently the finding — and pure-digit strings become real Integers.
evaluate_policy is a pure function comparing the normalized policy against a baseline hash, with zero subprocess dependency — fully testable on any OS with captured text.
$ ruby winpolicy_audit.rb --test-fixtures fixtures/net_accounts_weak.txt winpolicy_audit: local account policy force_logoff: (never/none) min_password_age_days: 0 max_password_age_days: (never/none) min_password_length: 0 password_history: (never/none) lockout_threshold: (never/none) lockout_duration_minutes: 30 lockout_window_minutes: 30 computer_role: WORKSTATION [CRIT] minimum password length is 0/unset -- any password, including empty, is accepted [CRIT] lockout threshold is "Never" -- unlimited password attempts, no brute-force protection [WARN] maximum password age is "Never" -- passwords do not expire; confirm this is an intentional NIST-800-63B-style policy and not an oversight [WARN] password history is "None" -- users can immediately reuse their previous password 2 CRIT, 2 WARN $ echo "exit=$?" exit=2 $ ruby winpolicy_audit.rb --test-fixtures fixtures/net_accounts_strong.txt winpolicy_audit: local account policy force_logoff: (never/none) min_password_age_days: 1 max_password_age_days: 90 min_password_length: 14 password_history: 24 lockout_threshold: 5 lockout_duration_minutes: 30 lockout_window_minutes: 30 computer_role: WORKSTATION no findings -- policy meets every baseline threshold $ echo "exit=$?" exit=0 $ ruby winpolicy_audit_test.rb parse_net_accounts: real captured "weak" output ok - min_password_length ok - max_password_age_days (Unlimited -> nil) ok - password_history (None -> nil) ok - lockout_threshold (Never -> nil) ok - computer_role parse_net_accounts: real captured "strong" output ok - min_password_length ok - max_password_age_days ok - password_history ok - lockout_threshold evaluate_policy: weak fixture -> 2 CRIT, 2 WARN (length, lockout, max-age, history) ok - severities evaluate_policy: strong fixture -> no findings ok - findings evaluate_policy: individual branches ok - length 0 -> CRIT ok - length 6 (< 8) -> CRIT ok - length 10 (8..13) -> WARN ok - length 14 (meets baseline) -> no length finding ok - lockout nil (Never) -> CRIT ok - lockout 50 (above baseline) -> WARN ok - lockout 5 (within baseline) -> no lockout finding 18 checks, 0 failures
What you need
- Ruby >= 2.7 (ships with the standard RubyInstaller build for Windows; Windows only for live use)
net.exeonPATH(present on every Windows install by default)- No gems —
optparse,json, andopen3are all Ruby standard library - On any other OS (Linux, macOS, CI): the CLI still runs against a captured text fixture with
--test-fixtures, and the entire evaluation engine is unit-tested without touchingnet.exeat all
Running it
# Audit the local machine's account policy (Windows)
ruby winpolicy_audit.rb
# Audit domain policy instead (net accounts /domain)
ruby winpolicy_audit.rb --domain
# Tighten or loosen the baseline
ruby winpolicy_audit.rb --min-length 14 --max-age-days 90 --min-history 5 --max-lockout-threshold 10
# On any OS, including CI: audit captured `net accounts` output
ruby winpolicy_audit.rb --test-fixtures fixtures/net_accounts_weak.txt --json
0— policy meets every baseline threshold1— at least one WARN-level finding2— at least one CRIT-level finding, ornet accountscouldn’t be run
How it works
Four pieces, same split this toolkit uses for every Windows-only script that needs to run its logic on Linux too:
run_net_accounts(domain)shells out tonet accounts(ornet accounts /domain) viaOpen3.capture2e.net.exenot existing (any non-Windows OS) is caught specifically viarescue Errno::ENOENTwith a message pointing at--test-fixturesrather than a raw stack trace.parse_net_accounts(text)reads everyLabel: Valuelinenet accountsprints and matches each label againstLABEL_MAP, a small table of regexes.normalize_valueturns"Never"/"None"/"Unlimited"intoniland pure-digit strings into realIntegers, so the risk engine never has to parse strings.evaluate_policy(policy, baseline)is the risk engine, and it’s pure: password length below 8 is CRIT, a length of0/unset is CRIT outright; lockout threshold ofnil(“Never”) is CRIT — unlimited login attempts, no brute-force protection; above the baseline ceiling is WARN; max password age ofnilis deliberately WARN, not CRIT (see Troubleshooting for why); password history ofnil/0or below baseline is WARN.- The
__FILE__ == $PROGRAM_NAMEguard keeps the whole filerequire_relative-able by the test suite without triggering CLI parsing, a livenet accountscall, orexit.
winpolicy_audit.rb
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# winpolicy_audit.rb
#
# Runs the built-in `net accounts` command (no extra modules, no WMI,
# ships on every Windows box) and audits the local password/lockout
# policy it reports against a configurable baseline: minimum password
# length, maximum password age, and account lockout threshold/duration.
# A firewall that's locked down and a password policy that lets anyone
# set a 1-character password with unlimited login attempts protect
# nothing -- this is the other half of "the box is actually secure."
#
# No gems required -- `optparse`, `json`, and `open3` are stdlib.
#
# Usage (on Windows):
# ruby winpolicy_audit.rb
# ruby winpolicy_audit.rb --domain
# ruby winpolicy_audit.rb --min-length 14 --max-age-days 90 --json
#
# Usage anywhere (Linux/macOS/CI), against captured `net accounts`
# output -- also what the test suite uses:
# ruby winpolicy_audit.rb --test-fixtures fixtures/net_accounts_weak.txt
#
# Exit codes (cron/monitoring friendly):
# 0 = policy meets every threshold
# 1 = WARN-level findings
# 2 = CRIT-level findings, or `net accounts` couldn't be run
require 'optparse'
require 'json'
require 'open3'
# ---------------------------------------------------------------------------
# Parsing -- `net accounts` prints one "Label: Value" line per
# setting. This maps the (English-locale) labels it uses to short keys;
# anything not recognized is ignored rather than raising, since output
# formatting has drifted slightly across Windows versions.
# ---------------------------------------------------------------------------
LABEL_MAP = {
/force user logoff/i => :force_logoff,
/minimum password age/i => :min_password_age_days,
/maximum password age/i => :max_password_age_days,
/minimum password length/i => :min_password_length,
/length of password history/i => :password_history,
/lockout threshold/i => :lockout_threshold,
/lockout duration/i => :lockout_duration_minutes,
/lockout observation window/i => :lockout_window_minutes,
/computer role/i => :computer_role
}.freeze
# Converts a raw string value from `net accounts` into a normalized
# Ruby value: "Never"/"None" become nil (meaning "no limit set" --
# which is itself often the finding), pure integers become Integer,
# everything else stays a String.
def normalize_value(raw)
v = raw.strip
return nil if v.match?(/\A(never|none|unlimited)\z/i)
return v.to_i if v.match?(/\A\d+\z/)
v
end
def parse_net_accounts(text)
policy = {}
text.each_line do |line|
next unless line.include?(':')
label, value = line.split(':', 2)
next unless value
key = LABEL_MAP.find { |pattern, _| label.match?(pattern) }&.last
next unless key
policy[key] = normalize_value(value)
end
policy
end
def run_net_accounts(domain)
cmd = domain ? %w[net accounts /domain] : %w[net accounts]
out, status = Open3.capture2e(*cmd)
raise "net accounts exited #{status.exitstatus}: #{out}" unless status.success?
parse_net_accounts(out)
end
# ---------------------------------------------------------------------------
# Risk logic -- pure function over the normalized policy hash + a
# baseline hash, no Open3/subprocess involved.
# ---------------------------------------------------------------------------
DEFAULT_BASELINE = {
min_password_length: 14,
max_password_age_days: 90,
min_password_history: 5,
max_lockout_threshold: 10
}.freeze
def evaluate_policy(policy, baseline)
findings = []
len = policy[:min_password_length]
if len.nil? || len.zero?
findings << { severity: 'CRIT', reason: 'minimum password length is 0/unset -- any password, including empty, is accepted' }
elsif len < baseline[:min_password_length]
sev = len < 8 ? 'CRIT' : 'WARN'
findings << { severity: sev, reason: "minimum password length is #{len}, below baseline of #{baseline[:min_password_length]}" }
end
max_age = policy[:max_password_age_days]
if max_age.nil?
findings << { severity: 'WARN', reason: 'maximum password age is "Never" -- passwords do not expire; confirm this is an intentional NIST-800-63B-style policy and not an oversight' }
elsif max_age > baseline[:max_password_age_days]
findings << { severity: 'WARN', reason: "maximum password age is #{max_age} days, above baseline of #{baseline[:max_password_age_days]}" }
end
hist = policy[:password_history]
if hist.nil? || hist.zero?
findings << { severity: 'WARN', reason: 'password history is "None" -- users can immediately reuse their previous password' }
elsif hist < baseline[:min_password_history]
findings << { severity: 'WARN', reason: "password history remembers only #{hist} password(s), below baseline of #{baseline[:min_password_history]}" }
end
threshold = policy[:lockout_threshold]
if threshold.nil?
findings << { severity: 'CRIT', reason: 'lockout threshold is "Never" -- unlimited password attempts, no brute-force protection' }
elsif threshold > baseline[:max_lockout_threshold]
findings << { severity: 'WARN', reason: "lockout threshold is #{threshold} attempts, above baseline of #{baseline[:max_lockout_threshold]}" }
end
findings
end
# ---------------------------------------------------------------------------
# Run
# ---------------------------------------------------------------------------
if __FILE__ == $PROGRAM_NAME
options = {
domain: false,
json: false,
test_fixtures: nil,
baseline: DEFAULT_BASELINE.dup
}
parser = OptionParser.new do |opts|
opts.banner = 'Usage: winpolicy_audit.rb [options]'
opts.on('--domain', 'Audit domain policy (net accounts /domain) instead of local') { options[:domain] = true }
opts.on('--min-length N', Integer, "Baseline minimum password length (default: #{DEFAULT_BASELINE[:min_password_length]})") { |v| options[:baseline][:min_password_length] = v }
opts.on('--max-age-days N', Integer, "Baseline maximum password age (default: #{DEFAULT_BASELINE[:max_password_age_days]})") { |v| options[:baseline][:max_password_age_days] = v }
opts.on('--min-history N', Integer, "Baseline minimum password history (default: #{DEFAULT_BASELINE[:min_password_history]})") { |v| options[:baseline][:min_password_history] = v }
opts.on('--max-lockout-threshold N', Integer, "Baseline max lockout threshold (default: #{DEFAULT_BASELINE[:max_lockout_threshold]})") { |v| options[:baseline][:max_lockout_threshold] = v }
opts.on('--json', 'Emit machine-readable JSON instead of text') { options[:json] = true }
opts.on('--test-fixtures PATH',
'Read captured `net accounts` text output from a file instead of ' \
'running it live (works on any OS -- see fixtures/ and the test suite)') { |v| options[:test_fixtures] = v }
opts.on('-h', '--help', 'Show this help') { puts opts; exit 0 }
end
parser.parse!
policy =
begin
if options[:test_fixtures]
parse_net_accounts(File.read(options[:test_fixtures]))
else
run_net_accounts(options[:domain])
end
rescue Errno::ENOENT
warn '`net` command not found -- this script audits live policy on Windows only. Use --test-fixtures PATH to audit captured output on any OS.'
exit 2
rescue StandardError => e
warn "failed to read policy: #{e.class}: #{e.message}"
exit 2
end
findings = evaluate_policy(policy, options[:baseline])
if options[:json]
puts JSON.pretty_generate(policy: policy, baseline: options[:baseline], findings: findings)
else
puts "winpolicy_audit: #{options[:domain] ? 'domain' : 'local'} account policy"
policy.each { |k, v| puts " #{k}: #{v.nil? ? '(never/none)' : v}" }
puts ''
if findings.empty?
puts 'no findings -- policy meets every baseline threshold'
else
findings.sort_by { |f| f[:severity] == 'CRIT' ? 0 : 1 }.each { |f| puts "[#{f[:severity]}] #{f[:reason]}" }
crit = findings.count { |f| f[:severity] == 'CRIT' }
warn_n = findings.count { |f| f[:severity] == 'WARN' }
puts "\n#{crit} CRIT, #{warn_n} WARN"
end
end
exit_code =
if findings.any? { |f| f[:severity] == 'CRIT' }
2
elsif findings.any? { |f| f[:severity] == 'WARN' }
1
else
0
end
exit exit_code
end
When it doesn't behave
- “maximum password age is Never” is only WARN, not CRIT — shouldn’t unlimited password age always be a failure? Deliberately not automatic: classic compliance baselines (PCI-DSS, older CIS benchmarks) expect forced periodic rotation, but current NIST 800-63B guidance actively recommends against forced rotation in favor of length, breach-list screening, and MFA. This script surfaces the setting either way and lets a human decide which policy your org is following — that’s why it’s WARN with an explanatory reason, not a hardcoded CRIT.
net accountsnot found / this script doesn’t do anything on my Mac or Linux box — expected;net.exeis Windows-only. Use--test-fixturesto audit previously captured output anywhere else, or run this on the Windows host itself.- Values look off after a locale change —
net accountsoutput is localized;LABEL_MAP‘s regexes match the English-locale wording. On a non-English Windows install, either capture output under an English code page, or extendLABEL_MAPwith the localized label text. --domainfails with an error about no domain controller — requires the machine to actually be domain-joined and able to reach a DC; on a standalone workstation, drop--domain.- A setting you changed with
secpol.mscisn’t reflected —net accountsreports the effective local security policy, which on a domain-joined machine can be overridden by Group Policy; that’s correct behavior, not a bug in this script.
net accounts only exists on Windows, so run_net_accounts itself could not be executed in this Linux sandbox — an honest limitation, not glossed over. What was fully tested: parse_net_accounts against two real, hand-captured net accounts-format text fixtures (formatted to match verified public output examples, including the Never/None/Unlimited sentinel values), confirming every label parses correctly; and evaluate_policy, a pure function with zero subprocess dependency, exercised against both fixtures end-to-end plus individual hand-built hashes covering every severity branch — 18/18 checks passing (winpolicy_audit_test.rb). The full CLI was also run directly against both fixtures in text and --json mode (see the output tab above), and the “net not found” path was verified for real by running the script unmodified in this Linux sandbox.
Where to take it next
- Additional policy surfaces —
net accountsdoesn’t cover password complexity requirements, which live in Local Security Policy. Cross-reference withsecedit /exportoutput for a fuller picture — same “parse text into a hash, evaluate the hash” shape as this script. - Localization — extend
LABEL_MAPwith additional regex alternatives per label to support non-English Windows installs without a forced-English capture. - Fleet mode — wrap
run_net_accountsin the same bounded-concurrency worker-pool pattern this toolkit’s other checkers use, invoked over WinRM across many hosts. - Baseline profiles — ship named presets (
--profile cis,--profile nist-800-63b) instead of only individual flags. - Auto-remediation — for CRIT findings, print (or behind an
--applyflag, actually run) the matchingnet accounts /minpwlen:Ncommand, mirroring this toolkit’scron-manager-style dry-run-by-default pattern.