You hardened the kernel once. Did it stay hardened? A single-file Ruby auditor that reads /proc/sys directly, scores the host against a declarative policy, and writes you a reviewable sysctl.d drop-in containing exactly the fixes it found.
Step through the build below:
Every hardening standard — CIS, DISA STIG, your own wiki page — eventually reduces to a list of /proc/sys knobs that must hold particular values. Setting them is trivial. Keeping them set is not.
Kernel parameters drift for boring reasons. A kernel upgrade ships a new default. A container runtime flips net.ipv4.ip_forward on and never puts it back. A host was hardened by hand in 2023, rebooted in 2024, and nobody noticed the values were never written to /etc/sysctl.d/. sysctl -w succeeds silently and survives exactly until the next reboot.
The failure mode is that nothing tells you. There is no log line for “ASLR is now partial”. You find out during an audit, or you do not find out at all.
policy.yml
name: baseline-linux-server-v1
checks:
- key: net.ipv4.ip_forward
op: eq
value: 0
severity: high
title: IP forwarding disabled
rationale: a non-router host that forwards packets can be used to pivot between networks
- key: net.ipv4.conf.all.accept_redirects
op: eq
value: 0
severity: high
title: ICMP redirects rejected
rationale: accepting redirects lets an attacker on the LAN reroute your traffic
- key: net.ipv4.conf.all.accept_source_route
op: eq
value: 0
severity: high
title: Source-routed packets dropped
rationale: source routing lets a sender pick the return path and bypass filtering
- key: net.ipv4.conf.all.rp_filter
op: in
value: [1, 2]
severity: medium
title: Reverse path filtering enabled
rationale: drops packets whose source address could not have arrived on that interface
- key: net.ipv4.tcp_syncookies
op: eq
value: 1
severity: medium
title: TCP SYN cookies enabled
rationale: keeps the listen queue usable during a SYN flood
- key: net.ipv4.conf.all.log_martians
op: eq
value: 1
severity: low
title: Martian packets logged
rationale: gives you evidence of spoofing attempts in the kernel log
- key: kernel.randomize_va_space
op: eq
value: 2
severity: critical
title: Full ASLR enabled
rationale: full address space randomisation is the cheapest exploit mitigation there is
- key: kernel.dmesg_restrict
op: eq
value: 1
severity: medium
title: dmesg restricted to privileged users
rationale: the kernel ring buffer leaks addresses and hardware detail useful to an attacker
- key: kernel.kptr_restrict
op: gte
value: 1
severity: medium
title: Kernel pointers hidden
rationale: exposed kernel pointers defeat KASLR
- key: fs.suid_dumpable
op: eq
value: 0
severity: high
title: setuid core dumps disabled
rationale: a core dump from a setuid binary can contain secrets readable by the caller
- key: fs.protected_hardlinks
op: eq
value: 1
severity: medium
title: Hardlink protection enabled
rationale: blocks a classic /tmp symlink-and-hardlink privilege escalation
- key: fs.protected_symlinks
op: eq
value: 1
severity: medium
title: Symlink protection enabled
rationale: blocks following symlinks in world-writable sticky directories
- key: net.ipv6.conf.all.accept_redirects
op: eq
value: 0
severity: medium
title: IPv6 ICMP redirects rejected
rationale: same pivot risk as IPv4 redirects
skip_if_absent: true
- key: kernel.unprivileged_bpf_disabled
op: gte
value: 1
severity: high
title: Unprivileged BPF disabled
rationale: unprivileged BPF has been a repeat source of local privilege escalation
skip_if_absent: true
sysctl_audit.rb
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# sysctl_audit.rb -- Kernel parameter hardening auditor for Linux
#
# Every Linux hardening standard (CIS, STIG, your company's own wiki page)
# eventually boils down to a list of /proc/sys knobs that must hold particular
# values. The knobs are easy to set and even easier to lose: a kernel upgrade
# ships a new default, a container runtime rewrites ip_forward, someone reboots
# a host that only ever had the values applied by hand.
#
# This script reads a declarative YAML policy, reads the LIVE values straight
# out of /proc/sys (not `sysctl -a`, so it works without procps installed),
# compares them with a per-check operator, and reports what drifted. It can
# also emit a ready-to-drop /etc/sysctl.d/ file containing exactly the fixes
# needed -- so remediation is a file you can review, not a shell one-liner you
# have to trust.
#
# Exit codes for cron / CI use:
# 0 fully compliant
# 1 at least one check failed
# 2 the audit could not run
#
# Usage:
# ruby sysctl_audit.rb --policy policy.yml
# ruby sysctl_audit.rb --policy policy.yml --min-severity high
# ruby sysctl_audit.rb --policy policy.yml --json
# ruby sysctl_audit.rb --policy policy.yml --remediate 99-hardening.conf
# ruby sysctl_audit.rb --policy policy.yml --root ./fixtures/proc # offline test
#
# Ruby >= 2.7, stdlib only.
require 'yaml'
require 'json'
require 'optparse'
require 'fileutils'
module SysctlAudit
VERSION = '1.0.0'
SEVERITIES = %w[low medium high critical].freeze
# ---------------------------------------------------------------------------
# Reading the live kernel. `sysctl -a` is a thin wrapper over this directory,
# so going straight to the filesystem removes a dependency and lets us point
# --root at a fixture tree for testing.
# ---------------------------------------------------------------------------
class ProcSysReader
class Missing < StandardError; end
def initialize(root: '/proc/sys') = @root = root
# net.ipv4.ip_forward -> <root>/net/ipv4/ip_forward
def path_for(key) = File.join(@root, key.tr('.', '/'))
def read(key)
path = path_for(key)
raise Missing, "#{key} not present (#{path})" unless File.file?(path)
# Multi-value knobs (e.g. net.ipv4.tcp_rmem) are tab separated on one
# line; squeeze all whitespace so comparisons are stable.
File.read(path).strip.split(/\s+/).join(' ')
rescue Errno::EACCES
raise Missing, "#{key} is not readable by uid #{Process.uid}"
rescue Errno::EIO, Errno::EINVAL
# A few knobs exist but refuse to be read on some kernels.
raise Missing, "#{key} exists but the kernel refused the read"
end
def available?(key) = File.file?(path_for(key))
end
# ---------------------------------------------------------------------------
# Comparison operators. Keeping these in a lookup table (rather than a case
# statement buried in the auditor) means adding a new one is a one-line change
# and the policy file can name any of them.
# ---------------------------------------------------------------------------
module Operators
HANDLERS = {
# exact string/numeric match -- the common case
'eq' => ->(actual, want) { numeric?(actual, want) ? f(actual) == f(want) : actual == want.to_s },
'ne' => ->(actual, want) { actual != want.to_s },
# "at least this hard" / "at most this loose"
'gte' => ->(actual, want) { f(actual) >= f(want) },
'lte' => ->(actual, want) { f(actual) <= f(want) },
# any of a set of acceptable values
'in' => ->(actual, want) { Array(want).map(&:to_s).include?(actual) },
# free-form, for string knobs like kernel.core_pattern
'match' => ->(actual, want) { Regexp.new(want.to_s) =~ actual ? true : false }
}.freeze
def self.f(v) = v.to_s.to_f
def self.numeric?(*vals) = vals.all? { |v| v.to_s.match?(/\A-?\d+(\.\d+)?\z/) }
def self.apply(op, actual, want)
handler = HANDLERS[op]
raise ArgumentError, "unknown operator '#{op}'" unless handler
handler.call(actual, want)
end
def self.describe(op, want)
case op
when 'eq' then "== #{want}"
when 'ne' then "!= #{want}"
when 'gte' then ">= #{want}"
when 'lte' then "<= #{want}"
when 'in' then "one of #{Array(want).join('|')}"
when 'match' then "=~ /#{want}/"
else "#{op} #{want}"
end
end
end
# ---------------------------------------------------------------------------
# Policy model
# ---------------------------------------------------------------------------
# checks:
# - key: net.ipv4.ip_forward
# op: eq
# value: 0
# severity: high
# title: IP forwarding disabled
# rationale: a non-router host that forwards packets can be used to pivot
# skip_if_absent: true
Check = Struct.new(:key, :op, :value, :severity, :title, :rationale,
:skip_if_absent, keyword_init: true) do
def expectation = Operators.describe(op, value)
# The literal line we would write into /etc/sysctl.d to satisfy this check.
# Only meaningful for operators that imply one concrete correct value.
def remediation_value
case op
when 'eq' then value.to_s
when 'gte', 'lte' then value.to_s
when 'in' then Array(value).first.to_s
end
end
end
class Policy
attr_reader :checks, :name
def initialize(data)
@name = data['name'] || 'unnamed policy'
@checks = Array(data['checks']).map do |c|
sev = (c['severity'] || 'medium').downcase
raise ArgumentError, "bad severity '#{sev}' on #{c['key']}" unless SEVERITIES.include?(sev)
Check.new(
key: c.fetch('key'),
op: (c['op'] || 'eq').downcase,
value: c['value'],
severity: sev,
title: c['title'] || c.fetch('key'),
rationale: c['rationale'].to_s,
skip_if_absent: c.fetch('skip_if_absent', false)
)
end
raise ArgumentError, 'policy contains no checks' if @checks.empty?
end
def self.load(path)
raise ArgumentError, "policy not found: #{path}" unless File.exist?(path)
new(YAML.safe_load(File.read(path)) || {})
end
end
# ---------------------------------------------------------------------------
# The audit itself
# ---------------------------------------------------------------------------
Result = Struct.new(:status, :key, :title, :severity, :expected, :actual,
:rationale, :fix, keyword_init: true)
class Auditor
def initialize(policy, reader) = (@policy = policy; @reader = reader)
def run
@policy.checks.map { |check| evaluate(check) }
end
private
def evaluate(check)
actual = @reader.read(check.key)
ok = Operators.apply(check.op, actual, check.value)
Result.new(
status: ok ? 'PASS' : 'FAIL',
key: check.key, title: check.title, severity: check.severity,
expected: check.expectation, actual: actual,
rationale: check.rationale,
fix: ok ? nil : fix_line(check)
)
rescue ProcSysReader::Missing => e
# A knob can be legitimately absent: IPv6 compiled out, a module not
# loaded, a container without the netfilter namespace. The policy author
# decides whether that is acceptable via skip_if_absent.
Result.new(
status: check.skip_if_absent ? 'SKIP' : 'ERROR',
key: check.key, title: check.title, severity: check.severity,
expected: check.expectation, actual: 'n/a',
rationale: e.message, fix: nil
)
rescue ArgumentError => e
Result.new(status: 'ERROR', key: check.key, title: check.title,
severity: check.severity, expected: check.expectation,
actual: 'n/a', rationale: e.message, fix: nil)
end
def fix_line(check)
v = check.remediation_value
v ? "#{check.key} = #{v}" : nil
end
end
# ---------------------------------------------------------------------------
# Scoring + output
# ---------------------------------------------------------------------------
class Report
WEIGHT = { 'critical' => 10, 'high' => 5, 'medium' => 2, 'low' => 1 }.freeze
MARK = { 'PASS' => '[PASS]', 'FAIL' => '[FAIL]', 'SKIP' => '[SKIP]',
'ERROR' => '[ERR ]' }.freeze
def initialize(results, policy_name) = (@results = results; @policy = policy_name)
def failures = @results.select { |r| r.status == 'FAIL' }
def counts = @results.group_by(&:status).transform_values(&:size)
# Weight the score by severity so ten low-risk misses do not look worse
# than one critical one.
def score
scored = @results.reject { |r| r.status == 'SKIP' }
return 100 if scored.empty?
total = scored.sum { |r| WEIGHT.fetch(r.severity, 1) }
earned = scored.select { |r| r.status == 'PASS' }
.sum { |r| WEIGHT.fetch(r.severity, 1) }
((earned.to_f / total) * 100).round
end
def text
w = 78
out = []
out << '=' * w
out << " KERNEL HARDENING AUDIT -- #{@policy}"
out << " host=#{host} kernel=#{kernel} #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
out << '=' * w
out << format(' %-6s %-8s %-34s %-12s %s', 'STATE', 'SEV', 'PARAMETER', 'EXPECTED', 'ACTUAL')
out << '-' * w
ordered.each do |r|
out << format(' %-6s %-8s %-34s %-12s %s', MARK[r.status], r.severity,
r.key[0, 34], r.expected.to_s[0, 12], r.actual.to_s[0, 14])
out << " reason: #{r.rationale}" if r.status == 'FAIL' && !r.rationale.empty?
out << " fix: #{r.fix}" if r.fix
end
out << '-' * w
out << " score: #{score}/100 (severity weighted) " \
"#{%w[PASS FAIL SKIP ERROR].map { |s| "#{s.downcase}=#{counts.fetch(s, 0)}" }.join(' ')}"
out << '=' * w
out.join("\n")
end
def json
JSON.pretty_generate(
policy: @policy, host: host, kernel: kernel,
generated_at: Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ'),
score: score, summary: counts,
results: @results.map(&:to_h)
)
end
# A drop-in file for /etc/sysctl.d/. Reviewable, version-controllable,
# and applied with `sysctl --system` rather than a blind `sysctl -w` loop.
def remediation_conf
lines = ["# Generated by sysctl_audit #{VERSION} on #{Time.now.strftime('%Y-%m-%d')}",
"# Policy: #{@policy} Host: #{host}",
'# Review before deploying. Apply with: sudo sysctl --system', '']
failures.select(&:fix).sort_by { |r| [-Report::WEIGHT.fetch(r.severity, 1), r.key] }
.each do |r|
lines << "# [#{r.severity}] #{r.title}"
lines << "# #{r.rationale}" unless r.rationale.empty?
lines << r.fix
lines << ''
end
lines << '# no remediation required' if failures.empty?
lines.join("\n")
end
private
ORDER = { 'FAIL' => 0, 'ERROR' => 1, 'SKIP' => 2, 'PASS' => 3 }.freeze
SEV_ORDER = { 'critical' => 0, 'high' => 1, 'medium' => 2, 'low' => 3 }.freeze
def ordered
@results.sort_by { |r| [ORDER.fetch(r.status, 9), SEV_ORDER.fetch(r.severity, 9), r.key] }
end
def host = @host ||= (ENV['HOSTNAME'] || `hostname 2>/dev/null`.strip)
def kernel = @kernel ||= (File.read('/proc/sys/kernel/osrelease').strip rescue 'unknown')
end
end
# -----------------------------------------------------------------------------
# CLI
# -----------------------------------------------------------------------------
if __FILE__ == $PROGRAM_NAME
opts = { policy: 'policy.yml', root: '/proc/sys', format: :text, min_severity: 'low' }
OptionParser.new do |o|
o.banner = 'Usage: sysctl_audit.rb [options]'
o.on('-p', '--policy PATH', 'YAML hardening policy') { |v| opts[:policy] = v }
o.on('-j', '--json', 'emit JSON') { opts[:format] = :json }
o.on('-r', '--remediate PATH', 'write a sysctl.d conf with the fixes') { |v| opts[:remediate] = v }
o.on('--root PATH', 'alternate /proc/sys root (testing)') { |v| opts[:root] = v }
o.on('--min-severity SEV', SysctlAudit::SEVERITIES,
'only report at or above this severity') { |v| opts[:min_severity] = v }
o.on('-v', '--version') { puts "sysctl_audit #{SysctlAudit::VERSION}"; exit 0 }
o.on('-h', '--help') { puts o; exit 0 }
end.parse!
begin
policy = SysctlAudit::Policy.load(opts[:policy])
reader = SysctlAudit::ProcSysReader.new(root: opts[:root])
results = SysctlAudit::Auditor.new(policy, reader).run
floor = SysctlAudit::SEVERITIES.index(opts[:min_severity])
results = results.select { |r| SysctlAudit::SEVERITIES.index(r.severity) >= floor }
report = SysctlAudit::Report.new(results, policy.name)
if opts[:remediate]
FileUtils.mkdir_p(File.dirname(opts[:remediate]))
File.write(opts[:remediate], report.remediation_conf)
warn "wrote #{report.failures.size} fix(es) to #{opts[:remediate]}"
end
puts opts[:format] == :json ? report.json : report.text
exit(report.failures.empty? ? 0 : 1)
rescue StandardError => e
warn "sysctl_audit: #{e.class}: #{e.message}"
exit 2
end
end
Read the filesystem, not procps. sysctl -a is a thin wrapper over /proc/sys, so going straight to the files removes a dependency and — usefully — makes the reader trivially mockable. The dotted key is just a path, which is why –root ./fixtures/proc lets the whole engine run in CI with no root and no real kernel.
Operators as a lookup table. Comparisons live in a hash of lambdas rather than a case statement buried in the auditor, so adding one is a one-line change and the policy file can name any of them: eq, ne, gte, lte, in, match.
Absent knobs are a policy decision. IPv6 compiled out, a module not loaded, a kernel too old for unprivileged_bpf_disabled — the script cannot know whether that is acceptable, so skip_if_absent lets the policy author say. SKIP results are excluded from the score entirely, because scoring a host down for a check that could never apply produces numbers nobody trusts.
Remediation is a file, not a command. The script never calls sysctl -w. It writes a drop-in containing only the failing keys, each with its rationale as a comment, sorted critical-first — reviewable like any other change, and it survives the next reboot.
$ ruby sysctl_audit.rb
$ ruby sysctl_audit.rb --policy policy.yml
==============================================================================
KERNEL HARDENING AUDIT -- baseline-linux-server-v1
host=claude kernel=6.8.0-136-generic 2026-08-18 14:41:05
==============================================================================
STATE SEV PARAMETER EXPECTED ACTUAL
------------------------------------------------------------------------------
[FAIL] high net.ipv4.conf.all.accept_redirects == 0 1
reason: accepting redirects lets an attacker on the LAN reroute your traffic
fix: net.ipv4.conf.all.accept_redirects = 0
[FAIL] medium net.ipv6.conf.all.accept_redirects == 0 1
reason: same pivot risk as IPv4 redirects
fix: net.ipv6.conf.all.accept_redirects = 0
[FAIL] low net.ipv4.conf.all.log_martians == 1 0
reason: gives you evidence of spoofing attempts in the kernel log
fix: net.ipv4.conf.all.log_martians = 1
[PASS] critical kernel.randomize_va_space == 2 2
[PASS] high fs.suid_dumpable == 0 0
[PASS] high kernel.unprivileged_bpf_disabled >= 1 2
[PASS] high net.ipv4.conf.all.accept_source_ro == 0 0
[PASS] high net.ipv4.ip_forward == 0 0
[PASS] medium fs.protected_hardlinks == 1 1
[PASS] medium fs.protected_symlinks == 1 1
[PASS] medium kernel.dmesg_restrict == 1 1
[PASS] medium kernel.kptr_restrict >= 1 1
[PASS] medium net.ipv4.conf.all.rp_filter one of 1|2 2
[PASS] medium net.ipv4.tcp_syncookies == 1 1
------------------------------------------------------------------------------
score: 84/100 (severity weighted) pass=11 fail=3 skip=0 error=0
==============================================================================
$ ruby sysctl_audit.rb --policy policy.yml --remediate 99-hardening.conf
wrote 3 fix(es) to 99-hardening.conf
$ cat 99-hardening.conf
# Generated by sysctl_audit 1.0.0 on 2026-08-18
# Policy: baseline-linux-server-v1 Host: claude
# Review before deploying. Apply with: sudo sysctl --system
# [high] ICMP redirects rejected
# accepting redirects lets an attacker on the LAN reroute your traffic
net.ipv4.conf.all.accept_redirects = 0
# [medium] IPv6 ICMP redirects rejected
# same pivot risk as IPv4 redirects
net.ipv6.conf.all.accept_redirects = 0
# [low] Martian packets logged
# gives you evidence of spoofing attempts in the kernel log
net.ipv4.conf.all.log_martians = 1
Full script, baseline files and README on GitHub: ruby-devops-toolkit/sysctl-hardening-audit
What you need
- Ruby ≥ 2.7. Tested on 3.0.2.
- No gems. Stdlib only:
yaml,json,optparse,fileutils. - Linux with
/proc/sysmounted. Notablyprocpsis not required — the script reads the filesystem directly, which is allsysctlitself does. - Reading is unprivileged. A handful of knobs are root-only; those report
ERRORwith the reason rather than failing the whole run.
sysctl_audit.rb
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# sysctl_audit.rb -- Kernel parameter hardening auditor for Linux
#
# Every Linux hardening standard (CIS, STIG, your company's own wiki page)
# eventually boils down to a list of /proc/sys knobs that must hold particular
# values. The knobs are easy to set and even easier to lose: a kernel upgrade
# ships a new default, a container runtime rewrites ip_forward, someone reboots
# a host that only ever had the values applied by hand.
#
# This script reads a declarative YAML policy, reads the LIVE values straight
# out of /proc/sys (not `sysctl -a`, so it works without procps installed),
# compares them with a per-check operator, and reports what drifted. It can
# also emit a ready-to-drop /etc/sysctl.d/ file containing exactly the fixes
# needed -- so remediation is a file you can review, not a shell one-liner you
# have to trust.
#
# Exit codes for cron / CI use:
# 0 fully compliant
# 1 at least one check failed
# 2 the audit could not run
#
# Usage:
# ruby sysctl_audit.rb --policy policy.yml
# ruby sysctl_audit.rb --policy policy.yml --min-severity high
# ruby sysctl_audit.rb --policy policy.yml --json
# ruby sysctl_audit.rb --policy policy.yml --remediate 99-hardening.conf
# ruby sysctl_audit.rb --policy policy.yml --root ./fixtures/proc # offline test
#
# Ruby >= 2.7, stdlib only.
require 'yaml'
require 'json'
require 'optparse'
require 'fileutils'
module SysctlAudit
VERSION = '1.0.0'
SEVERITIES = %w[low medium high critical].freeze
# ---------------------------------------------------------------------------
# Reading the live kernel. `sysctl -a` is a thin wrapper over this directory,
# so going straight to the filesystem removes a dependency and lets us point
# --root at a fixture tree for testing.
# ---------------------------------------------------------------------------
class ProcSysReader
class Missing < StandardError; end
def initialize(root: '/proc/sys') = @root = root
# net.ipv4.ip_forward -> <root>/net/ipv4/ip_forward
def path_for(key) = File.join(@root, key.tr('.', '/'))
def read(key)
path = path_for(key)
raise Missing, "#{key} not present (#{path})" unless File.file?(path)
# Multi-value knobs (e.g. net.ipv4.tcp_rmem) are tab separated on one
# line; squeeze all whitespace so comparisons are stable.
File.read(path).strip.split(/\s+/).join(' ')
rescue Errno::EACCES
raise Missing, "#{key} is not readable by uid #{Process.uid}"
rescue Errno::EIO, Errno::EINVAL
# A few knobs exist but refuse to be read on some kernels.
raise Missing, "#{key} exists but the kernel refused the read"
end
def available?(key) = File.file?(path_for(key))
end
# ---------------------------------------------------------------------------
# Comparison operators. Keeping these in a lookup table (rather than a case
# statement buried in the auditor) means adding a new one is a one-line change
# and the policy file can name any of them.
# ---------------------------------------------------------------------------
module Operators
HANDLERS = {
# exact string/numeric match -- the common case
'eq' => ->(actual, want) { numeric?(actual, want) ? f(actual) == f(want) : actual == want.to_s },
'ne' => ->(actual, want) { actual != want.to_s },
# "at least this hard" / "at most this loose"
'gte' => ->(actual, want) { f(actual) >= f(want) },
'lte' => ->(actual, want) { f(actual) <= f(want) },
# any of a set of acceptable values
'in' => ->(actual, want) { Array(want).map(&:to_s).include?(actual) },
# free-form, for string knobs like kernel.core_pattern
'match' => ->(actual, want) { Regexp.new(want.to_s) =~ actual ? true : false }
}.freeze
def self.f(v) = v.to_s.to_f
def self.numeric?(*vals) = vals.all? { |v| v.to_s.match?(/\A-?\d+(\.\d+)?\z/) }
def self.apply(op, actual, want)
handler = HANDLERS[op]
raise ArgumentError, "unknown operator '#{op}'" unless handler
handler.call(actual, want)
end
def self.describe(op, want)
case op
when 'eq' then "== #{want}"
when 'ne' then "!= #{want}"
when 'gte' then ">= #{want}"
when 'lte' then "<= #{want}"
when 'in' then "one of #{Array(want).join('|')}"
when 'match' then "=~ /#{want}/"
else "#{op} #{want}"
end
end
end
# ---------------------------------------------------------------------------
# Policy model
# ---------------------------------------------------------------------------
# checks:
# - key: net.ipv4.ip_forward
# op: eq
# value: 0
# severity: high
# title: IP forwarding disabled
# rationale: a non-router host that forwards packets can be used to pivot
# skip_if_absent: true
Check = Struct.new(:key, :op, :value, :severity, :title, :rationale,
:skip_if_absent, keyword_init: true) do
def expectation = Operators.describe(op, value)
# The literal line we would write into /etc/sysctl.d to satisfy this check.
# Only meaningful for operators that imply one concrete correct value.
def remediation_value
case op
when 'eq' then value.to_s
when 'gte', 'lte' then value.to_s
when 'in' then Array(value).first.to_s
end
end
end
class Policy
attr_reader :checks, :name
def initialize(data)
@name = data['name'] || 'unnamed policy'
@checks = Array(data['checks']).map do |c|
sev = (c['severity'] || 'medium').downcase
raise ArgumentError, "bad severity '#{sev}' on #{c['key']}" unless SEVERITIES.include?(sev)
Check.new(
key: c.fetch('key'),
op: (c['op'] || 'eq').downcase,
value: c['value'],
severity: sev,
title: c['title'] || c.fetch('key'),
rationale: c['rationale'].to_s,
skip_if_absent: c.fetch('skip_if_absent', false)
)
end
raise ArgumentError, 'policy contains no checks' if @checks.empty?
end
def self.load(path)
raise ArgumentError, "policy not found: #{path}" unless File.exist?(path)
new(YAML.safe_load(File.read(path)) || {})
end
end
# ---------------------------------------------------------------------------
# The audit itself
# ---------------------------------------------------------------------------
Result = Struct.new(:status, :key, :title, :severity, :expected, :actual,
:rationale, :fix, keyword_init: true)
class Auditor
def initialize(policy, reader) = (@policy = policy; @reader = reader)
def run
@policy.checks.map { |check| evaluate(check) }
end
private
def evaluate(check)
actual = @reader.read(check.key)
ok = Operators.apply(check.op, actual, check.value)
Result.new(
status: ok ? 'PASS' : 'FAIL',
key: check.key, title: check.title, severity: check.severity,
expected: check.expectation, actual: actual,
rationale: check.rationale,
fix: ok ? nil : fix_line(check)
)
rescue ProcSysReader::Missing => e
# A knob can be legitimately absent: IPv6 compiled out, a module not
# loaded, a container without the netfilter namespace. The policy author
# decides whether that is acceptable via skip_if_absent.
Result.new(
status: check.skip_if_absent ? 'SKIP' : 'ERROR',
key: check.key, title: check.title, severity: check.severity,
expected: check.expectation, actual: 'n/a',
rationale: e.message, fix: nil
)
rescue ArgumentError => e
Result.new(status: 'ERROR', key: check.key, title: check.title,
severity: check.severity, expected: check.expectation,
actual: 'n/a', rationale: e.message, fix: nil)
end
def fix_line(check)
v = check.remediation_value
v ? "#{check.key} = #{v}" : nil
end
end
# ---------------------------------------------------------------------------
# Scoring + output
# ---------------------------------------------------------------------------
class Report
WEIGHT = { 'critical' => 10, 'high' => 5, 'medium' => 2, 'low' => 1 }.freeze
MARK = { 'PASS' => '[PASS]', 'FAIL' => '[FAIL]', 'SKIP' => '[SKIP]',
'ERROR' => '[ERR ]' }.freeze
def initialize(results, policy_name) = (@results = results; @policy = policy_name)
def failures = @results.select { |r| r.status == 'FAIL' }
def counts = @results.group_by(&:status).transform_values(&:size)
# Weight the score by severity so ten low-risk misses do not look worse
# than one critical one.
def score
scored = @results.reject { |r| r.status == 'SKIP' }
return 100 if scored.empty?
total = scored.sum { |r| WEIGHT.fetch(r.severity, 1) }
earned = scored.select { |r| r.status == 'PASS' }
.sum { |r| WEIGHT.fetch(r.severity, 1) }
((earned.to_f / total) * 100).round
end
def text
w = 78
out = []
out << '=' * w
out << " KERNEL HARDENING AUDIT -- #{@policy}"
out << " host=#{host} kernel=#{kernel} #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
out << '=' * w
out << format(' %-6s %-8s %-34s %-12s %s', 'STATE', 'SEV', 'PARAMETER', 'EXPECTED', 'ACTUAL')
out << '-' * w
ordered.each do |r|
out << format(' %-6s %-8s %-34s %-12s %s', MARK[r.status], r.severity,
r.key[0, 34], r.expected.to_s[0, 12], r.actual.to_s[0, 14])
out << " reason: #{r.rationale}" if r.status == 'FAIL' && !r.rationale.empty?
out << " fix: #{r.fix}" if r.fix
end
out << '-' * w
out << " score: #{score}/100 (severity weighted) " \
"#{%w[PASS FAIL SKIP ERROR].map { |s| "#{s.downcase}=#{counts.fetch(s, 0)}" }.join(' ')}"
out << '=' * w
out.join("\n")
end
def json
JSON.pretty_generate(
policy: @policy, host: host, kernel: kernel,
generated_at: Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ'),
score: score, summary: counts,
results: @results.map(&:to_h)
)
end
# A drop-in file for /etc/sysctl.d/. Reviewable, version-controllable,
# and applied with `sysctl --system` rather than a blind `sysctl -w` loop.
def remediation_conf
lines = ["# Generated by sysctl_audit #{VERSION} on #{Time.now.strftime('%Y-%m-%d')}",
"# Policy: #{@policy} Host: #{host}",
'# Review before deploying. Apply with: sudo sysctl --system', '']
failures.select(&:fix).sort_by { |r| [-Report::WEIGHT.fetch(r.severity, 1), r.key] }
.each do |r|
lines << "# [#{r.severity}] #{r.title}"
lines << "# #{r.rationale}" unless r.rationale.empty?
lines << r.fix
lines << ''
end
lines << '# no remediation required' if failures.empty?
lines.join("\n")
end
private
ORDER = { 'FAIL' => 0, 'ERROR' => 1, 'SKIP' => 2, 'PASS' => 3 }.freeze
SEV_ORDER = { 'critical' => 0, 'high' => 1, 'medium' => 2, 'low' => 3 }.freeze
def ordered
@results.sort_by { |r| [ORDER.fetch(r.status, 9), SEV_ORDER.fetch(r.severity, 9), r.key] }
end
def host = @host ||= (ENV['HOSTNAME'] || `hostname 2>/dev/null`.strip)
def kernel = @kernel ||= (File.read('/proc/sys/kernel/osrelease').strip rescue 'unknown')
end
end
# -----------------------------------------------------------------------------
# CLI
# -----------------------------------------------------------------------------
if __FILE__ == $PROGRAM_NAME
opts = { policy: 'policy.yml', root: '/proc/sys', format: :text, min_severity: 'low' }
OptionParser.new do |o|
o.banner = 'Usage: sysctl_audit.rb [options]'
o.on('-p', '--policy PATH', 'YAML hardening policy') { |v| opts[:policy] = v }
o.on('-j', '--json', 'emit JSON') { opts[:format] = :json }
o.on('-r', '--remediate PATH', 'write a sysctl.d conf with the fixes') { |v| opts[:remediate] = v }
o.on('--root PATH', 'alternate /proc/sys root (testing)') { |v| opts[:root] = v }
o.on('--min-severity SEV', SysctlAudit::SEVERITIES,
'only report at or above this severity') { |v| opts[:min_severity] = v }
o.on('-v', '--version') { puts "sysctl_audit #{SysctlAudit::VERSION}"; exit 0 }
o.on('-h', '--help') { puts o; exit 0 }
end.parse!
begin
policy = SysctlAudit::Policy.load(opts[:policy])
reader = SysctlAudit::ProcSysReader.new(root: opts[:root])
results = SysctlAudit::Auditor.new(policy, reader).run
floor = SysctlAudit::SEVERITIES.index(opts[:min_severity])
results = results.select { |r| SysctlAudit::SEVERITIES.index(r.severity) >= floor }
report = SysctlAudit::Report.new(results, policy.name)
if opts[:remediate]
FileUtils.mkdir_p(File.dirname(opts[:remediate]))
File.write(opts[:remediate], report.remediation_conf)
warn "wrote #{report.failures.size} fix(es) to #{opts[:remediate]}"
end
puts opts[:format] == :json ? report.json : report.text
exit(report.failures.empty? ? 0 : 1)
rescue StandardError => e
warn "sysctl_audit: #{e.class}: #{e.message}"
exit 2
end
end
How it works
The dotted key is the path
The single design decision everything else follows from: a sysctl key maps onto a filesystem path by replacing dots with slashes.
def path_for(key) = File.join(@root, key.tr('.', '/'))
# net.ipv4.ip_forward -> /proc/sys/net/ipv4/ip_forward
That one line buys three things. No procps dependency. No shelling out and parsing. And an @root you can point somewhere else — --root ./fixtures/proc swaps the live kernel for a tree of plain files, so the comparison engine is testable in CI without root, without a real kernel, and without mutating anything.
net.ipv4.tcp_rmem is three tab-separated integers. The reader squeezes all whitespace to single spaces so comparisons stay stable across kernels that pad differently.
Operators in a table, not a case statement
Hardening rules are not all equality checks. Reverse-path filtering is acceptable at either 1 or 2. kptr_restrict should be at least 1. core_pattern needs a regex. Keeping the comparisons in a hash of lambdas means the policy file can name any of them and adding one is a single line:
HANDLERS = {
'eq' => ->(actual, want) { numeric?(actual, want) ? f(actual) == f(want)
: actual == want.to_s },
'gte' => ->(actual, want) { f(actual) >= f(want) },
'lte' => ->(actual, want) { f(actual) <= f(want) },
'in' => ->(actual, want) { Array(want).map(&:to_s).include?(actual) },
'match' => ->(actual, want) { Regexp.new(want.to_s) =~ actual ? true : false }
}.freeze
eq deliberately compares numerically when both sides look numeric, so 1, 1.0 and "1" all agree, and falls back to string comparison for text knobs.
Missing is not the same as failing
A knob can be legitimately absent. IPv6 compiled out. A module not loaded. A container without the netfilter namespace. A kernel too old to have unprivileged_bpf_disabled. Whether that is acceptable is not something the script can know, so the policy author declares it per check:
rescue ProcSysReader::Missing => e
Result.new(
status: check.skip_if_absent ? 'SKIP' : 'ERROR',
key: check.key, rationale: e.message, ...
)
SKIP results are excluded from the score entirely. Scoring a host down for a check that could never apply to it produces a number nobody trusts, and a number nobody trusts gets ignored.
Weighting the score
A flat pass percentage lets ten trivial misses look worse than one critical one. Weighting fixes that:
WEIGHT = { 'critical' => 10, 'high' => 5, 'medium' => 2, 'low' => 1 }.freeze
The score is earned weight over total weight, so missing randomize_va_space costs ten times what missing log_martians does — which is roughly the right ratio.
Remediation you can review
The script never calls sysctl -w, and that is deliberate. --remediate writes a drop-in containing only the failing keys, each preceded by its severity, title and rationale as comments, sorted critical-first.
A real 6.8 kernel, three genuine failures
$ ruby sysctl_audit.rb --policy policy.yml
==============================================================================
KERNEL HARDENING AUDIT -- baseline-linux-server-v1
host=claude kernel=6.8.0-136-generic 2026-08-18 14:41:05
==============================================================================
STATE SEV PARAMETER EXPECTED ACTUAL
------------------------------------------------------------------------------
[FAIL] high net.ipv4.conf.all.accept_redirects == 0 1
reason: accepting redirects lets an attacker on the LAN reroute your traffic
fix: net.ipv4.conf.all.accept_redirects = 0
[FAIL] medium net.ipv6.conf.all.accept_redirects == 0 1
reason: same pivot risk as IPv4 redirects
fix: net.ipv6.conf.all.accept_redirects = 0
[FAIL] low net.ipv4.conf.all.log_martians == 1 0
reason: gives you evidence of spoofing attempts in the kernel log
fix: net.ipv4.conf.all.log_martians = 1
[PASS] critical kernel.randomize_va_space == 2 2
[PASS] high fs.suid_dumpable == 0 0
[PASS] high kernel.unprivileged_bpf_disabled >= 1 2
[PASS] high net.ipv4.conf.all.accept_source_ro == 0 0
[PASS] high net.ipv4.ip_forward == 0 0
[PASS] medium fs.protected_hardlinks == 1 1
[PASS] medium fs.protected_symlinks == 1 1
[PASS] medium kernel.dmesg_restrict == 1 1
[PASS] medium kernel.kptr_restrict >= 1 1
[PASS] medium net.ipv4.conf.all.rp_filter one of 1|2 2
[PASS] medium net.ipv4.tcp_syncookies == 1 1
------------------------------------------------------------------------------
score: 84/100 (severity weighted) pass=11 fail=3 skip=0 error=0
==============================================================================
$ ruby sysctl_audit.rb --policy policy.yml --remediate 99-hardening.conf
wrote 3 fix(es) to 99-hardening.conf
$ cat 99-hardening.conf
# Generated by sysctl_audit 1.0.0 on 2026-08-18
# Policy: baseline-linux-server-v1 Host: claude
# Review before deploying. Apply with: sudo sysctl --system
# [high] ICMP redirects rejected
# accepting redirects lets an attacker on the LAN reroute your traffic
net.ipv4.conf.all.accept_redirects = 0
# [medium] IPv6 ICMP redirects rejected
# same pivot risk as IPv4 redirects
net.ipv6.conf.all.accept_redirects = 0
# [low] Martian packets logged
# gives you evidence of spoofing attempts in the kernel log
net.ipv4.conf.all.log_martians = 1
When it does not behave
- Everything reports ERROR / not present. You pointed
--rootat a tree that does not contain the knobs, or /proc is not mounted. Confirm withls /proc/sys/kernel/randomize_va_space. - A knob that clearly exists reports ERROR. Some are mode 0600 and root-only. The reader distinguishes “missing” from “not readable by uid N” in the message — read it before blaming the kernel.
- A fix reverted after reboot. That is
sysctl -wworking as designed. Use--remediateand drop the file in/etc/sysctl.d/. conf.all.*is set but one interface misbehaves. Theallknob and the per-interface knob are combined, and for several parameters the kernel takes the maximum. Add per-interface checks if you need certainty.- The score moved but no check changed. Adding a critical check changes the denominator. Version your policy with
name:and compare scores only within a version.
Where to take it next
- Ship the CIS list. The included policy is a starting baseline; the CIS Benchmark section 3 knobs map onto this format one-to-one.
- Per-role policies. Deep-merge
common.ymlwithrole-router.ymlso a genuine router can overrideip_forwardwithout weakening every other host. - Drift over time. Store the JSON per run and alert on the score falling — that catches regressions faster than an absolute threshold.
- Other config surfaces. The Check / Operators / Report split is not sysctl-specific. Swap
ProcSysReaderfor one over/sys/module/*/parametersand everything else is reused. - Config-management handoff. Emit the failures as an Ansible task list rather than a conf file if your fleet is already managed.