#!/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 <ini file>
./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
