CIS-based hardening script for FreeBSD
Fukuoka, 2025.07.17
This is a hardening script for FreeBSD 14.3. It is based on the CIS hardening guidance for select security howlers. It checks for updates and does some configuration tweaking.
In the presence of:
- dnsmasq
- mysql
- nginx
It will alter the configuration of these services.
It also assumes that you don't need RPC, kernel-level IP forwarding, or source routing. It tweaks cookies and DNS settings a bit.
#!/usr/local/bin/perl
#
# FreeBSD CIS Hardening Script
# Based on CIS FreeBSD Benchmark recommendations
#
use strict;
use warnings;
use File::Copy;
use File::Slurp;
use POSIX qw(strftime);
my $DEBUG = 1;
my $DRY_RUN = 0; # Set to 0 to actually make changes
my $BACKUP_DIR = "/root/hardening_backups_" . strftime("%Y%m%d_%H%M%S", localtime());# Color output for readability
my $RED = "033[31m";
my $GREEN = "033[32m";
my $YELLOW = "033[33m";
my $BLUE = "033[34m";
my $NC = "033[0m"; # No Color
print "${BLUE}FreeBSD CIS Hardening Script${NC}n";print "Backup directory: $BACKUP_DIRn";
print "Mode: " . ($DRY_RUN ? "${YELLOW}DRY RUN${NC}" : "${RED}LIVE MODE${NC}") . "nn";# Create backup directory
mkdir($BACKUP_DIR) unless $DRY_RUN;
sub log_message {my ($level, $message) = @_;
my $color = $level eq 'PASS' ? $GREEN :
$level eq 'FAIL' ? $RED :
$level eq 'WARN' ? $YELLOW : $NC;
print "[$color$level$NC] $messagen";
}
sub backup_file {my ($file) = @_;
return unless -f $file;
return if $DRY_RUN;
my $backup_name = $file;
$backup_name =~ s|/|_|g;
copy($file, "$BACKUP_DIR/$backup_name") or warn "Could not backup $file: $!";
}
sub check_file_exists {my ($file, $description) = @_;
if (-f $file) { log_message('PASS', "$description exists: $file");return 1;
}
else { log_message('FAIL', "$description missing: $file");return 0;
}
}
sub check_file_permissions {my ($file, $expected_mode, $description) = @_;
return 0 unless -f $file;
my $mode = (stat($file))[2] & 07777;
my $mode_oct = sprintf("%o", $mode); if ($mode == oct($expected_mode)) { log_message('PASS', "$description has correct permissions ($mode_oct)");return 1;
}
else { log_message('FAIL', "$description has incorrect permissions ($mode_oct, should be $expected_mode)");return 0;
}
}
sub set_file_permissions {my ($file, $mode, $description) = @_;
return unless -f $file;
backup_file($file);
if ($DRY_RUN) { log_message('INFO', "Would set $description permissions to $mode");return;
}
if (chmod(oct($mode), $file)) { log_message('PASS', "Set $description permissions to $mode");}
else { log_message('FAIL', "Failed to set $description permissions: $!");}
}
sub check_sysctl_setting {my ($setting, $expected_value, $description) = @_;
my $current = `sysctl -n $setting 2>/dev/null`;
chomp $current;
if ($current eq $expected_value) { log_message('PASS', "$description: $setting = $current");return 1;
}
else { log_message('FAIL', "$description: $setting = $current (should be $expected_value)");return 0;
}
}
sub set_sysctl_setting {my ($setting, $value, $description) = @_;
if ($DRY_RUN) { log_message('INFO', "Would set $description: $setting = $value");return;
}
# Set immediately
system("sysctl $setting=$value");# Make persistent in /etc/sysctl.conf
backup_file('/etc/sysctl.conf');my $sysctl_conf = '/etc/sysctl.conf';
my @lines = -f $sysctl_conf ? read_file($sysctl_conf) : ();
# Remove existing setting
@lines = grep { !/^$settings*=/ } @lines;# Add new setting
push @lines, "$setting=$valuen";
write_file($sysctl_conf, @lines);
log_message('PASS', "Set $description: $setting = $value");}
sub check_rc_conf_setting {my ($setting, $expected_value, $description) = @_;
return 0 unless -f '/etc/rc.conf';
my $content = read_file('/etc/rc.conf'); if ($content =~ /^$settings*=s*"?$expected_value"?s*$/m) { log_message('PASS', "$description: $setting = $expected_value");return 1;
}
else { log_message('FAIL', "$description: $setting not set to $expected_value");return 0;
}
}
sub set_rc_conf_setting {my ($setting, $value, $description) = @_;
if ($DRY_RUN) { log_message('INFO', "Would set $description: $setting = $value in /etc/rc.conf");return;
}
backup_file('/etc/rc.conf'); my @lines = -f '/etc/rc.conf' ? read_file('/etc/rc.conf') : ();# Remove existing setting
@lines = grep { !/^$settings*=/ } @lines;# Add new setting
push @lines, "$setting="$value"n";
write_file('/etc/rc.conf', @lines); log_message('PASS', "Set $description: $setting = $value");}
# =============================================================================
# FREEBSD SYSTEM HARDENING
# =============================================================================
print "n${BLUE}=== FreeBSD System Hardening ===${NC}n";# 1. Filesystem Security
print "n--- Filesystem Security ---n";
unless (check_file_permissions('/etc/passwd', '644', '/etc/passwd')) { set_file_permissions('/etc/passwd', '644', '/etc/passwd');}
unless (check_file_permissions('/etc/group', '644', '/etc/group')) { set_file_permissions('/etc/group', '644', '/etc/group');}
unless (check_file_permissions('/etc/master.passwd', '600', '/etc/master.passwd')) { set_file_permissions('/etc/master.passwd', '600', '/etc/master.passwd');}
# 2. Network Security
print "n--- Network Security ---n";
# Disable IP forwarding
unless (check_sysctl_setting('net.inet.ip.forwarding', '0', 'IP forwarding disabled')) { set_sysctl_setting('net.inet.ip.forwarding', '0', 'IP forwarding disabled');}
# Disable source routing
unless (check_sysctl_setting('net.inet.ip.sourceroute', '0', 'Source routing disabled')) { set_sysctl_setting('net.inet.ip.sourceroute', '0', 'Source routing disabled');}
# Disable ICMP redirects
unless (check_sysctl_setting('net.inet.ip.redirect', '0', 'ICMP redirects disabled')) { set_sysctl_setting('net.inet.ip.redirect', '0', 'ICMP redirects disabled');}
# Enable SYN flood protection
unless (check_sysctl_setting('net.inet.tcp.syncookies', '1', 'SYN flood protection enabled')) { set_sysctl_setting('net.inet.tcp.syncookies', '1', 'SYN flood protection enabled');}
# 3. Service Security
print "n--- Service Security ---n";
# Disable unnecessary services
my @services_to_disable = qw(sendmail_enable inetd_enable portmap_enable nfs_server_enable);
foreach my $service (@services_to_disable) { unless (check_rc_conf_setting($service, 'NO', "Service $service disabled")) {set_rc_conf_setting($service, 'NO', "Service $service disabled");
}
}
# =============================================================================
# MYSQL HARDENING
# =============================================================================
print "n${BLUE}=== MySQL Hardening ===${NC}n";my $mysql_conf = '/usr/local/etc/mysql/my.cnf';
if (-f $mysql_conf) {backup_file($mysql_conf);
my $content = read_file($mysql_conf);
my @security_settings = (
'skip-show-database',
'skip-symbolic-links',
'local-infile=0',
'skip-networking=0',
'bind-address=127.0.0.1'
);
foreach my $setting (@security_settings) { if ($content !~ /^$setting/m) { log_message('WARN', "MySQL setting missing: $setting"); unless ($DRY_RUN) {$content .= "n$settingn";
}
}
else { log_message('PASS', "MySQL setting present: $setting");}
}
unless ($DRY_RUN) {write_file($mysql_conf, $content);
}
}
else { log_message('WARN', "MySQL configuration file not found: $mysql_conf");}
# Check MySQL data directory permissions
my $mysql_data_dir = '/var/db/mysql';
if (-d $mysql_data_dir) {check_file_permissions($mysql_data_dir, '700', 'MySQL data directory');
set_file_permissions($mysql_data_dir, '700', 'MySQL data directory') unless check_file_permissions($mysql_data_dir, '700', 'MySQL data directory');
}
else { log_message('WARN', "MySQL data directory not found: $mysql_data_dir");}
# =============================================================================
# NGINX HARDENING
# =============================================================================
print "n${BLUE}=== Nginx Hardening ===${NC}n";my $nginx_conf = '/usr/local/etc/nginx/nginx.conf';
if (-f $nginx_conf) {backup_file($nginx_conf);
my $content = read_file($nginx_conf);
my %security_headers = (
'add_header X-Frame-Options DENY;' => 'X-Frame-Options header',
'add_header X-Content-Type-Options nosniff;' => 'X-Content-Type-Options header',
'add_header X-XSS-Protection "1; mode=block";' => 'X-XSS-Protection header',
'add_header Referrer-Policy "strict-origin-when-cross-origin";' => 'Referrer-Policy header',
'server_tokens off;' => 'Server tokens disabled'
);
# Check for WebDAV methods disabled
my $webdav_disabled = 0;
if ($content =~ /dav_methodss+off/i ||
$content =~ /if.*REQUEST_METHOD.*PROPFIND.*PROPPATCH.*MKCOL.*COPY.*MOVE.*LOCK.*UNLOCK.*returns+405/i) { log_message('PASS', 'WebDAV methods disabled');$webdav_disabled = 1;
}
else { log_message('WARN', 'WebDAV methods not explicitly disabled');}
my $modified = 0;
foreach my $header (keys %security_headers) {my $check = $header;
$check =~ s/;$//; # Remove semicolon for checking
if ($content =~ /Q$checkE/i) { log_message('PASS', "Nginx security setting present: $check");}
else { log_message('WARN', "Nginx security setting missing: $check"); if (!$DRY_RUN) {# Add to http block
if ($content =~ /(https*{[^}]*?)(s*})/s) {my $http_block = $1;
my $closing = $2;
# Add the header before the closing brace
$http_block .= "n $header";
$content =~ s/(https*{[^}]*?)(s*})/$http_block$2/s;$modified = 1;
log_message('PASS', "Added Nginx security setting: $check");}
}
else { log_message('INFO', "Would add Nginx security setting: $check");}
}
}
# Add WebDAV disabling if not present
if (!$webdav_disabled) { if (!$DRY_RUN) { if ($content =~ /(https*{[^}]*?)(s*})/s) {my $http_block = $1;
my $closing = $2;
# Add WebDAV method blocking
my $webdav_block = q{# Disable WebDAV methods
if ($request_method ~ ^(PROPFIND|PROPPATCH|MKCOL|COPY|MOVE|LOCK|UNLOCK)$) {return 405;
}};
$http_block .= $webdav_block;
$content =~ s/(https*{[^}]*?)(s*})/$http_block$2/s;$modified = 1;
log_message('PASS', 'Added WebDAV method blocking');}
}
else { log_message('INFO', 'Would add WebDAV method blocking');}
}
# Write back if modified
if ($modified && !$DRY_RUN) {write_file($nginx_conf, $content);
log_message('PASS', 'Updated Nginx configuration with security headers');}
# Check SSL settings
if ($content =~ /ssl_protocols/) { log_message('PASS', 'SSL protocols configured');}
else { log_message('WARN', 'SSL protocols not explicitly configured'); if (!$DRY_RUN && $content =~ /(https*{[^}]*?)(s*})/s) {my $http_block = $1;
my $closing = $2;
$http_block .= "n ssl_protocols TLSv1.2 TLSv1.3;";
$http_block .= "n ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384;";
$content =~ s/(https*{[^}]*?)(s*})/$http_block$2/s;write_file($nginx_conf, $content);
log_message('PASS', 'Added SSL protocol configuration');}
}
}
else { log_message('WARN', "Nginx configuration file not found: $nginx_conf");}
# =============================================================================
# DNSMASQ HARDENING
# =============================================================================
print "n${BLUE}=== Dnsmasq Hardening ===${NC}n";my $dnsmasq_conf = '/usr/local/etc/dnsmasq.conf';
if (-f $dnsmasq_conf) {backup_file($dnsmasq_conf);
my $content = read_file($dnsmasq_conf);
my %security_settings = (
'domain-needed' => 'Domain needed for DNS queries',
'bogus-priv' => 'Bogus private reverse lookups',
'no-resolv' => 'Do not read /etc/resolv.conf',
'stop-dns-rebind' => 'Stop DNS rebinding attacks',
'rebind-localhost-ok' => 'Allow localhost rebinding'
);
my $modified = 0;
foreach my $setting (keys %security_settings) { if ($content =~ /^$setting/m) { log_message('PASS', "Dnsmasq security setting present: $setting");}
else { log_message('WARN', "Dnsmasq security setting missing: $setting"); if (!$DRY_RUN) {$content .= "n$settingn";
$modified = 1;
log_message('PASS', "Added Dnsmasq security setting: $setting");}
else { log_message('INFO', "Would add Dnsmasq security setting: $setting");}
}
}
# Write back if modified
if ($modified && !$DRY_RUN) {write_file($dnsmasq_conf, $content);
log_message('PASS', 'Updated Dnsmasq configuration with security settings');}
}
else { log_message('INFO', "Dnsmasq configuration file not found: $dnsmasq_conf");}
# =============================================================================
# ADDITIONAL SECURITY CHECKS
# =============================================================================
print "n${BLUE}=== Additional Security Checks ===${NC}n";# Check for updates
print "n--- System Updates ---n";
my $freebsd_update = `freebsd-update fetch --not-running-from-cron 2>&1 | tail -5`;
if ($freebsd_update =~ /No updates needed/) { log_message('PASS', 'System is up to date');}
else { log_message('WARN', 'System updates may be available');}
# Check SSH configuration
print "n--- SSH Security ---n";
my $sshd_config = '/etc/ssh/sshd_config';
if (-f $sshd_config) {my $ssh_content = read_file($sshd_config);
# Check PermitRootLogin
if ($ssh_content =~ /^s*PermitRootLogins+nos*$/mi) { log_message('PASS', 'Root login disabled');}
else { log_message('WARN', 'Root login not explicitly disabled');}
# Check PasswordAuthentication
if ($ssh_content =~ /^s*PasswordAuthentications+nos*$/mi) { log_message('PASS', 'Password authentication disabled');}
else { log_message('WARN', 'Password authentication not explicitly disabled');}
# Check PermitEmptyPasswords
if ($ssh_content =~ /^s*PermitEmptyPasswordss+nos*$/mi) { log_message('PASS', 'Empty passwords disabled');}
else { log_message('WARN', 'Empty passwords not explicitly disabled');}
# Check Protocol (Note: Protocol directive is deprecated in newer SSH versions)
if ($ssh_content =~ /^s*Protocols+2s*$/mi) { log_message('PASS', 'SSH Protocol 2 explicitly set');}
elsif ($ssh_content =~ /^s*Protocols+/mi) { log_message('WARN', 'SSH Protocol set to something other than 2');}
else { log_message('PASS', 'SSH Protocol 2 (default in modern SSH - no explicit setting needed)');}
}
else { log_message('WARN', 'SSH configuration file not found');}
# =============================================================================
# SUMMARY
# =============================================================================
print "n${BLUE}=== Hardening Summary ===${NC}n";print "Script completed. Review the output above for any FAIL or WARN items.n";
print "Backups created in: $BACKUP_DIRn" unless $DRY_RUN;
print "nRecommended next steps:n";
print "1. Review and test all changesn";
print "2. Restart affected servicesn";
print "3. Monitor logs for any issuesn";
print "4. Run security scans to verify improvementsn";
if ($DRY_RUN) { print "n${YELLOW}This was a DRY RUN. Set $DRY_RUN = 0 to apply changes.${NC}n";}