diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml new file mode 100644 index 0000000..c049557 --- /dev/null +++ b/.pre-commit-hooks.yaml @@ -0,0 +1,8 @@ +- id: sops-encryption + language: python + entry: python3 -m pre_commit_hook_ensure_sops + name: Ensure secrets are encrypted with sops + # Be aggressive - ensure anything with the word secret in the filename + # or file path is encryped. Users of individual repos can exclude things + # with `exclude` if necessary. + files: .*secret.* \ No newline at end of file diff --git a/README.md b/README.md index e83bd6e..fa6db0d 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,25 @@ # pre-commit-hook-ensure-sops -pre-commit hook to ensure that files that should be encrypted with sops are + +A [pre-commit](https://pre-commit.com/) hook to ensure that users don't +accidentally check-in unencrypted files into a repository that uses +[sops](https://github.com/mozilla/sops) to safely store encrypted secrets. + +By default, any file with the word `secret` in its path is required to +be encrypted with `sops`. This means any files under a directory +named `secret` are also required to be encrypted. If you want to exempt +specific files or directories from this requirement in your repository, +use the `exclude` option in your `.pre-commit-config.yaml`. When pushing +secrets to a repo, better safe than sorry :) + +## Installation + +Add this to your `.pre-commit-config.yaml`: + +```yaml + - repo: https://github.com/yuvipanda/pre-commit-hook-ensure-sops + rev: v1.0 + hooks: + - id: sops-encryption + # Uncomment to exclude all markdown files from encryption + # exclude: *.\.md +``` diff --git a/pre_commit_hook_ensure_sops/__init__.py b/pre_commit_hook_ensure_sops/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pre_commit_hook_ensure_sops/__main__.py b/pre_commit_hook_ensure_sops/__main__.py new file mode 100644 index 0000000..ec78b38 --- /dev/null +++ b/pre_commit_hook_ensure_sops/__main__.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +""" +Validate if given list of files are encrypted with sops. +""" +from argparse import ArgumentParser +from ruamel.yaml import YAML +from ruamel.yaml.parser import ParserError +import sys + +yaml = YAML(typ='safe') + + +def validate_enc(item): + """ + Validate given item is encrypted. + + All leaf values in a sops encrypted file must be strings that + start with ENC[. We iterate through lists and dicts, checking + only for leaf strings. Presence of any other data type (like + bool, number, etc) also makes the file invalid. + """ + + if isinstance(item, str): + return item.startswith('ENC[') + elif isinstance(item, list): + return all(validate_enc(i) for i in item) + elif isinstance(item, dict): + return all(validate_enc(i) for i in item.values()) + else: + return False + +def check_file(filename): + """ + Check if a file has been encrypted properly with sops. + + Returns a boolean indicating wether given file is valid or not, as well as + a string with a human readable success / failure message. + """ + # sops doesn't have a --verify (https://github.com/mozilla/sops/issues/437) + # so we implement some heuristics, primarily to guard against unencrypted + # files being checked in. + with open(filename) as f: + try: + # Use the YAML parser to load files. All JSON is valid YAML, so this + # properly deals with JSON files too + doc = yaml.load(f) + except ParserError: + # All sops encrypted files are valid JSON or YAML + return False, f"{filename}: Not valid JSON or YAML, is not properly encrypted" + + if 'sops' not in doc: + # sops puts a `sops` key in the encrypted output. If it is not + # present, very likely the file is not encrypted. + return False, f"{filename}: sops metadata key not found in file, is not properly encrypted" + + invalid_keys = [] + for k in doc: + if k != 'sops': + # Values under the `sops` key are not encrypted. + if not validate_enc(doc[k]): + # Collect all invalid keys so we can provide useful error message + invalid_keys.append(k) + + if invalid_keys: + return False, f"{filename}: Unencrypted values found nested under keys: {','.join(invalid_keys)}" + + return True, f"{filename}: Valid encryption" + +def main(): + argparser = ArgumentParser() + argparser.add_argument('filenames', nargs='+') + + args = argparser.parse_args() + + failed_messages = [] + + for f in args.filenames: + is_valid, message = check_file(f) + + if not is_valid: + failed_messages.append(message) + + if failed_messages: + print('\n'.join(failed_messages)) + return 1 + + return 0 + +if __name__ == '__main__': + sys.exit(main()) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..7a2ec88 --- /dev/null +++ b/setup.py @@ -0,0 +1,19 @@ +import setuptools + +with open("README.md", "r") as fh: + long_description = fh.read() + +setuptools.setup( + name="pre-commit-hook-ensure-sops", + version="0.1", + author="Yuvi Panda", + author_email="yuvipanda@gmail.com", + description="pre-commit hook to ensure that files that should be encrypted with sops are in fact encrypted", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/yuvipanda/pre-commit-hook-ensure-sops", + packages=setuptools.find_packages(), + install_requires=[ + "ruamel.yaml", + ], +)