Automating Server Hardening with Puppet: From CIS Benchmarks to Continuous Compliance

Manual server hardening is inherently inconsistent. An engineer running a checklist at 11 PM before a deadline will miss steps. Configuration drift accumulates silently until an audit or a breach reveals it. Puppet solves this by encoding your security baseline as code, enforcing it continuously across your fleet, and providing evidence of compliance on demand. This guide covers building a CIS Benchmark-aligned Puppet hardening module and integrating it into a continuous compliance workflow.

Why Puppet for Security Hardening

Puppet’s declarative model is a natural fit for compliance: you describe the desired state, Puppet enforces it, and the catalog serves as machine-readable documentation of every setting. Unlike one-shot scripts, Puppet re-applies its catalog on every agent run (default: 30 minutes), automatically correcting drift. This transforms hardening from a point-in-time event into a continuous control.

The Puppet Forge hosts community CIS modules, but production environments benefit from custom modules that encode your specific deviation register — the documented, approved exceptions where your environment legitimately differs from the benchmark.

Module Structure

Organize your hardening module by control family:

modules/
  profile_hardening/
    manifests/
      init.pp          # Entry point, includes all classes
      ssh.pp           # SSH daemon hardening
      kernel.pp        # sysctl / kernel parameters
      auditd.pp        # Audit daemon configuration
      filesystem.pp    # Mount options, permissions
      accounts.pp      # User/group policies
      pam.pp           # PAM password/auth policies
      services.pp      # Disable unnecessary services
    files/
      auditd.rules     # CIS audit rules
      sshd_config      # Hardened SSH configuration
    templates/
      sysctl.conf.erb  # Kernel parameter template
    data/
      common.yaml      # Hiera defaults
      RedHat-9.yaml    # OS-specific overrides

SSH Hardening

SSH is the most common entry point for attackers who obtain credentials. The CIS benchmark specifies a conservative set of directives that eliminate weak algorithms and unnecessary attack surface:

# manifests/ssh.pp
class profile_hardening::ssh {
  file { '/etc/ssh/sshd_config':
    ensure  => file,
    owner   => 'root',
    group   => 'root',
    mode    => '0600',
    source  => 'puppet:///modules/profile_hardening/sshd_config',
    notify  => Service['sshd'],
  }

  service { 'sshd':
    ensure => running,
    enable => true,
  }
}

The managed sshd_config file enforces:

Protocol 2
PermitRootLogin no
PasswordAuthentication no
PermitEmptyPasswords no
MaxAuthTries 4
LoginGraceTime 60
AllowTcpForwarding no
X11Forwarding no
PrintLastLog yes
Banner /etc/issue.net
Ciphers [email protected],[email protected],aes256-ctr
MACs [email protected],[email protected]
KexAlgorithms curve25519-sha256,[email protected]
ClientAliveInterval 300
ClientAliveCountMax 0

Kernel Parameter Hardening

The CIS benchmark includes dozens of sysctl settings. Manage them as a single file to ensure atomicity and avoid partial application:

# manifests/kernel.pp
class profile_hardening::kernel {
  $sysctl_settings = {
    # Network hardening
    'net.ipv4.ip_forward'                  => 0,
    'net.ipv4.conf.all.send_redirects'     => 0,
    'net.ipv4.conf.default.send_redirects' => 0,
    'net.ipv4.conf.all.accept_redirects'   => 0,
    'net.ipv4.conf.all.rp_filter'          => 1,
    'net.ipv4.tcp_syncookies'              => 1,
    'net.ipv6.conf.all.disable_ipv6'       => 1,
    # Kernel hardening
    'kernel.randomize_va_space'            => 2,
    'kernel.dmesg_restrict'                => 1,
    'kernel.kptr_restrict'                 => 2,
    'kernel.yama.ptrace_scope'             => 1,
    'fs.protected_hardlinks'               => 1,
    'fs.protected_symlinks'                => 1,
    'fs.suid_dumpable'                     => 0,
  }

  $sysctl_settings.each |$key, $value| {
    sysctl { $key:
      value   => $value,
      persist => true,
    }
  }
}

Use the herculesteam/augeasproviders_sysctl module from the Forge for the sysctl resource type — it writes to /etc/sysctl.d/99-cis.conf and applies settings immediately without a reboot.

Auditd Rules

The Linux audit subsystem records security-relevant events to a tamper-evident log. CIS specifies rules covering privileged command execution, user/group modifications, file access, and network configuration changes:

# files/auditd.rules (abbreviated — CIS section 4)
-D
-b 8192
-f 2

## Identity changes
-w /etc/group  -p wa -k identity
-w /etc/passwd -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/sudoers -p wa -k scope

## Privileged commands
-a always,exit -F path=/usr/bin/sudo    -F perm=x -F auid>=1000 -k privileged
-a always,exit -F path=/usr/bin/su      -F perm=x -F auid>=1000 -k privileged
-a always,exit -F path=/usr/bin/newgrp  -F perm=x -F auid>=1000 -k privileged

## Network config changes
-a always,exit -F arch=b64 -S sethostname -S setdomainname -k system-locale
-w /etc/hosts  -p wa -k system-locale

## Session tracking
-w /var/run/utmp -p wa -k session
-w /var/log/wtmp -p wa -k logins
-w /var/log/btmp -p wa -k logins

## Make config immutable
-e 2

The -e 2 flag at the end makes the ruleset immutable — changes require a reboot, preventing an attacker with root access from silently disabling auditing.

File System Hardening

Temporary directories should have restrictive mount options to prevent execution of attacker-placed scripts:

# manifests/filesystem.pp
class profile_hardening::filesystem {
  mount { '/tmp':
    ensure  => mounted,
    device  => 'tmpfs',
    fstype  => 'tmpfs',
    options => 'defaults,noexec,nosuid,nodev,size=2G',
  }

  mount { '/dev/shm':
    ensure  => mounted,
    device  => 'tmpfs',
    fstype  => 'tmpfs',
    options => 'defaults,noexec,nosuid,nodev',
  }

  # World-writable directories without sticky bit
  exec { 'set-sticky-bit-world-writable':
    command => 'find / -xdev -type d -perm -0002 ! -perm -1000 -exec chmod +t {} \;',
    path    => ['/usr/bin', '/bin'],
    onlyif  => 'find / -xdev -type d -perm -0002 ! -perm -1000 | grep -q .',
  }
}

PAM Password Policy

Enforce password complexity and lockout through PAM:

# manifests/pam.pp
class profile_hardening::pam {
  package { 'libpwquality':
    ensure => installed,
  }

  file { '/etc/security/pwquality.conf':
    ensure  => file,
    content => @("EOT")
      minlen  = 14
      dcredit = -1
      ucredit = -1
      ocredit = -1
      lcredit = -1
      maxrepeat = 3
      gecoscheck = 1
      | EOT
  }

  # pam_faillock: lock after 5 failures, unlock after 900 seconds
  augeas { 'pam-faillock':
    context => '/files/etc/pam.d/system-auth',
    changes => [
      "ins 1 before /files/etc/pam.d/system-auth/1",
      "set 1/type auth",
      "set 1/control required",
      "set 1/module pam_faillock.so",
      "set 1/argument[1] preauth",
    ],
  }
}

Automated Remediation and Reporting

The Puppet agent runs every 30 minutes by default, re-applying the catalog and correcting drift automatically. For immediate enforcement after a policy change, use MCollective or PuppetDB to trigger an agent run across all nodes simultaneously.

For compliance reporting, query PuppetDB for the last catalog apply time and resource state across your fleet:

# Query PuppetDB for nodes with configuration drift
curl -s 'https://puppetdb.example-corp.com:8081/pdb/query/v4/reports' \
  -d '["and",["=","certname","bastion01.example-corp.com"],["=","status","changed"]]' | \
  jq '.[].metrics.values[] | select(.name=="changes") | .value'

Integrate Puppet reports with your SIEM. The JSON report format includes every resource change with before/after values — this provides a continuous, machine-readable change log that satisfies most audit requirements for configuration change tracking.

Handling Exceptions

Every environment has legitimate deviations from the benchmark. Document exceptions in Hiera:

# data/exceptions/webservers.yaml
profile_hardening::kernel::overrides:
  net.ipv4.ip_forward: 1  # Required: webservers forward to backend pool

profile_hardening::ssh::allow_password_auth: true  # Exception: legacy monitoring agent

The Puppet class reads these overrides and applies them after the baseline. This approach keeps exceptions visible, version-controlled, and auditable — no more undocumented manual changes that get overwritten on the next agent run.

Conclusion

Puppet transforms CIS hardening from a periodic checklist into a continuous, self-healing control. The key practices are encoding the benchmark as structured Puppet classes, managing exceptions explicitly in Hiera, shipping audit and configuration reports to your SIEM, and treating your Puppet code with the same rigor as application code — peer review, testing in a staging environment, and version control. When these are in place, compliance becomes an operational property of your infrastructure rather than a point-in-time event.

Scroll to Top