#!/bin/python3 # Dict of file paths with their sha256 checksums # make sure to run with `--auto-update` flag if you're running first time on this machine # to automatically update all stored checksums to new values files = { '/etc/pam.d/system-auth': '89d62406b2d623a76d53c33aca98ce8ee124ed4a450ff6c8a44cfccca78baa2f', '/etc/pam.d/su': '7d8962b4a2cd10cf4bc13da8949a4a6151b572d39e87b7125be55f882b16c4da', '/etc/pam.d/sudo': 'd1738818070684a5d2c9b26224906aad69a4fea77aabd960fc2675aee2df1fa2', '/etc/passwd': '28d6bec52ac5b4957a2c30dfcd15008dc1a39665c27abce97408489f3dbf02c9', '/etc/shadow': 'a24f72cba4cbc6b0a8433da2f4b011f31345068e3e5d6bebed6fb6a35769bd59', '/etc/ssh/sshd_config': '515db2484625122b4254472f7e673649e3d89b57577eaa29395017676735907b', '/bin/sudo': '0ffaf9e93a080ca1698837729641c283d24500d6cdd2cb4eb8e42427566a230e', '/bin/su': '3101438405d98e71e9eb68fbc5a33536f1ad0dad5a1c8aacd6da6c95ef082194', '/usr/bin/passwd': 'd4df1659159737bb4c08a430d493d257d75cdd93e18427946265ae5862a714c7', '/usr/bin/chsh': '6bc0ae69620dde18f7942e2573afb4a6200b10269612151f48f54ef8423a64fe', '/usr/bin/chfn': '63178af1347a62f58874640d38d605d3cb1bebe8092533787965ba317e8b553b', '/home/itsdrike/.ssh/authorized_keys': '674806197893dbf67d3c9ba3abf049d30e571de0c4b450fc9819d3e8b0f854cc', } # default state of VERBOSE variable, this is controlled by flag # `--verbose`, and should probably be kept as false by default VERBOSE = False # default state of ENABLE_UPDATE variable, this should be kept to false # to make sure this script can run as cronjob, not just manually by user # this variable will get set to True if the script is ran with `--update` ENABLE_UPDATE = False # default state of AUTO_UPDATE variable, this has no effect if above is # False, if not and this variable is True, user input is skipped entirely # and the checksums are updated automatically, otherwise user confirmation # is needed, controlled with `--no-confirm`, or `--auto-update` AUTO_UPDATE = False # default state of TO_ADD variable, this controls new files, checksums of # which should be added to the `files` dictionary, this is controlled # by flag `--add=/path/to/file` TO_ADD = [] # ----------------------------------------------------------------------------------------------- # - CODE PART, DON'T EDIT UNLESS YOU KNOW WHAT YOU'RE DOING - # ----------------------------------------------------------------------------------------------- import subprocess import sys import os import re import colorama colorama.init(autoreset=True) def _print_help(prepend_newline=False): if prepend_newline: print() print( 'Accepted flags:\n' ' `-u`/`--update`: If invalid checksum is found, ask user if it should be updated (y/n)\n' ' `-a=path`/`--add=path`: Add a new file to the list of check entries\n' ' `--no-confirm`: Used in combination with `--update`, automatically assumes `y` for all questions\n' ' `--auto-update`: Combines `--update` and `--no-confirm`\n' ' `-e`/`--edit`: Edit this file using your $EDITOR (falls back to vi)\n' ' `-v`/`--verbose`: Verbose mode, show checksums on failures and some more info\n' ' `-h`/`--help`: Show this help' ) def _yes_no(text, add_yn=True): if add_yn: text += ' (y/n): ' while True: user_inp = input(text).lower() if user_inp in ('y', 'yes'): return True elif user_inp in ('n', 'no'): return False def _add_file(file_path, checksum): this = os.path.abspath(__file__) pattern = re.compile(r"files = \{\n(\s+)(['\"])(.+\n)+\}") if file_path in files: print( f"{colorama.Fore.RED}Path {colorama.Fore.RESET}" f"'{colorama.Fore.BLUE}{file_path}{colorama.Fore.RESET}' {colorama.Fore.RED}" "is already in the 'files' dictionary perhaps you wanted `--update`?" ) return False with open(this, 'r') as f: contents = f.read() try: match = list(pattern.finditer(contents))[0] except IndexError: raise RuntimeError("Unable to detect files dict with regex, changed dict structure?") new_line = f"{match[1]}{match[2]}{file_path}{match[2]}: {match[2]}{checksum}{match[2]},\n" add_position = match.end() - 1 # before bracket symbol if not match[3].endswith(',\n'): add_position -= 1 # before newline character on non-comma line contents = contents[:add_position] + ',' + contents[add_position:] add_position += 2 new_contents = contents[:add_position] + new_line + contents[add_position:] try: with open(this, 'w') as f: f.write(new_contents) except PermissionError: print( f'{colorama.Fore.RED}PermissionError: {colorama.Fore.RESET}' 'To add a new rule, you must have write access to: ' f"'{colorama.Fore.BLUE}{this}{colorama.Fore.RESET}' (forgot sudo?)" ) exit(2) return True def _update_file(file_path, new_checksum, stored_checksum): this = os.path.abspath(__file__) pattern = re.compile(rf"(\s+)(['\"]){file_path}['\"]:(\s+)['\"]{stored_checksum}['\"],?") with open(this, 'r') as f: contents = f.read() new_contents = re.sub( pattern, rf'\1\2{file_path}\2:\3\g<2>{new_checksum}\2,', contents ) if contents == new_contents: # Line wasn't find, perhaps it's a new file? return False try: with open(this, 'w') as f: f.write(new_contents) except PermissionError as e: print( f'{colorama.Fore.RED}PermissionError: {colorama.Fore.RESET}' 'To update a rule, you must have write access to: ' f"'{colorama.Fore.BLUE}{this}{colorama.Fore.RESET}' (forgot sudo?)" ) exit(2) return True def _get_checksum(file): proc = subprocess.run(['sha256sum', file], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) proc_stdout = proc.stdout.decode('utf-8') if "No such file or directory" in proc_stdout: print( f'{colorama.Fore.RED}File {colorama.Fore.RESET}' f"'{colorama.Fore.BLUE}{file}{colorama.Fore.RESET}'{colorama.Fore.RED} not found, " "can't produce sha256 checksum, check the 'files' dictionary on the top of the " 'program and remove this entry.' ) exit(2) elif "Permission denied" in proc_stdout: raise PermissionError(f"PermissionError: Unable to read file '{file}'") return proc_stdout.replace(f' {file}\n', '') def _command_exists(command): proc = subprocess.run(f'which {command}', stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=True) return proc.returncode == 0 def ask_update(file_path): new_checksum = _get_checksum(file_path) stored_checksum = files[file_path] file_line = f"'{colorama.Fore.BLUE}{file_path}{colorama.Fore.RESET}'" if AUTO_UPDATE or _yes_no(' - ' + file_line + ' update checksum?'): if AUTO_UPDATE: print(' - updating checksum for ' + file_line) # path won't get printed by _yes_no(), so print here result = _update_file(file_path, new_checksum, stored_checksum) print(f'{colorama.Fore.GREEN} -> Updated') return result print(f'{colorama.Fore.RED} -> Staying mismatched') return False def run_editor(): try: editor = os.environ['EDITOR'] except KeyError: for candidate in ('nvim', 'vim', 'vi', 'emacs', 'nano', 'ne', 'tilde'): if _command_exists(candidate): editor = candidate break else: print(f'{colorama.Fore.RED}Unable to find editor software, set {colorama.Fore.YELLOW}$EDITOR') exit(2) this = os.path.abspath(__file__) cmd = f'{editor} {this}' if not os.access(this, os.W_OK): if _command_exists('sudo'): cmd = 'sudo ' + cmd if VERBOSE: print( f'{colorama.Fore.CYAN}' f'Running editor in escalated mode ({colorama.Fore.YELLOW}sudo{colorama.Fore.CYAN})' ) elif _command_exists('doas'): cmd = 'doas ' + cmd if VERBOSE: print( f'{colorama.Fore.CYAN}' 'Running editor in escalated mode ({colorama.Fore.YELLOW}doas{colorama.Fore.CYAN})' ) else: if VERBOSE: print( f'{colorama.Fore.RED}' 'Running editor non-escalated, sudo/doas unaviable ' f'({colorama.Fore.YELLOW}no write perms){colorama.Fore.RED}' ) if VERBOSE: print(f'Starting editor: {colorama.Fore.YELLOW}{cmd}') return subprocess.run(cmd, shell=True) def run_check(): not_matched = [] for file, checksum in files.items(): try: sha256_sum = _get_checksum(file) except PermissionError as e: print( f"Checksum of '{colorama.Fore.BLUE}{file}{colorama.Fore.RESET}': " f'{colorama.Fore.YELLOW}SKIPPED [PermissionError - no read perms]') continue if sha256_sum != checksum: not_matched.append(file) print( f"Checksum of '{colorama.Fore.BLUE}{file}{colorama.Fore.RESET}': " f'{colorama.Fore.RED}FAIL [Checksum Mismatch]' ) if VERBOSE: print(f" -> detected: {colorama.Fore.CYAN}{sha256_sum}") print(f" -> stored: {colorama.Fore.CYAN}{checksum}") else: print(f"Checksum of '{colorama.Fore.BLUE}{file}{colorama.Fore.RESET}': {colorama.Fore.GREEN}OK") return not_matched def analyze_args(): # Using globals isn't usually ideal solution, # but it is the easiest way to handle this global VERBOSE, ENABLE_UPDATE, AUTO_UPDATE, TO_ADD try: args = sys.argv[1:] except IndexError: return for arg in args: if arg in ('-u', '--update'): ENABLE_UPDATE = True elif arg == '--no-confirm': AUTO_UPDATE = True elif arg == '--auto-update': ENABLE_UPDATE = True AUTO_UPDATE = True elif '--add=' in arg or '-a=' in arg: path = arg.replace('--add=', '').replace('-a=', '') if os.path.exists(path): TO_ADD.append(path) else: print( f'{colorama.Fore.RED}FileNotFoundError: {colorama.Fore.RESET}' f"'{colorama.Fore.BLUE}{path}{colorama.Fore.RESET}' -> invalid path" ) exit(2) elif arg in ('-v', '--verbose'): VERBOSE = True elif arg in ('-e', '--edit'): run_editor() exit() elif arg in ('-h', '--help'): _print_help() exit() else: print(f'{colorama.Fore.RED}Unrecognized flag: {colorama.Fore.YELLOW}{arg}') _print_help(prepend_newline=True) exit(2) if __name__ == '__main__': analyze_args() # if file adding was requested, handle it if len(TO_ADD) > 0: for file in TO_ADD: checksum = _get_checksum(file) if _add_file(file, checksum): print( f"Added '{colorama.Fore.YELLOW}{file}{colorama.Fore.RESET}': " f"'{colorama.Fore.CYAN}{checksum}{colorama.Fore.RESET}'" ) exit(0) # when adding files, don't run the check too # run the actual checksum verifier not_matched = run_check() if len(not_matched) > 0 and ENABLE_UPDATE is True: print(f'\nFiles with mismatched checksums:') unfixed = not_matched.copy() for mismatched_file in not_matched: if ask_update(mismatched_file) is True: # User did not choose to update the checksum unfixed.remove(mismatched_file) if len(unfixed) > 0: exit(1) # we still have some unfixed mismatched checksums, exit with 1 elif len(not_matched) > 0: print(f'\nFiles with mismatched checksums:') for mismatched_file in not_matched: print( f'{colorama.Fore.RED} - ' f"{colorama.Fore.RESET}'{colorama.Fore.BLUE}{mismatched_file}{colorama.Fore.RESET}'" ) exit(1) # exit with error code in case we have changed checksums else: print(f'\n{colorama.Fore.GREEN}All checksums are correct')