Hardening policy and /proc/sys feeding a comparison engine that emits a scorecard, a sysctl.d file and JSON
the shed // kernel hardening audit

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:

sysctl_audit.rb

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
Get the code

Full script, baseline files and README on GitHub: ruby-devops-toolkit/sysctl-hardening-audit

prerequisites

What you need

requirements
  • Ruby ≥ 2.7. Tested on 3.0.2.
  • No gems. Stdlib only: yaml, json, optparse, fileutils.
  • Linux with /proc/sys mounted. Notably procps is not required — the script reads the filesystem directly, which is all sysctl itself does.
  • Reading is unprivileged. A handful of knobs are root-only; those report ERROR with the reason rather than failing the whole run.
Policy and /proc/sys feeding a comparison engine that emits a scorecard, a sysctl.d file and JSON

One comparison engine, three outputs: a scorecard, a reviewable fix file, and JSON for the pipeline.
the script

sysctl_audit.rb

sysctl_audit.rbruby
#!/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
walkthrough

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.

sysctl_audit.rb &mdash; the readerruby
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.

Multi-value knobs

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:

sysctl_audit.rb &mdash; Operatorsruby
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:

sysctl_audit.rb &mdash; absent handlingruby
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:

sysctl_audit.rb &mdash; weightsruby
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.

sysctl -w does not survive a reboot

It only writes the running kernel. A drop-in in /etc/sysctl.d/ applied with sysctl –system does, and goes through code review on the way.
example output

A real 6.8 kernel, three genuine failures

$ ruby sysctl_audit.rb –policy policy.ymltext
$ 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
84/100
weighted score
14
checks
0
gems
troubleshooting

When it does not behave

common problems
  • Everything reports ERROR / not present. You pointed --root at a tree that does not contain the knobs, or /proc is not mounted. Confirm with ls /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 -w working as designed. Use --remediate and drop the file in /etc/sysctl.d/.
  • conf.all.* is set but one interface misbehaves. The all knob 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.
extending

Where to take it next

ideas
  • 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.yml with role-router.yml so a genuine router can override ip_forward without 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 ProcSysReader for one over /sys/module/*/parameters and 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.