Introduction
In an era of “infrastructure as code” buzz and container orchestration platforms, it is tempting to assume that traditional configuration management tools have been superseded. They have not. For security teams managing heterogeneous environments — a mix of bare-metal servers, virtual machines, LXC containers, and Windows endpoints — Puppet remains one of the most reliable ways to enforce security baselines at scale and detect when systems drift from their hardened state.
This guide walks through the practical realities of using Puppet for infrastructure hardening. Every example uses sanitized hostnames and generic configurations. The goal is not to showcase a particular deployment but to give security engineers a reusable blueprint they can adapt to their own environments.
The Problem: Configuration Drift Is a Security Gap
Configuration drift is the gradual, often invisible divergence of a system’s actual state from its intended state. It happens for mundane reasons: an engineer SSH-ed in and changed a sysctl value to debug a performance issue, a package update overwrote an SSH config, someone added a permissive firewall rule “temporarily” six months ago.
For security teams, drift is not a nuisance — it is a vulnerability factory. A single server running SSH with password authentication enabled, in an environment that mandates key-only access, is a foothold waiting to happen. A kernel parameter that was supposed to disable IP forwarding, quietly re-enabled after a network troubleshooting session, can turn a compromised web server into a pivot point.
The fundamental value of configuration management is not initial provisioning. It is continuous enforcement. Puppet’s agent runs every 30 minutes by default. If someone manually weakens an SSH cipher suite, Puppet reverts it on the next run. If an auditd rule file gets truncated, Puppet restores it. This is not theoretical — it is the operational difference between “we hardened that server once” and “that server is hardened right now.”
Structuring a Security-Focused Puppet Codebase
Before diving into specific hardening modules, the organizational pattern matters. The roles and profiles pattern is the standard approach for Puppet codebases of any meaningful size, and it is especially important for security work.
- Modules contain reusable, technology-specific logic:
ssh_hardening,kernel_hardening,audit_framework. - Profiles compose modules into functional layers:
profile::baseline_securitypulls in SSH hardening, kernel tuning, and audit rules together. - Roles map one-to-one with machine purpose:
role::bastion,role::webserver,role::siem_collector.
A node’s role is assigned via Hiera (Puppet’s hierarchical data lookup system), typically keyed on the node’s certname:
# hieradata/nodes/bastion01.example-corp.com.yaml
role: bastion
profile::ssh_hardening::permit_root_login: 'no'
profile::ssh_hardening::allowed_users:
- 'deploy'
- 'secops'
profile::firewall_baseline::additional_ports:
- '30022'
Hiera is also where secrets belong. Using hiera-eyaml with asymmetric encryption, you can store encrypted values directly in your Hiera YAML files. The Puppet server decrypts them at catalog compilation time, and plaintext secrets never touch disk on the master outside of memory.
# Encrypted with eyaml
profile::certificate_management::wildcard_key: >
ENC[PKCS7,MIIBmQYJKoZIhvcNAQcDoIIBijCCA...]
SSH Hardening
SSH is typically the first module security teams write because it is the most exposed service and the most frequently misconfigured.
A practical SSH hardening module manages several concerns simultaneously:
class ssh_hardening (
String $permit_root_login = 'no',
String $password_authentication = 'no',
Array $allowed_ciphers = ['[email protected]',
'[email protected]'],
Array $allowed_kex = ['curve25519-sha256',
'[email protected]'],
Array $allowed_macs = ['[email protected]',
'[email protected]'],
Integer $max_auth_tries = 3,
String $log_level = 'VERBOSE',
) {
file { '/etc/ssh/sshd_config':
ensure => file,
content => template('ssh_hardening/sshd_config.erb'),
owner => 'root',
mode => '0600',
notify => Service['sshd'],
}
file { '/etc/ssh/banner.txt':
ensure => file,
source => 'puppet:///modules/ssh_hardening/banner.txt',
}
service { 'sshd':
ensure => running,
enable => true,
}
}
The banner file matters for compliance — many frameworks (STIG, CIS) require a login warning banner. The cipher and key exchange restrictions eliminate legacy algorithms that are vulnerable to known attacks. Setting LogLevel VERBOSE ensures that key fingerprints are logged at authentication time, which is critical for forensic correlation in your SIEM.
Fail2ban integration is handled as a separate class that the SSH profile includes:
class profile::ssh_security {
include ssh_hardening
include fail2ban
fail2ban::jail { 'sshd':
enabled => true,
maxretry => 5,
bantime => 3600,
findtime => 600,
}
}
Kernel and Sysctl Hardening
Kernel parameters are a silent but powerful hardening layer. A well-tuned sysctl configuration can prevent entire classes of attacks:
class kernel_hardening {
$sysctl_settings = {
'net.ipv4.ip_forward' => 0, # Disable unless router/gateway
'net.ipv4.conf.all.rp_filter' => 1, # Reverse path filtering
'net.ipv4.conf.all.accept_redirects' => 0,
'net.ipv6.conf.all.accept_redirects' => 0,
'kernel.randomize_va_space' => 2, # Full ASLR
'fs.suid_dumpable' => 0, # No core dumps for SUID
'kernel.core_pattern' => '|/bin/false',
'kernel.kptr_restrict' => 2, # Hide kernel pointers
'kernel.yama.ptrace_scope' => 1, # Restrict ptrace
}
$sysctl_settings.each |$key, $value| {
sysctl { $key:
ensure => present,
value => String($value),
}
}
}
The key nuance here is per-role overrides. Gateway and firewall nodes legitimately need ip_forward enabled. This is where Hiera shines — set the secure default in common.yaml and override it only for the gateway role:
# hieradata/roles/gateway.yaml
kernel_hardening::sysctl_overrides:
'net.ipv4.ip_forward': 1
Audit Framework and STIG Compliance
The Linux audit subsystem (auditd) is required by virtually every compliance framework. Puppet can manage both the daemon configuration and the rule files:
class audit_framework (
Boolean $stig_rules = true,
) {
package { 'auditd': ensure => installed }
service { 'auditd':
ensure => running,
enable => true,
# auditd requires a special restart method
restart => '/sbin/service auditd restart',
}
file { '/etc/audit/rules.d/99-stig.rules':
ensure => file,
source => 'puppet:///modules/audit_framework/stig-rules.rules',
notify => Exec['load-audit-rules'],
}
exec { 'load-audit-rules':
command => '/sbin/augenrules --load',
refreshonly => true,
}
}
A practical gotcha: LXC containers share the host kernel’s audit subsystem. You cannot run independent auditd instances inside unprivileged containers. Your Puppet code needs to detect this and skip audit rule management on containers while still enforcing everything else:
if $facts['virtual'] != 'lxc' {
include audit_framework
}
This is the kind of environment-aware logic that separates a production Puppet codebase from a textbook example. Mixed VM and container environments are common, and modules must handle both gracefully.
Firewall Management
Managing iptables or nftables with Puppet requires a balance between a secure baseline and per-host customization. The pattern that works best is a default-deny baseline with explicit allow rules layered on top:
class firewall_baseline (
Array $additional_ports = [],
) {
# Default policies
firewallchain { 'INPUT:filter:IPv4':
policy => 'drop',
}
# Allow established connections
firewall { '001 accept established':
proto => 'all',
state => ['RELATED', 'ESTABLISHED'],
action => 'accept',
chain => 'INPUT',
}
# Allow loopback
firewall { '002 accept loopback':
proto => 'all',
iniface => 'lo',
action => 'accept',
chain => 'INPUT',
}
# Allow SSH (always)
firewall { '010 accept ssh':
dport => 22,
proto => 'tcp',
action => 'accept',
chain => 'INPUT',
}
# Per-host additional ports from Hiera
$additional_ports.each |$index, $port| {
firewall { "100 accept custom port ${port}":
dport => $port,
proto => 'tcp',
action => 'accept',
chain => 'INPUT',
}
}
}
The critical implementation detail is rule ordering. Puppet’s firewall type uses the numeric prefix to determine rule order. Establishing a convention — 001-009 for baseline, 010-099 for common services, 100+ for per-host rules — prevents the chaotic rule tables that accumulate under manual management.
User Management and Sudo Policies
User management in a security context goes beyond creating accounts. It encompasses enforcing least-privilege sudo policies, distributing SSH authorized keys, and removing default or unused accounts:
class user_management (
Hash $sudo_users = {},
Array $remove_users = ['games', 'ftp', 'news'],
) {
# Remove unnecessary default accounts
$remove_users.each |$user| {
user { $user:
ensure => absent,
}
}
# Manage sudo access via drop-in files
$sudo_users.each |$username, $config| {
file { "/etc/sudoers.d/${username}":
ensure => file,
content => "${username} ${config['hosts']} = ${config['commands']}n",
mode => '0440',
owner => 'root',
}
}
# Enforce no passwordless sudo in main sudoers
file_line { 'remove_nopasswd_defaults':
path => '/etc/sudoers',
match => '^%sudos+ALL=.*NOPASSWD',
line => '%sudo ALL=(ALL:ALL) ALL',
}
}
SSH key distribution through Puppet ensures that when an employee leaves, removing their key from Hiera data propagates across every server on the next agent run — no manual cleanup across dozens of authorized_keys files.
Windows Hardening
Puppet is not limited to Linux. For organizations managing mixed environments, Windows hardening through Puppet provides the same drift-prevention guarantees:
class windows_hardening {
# Enable PowerShell script block logging
registry_value { 'HKLMSOFTWAREPoliciesMicrosoftWindowsPowerShellScriptBlockLoggingEnableScriptBlockLogging':
ensure => present,
type => dword,
data => 1,
}
# Enable PowerShell module logging
registry_value { 'HKLMSOFTWAREPoliciesMicrosoftWindowsPowerShellModuleLoggingEnableModuleLogging':
ensure => present,
type => dword,
data => 1,
}
# Restrict local administrator account
user { 'Administrator':
ensure => present,
password => Sensitive(lookup('windows_admin_password')),
groups => ['Administrators'],
}
# Configure Windows Defender real-time protection
dsc { 'defender_realtime':
resource_name => 'WindowsDefender',
module => 'WindowsDefenderDsc',
properties => {
'IsSingleInstance' => 'Yes',
'RealTimeScanDirection' => 'Both',
'DisableRealtimeMonitoring' => false,
},
}
}
PowerShell logging is particularly important for security operations. Without script block and module logging enabled, an attacker using PowerShell-based tooling (which is the overwhelming majority of Windows post-exploitation) leaves minimal forensic evidence. Puppet ensures these logging policies cannot be silently disabled.
Certificate Management
Automated certificate management eliminates one of the most common causes of production incidents and security gaps — expired certificates:
class certificate_management (
String $acme_email = '[email protected]',
Array $managed_domains = [],
) {
package { 'certbot': ensure => installed }
$managed_domains.each |$domain| {
exec { "certbot-${domain}":
command => "certbot certonly --standalone -d ${domain} --agree-tos -m ${acme_email} -n",
creates => "/etc/letsencrypt/live/${domain}/fullchain.pem",
path => ['/usr/bin', '/usr/sbin'],
}
cron { "certbot-renew-${domain}":
command => "certbot renew --cert-name ${domain} --quiet --post-hook 'systemctl reload apache2'",
hour => fqdn_rand(24, $domain), # Distribute renewal times
minute => fqdn_rand(60, $domain),
weekday => 1, # Weekly check
}
}
# Distribute internal CA trust chain
file { '/usr/local/share/ca-certificates/example-corp-ca.crt':
ensure => file,
source => 'puppet:///modules/certificate_management/internal-ca.crt',
notify => Exec['update-ca-trust'],
}
exec { 'update-ca-trust':
command => '/usr/sbin/update-ca-certificates',
refreshonly => true,
}
}
Using fqdn_rand to distribute renewal times across the fleet is a small but important detail — it prevents every server from hitting the ACME provider simultaneously.
Compliance Reporting
Puppet can generate compliance data that feeds directly into audit workflows:
class compliance_reporting {
# Generate CIS benchmark check results
cron { 'cis-benchmark-scan':
command => '/opt/security/cis-scanner.sh --format json --output /var/log/compliance/cis-latest.json 2>&1',
hour => 3,
minute => 0,
}
file { '/opt/security/cis-scanner.sh':
ensure => file,
source => 'puppet:///modules/compliance_reporting/cis-scanner.sh',
mode => '0750',
}
# Ship results to central compliance store
file { '/etc/filebeat/modules.d/compliance.yml':
ensure => file,
content => template('compliance_reporting/filebeat-compliance.yml.erb'),
notify => Service['filebeat'],
}
}
The compliance scan results, formatted as structured JSON and shipped via Filebeat, integrate directly with your SIEM for dashboard visualization and alerting on regression.
SIEM Integration: Puppet Errors as Security Signals
This is an underappreciated capability. When Puppet fails to enforce a configuration — because someone manually changed a file and locked the permissions, because a package was removed that Puppet expects, because a service was masked — that failure is a signal. In a properly hardened environment, Puppet enforcement failures may indicate active tampering.
Configure your SIEM (Wazuh, Splunk, or ELK) to ingest Puppet agent logs and alert on enforcement failures:
<!-- Wazuh local rule example -->
<group name="puppet,">
<rule id="100500" level="10">
<decoded_as>puppet</decoded_as>
<match>Could not update|Failed to apply|Error:</match>
<description>Puppet enforcement failure - possible configuration tampering</description>
<group>configuration_drift,</group>
</rule>
<rule id="100501" level="12">
<if_sid>100500</if_sid>
<match>sshd_config|sudoers|audit.rules</match>
<description>Puppet failed to enforce security-critical configuration</description>
<group>configuration_drift,high_priority,</group>
</rule>
</group>
A level 12 alert for SSH or sudoers enforcement failures means your SOC gets notified immediately if someone is fighting against your hardening baseline. This transforms Puppet from a configuration tool into a lightweight integrity monitoring system.
Common Pitfalls
Resource conflicts between modules. Two modules both managing /etc/ssh/sshd_config will cause catalog compilation failures. The solution is to ensure only one module owns each file and use Hiera to parameterize it. If you inherit a third-party module that conflicts, wrap it in a profile that resolves the conflict.
Container versus VM assumptions. As noted in the audit section, modules that assume they run on a full kernel will break in LXC or Docker. Always gate kernel-level operations on $facts['virtual'] and test your modules across your actual fleet topology.
Idempotency failures. An exec resource that runs every time the agent converges, rather than only when needed, will generate noise in your logs, waste resources, and potentially cause downtime. Use creates, onlyif, unless, or refreshonly to ensure exec resources are truly idempotent.
Secret sprawl. Hardcoded passwords or API keys in Puppet code will end up in your version control history. Use hiera-eyaml from day one and enforce pre-commit hooks that reject plaintext secrets in manifests.
Testing neglect. Puppet code is code. Use puppet-lint for style, rspec-puppet for unit tests, and Litmus or Beaker for acceptance tests. A hardening module that breaks SSH access because of a template typo is worse than no hardening at all.
Conclusion
Configuration management is not a solved problem that modern tooling has made obsolete. It is an ongoing operational discipline, and for security teams, it is one of the highest-leverage investments available. A well-maintained Puppet codebase gives you continuous enforcement of security baselines, automatic drift detection, compliance evidence generation, and integration with your SOC workflow.
The tools are mature, the patterns are well-documented, and the alternative — manual hardening with checklists and hope — does not scale. If your organization manages more than a handful of servers and cares about its security posture, configuration management is not optional. It is infrastructure.
—
