the shed // ruby devops

Walks a fleet of home directories, parses every authorized_keys entry against the real sshd wire format, and flags weak/DSA keys, unrestricted service-account access, bad permissions, and keys shared across multiple accounts — no gems required.

Step through the build below:

ssh_key_audit.rb

The problem: authorized_keys files accrete for
years and nobody audits them. Someone leaves the company and their key never gets removed. A
deploy key gets pasted into five different accounts because it was convenient at 5pm on a
Friday. A 1024-bit RSA key from 2011 is still accepted right alongside brand-new ed25519 keys.
A svc-backup automation account has an unrestricted key that can be used
interactively from anywhere, when it should only ever run one backup command from one host.

None of this shows up until an audit, a pentest, or an incident forces
someone to actually read every authorized_keys file on the fleet by hand.
ssh_key_audit.rb does that read for you: it walks a set of home directories, parses
every entry per the real sshd(8) authorized_keys format (including the
options field — command=, from=, etc.), and flags the specific
hygiene problems above with a severity.

#!/usr/bin/env ruby
# frozen_string_literal: true
#
# ssh_key_audit.rb -- Audits authorized_keys files across a fleet of home
# directories for SSH key hygiene problems, no gems required.
#
# Problem it solves: authorized_keys files accrete over years. People leave
# the company and their key never gets removed; someone pastes the same
# deploy key into five accounts because it was convenient; an old DSA or
# 1024-bit RSA key from 2011 is still accepted; a "svc-backup" automation
# account has an unrestricted key that can be used interactively from
# anywhere. None of this shows up until an audit or an incident. This script
# walks a set of home directories, parses every authorized_keys entry, and
# flags the specific hygiene problems above with a severity so you can wire
# it into a periodic security check.
#
# Usage:
#   ruby ssh_key_audit.rb /home /root
#   ruby ssh_key_audit.rb /home --denylist departed_users.json
#   ruby ssh_key_audit.rb /home --json
#
# Exit codes: 0 = no CRIT findings, 2 = one or more CRIT findings (cron/CI
# friendly), 1 = usage/config error.
require 'optparse'
require 'json'
require 'base64'
require 'stringio'
module SshKeyAudit
  Finding = Struct.new(:severity, :user, :file, :line, :message, keyword_init: true)
  # Accounts matching these patterns are treated as automation/service
  # accounts: they are held to a stricter standard (must have from= or
  # command= restrictions on every key).
  SERVICE_ACCOUNT_PATTERNS = [/\Asvc[-_]/, /\Adeploy/, /\Abackup/, /\Aci[-_]/, /\Aautomation/].freeze
  WEAK_TYPES = %w[ssh-dss [email protected]].freeze
  MIN_RSA_BITS = 2048
  # Parses the base64 SSH wire-format blob to determine key type/strength
  # without shelling out to `ssh-keygen -l` (which may not be installed, and
  # this way the logic is fully testable without any external process).
  module KeyBlob
    module_function
    def bit_strength(key_type, blob_b64)
      raw = Base64.decode64(blob_b64)
      io = StringIO.new(raw)
      type_in_blob = read_string(io)
      case key_type
      when 'ssh-rsa'
        _e = read_mpint(io)
        n = read_mpint(io)
        bits_of(n)
      when 'ssh-dss'
        64 # DSA is capped at 1024-bit by the classic spec in practice; treat as weak regardless
      when /\Aecdsa-sha2-/
        # curve name tells us the strength directly
        curve = read_string(io)
        { 'nistp256' => 256, 'nistp384' => 384, 'nistp521' => 521 }[curve] || 256
      when 'ssh-ed25519'
        256
      else
        nil
      end
    rescue StandardError
      nil
    end
    def read_string(io)
      len = io.read(4)
      return nil unless len && len.bytesize == 4
      n = len.unpack1('N')
      io.read(n)
    end
    def read_mpint(io)
      read_string(io)
    end
    # Number of significant bits in a big-endian two's-complement-ish mpint
    # as SSH encodes it (leading 0x00 byte only present to keep it
    # non-negative when the high bit of the first real byte is set).
    def bits_of(bytes)
      return 0 unless bytes
      bytes = bytes.dup
      bytes = bytes[1..] while bytes.bytesize > 1 && bytes.getbyte(0) == 0
      return 0 if bytes.empty?
      top_byte = bytes.getbyte(0)
      (bytes.bytesize - 1) * 8 + bit_length(top_byte)
    end
    def bit_length(byte)
      len = 0
      len += 1 while byte >> len > 0
      len
    end
  end
  AuthorizedKeyLine = Struct.new(:options, :key_type, :key_blob, :comment, :raw, keyword_init: true)
  class Parser
    # Splits a single authorized_keys line into (options, type, blob, comment).
    # Handles the optional leading `options` field (comma-separated, with
    # quoted values like command="rsync --server ...") per sshd's format.
    def self.parse_line(line)
      return nil if line.nil?
      line = line.strip
      return nil if line.empty? || line.start_with?('#')
      tokens = tokenize(line)
      return nil if tokens.empty?
      key_types = %w[ssh-rsa ssh-dss ssh-ed25519] + %w[ecdsa-sha2-nistp256 ecdsa-sha2-nistp384 ecdsa-sha2-nistp521]
      if key_types.include?(tokens[0])
        AuthorizedKeyLine.new(options: '', key_type: tokens[0], key_blob: tokens[1], comment: tokens[2..]&.join(' ').to_s, raw: line)
      elsif tokens.size >= 2 && key_types.include?(tokens[1])
        AuthorizedKeyLine.new(options: tokens[0], key_type: tokens[1], key_blob: tokens[2], comment: tokens[3..]&.join(' ').to_s, raw: line)
      end
    end
    # Tokenizes respecting quoted strings so `command="foo bar",no-pty ssh-rsa AAAA... name@host`
    # splits into ['command="foo bar",no-pty', 'ssh-rsa', 'AAAA...', 'name@host'].
    def self.tokenize(line)
      tokens = []
      buf = +''
      in_quotes = false
      line.each_char do |c|
        if c == '"'
          in_quotes = !in_quotes
          buf << c
        elsif c == ' ' && !in_quotes
          unless buf.empty?
            tokens << buf
            buf = +''
          end
        else
          buf << c
        end
      end
      tokens << buf unless buf.empty?
      tokens
    end
  end
  class Auditor
    def initialize(home_dirs, denylist: [])
      @home_dirs = home_dirs
      @denylist = denylist
      @findings = []
      @seen_blobs = Hash.new { |h, k| h[k] = [] } # key_blob => [ "user:file", ... ]
    end
    def run
      each_authorized_keys_file do |user, path|
        check_permissions(user, path)
        parse_and_check_keys(user, path)
      end
      check_duplicates
      @findings
    end
    private
    def each_authorized_keys_file
      @home_dirs.each do |home_root|
        next unless Dir.exist?(home_root)
        Dir.children(home_root).sort.each do |user|
          user_home = File.join(home_root, user)
          next unless File.directory?(user_home)
          ak_path = File.join(user_home, '.ssh', 'authorized_keys')
          next unless File.exist?(ak_path)
          yield user, ak_path
        end
      end
    end
    def check_permissions(user, path)
      mode = format('%o', File.stat(path).mode & 0o777)
      if mode != '600' && mode != '400'
        add(:warn, user, path, nil, "authorized_keys mode is #{mode}, expected 600 (group/world access should be denied)")
      end
      ssh_dir = File.dirname(path)
      dir_mode = format('%o', File.stat(ssh_dir).mode & 0o777)
      unless %w[700 500].include?(dir_mode)
        add(:warn, user, ssh_dir, nil, ".ssh directory mode is #{dir_mode}, expected 700")
      end
    end
    def parse_and_check_keys(user, path)
      lines = File.readlines(path)
      lines.each_with_index do |raw_line, idx|
        parsed = Parser.parse_line(raw_line)
        next unless parsed
        line_no = idx + 1
        check_weak_type(user, path, line_no, parsed)
        check_service_account_restrictions(user, path, line_no, parsed)
        check_denylisted_comment(user, path, line_no, parsed)
        @seen_blobs[parsed.key_blob] << "#{user}:#{path}:#{line_no}" if parsed.key_blob
      end
    rescue Errno::EACCES
      add(:warn, user, path, nil, 'permission denied reading authorized_keys (audit ran as an unprivileged user)')
    end
    def check_weak_type(user, path, line_no, parsed)
      if WEAK_TYPES.include?(parsed.key_type)
        add(:crit, user, path, line_no, "#{parsed.key_type} key is cryptographically weak (DSA) -- should be replaced with ed25519 or rsa >= 2048")
        return
      end
      bits = KeyBlob.bit_strength(parsed.key_type, parsed.key_blob)
      return unless bits
      if parsed.key_type == 'ssh-rsa' && bits < MIN_RSA_BITS
        add(:crit, user, path, line_no, "ssh-rsa key is only #{bits}-bit (< #{MIN_RSA_BITS}); replace with ed25519 or a >= 2048-bit RSA key")
      end
    end
    def service_account?(user)
      SERVICE_ACCOUNT_PATTERNS.any? { |re| user.match?(re) }
    end
    def check_service_account_restrictions(user, path, line_no, parsed)
      return unless service_account?(user)
      has_restriction = parsed.options.include?('from=') || parsed.options.include?('command=')
      unless has_restriction
        add(:crit, user, path, line_no,
            "service account '#{user}' has a key with no from= or command= restriction -- " \
            'anyone holding the private key can log in interactively from anywhere')
      end
    end
    def check_denylisted_comment(user, path, line_no, parsed)
      return if @denylist.empty?
      hit = @denylist.find { |name| parsed.comment.to_s.include?(name) || user == name }
      add(:crit, user, path, line_no, "key comment/user matches denylisted (departed) identity '#{hit}'") if hit
    end
    def check_duplicates
      @seen_blobs.each do |blob, locations|
        next if blob.nil? || locations.size < 2
        users = locations.map { |l| l.split(':').first }.uniq
        next if users.size < 2
        add(:warn, users.join(','), locations.map { |l| l.split(':')[1] }.uniq.join(', '), nil,
            "identical public key is authorized for #{users.size} different accounts (#{users.join(', ')}) -- " \
            'shared keys make it impossible to attribute access to one person')
      end
    end
    def add(severity, user, file, line, message)
      @findings << Finding.new(severity: severity, user: user, file: file, line: line, message: message)
    end
  end
end
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if __FILE__ == $PROGRAM_NAME
  options = { denylist: [], json: false }
  parser = OptionParser.new do |opts|
    opts.banner = 'Usage: ssh_key_audit.rb HOME_DIR [HOME_DIR ...] [--denylist FILE] [--json]'
    opts.on('--denylist PATH', 'JSON array of departed usernames/comment fragments to flag') do |v|
      options[:denylist] = JSON.parse(File.read(v))
    end
    opts.on('--json', 'Emit machine-readable JSON instead of text') { options[:json] = true }
  end
  parser.parse!
  home_dirs = ARGV
  if home_dirs.empty?
    warn parser.banner
    exit 1
  end
  findings = SshKeyAudit::Auditor.new(home_dirs, denylist: options[:denylist]).run
  crit_count = findings.count { |f| f.severity == :crit }
  warn_count = findings.count { |f| f.severity == :warn }
  if options[:json]
    puts JSON.pretty_generate(findings.map(&:to_h))
  elsif findings.empty?
    puts 'No findings -- all authorized_keys entries look healthy.'
  else
    findings.sort_by { |f| [f.severity == :crit ? 0 : 1, f.user.to_s] }.each do |f|
      tag = f.severity == :crit ? 'CRIT' : 'WARN'
      loc = f.line ? "#{f.file}:#{f.line}" : f.file
      puts "[#{tag}] #{f.user} #{loc} -- #{f.message}"
    end
    puts "\n#{crit_count} critical, #{warn_count} warnings"
  end
  exit(crit_count.positive? ? 2 : 0)
end

Why the RSA key strength check decodes the base64 blob instead of
shelling out to ssh-keygen -l:
the SSH wire format for a public key is a
well-documented sequence of length-prefixed fields, and for ssh-rsa it’s just
(type, e, n) as big-endian mpints. KeyBlob.bit_strength reads those
fields with a small StringIO reader and counts the significant bits of
n directly. That keeps the whole auditor dependency-free and fully unit-testable
— no shelling out, no parsing another program’s text output, no assuming
ssh-keygen is even installed on the audit host.

Why Parser.tokenize is hand-rolled instead of a
.split(' '):
the optional leading options field can itself
contain spaces inside quotes — command="rsync --server ...",no-pty is one
token, not several. A naive space-split would break that apart incorrectly and misidentify the
key type. The hand-rolled tokenizer tracks quote state character-by-character so quoted commas
and spaces stay part of one token.

Why duplicate-key detection is a second pass, not inline:
check_duplicates needs to see every user’s keys before it can tell whether a blob
repeats across accounts, so Auditor#run builds up a
key_blob => [locations] map while it does the per-file checks, then does one final
pass over that map once every file has been read. This is the same reason it’s a security smell
in the first place: a shared key makes it structurally impossible to answer “who logged in as
this account” from the key alone.

$ ruby ssh_key_audit.rb /home --denylist departed_users.json
[CRIT] bob /home/bob/.ssh/authorized_keys:1 -- ssh-rsa key is only 1024-bit (< 2048); replace with ed25519 or a >= 2048-bit RSA key
[CRIT] frank /home/frank/.ssh/authorized_keys:1 -- key comment/user matches denylisted (departed) identity 'jsmith'
[CRIT] svc-backup /home/svc-backup/.ssh/authorized_keys:1 -- service account 'svc-backup' has a key with no from= or command= restriction -- anyone holding the private key can log in interactively from anywhere
[WARN] bob /home/bob/.ssh/authorized_keys -- authorized_keys mode is 644, expected 600 (group/world access should be denied)
[WARN] bob /home/bob/.ssh -- .ssh directory mode is 755, expected 700
3 critical, 2 warnings
exit: 2
Get the code

Full script + README on GitHub: ruby-devops-toolkit/ssh-key-audit

SSH key hygiene is one of those things every security checklist mentions and almost nobody
actually verifies on a schedule, because verifying it means reading raw
authorized_keys files across every account on every host. That’s exactly the kind
of tedious, mechanical, easy-to-get-wrong-by-hand task Ruby is good for automating.
ssh_key_audit.rb turns “does anyone still have a DSA key or a departed employee’s
access?” from a manual grep-and-hope exercise into a script you can run in CI or cron and trust
the exit code of.

Prerequisites
  • Ruby 2.7+ (tested on 3.0.2; uses only optparse, json,
    base64, and stringio from the standard library — no gems to
    install).
  • Linux or macOS host structure (/home/<user>/.ssh/authorized_keys);
    the parsing and risk logic are platform-independent, but the directory-walk assumes a Unix-style
    home directory layout.
  • Read access to the home directories you want to audit — typically run
    as root (or a user in an equivalent group) since individual users’ .ssh directories
    are usually 700.
  • Optionally, a JSON array of departed-employee usernames/comment fragments for the
    --denylist flag.
SSH key audit fleet scan workflow diagram

Fleet-wide scan: per-user parse and checks, followed by a cross-account duplicate-key pass.
format

The authorized_keys Format, Briefly

Per sshd(8), each line is: an optional comma-separated options
field (only present if the line doesn’t start directly with a key type), the key type
(ssh-ed25519, ssh-rsa, ecdsa-sha2-*, or the legacy weak
ssh-dss), the base64-encoded key blob, and an optional trailing comment (by
convention, usually user@host):

authorized_keystext
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIxxxx... alice@laptop
command="/opt/backup/run.sh",no-pty,no-X11-forwarding ssh-ed25519 AAAAC3Nza... backup-automation
walkthrough

Step-by-Step Walkthrough

1. Parser.parse_line / tokenize

Tokenizes the line character-by-character, tracking whether we’re inside a double-quoted
value so options like command="rsync --server . dest" don’t get split on their
internal spaces. Once tokenized, it checks whether the first or second token is a recognized key
type to decide whether an options field is present at all.

2. KeyBlob.bit_strength

Base64-decodes the key blob and walks it as SSH wire-format fields
(4-byte big-endian length + payload, repeated). For ssh-rsa it skips the exponent
and reads the bit-length of the modulus directly; for ecdsa-sha2-* it reads the
curve name; ssh-ed25519 is always 256-bit and ssh-dss is always
flagged regardless of computed size, since DSA is deprecated on its own merits.

3. The per-key checks

check_weak_type (DSA / short RSA), check_service_account_restrictions
(accounts matching svc-*, deploy*, backup*, ci-*,
or automation* must have from= or command= on every key),
and check_denylisted_comment (matches a provided list of departed identities against
both the username and the key’s trailing comment) all run per authorized_keys line as it’s
parsed.

4. check_duplicates

Runs once, after every file has been scanned, against the key_blob => [locations]
map built up during the per-file pass. Any blob that maps to two or more different usernames is
a shared-key finding — it’s the one check in the file that’s inherently cross-account
rather than per-file.

troubleshooting

Troubleshooting

Common issues
  • “permission denied reading authorized_keys” — the auditor ran as a
    user without read access into someone’s 700 home directory. Run as root (or a
    dedicated audit account in the right group) for a complete fleet scan; the script logs this as a
    WARN per-file rather than crashing.
  • Service account false positives — the
    SERVICE_ACCOUNT_PATTERNS regex list is a starting point
    (svc-, deploy, backup, ci-,
    automation). If your naming convention differs, edit that constant to match your
    fleet before relying on the restriction check.
  • Legitimate shared key flagged as a duplicate — some teams intentionally
    share a single break-glass key across a small on-call group. That’s a real finding worth
    knowing about even if it’s accepted risk; treat the WARN as documentation, not necessarily a bug
    to fix.
  • How this was tested — run live in a Linux sandbox against real
    ssh-keygen-generated fixtures covering every check: a healthy ed25519 key, a weak
    1024-bit RSA key, a healthy 3072-bit RSA key, a DSA key, a correctly-restricted service account,
    an incorrectly-unrestricted service account, a duplicated key across two accounts, and a
    denylist-matched departed-employee comment. All eight scenarios produced the expected
    findings.
extending

Extending It

Ideas
  • Key age — cross-reference each authorized_keys line’s file mtime (or a
    maintained changelog) against a maximum key age policy, flagging keys nobody has rotated in N
    years.
  • Certificate-based auth awareness — if your fleet uses SSH
    certificates (@cert-authority entries) instead of raw keys, extend the parser to
    recognize and separately report on those rather than treating them as regular keys.
  • Fleet-wide execution over SSH — pair this with this series’
    ssh-fleet-runner script to run the audit against remote hosts’ home directories
    instead of only a local mount.
  • CI gate — run against a golden/staging image in CI and fail the build
    if any CRIT finding appears in a base image before it ships.