Core dumps are a debugging gift and a security leak in one file: they hold passwords, keys and session data, and a crash loop can fill /var overnight. This script checks where cores go, who can read them and how much disk they already eat.
Step through the build below:
Most teams never look at crash-dump policy until a disk fills or a security review asks why a setuid binary dumped memory into a world-readable directory. The answer lives in four places: kernel.core_pattern (where cores go, or which program they are piped to), fs.suid_dumpable (whether setuid programs may dump at all), limits.conf (per-login ulimit -c) and the dump directories themselves. The script reads all four straight from /proc and /etc and prints the exact fix for anything wrong.
// core_dump_audit.rb
#!/usr/bin/env ruby
# frozen_string_literal: true
# core_dump_audit.rb - audit how a Linux host handles crashes / core dumps.
# Checks: core_pattern handler, suid_dumpable, ulimit -c in limits.d, and how
# much disk existing dump directories consume (a crash loop can fill /var).
# Usage: ruby core_dump_audit.rb [--root DIR] [--json] [--dump-warn-mb N]
# --root prefixes every path, so a captured tree can be audited anywhere.
# Exit: 0 OK, 1 WARN, 2 CRIT. Stdlib only.
require 'optparse'
require 'json'
opts = { root: '', json: false, dump_warn_mb: 1024, dump_crit_mb: 5120 }
OptionParser.new do |o|
o.on('--root DIR') { |v| opts[:root] = v.chomp('/') }
o.on('--json') { opts[:json] = true }
o.on('--dump-warn-mb N', Integer) { |v| opts[:dump_warn_mb] = v }
o.on('--dump-crit-mb N', Integer) { |v| opts[:dump_crit_mb] = v }
end.parse!
def path(opts, p) = "#{opts[:root]}#{p}"
def slurp(opts, p) = (File.read(path(opts, p)).strip rescue nil)
Finding = Struct.new(:sev, :check, :detail, :fix)
findings = []
# 1. Where do cores go?
pattern = slurp(opts, '/proc/sys/kernel/core_pattern')
if pattern.nil?
findings << Finding.new('WARN', 'core_pattern', 'unreadable', nil)
elsif pattern.start_with?('|')
handler = pattern[1..].split.first
base = File.basename(handler)
known = %w[systemd-coredump apport abrt-hook-ccpp].any? { |k| base.include?(k) }
if known then findings << Finding.new('OK', 'core_pattern', "piped to #{base} (managed)", nil)
else findings << Finding.new('WARN', 'core_pattern', "piped to unrecognised handler #{handler}",
'confirm the handler is trusted and runs as an unprivileged user')
end
elsif !pattern.start_with?('/')
findings << Finding.new('WARN', 'core_pattern',
"relative pattern '#{pattern}': cores land in each crashing process's cwd (possibly world-readable)",
'sysctl -w kernel.core_pattern=/var/lib/coredumps/core.%e.%p.%t')
else
findings << Finding.new('OK', 'core_pattern', "absolute path #{pattern}", nil)
end
# 2. SUID programs dumping core can leak root-only memory.
sd = slurp(opts, '/proc/sys/fs/suid_dumpable')
case sd
when '0' then findings << Finding.new('OK', 'suid_dumpable', '0 (setuid programs never dump)', nil)
when '2' then findings << Finding.new('WARN', 'suid_dumpable', '2 (suidsafe: only safe if core_pattern is piped or absolute)', 'sysctl -w fs.suid_dumpable=0')
when '1' then findings << Finding.new('CRIT', 'suid_dumpable', '1 (setuid processes dump readable by the user)', 'sysctl -w fs.suid_dumpable=0')
else findings << Finding.new('WARN', 'suid_dumpable', "unexpected value #{sd.inspect}", nil)
end
# 3. limits.conf / limits.d "core" lines - effective ulimit -c for logins.
limit_files = Dir[path(opts, '/etc/security/limits.conf'), path(opts, '/etc/security/limits.d/*.conf')].sort
core_lines = limit_files.flat_map do |f|
File.readlines(f).map(&:strip).reject { |l| l.empty? || l.start_with?('#') }
.map(&:split).select { |a| a[2] == 'core' }.map { |a| [File.basename(f), a] }
end
unlimited = core_lines.select { |_, a| a[3] == 'unlimited' && a[1] == 'soft' || a[3] == 'unlimited' && a[1] == '-' }
if unlimited.any?
findings << Finding.new('WARN', 'limits', "unlimited core size set in #{unlimited.map(&:first).uniq.join(', ')}", 'use a finite cap, e.g. "* hard core 0" on non-debug hosts')
elsif core_lines.empty?
findings << Finding.new('OK', 'limits', 'no core overrides (distro default: soft limit 0)', nil)
else
findings << Finding.new('OK', 'limits', "#{core_lines.size} core limit line(s), none unlimited", nil)
end
# 4. Disk consumed by existing dumps.
dump_dirs = %w[/var/lib/systemd/coredump /var/crash /var/lib/apport/coredump /var/spool/abrt]
dump_dirs.each do |d|
full = path(opts, d)
next unless Dir.exist?(full)
files = Dir.glob("#{full}/**/*").select { |f| File.file?(f) }
mb = files.sum { |f| File.size(f) } / 1_048_576.0
sev = mb >= opts[:dump_crit_mb] ? 'CRIT' : mb >= opts[:dump_warn_mb] ? 'WARN' : 'OK'
newest = files.map { |f| File.mtime(f) }.max
detail = format('%d dump(s), %.1f MB%s', files.size, mb, newest ? ", newest #{newest.strftime('%Y-%m-%d %H:%M')}" : '')
findings << Finding.new(sev, "dumps:#{d}", detail, sev == 'OK' ? nil : 'coredumpctl / rm old dumps; set MaxUse= in coredump.conf')
end
worst = (%w[CRIT WARN OK] & findings.map(&:sev)).first
if opts[:json]
puts JSON.pretty_generate(overall: worst, findings: findings.map(&:to_h))
else
puts "core-dump-audit: #{worst}"
findings.each do |f|
puts format(' [%-4s] %-28s %s', f.sev, f.check, f.detail)
puts " fix: #{f.fix}" if f.fix
end
end
exit({ 'OK' => 0, 'WARN' => 1, 'CRIT' => 2 }[worst])
1. Classify core_pattern. A leading | means the kernel pipes the core to a handler. Known handlers (systemd-coredump, apport, abrt) are OK; an unknown handler is WARN, because it runs as root with the crashing process’s memory. A relative pattern such as core drops files in the process’s cwd.
2. Grade suid_dumpable. 0 is safe, 2 (suidsafe) is only safe with a piped or absolute pattern, and 1 lets setuid processes dump memory readable by the user – CRIT.
3. Parse limits.conf and limits.d. Comment and blank lines are skipped, each line is split into domain/type/item/value, and only core items are kept. unlimited soft or both limits is a WARN.
4. Measure dump directories. Known dump dirs (/var/lib/systemd/coredump, /var/crash, apport, abrt) are walked and summed, with WARN/CRIT at configurable MB thresholds and the newest file’s time shown.
5. Portable via –root. Every path is prefixed by --root, so a captured tree from another host can be audited on your laptop – and the tests build fake trees in a temp dir.
// captured in the sandbox
core-dump-audit: CRIT
[WARN] core_pattern relative pattern 'core': cores land in each crashing process's cwd (possibly world-readable)
fix: sysctl -w kernel.core_pattern=/var/lib/coredumps/core.%e.%p.%t
[CRIT] suid_dumpable 1 (setuid processes dump readable by the user)
fix: sysctl -w fs.suid_dumpable=0
[WARN] limits unlimited core size set in 99-core.conf
fix: use a finite cap, e.g. "* hard core 0" on non-debug hosts
[WARN] dumps:/var/lib/systemd/coredump 1 dump(s), 1500.0 MB, newest 2026-09-29 16:45
fix: coredumpctl / rm old dumps; set MaxUse= in coredump.conf
exit=2
Finished in 0.547363s, 10.9617 runs/s, 16.4425 assertions/s.
6 runs, 9 assertions, 0 failures, 0 errors, 0 skips
Full script + README on GitHub: ruby-devops-toolkit/core-dump-audit
Why this matters
Most teams never look at crash-dump policy until a disk fills or a security review asks why a setuid binary dumped memory into a world-readable directory. The answer lives in four places: kernel.core_pattern (where cores go, or which program they are piped to), fs.suid_dumpable (whether setuid programs may dump at all), limits.conf (per-login ulimit -c) and the dump directories themselves. The script reads all four straight from /proc and /etc and prints the exact fix for anything wrong.
- Ruby 2.7+ (tested on 3.3.6); stdlib only
- Linux; reading
/proc/sysand/etc/securityworks unprivileged, though some dump directories are root-only (they are then skipped or undercounted)
Complete code
#!/usr/bin/env ruby
# frozen_string_literal: true
# core_dump_audit.rb - audit how a Linux host handles crashes / core dumps.
# Checks: core_pattern handler, suid_dumpable, ulimit -c in limits.d, and how
# much disk existing dump directories consume (a crash loop can fill /var).
# Usage: ruby core_dump_audit.rb [--root DIR] [--json] [--dump-warn-mb N]
# --root prefixes every path, so a captured tree can be audited anywhere.
# Exit: 0 OK, 1 WARN, 2 CRIT. Stdlib only.
require 'optparse'
require 'json'
opts = { root: '', json: false, dump_warn_mb: 1024, dump_crit_mb: 5120 }
OptionParser.new do |o|
o.on('--root DIR') { |v| opts[:root] = v.chomp('/') }
o.on('--json') { opts[:json] = true }
o.on('--dump-warn-mb N', Integer) { |v| opts[:dump_warn_mb] = v }
o.on('--dump-crit-mb N', Integer) { |v| opts[:dump_crit_mb] = v }
end.parse!
def path(opts, p) = "#{opts[:root]}#{p}"
def slurp(opts, p) = (File.read(path(opts, p)).strip rescue nil)
Finding = Struct.new(:sev, :check, :detail, :fix)
findings = []
# 1. Where do cores go?
pattern = slurp(opts, '/proc/sys/kernel/core_pattern')
if pattern.nil?
findings << Finding.new('WARN', 'core_pattern', 'unreadable', nil)
elsif pattern.start_with?('|')
handler = pattern[1..].split.first
base = File.basename(handler)
known = %w[systemd-coredump apport abrt-hook-ccpp].any? { |k| base.include?(k) }
if known then findings << Finding.new('OK', 'core_pattern', "piped to #{base} (managed)", nil)
else findings << Finding.new('WARN', 'core_pattern', "piped to unrecognised handler #{handler}",
'confirm the handler is trusted and runs as an unprivileged user')
end
elsif !pattern.start_with?('/')
findings << Finding.new('WARN', 'core_pattern',
"relative pattern '#{pattern}': cores land in each crashing process's cwd (possibly world-readable)",
'sysctl -w kernel.core_pattern=/var/lib/coredumps/core.%e.%p.%t')
else
findings << Finding.new('OK', 'core_pattern', "absolute path #{pattern}", nil)
end
# 2. SUID programs dumping core can leak root-only memory.
sd = slurp(opts, '/proc/sys/fs/suid_dumpable')
case sd
when '0' then findings << Finding.new('OK', 'suid_dumpable', '0 (setuid programs never dump)', nil)
when '2' then findings << Finding.new('WARN', 'suid_dumpable', '2 (suidsafe: only safe if core_pattern is piped or absolute)', 'sysctl -w fs.suid_dumpable=0')
when '1' then findings << Finding.new('CRIT', 'suid_dumpable', '1 (setuid processes dump readable by the user)', 'sysctl -w fs.suid_dumpable=0')
else findings << Finding.new('WARN', 'suid_dumpable', "unexpected value #{sd.inspect}", nil)
end
# 3. limits.conf / limits.d "core" lines - effective ulimit -c for logins.
limit_files = Dir[path(opts, '/etc/security/limits.conf'), path(opts, '/etc/security/limits.d/*.conf')].sort
core_lines = limit_files.flat_map do |f|
File.readlines(f).map(&:strip).reject { |l| l.empty? || l.start_with?('#') }
.map(&:split).select { |a| a[2] == 'core' }.map { |a| [File.basename(f), a] }
end
unlimited = core_lines.select { |_, a| a[3] == 'unlimited' && a[1] == 'soft' || a[3] == 'unlimited' && a[1] == '-' }
if unlimited.any?
findings << Finding.new('WARN', 'limits', "unlimited core size set in #{unlimited.map(&:first).uniq.join(', ')}", 'use a finite cap, e.g. "* hard core 0" on non-debug hosts')
elsif core_lines.empty?
findings << Finding.new('OK', 'limits', 'no core overrides (distro default: soft limit 0)', nil)
else
findings << Finding.new('OK', 'limits', "#{core_lines.size} core limit line(s), none unlimited", nil)
end
# 4. Disk consumed by existing dumps.
dump_dirs = %w[/var/lib/systemd/coredump /var/crash /var/lib/apport/coredump /var/spool/abrt]
dump_dirs.each do |d|
full = path(opts, d)
next unless Dir.exist?(full)
files = Dir.glob("#{full}/**/*").select { |f| File.file?(f) }
mb = files.sum { |f| File.size(f) } / 1_048_576.0
sev = mb >= opts[:dump_crit_mb] ? 'CRIT' : mb >= opts[:dump_warn_mb] ? 'WARN' : 'OK'
newest = files.map { |f| File.mtime(f) }.max
detail = format('%d dump(s), %.1f MB%s', files.size, mb, newest ? ", newest #{newest.strftime('%Y-%m-%d %H:%M')}" : '')
findings << Finding.new(sev, "dumps:#{d}", detail, sev == 'OK' ? nil : 'coredumpctl / rm old dumps; set MaxUse= in coredump.conf')
end
worst = (%w[CRIT WARN OK] & findings.map(&:sev)).first
if opts[:json]
puts JSON.pretty_generate(overall: worst, findings: findings.map(&:to_h))
else
puts "core-dump-audit: #{worst}"
findings.each do |f|
puts format(' [%-4s] %-28s %s', f.sev, f.check, f.detail)
puts " fix: #{f.fix}" if f.fix
end
end
exit({ 'OK' => 0, 'WARN' => 1, 'CRIT' => 2 }[worst])
How it works, step by step
1. Classify core_pattern
A leading | means the kernel pipes the core to a handler. Known handlers (systemd-coredump, apport, abrt) are OK; an unknown handler is WARN, because it runs as root with the crashing process’s memory. A relative pattern such as core drops files in the process’s cwd.
2. Grade suid_dumpable
0 is safe, 2 (suidsafe) is only safe with a piped or absolute pattern, and 1 lets setuid processes dump memory readable by the user – CRIT.
3. Parse limits.conf and limits.d
Comment and blank lines are skipped, each line is split into domain/type/item/value, and only core items are kept. unlimited soft or both limits is a WARN.
4. Measure dump directories
Known dump dirs (/var/lib/systemd/coredump, /var/crash, apport, abrt) are walked and summed, with WARN/CRIT at configurable MB thresholds and the newest file’s time shown.
5. Portable via –root
Every path is prefixed by --root, so a captured tree from another host can be audited on your laptop – and the tests build fake trees in a temp dir.
Run it
Tested live in a Linux sandbox: 6 minitest cases against fixture trees (healthy, relative pattern, unknown pipe handler, suid_dumpable=1, unlimited limit, dump-size thresholds) plus a run against the sandbox’s own /proc/sys.
When it doesn't work
- Dump dir shows 0 files – the directory is root-only (
0700). Run with sudo for a true size. - Wrong for containers –
core_patternis a host-wide (non-namespaced) setting; run the audit on the host, not inside a container. - Changes don’t stick –
sysctl -wis temporary. Persist the suggested fixes in a file under/etc/sysctl.d/.
Extending it
- Read
/etc/systemd/coredump.confand checkStorage=,MaxUse=,ProcessSizeMax= - Cross-check
coredumpctl listfor the most-crashing binaries - Write the suggested sysctls to a reviewable drop-in
- Run across a fleet and diff