the shed // ruby / devops

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.

Get the code

Full script, tests, and README on GitHub: ruby-devops-toolkit/winpolicy-audit

Step through the build below:

winpolicy_audit.rb
Password and lockout policy is exactly the kind of setting that gets configured once during imaging and never looked at again. 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
One risk engine, two data sources. 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
01 / prerequisites

What you need

  • Ruby >= 2.7 (ships with the standard RubyInstaller build for Windows; Windows only for live use)
  • net.exe on PATH (present on every Windows install by default)
  • No gems — optparse, json, and open3 are 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 touching net.exe at all
02 / usage

Running it

usagebash
# 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 threshold
  • 1 — at least one WARN-level finding
  • 2 — at least one CRIT-level finding, or net accounts couldn’t be run
03 / walkthrough

How it works

winpolicy-audit architecture diagram: one risk engine fed by either live net accounts or captured fixtures

One risk engine, two data sources: live Windows or captured fixtures

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 to net accounts (or net accounts /domain) via Open3.capture2e. net.exe not existing (any non-Windows OS) is caught specifically via rescue Errno::ENOENT with a message pointing at --test-fixtures rather than a raw stack trace.
  • parse_net_accounts(text) reads every Label: Value line net accounts prints and matches each label against LABEL_MAP, a small table of regexes. normalize_value turns "Never"/"None"/"Unlimited" into nil and pure-digit strings into real Integers, 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 of 0/unset is CRIT outright; lockout threshold of nil (“Never”) is CRIT — unlimited login attempts, no brute-force protection; above the baseline ceiling is WARN; max password age of nil is deliberately WARN, not CRIT (see Troubleshooting for why); password history of nil/0 or below baseline is WARN.
  • The __FILE__ == $PROGRAM_NAME guard keeps the whole file require_relative-able by the test suite without triggering CLI parsing, a live net accounts call, or exit.
04 / full source

winpolicy_audit.rb

winpolicy_audit.rbruby
#!/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
05 / troubleshooting

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 accounts not found / this script doesn’t do anything on my Mac or Linux box — expected; net.exe is Windows-only. Use --test-fixtures to audit previously captured output anywhere else, or run this on the Windows host itself.
  • Values look off after a locale changenet accounts output 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 extend LABEL_MAP with the localized label text.
  • --domain fails 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.msc isn’t reflectednet accounts reports 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.
Tested how

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.

06 / extending

Where to take it next

  • Additional policy surfacesnet accounts doesn’t cover password complexity requirements, which live in Local Security Policy. Cross-reference with secedit /export output for a fuller picture — same “parse text into a hash, evaluate the hash” shape as this script.
  • Localization — extend LABEL_MAP with additional regex alternatives per label to support non-English Windows installs without a forced-English capture.
  • Fleet mode — wrap run_net_accounts in 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 --apply flag, actually run) the matching net accounts /minpwlen:N command, mirroring this toolkit’s cron-manager-style dry-run-by-default pattern.