the shed // ruby for devops

The one thing a live TLS check will never tell you is what’s sitting on disk. This script scans a directory tree for every .pem, .crt, and .key file it can find and reports on all of them at once — expired certs, weak keys, world-readable private keys, and cert/key pairs that quietly stopped matching.

Step through the build below: the problem, the full script, how it works, and the real audit output.

cert_store_audit.rb

A live TLS checker (openssl s_client, or a script that connects to host:443) tells you about exactly one certificate: whatever the server happens to be presenting right now. It says nothing about the dozen other .pem/.crt/.key files actually sitting on that box — the renewal someone forgot to delete, the private key that’s been world-readable since the last rushed deploy, or the cert/key pair where the wrong key got copied during a hotfix.

Those problems live on the filesystem, not on the wire, so a filesystem-level audit is the only way to catch them before they cause an incident. That’s what this script does: walk one or more directories, find every certificate and private key it can read, and report on expiry, key strength, self-signed status, file permissions, and whether cert/key pairs actually match — all in one pass, no gems required.

#!/usr/bin/env ruby# frozen_string_literal: true## cert_store_audit.rb -- Local certificate & private-key store auditor.## Live TLS checkers (openssl s_client, or a script that connects to host:443)# only tell you about the ONE certificate a server happens to be presenting# right now. They say nothing about the dozen other .pem/.crt/.key files# sitting on that box: the old cert someone forgot to delete, the key file# world-readable since the last deploy, the self-signed cert some app picked# up as a "just get it working" default eighteen months ago. This script# walks a directory tree, finds every certificate and private key it can# read, and reports on all of them at once -- expiry, key strength,# self-signed status, file permissions, and whether cert/key pairs actually# match.## No gems required -- everything here is Ruby stdlib (openssl, find,# optparse, json).## Usage:#   ruby cert_store_audit.rb [dir ...] [options]## Examples:#   ruby cert_store_audit.rb /etc/ssl /etc/nginx /opt/app/certs#   ruby cert_store_audit.rb /etc/ssl --json#   ruby cert_store_audit.rb /etc/ssl --min-days 45 --min-key-bits 3072## Exit codes (cron/CI friendly):#   0 - everything OK#   1 - warnings only (expiring soon, weak-but-not-broken key, etc.)#   2 - critical findings (expired cert, key/cert mismatch, world-readable#       private key, key below the minimum bit length)require 'openssl'require 'find'require 'optparse'require 'json'require 'time'# ---------------------------------------------------------------------------# Options# ---------------------------------------------------------------------------options = {  min_days: 30,  min_key_bits: 2048,  extensions: %w[.pem .crt .cer .key],  json: false,  quiet: false}parser = OptionParser.new do |opts|  opts.banner = "Usage: cert_store_audit.rb [dir ...] [options]"  opts.on('--min-days N', Integer, 'Days-to-expiry warning threshold (default 30)') { |v| options[:min_days] = v }  opts.on('--min-key-bits N', Integer, 'Minimum acceptable RSA key size (default 2048)') { |v| options[:min_key_bits] = v }  opts.on('--ext LIST', String, 'Comma-separated extensions to scan (default .pem,.crt,.cer,.key)') do |v|    options[:extensions] = v.split(',').map { |e| e.start_with?('.') ? e : ".#{e}" }  end  opts.on('--json', 'Emit machine-readable JSON instead of text') { options[:json] = true }  opts.on('-q', '--quiet', 'Only print WARN/CRIT findings (text mode)') { options[:quiet] = true }  opts.on('-h', '--help', 'Show this help') { puts opts; exit 0 }endparser.parse!dirs = ARGV.empty? ? ['.'] : ARGVdirs.each do |d|  unless Dir.exist?(d)    warn "cert_store_audit: no such directory: #{d}"    exit 3  endend# ---------------------------------------------------------------------------# Discovery# ---------------------------------------------------------------------------def find_candidate_files(dirs, extensions)  files = []  dirs.each do |dir|    Find.find(dir) do |path|      next unless File.file?(path)      next unless extensions.include?(File.extname(path).downcase)      # Skip obviously-huge files (e.g. accidentally pointed at a data dir) --      # certs/keys are always small text files.      next if File.size(path) > 1_000_000      files << path    rescue Errno::EACCES, Errno::ENOENT      next    end  end  files.sortend# Split a PEM bundle into individual PEM blocks. A single .pem/.crt file can# contain a full chain (leaf + intermediates), so we can't assume one# object per file.def split_pem_blocks(content)  content.scan(/-----BEGIN ([A-Z ]+)-----.*?-----END \1-----/m)         .map(&:to_s) # not used; kept for clarity  content.scan(/-----BEGIN [A-Z ]+-----.*?-----END [A-Z ]+-----/m)end# ---------------------------------------------------------------------------# Certificate analysis# ---------------------------------------------------------------------------def rsa_key_bits(pkey)  return nil unless pkey.is_a?(OpenSSL::PKey::RSA)  pkey.n.num_bitsenddef analyze_certificate(path, block, min_days)  cert = OpenSSL::X509::Certificate.new(block)  now = Time.now  days_left = ((cert.not_after - now) / 86_400).floor  status =    if cert.not_after < now      :crit    elsif days_left <= min_days      :warn    else      :ok    end  key_bits = rsa_key_bits(cert.public_key)  weak_key = key_bits && key_bits < 2048  self_signed = cert.issuer.to_s == cert.subject.to_s  {    file: path,    type: 'certificate',    subject: cert.subject.to_s,    issuer: cert.issuer.to_s,    not_before: cert.not_before.utc.iso8601,    not_after: cert.not_after.utc.iso8601,    days_left: days_left,    self_signed: self_signed,    key_algorithm: cert.public_key.class.to_s.split('::').last,    key_bits: key_bits,    weak_key: weak_key,    serial: cert.serial.to_s,    status: status,    notes: build_cert_notes(status, days_left, self_signed, weak_key, min_days)  }rescue OpenSSL::X509::CertificateError, ArgumentError => e  { file: path, type: 'certificate', status: :error, error: e.message }enddef build_cert_notes(status, days_left, self_signed, weak_key, min_days)  notes = []  case status  when :crit    notes << "EXPIRED #{-days_left} day(s) ago"  when :warn    notes << "expires in #{days_left} day(s) (threshold: #{min_days})"  end  notes << 'self-signed' if self_signed  notes << 'weak RSA key (<2048 bits)' if weak_key  notesenddef analyze_key(path, block, min_key_bits)  pkey = OpenSSL::PKey.read(block)  bits = rsa_key_bits(pkey)  weak = bits && bits < min_key_bits  perms = File.stat(path).mode & 0o777  world_readable = (perms & 0o077) != 0  status = :ok  status = :warn if weak  status = :crit if world_readable || (bits && bits < 1024)  notes = []  notes << "world/group-readable private key (mode #{format('%o', perms)})" if world_readable  notes << "key size #{bits} bits below minimum #{min_key_bits}" if weak  {    file: path,    type: 'private_key',    key_algorithm: pkey.class.to_s.split('::').last,    key_bits: bits,    file_mode: format('%o', perms),    world_readable: world_readable,    status: status,    notes: notes  }rescue OpenSSL::PKey::PKeyError, ArgumentError => e  # Most common cause: password-protected key. We don't prompt for  # passwords in an unattended audit script -- flag it as skipped instead  # of crashing.  { file: path, type: 'private_key', status: :skipped, error: "unreadable (#{e.message}); likely password-protected" }end# Match cert/key pairs by comparing RSA modulus (n). Two files whose base# name matches (cert.pem / cert.key) but whose public keys DON'T match is a# classic "wrong key got copied during deploy" bug.def find_mismatches(cert_results, key_results)  mismatches = []  by_stem = Hash.new { |h, k| h[k] = { certs: [], keys: [] } }  cert_results.each do |c|    next unless c[:status] && c[:key_bits]    stem = File.basename(c[:file], File.extname(c[:file]))    by_stem[stem][:certs] << c  end  key_results.each do |k|    next unless k[:status] && k[:key_bits]    stem = File.basename(k[:file], File.extname(k[:file]))    by_stem[stem][:keys] << k  end  by_stem.each do |stem, pair|    next if pair[:certs].empty? || pair[:keys].empty?    pair[:certs].each do |c|      pair[:keys].each do |k|        next unless c[:key_bits] == k[:key_bits] # cheap pre-filter        cert_obj = OpenSSL::X509::Certificate.new(File.read(c[:file]))        key_obj = OpenSSL::PKey.read(File.read(k[:file]))        matches = cert_obj.check_private_key(key_obj)        mismatches << { cert: c[:file], key: k[:file], stem: stem } unless matches      rescue StandardError        next      end    end  end  mismatchesend# ---------------------------------------------------------------------------# Run the audit# ---------------------------------------------------------------------------files = find_candidate_files(dirs, options[:extensions])cert_results = []key_results = []files.each do |path|  content = File.read(path)  blocks = split_pem_blocks(content)  if blocks.empty?    next  end  blocks.each do |block|    if block.include?('BEGIN CERTIFICATE')      cert_results << analyze_certificate(path, block, options[:min_days])    elsif block.include?('PRIVATE KEY')      key_results << analyze_key(path, block, options[:min_key_bits])    end  endrescue Errno::EACCES => e  cert_results << { file: path, type: 'unknown', status: :error, error: e.message }endmismatches = find_mismatches(cert_results, key_results)all_results = cert_results + key_resultsworst = all_results.map { |r| r[:status] }.compactexit_code =  if !mismatches.empty? || worst.include?(:crit)    2  elsif worst.include?(:warn)    1  else    0  end# ---------------------------------------------------------------------------# Output# ---------------------------------------------------------------------------if options[:json]  puts JSON.pretty_generate(    scanned_dirs: dirs,    files_scanned: files.size,    certificates: cert_results,    private_keys: key_results,    key_mismatches: mismatches,    exit_code: exit_code  )else  puts "cert-store-audit: scanned #{files.size} file(s) under #{dirs.join(', ')}"  puts  cert_results.each do |c|    next if options[:quiet] && c[:status] == :ok    tag = c[:status].to_s.upcase.rjust(5)    if c[:status] == :error      puts "[ERROR] #{c[:file]} -- #{c[:error]}"      next    end    puts "[#{tag}] #{c[:file]}"    puts "        subject: #{c[:subject]}"    puts "        expires: #{c[:not_after]} (#{c[:days_left]} days) | key: #{c[:key_algorithm]} #{c[:key_bits]}"    c[:notes].each { |n| puts "        - #{n}" }  end  key_results.each do |k|    next if options[:quiet] && k[:status] == :ok    tag = k[:status].to_s.upcase.rjust(5)    if k[:status] == :error || k[:status] == :skipped      puts "[#{tag}] #{k[:file]} -- #{k[:error]}"      next    end    puts "[#{tag}] #{k[:file]}"    puts "        key: #{k[:key_algorithm]} #{k[:key_bits]} bits, mode #{k[:file_mode]}"    k[:notes].each { |n| puts "        - #{n}" }  end  unless mismatches.empty?    puts    puts 'KEY/CERT MISMATCHES:'    mismatches.each { |m| puts "  [CRIT] #{m[:cert]}  <->  #{m[:key]}  (public keys do not match)" }  end  puts  puts "Summary: #{cert_results.count { |c| c[:status] == :ok }} OK certs, " \       "#{cert_results.count { |c| c[:status] == :warn }} expiring soon, " \       "#{cert_results.count { |c| c[:status] == :crit }} expired, " \       "#{key_results.count { |k| k[:status] == :crit }} key issue(s), " \       "#{mismatches.size} mismatch(es)"endexit exit_code

Discovery first, parsing second. Find.find walks each directory, keeping only files whose extension is in --ext and whose size is under 1 MB — certs and keys are always small text files, so this cheaply guards against accidentally pointing the scan at the wrong directory.

A file is not an object — it’s a bag of PEM blocks. A single .pem can hold a full chain (leaf + intermediates), so instead of assuming one certificate per file, the script regex-scans for every -----BEGIN ...-----END ...----- block and classifies each one independently as a certificate or a private key.

Password-protected keys are skipped, not crashed on. An unattended audit script can’t prompt for a passphrase, so OpenSSL::PKey::PKeyError is caught and reported as “likely password-protected” instead of blowing up the whole run.

The cert/key matching step is the one people don’t expect. Files are grouped by basename stem (deploy.pem and deploy.key share the stem deploy), and for same-size RSA pairs, cert.check_private_key(key) confirms the key actually belongs to that certificate — catching the exact “wrong key got copied during deploy” class of bug that nothing else in a typical monitoring stack looks for.

cert-store-audit: scanned 11 file(s) under testfixtures[   OK] testfixtures/badperm/exposed.pem        subject: /CN=exposed.internal        expires: 2027-06-04T17:04:02Z (299 days) | key: RSA 2048        - self-signed[ CRIT] testfixtures/expired/old.pem        subject: /CN=old.internal        expires: 2026-08-03T17:04:02Z (-6 days) | key: RSA 2048        - EXPIRED 6 day(s) ago        - self-signed[   OK] testfixtures/good/app.pem        subject: /CN=good.internal        expires: 2027-08-08T17:04:02Z (364 days) | key: RSA 2048        - self-signed[   OK] testfixtures/mismatch/deploy.pem        subject: /CN=deploy.internal        expires: 2027-06-04T17:04:02Z (299 days) | key: RSA 2048        - self-signed[ WARN] testfixtures/warn/soon.pem        subject: /CN=soon.internal        expires: 2026-08-18T17:04:02Z (9 days) | key: RSA 2048        - expires in 9 day(s) (threshold: 30)        - self-signed[   OK] testfixtures/weak/legacy.pem        subject: /CN=legacy.internal        expires: 2027-02-24T17:04:02Z (199 days) | key: RSA 1024        - self-signed        - weak RSA key (<2048 bits)[ CRIT] testfixtures/badperm/exposed.key        key: RSA 2048 bits, mode 644        - world/group-readable private key (mode 644)[   OK] testfixtures/good/app.key        key: RSA 2048 bits, mode 600[   OK] testfixtures/mismatch/deploy.key        key: RSA 2048 bits, mode 600[   OK] testfixtures/warn/soon.key        key: RSA 2048 bits, mode 600[ WARN] testfixtures/weak/legacy.key        key: RSA 1024 bits, mode 600        - key size 1024 bits below minimum 2048KEY/CERT MISMATCHES:  [CRIT] testfixtures/mismatch/deploy.pem  <->  testfixtures/mismatch/deploy.key  (public keys do not match)Summary: 4 OK certs, 1 expiring soon, 1 expired, 1 key issue(s), 1 mismatch(es)
Get the code

Full script + README on GitHub: ruby-devops-toolkit/cert-store-audit

Prerequisites
  • Ruby >= 2.7 (tested on 3.0.2)
  • No gemsopenssl, find, optparse, json, and time are all Ruby standard library
  • Read access to the certificate/key directories you’re auditing
  • Linux, macOS, or Windows — nothing here is platform-specific
usage

Running it

ruby cert_store_audit.rb <dir> [<dir> …] [options]

Key options
  • --min-days N — days-to-expiry WARN threshold (default 30)
  • --min-key-bits N — minimum acceptable RSA key size (default 2048)
  • --ext LIST — comma-separated extensions to scan (default .pem,.crt,.cer,.key)
  • --json — emit machine-readable JSON instead of text
  • -q, --quiet — text mode: only print WARN/CRIT findings
examples.shbash
# Audit everything under /etc/ssl and /opt/app/certs
ruby cert_store_audit.rb /etc/ssl /opt/app/certs
# Tighter expiry window, JSON for a monitoring pipeline
ruby cert_store_audit.rb /etc/ssl --min-days 45 --json
# Require modern key sizes
ruby cert_store_audit.rb /etc/ssl --min-key-bits 3072
cert-store-audit workflow diagram

Directory scan -> PEM split -> cert/key analysis -> cross-match -> report
how it works

Full walkthrough

1. Discovery

Find.find walks each directory recursively. Files are kept only if their extension matches –ext and their size is under 1MB, which cheaply skips a directory scan accidentally pointed at something huge.

2. PEM splitting

A regex scan for -----BEGIN ...-----END ...----- blocks means a single file holding a full chain (leaf + intermediates) is handled correctly instead of assumed to be exactly one object.

3. Certificate analysis

Each certificate block is parsed with OpenSSL::X509::Certificate. The script computes days until not_after, classifies OK/WARN/CRIT against --min-days, checks whether issuer == subject (self-signed), and reads the RSA key size off the public key.

4. Private key analysis

Each private-key block is parsed with OpenSSL::PKey.read. File mode is checked with File.stat(path).mode & 0o777; anything with group or world bits set is flagged CRIT immediately, independent of key strength.

5. Cert/key matching

Files are grouped by basename stem. For each cert/key pair with matching RSA bit length, cert.check_private_key(key) confirms the key actually belongs to that certificate — a mismatch is always CRIT.

6. Reporting

Text or --json, with a summary line and a process exit code (0/1/2), so this drops straight into cron or a monitoring pipeline.

example output

What a real run looks like

cert_store_audit.rb — testfixtures
cert-store-audit: scanned 11 file(s) under testfixtures
[ OK] testfixtures/badperm/exposed.pem
subject: /CN=exposed.internal
expires: 2027-06-04T17:04:02Z (299 days) | key: RSA 2048
– self-signed
[ CRIT] testfixtures/expired/old.pem
subject: /CN=old.internal
expires: 2026-08-03T17:04:02Z (-6 days) | key: RSA 2048
– EXPIRED 6 day(s) ago
– self-signed
[ OK] testfixtures/good/app.pem
subject: /CN=good.internal
expires: 2027-08-08T17:04:02Z (364 days) | key: RSA 2048
– self-signed
[ OK] testfixtures/mismatch/deploy.pem
subject: /CN=deploy.internal
expires: 2027-06-04T17:04:02Z (299 days) | key: RSA 2048
– self-signed
[ WARN] testfixtures/warn/soon.pem
subject: /CN=soon.internal
expires: 2026-08-18T17:04:02Z (9 days) | key: RSA 2048
– expires in 9 day(s) (threshold: 30)
– self-signed
[ OK] testfixtures/weak/legacy.pem
subject: /CN=legacy.internal
expires: 2027-02-24T17:04:02Z (199 days) | key: RSA 1024
– self-signed
– weak RSA key (<2048 bits)
[ CRIT] testfixtures/badperm/exposed.key
key: RSA 2048 bits, mode 644
– world/group-readable private key (mode 644)
[ OK] testfixtures/good/app.key
key: RSA 2048 bits, mode 600
[ OK] testfixtures/mismatch/deploy.key
key: RSA 2048 bits, mode 600
[ OK] testfixtures/warn/soon.key
key: RSA 2048 bits, mode 600
[ WARN] testfixtures/weak/legacy.key
key: RSA 1024 bits, mode 600
– key size 1024 bits below minimum 2048
KEY/CERT MISMATCHES:
[CRIT] testfixtures/mismatch/deploy.pem <-> testfixtures/mismatch/deploy.key (public keys do not match)
Summary: 4 OK certs, 1 expiring soon, 1 expired, 1 key issue(s), 1 mismatch(es)
4
OK certs
1
expiring soon
2
critical findings
troubleshooting

Troubleshooting

Common issues
  • “unreadable (…); likely password-protected” — expected and safe. The script deliberately doesn’t prompt for private-key passphrases in an unattended run; it reports the file as skipped instead of hanging.
  • World-readable check doesn’t fire when you expect it to — some overlay/network filesystems (sshfs, certain container bind mounts, FUSE mounts) silently normalize permissions and don’t honor chmod. This was actually hit during development of this script — see the testing notes on GitHub.
  • A directory with thousands of certs is slow — the cert/key matching step is O(certs × keys) within a basename stem, not across the whole tree, so it stays fast in practice.
  • False positive on a legitimate self-signed internal CA root — “self-signed” is informational and doesn’t affect the exit code by itself; it’s there so you can eyeball whether it’s expected.
extending it

Where to take this next

Ideas
  • Add OCSP/CRL revocation checking for certs that are still time-valid but have been revoked
  • Add EC key support to the weak-key check (currently RSA-only)
  • Wire the JSON output into a Slack/webhook alerter for CRIT paging
  • Add a --fix-perms flag that chmods world-readable keys, gated behind explicit confirmation