#!/usr/bin/env python """ Harden FreeBSD system perms, settings. Set and reset rc, sysctl, login, confs; set file perms, run shell commands Uses ini file in the same directory. Usage: ./harden-freebsd.py ./harden-freebsd.py ./harden-freebsd.py restore """ __author__ = "Elias Christopher Griffin" __url__ = "https://www.quadhelion.engineering" __license__ = "QHELP-OME-NC-ND-HI" __copyright__ = "https://www.quadhelion.engineering/qhelp.html" __version__ = "3.1" __date__ = "01/20/2024" __email__ = "elias@quadhelion.engineering" __status__ = "Production" from pathlib import Path from datetime import datetime import os, re, subprocess, syslog, configparser, shutil, sys _date = datetime.now() date_time = _date.strftime("%m/%d/%Y, %H:%M") # Setup for script argument processing sysargs = sys.argv config = configparser.ConfigParser() # Setup to manipulate all the system files harden_freebsd_log = Path("/var/log/harden-freebsd.log") rc_conf = Path("/etc/rc.conf") sysctl_conf = Path("/etc/sysctl.conf") loader_conf = Path("/boot/loader.conf") login_conf = Path("/etc/login.conf") cron_access = Path("/var/cron/allow") at_access = Path("/var/at/at.allow") # Set the backup file names backup_suffix = ".original" rc_backup = rc_conf.with_name(rc_conf.name + backup_suffix) sysctl_backup = sysctl_conf.with_name(sysctl_conf.name + backup_suffix) loader_backup = loader_conf.with_name(loader_conf.name + backup_suffix) login_backup = login_conf.with_name(login_conf.name + backup_suffix) # Handle script arguments sysargs = sys.argv config = configparser.ConfigParser() if len(sys.argv) == 2: script_argument = sys.argv[1] script_argument_path = Path(script_argument) if script_argument_path.exists and script_argument_path.suffix == ".ini": config.read(script_argument) elif sys.argv[1] == "restore": rc_conf.write_bytes(rc_backup.read_bytes()) sysctl_conf.write_bytes(sysctl_backup.read_bytes()) loader_conf.write_bytes(loader_backup.read_bytes()) print(f"\n*********************\033[38;5;76m Success \033[0;0m*************************") print("Original rc.conf, sysctl.conf, loader.conf restored") print(f"*******************************************************\n") exit(0) else: pass else: config.read('settings.ini') # Handle Errors def exception_handler(func): def intake(*args, **kwargs): try: func(*args, **kwargs) except PermissionError as e: print(f"\n******************\033[38;5;1m Permissions \033[0;0m************************\n") print(f"Insufficient permissions {e}") print(f"*******************************************************\n") except OSError as e: print(f"\n********************\033[38;5;1m Locked \033[0;0m**************************\n") print(f"Perhaps file is busy, locked, process blocked, or raced:\n") print(f"{e}") print(f"*******************************************************\n") return intake # Write to either "syslog" (/var/log/messages) or "script" (/var/log/harden-freebsd.log) @exception_handler def writeLog(log_type, content): harden_freebsd_logwriter = open(harden_freebsd_log, "a") syslog.openlog("LOG_INFO") if log_type == "script": harden_freebsd_logwriter.writelines(content + os.linesep) elif log_type == "syslog": syslog.syslog(1, content) else: print(f"*******************************************************\n") print(f"\033[38;5;63m LOG: \033[0;0m {content}") print(f"*******************************************************\n") # Make *.original backups of all files only once if config['SCRIPT']['first_run'] == "True": try: harden_freebsd_log.touch() cron_access.touch() at_access.touch() rc_backup.write_bytes(rc_conf.read_bytes()) sysctl_backup.write_bytes(sysctl_conf.read_bytes()) loader_backup.write_bytes(loader_conf.read_bytes()) login_backup.write_bytes(login_conf.read_bytes()) except FileNotFoundError as e: error_path = Path(e.filename) print(f"\n********************\033[38;5;1m File Not Found \033[0;0m*******************") print(f"Filename: {error_path.parts}") print("*******************************************************\n") except PermissionError as e: print(f"\n******************\033[38;5;1m Permissions \033[0;0m************************\n") print(f"Insufficient permissions {e}") print(f"*******************************************************\n") except OSError as e: print(f"\n********************\033[38;5;1m Locked \033[0;0m**************************\n") print(f"Perhaps file is busy, locked, process blocked, or raced:\n") print(f"{e}") print(f"*******************************************************\n") else: writeLog("syslog", "System file backups complete") with open('settings.ini', 'w') as configfile: config.set('SCRIPT', 'first_run', 'False') config.write(configfile) print(f"\n*********************\033[38;5;76m Success \033[0;0m*************************") print(f"\033[38;5;75mCreated: \033[0;0m") print(f" {cron_access.name}, {at_access.name} \n") print(f"\033[38;5;75mBackups Made: \033[0;0m") print(f" {rc_backup.name}, {sysctl_backup.name}") print(f" {login_backup.name}, {loader_backup.name} \n") print(f"*******************************************************\n") # Read the system file content try: rc_content = rc_conf.read_text(encoding="utf-8") sysctl_content = sysctl_conf.read_text(encoding="utf-8") login_content = login_conf.read_text(encoding="utf-8") loader_content = loader_conf.read_text(encoding="utf-8") except FileNotFoundError as e: error_path = Path(e.filename) writeLog("script", "Error finding file " + error_path) print(f"\n*******************\033[38;5;1m File Not Found \033[0;0m********************") print(f"Filename: {error_path.name}") print(f"Directories used: {error_path.parts}\n") print("*******************************************************\n") except PermissionError as e: print(f"\n******************\033[38;5;1m Permissions \033[0;0m************************\n") print(f"Permission to read/append {e}") print(f"{os.stat(rc_conf)}{os.linesep}") print(f"{os.stat(sysctl_conf)}{os.linesep}") print(f"{os.stat(loader_conf)}{os.linesep}") print(f"{os.stat(cron_access)}{os.linesep}") print(f"{os.stat(at_access)}{os.linesep}") print(f"{os.stat(login_conf)}{os.linesep}") print(f"*******************************************************\n") else: print( f"{os.linesep}*********************\033[38;5;75m Running \033[0;0m*************************{os.linesep}" f"Loaded system files for read/append\n" f"*******************************************************{os.linesep}" ) finally: writeLog("syslog", "Hardening in progress") print(f"\n********************\033[38;5;75m Info Panel \033[0;0m***********************") print(f"Executing {__file__}") print(f"Executing {date_time}") print(f"*******************************************************\n") # Main working class dealing with rc.conf and sysctl.conf class Conf: def __init__(self, file, setting, flag): self.file = file self.setting = setting self.flag = flag # Changes the flag from whatever it is currently to flag in settings.ini def setConf(self): try: with open(self.file, 'r+', encoding="us-ascii") as file_content: lines = file_content.readlines() for i, line in enumerate(lines): if line.startswith(self.setting): lines[i] = self.setting + "=" + self.flag + os.linesep file_content.seek(0) for line in lines: file_content.write(line) file_content.truncate() writeLog("script", self.setting + " was set to " + self.flag) except OSError as e: print(f"\n********************\033[38;5;1m Locked \033[0;0m**************************\n") print(f"Perhaps file is busy, locked, process blocked, or raced:\n") print(f"{e}") print(f"*******************************************************\n") else: print(f"\033[38;5;208m {self.setting} \033[0;0m changed to\033[38;5;208m {self.flag}\033[0;0m in\033[38;5;75m {self.file}\033[0;0m ") # Appends at the end of a file a directive that was not present previously def addConf(self): try: with open(self.file, 'a') as file_content: file_content.write(self.setting + "=" + self.flag + os.linesep) writeLog("script", self.setting + "=" + self.flag + " added") except OSError as e: print(f"\n********************\033[38;5;1m Locked \033[0;0m**************************\n") print(f"Perhaps file is busy, locked, process blocked, or raced:\n") print(f"{e}") print(f"*******************************************************\n") else: print(f"\033[38;5;63m {self.setting} \033[0;0m added to \033[38;5;75m{self.file}\033[0;0m ") # Checks to see if the directive is already in the conf, returns True if present. def checkConf(self) -> bool: self.found = False try: with open(self.file, 'r') as file_content: lines = file_content.readlines() for i, line in enumerate(lines): if line.startswith(self.setting): self.found = True else: pass return self.found except OSError as e: print(f"\n********************\033[38;5;1m Locked \033[0;0m**************************\n") print(f"Perhaps file is busy, locked, process blocked, or raced:\n") print(f"{e}") print(f"*******************************************************\n") # Checks proper flag and equality syntax in rc.conf and sysctl.conf with first-boot 13.2 directives # May not work with advanced flags added later def verifyConf(self): global conf_directives conf_directives = [] sysctl_conf_verify = re.compile(r'[^\"]') # No quotes loader_rc_conf_verify = re.compile(r'^[\"].+[$\"]') # Pair of quotes try: with open(self.file, 'r+') as file_content: lines = file_content.readlines() for i, line in enumerate(lines): partitioned_line = line.partition("=") if line.isspace(): pass elif line.startswith("#"): pass elif partitioned_line[1] != "=": print(f"\n*******************************************************") print(f"Error at {lines[i]}: No equality operator. Restored original.") print(f"*******************************************************\n") writeLog("script", "No equality operator at line " + lines[i].rstrip() + " in " + self.file.name) self.restoreConf() sys.exit() elif self.file == rc_conf and re.match(loader_rc_conf_verify, partitioned_line[2]) == None: print(f"\n*******************************************************") print(f"Error: {self.flag} not allowed in {lines[i]} in {self.file}. Restored original.") print(f"*******************************************************\n") writeLog("script", "Quote matching error at line " + lines[i].rstrip()) self.restoreConf() sys.exit() elif self.file == loader_conf and re.match(loader_rc_conf_verify, partitioned_line[2]) == None: print(f"\n*******************************************************") print(f"Error: {self.flag} not allowed in {lines[i]} in {self.file}. Restored original.") print(f"*******************************************************\n") writeLog("script", "Quote matching error at line " + lines[i].rstrip()) self.restoreConf() sys.exit() elif self.file == sysctl_conf and re.match(sysctl_conf_verify, partitioned_line[2]) == None: print(f"\n*******************************************************") print(f"Error: {self.flag} not allowed in {lines[i].rstrip()} in {self.file}. Restored original.") print(f"*******************************************************\n") writeLog("script", "Quote in sysctl.conf at line " + lines[i]) self.restoreConf() sys.exit() else: conf_directives.append(line.rstrip()) except OSError as e: print(f"\n********************\033[38;5;1m Locked \033[0;0m**************************\n") print(f"Perhaps file is busy, locked, process blocked, or raced:\n") print(f"{e}") print(f"*******************************************************\n") # If syntax verification fails, restore *.originals to prevent boot failure # If in single user read-only mode use commands: # zfs set readonly=false zroot # zfs mount -a def restoreConf(self): try: if self.file == rc_conf: shutil.copy(rc_backup, rc_conf) elif self.file == sysctl_conf: shutil.copy(sysctl_backup, sysctl_conf) elif self.file == loader_conf: shutil.copy(loader_backup, loader_conf) except FileNotFoundError as e: error_path = Path(e.filename) print(f"\n*******************\033[38;5;1m File Not Found \033[0;0m********************") print(f"Filename: {error_path.name}") print(f"Directories used: {error_path.parts}\n") print("*******************************************************\n") else: print(f"\n*********************\033[38;5;76m Success \033[0;0m*************************") print(f"Files restored") print("*******************************************************\n") # Hardcoded sections as only t e contain flags we can dynamically set and re-set. # Loops through all directives and sets each class SetOpts: def __init__(self, section): self.section = section if self.section == "STARTUP": file = rc_conf elif self.section == "SYSTEM": file = sysctl_conf elif self.section == "KERNEL": file = loader_conf else: pass for opt in config[self.section]: value = config[self.section][opt] conf_runner = Conf(file, opt, value) setting_present = conf_runner.checkConf() if setting_present: conf_runner.setConf() conf_runner.verifyConf() else: conf_runner.addConf() conf_runner.verifyConf() # Run shell commands for named section. Will error if sent setting.ini sections that have no shell commands. def shellCommand(section): try: for opt in config[section]: value = config[section][opt] command_result = subprocess.run([value], shell=True, timeout=1.7) except subprocess.CalledProcessError as e: syslog.syslog(syslog.LOG_ERR, "Failure: Shell Command") print(f"\n*********************\033[38;5;1m Shell Error \033[0;0m*********************") print(f"Command {e.args[1]} failed") print(f"Terminated by {command_result.returncode}") print(f"{command_result.stderr}") print("*******************************************************\n") except OSError as e: print(f"\n********************\033[38;5;1m Locked \033[0;0m**************************\n") print(f"Perhaps file is busy, locked, process blocked, or raced:\n") print(f"{e}") print(f"*******************************************************\n") else: print(f"\n*********************\033[38;5;76m Success \033[0;0m*************************") print(f"\033[38;5;208mShell Error: {opt}\033[0;0m {command_result.stdout}") print(f"*******************************************************\n") # Set chmod for added convienence of the adminstrator with error handling and logging @exception_handler def setChmod(file, setting): os.fchmod(file, setting) writeLog("script", file + "was set to " + setting ) # Main writeLog("script", date_time) SetOpts("STARTUP") SetOpts("SYSTEM") SetOpts("KERNEL") shellCommand("FILESEC") shellCommand("USERSEC") # Write succesfull completion to console and syslog writeLog("script", "************ SUCCESS ************") writeLog("script", "All files and directives validate") writeLog("script", "*********************************") writeLog("syslog", "SUCCESS: Hardening completed") print(f"\n*********************\033[38;5;76m Success \033[0;0m*************************") print("All files and directives validate") print("Package Security Report generated; pkg-audit-report") print(f"*******************************************************\n") # EOF