journal features
movie reviews
photo of the day

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:

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";
     }