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.
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)
Full script + README on GitHub: ruby-devops-toolkit/cert-store-audit
- Ruby >= 2.7 (tested on 3.0.2)
- No gems —
openssl,find,optparse,json, andtimeare all Ruby standard library - Read access to the certificate/key directories you’re auditing
- Linux, macOS, or Windows — nothing here is platform-specific
Running it
ruby cert_store_audit.rb <dir> [<dir> …] [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
# 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
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.
What a real run looks like
Troubleshooting
- “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.
Where to take this next
- 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-permsflag that chmods world-readable keys, gated behind explicit confirmation