├── tests ├── __init__.py └── test_main.py ├── pre_commit_hook_ensure_sops ├── __init__.py └── __main__.py ├── .pre-commit-hooks.yaml ├── .github └── workflows │ └── test.yaml ├── setup.py ├── README.md ├── LICENSE └── .gitignore /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pre_commit_hook_ensure_sops/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: sops-encryption 2 | language: python 3 | entry: python3 -m pre_commit_hook_ensure_sops 4 | name: Ensure secrets are encrypted with sops 5 | # Be aggressive - ensure anything with the word secret in the filename 6 | # or file path is encryped. Users of individual repos can exclude things 7 | # with `exclude` if necessary. 8 | files: .*secret.* -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | 4 | jobs: 5 | test: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v3 9 | - uses: actions/setup-python@v4 10 | with: 11 | python-version: 3.x 12 | - name: Install dependencies 13 | run: | 14 | python -m pip install --upgrade pip 15 | python -m pip install . pytest 16 | - name: Run tests 17 | run: pytest 18 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setuptools.setup( 7 | name="pre-commit-hook-ensure-sops", 8 | version="1.1", 9 | author="Yuvi Panda", 10 | author_email="yuvipanda@gmail.com", 11 | description="pre-commit hook to ensure that files that should be encrypted with sops are in fact encrypted", 12 | long_description=long_description, 13 | long_description_content_type="text/markdown", 14 | url="https://github.com/yuvipanda/pre-commit-hook-ensure-sops", 15 | packages=setuptools.find_packages(), 16 | entry_points={ 17 | "console_scripts": [ 18 | "pre-commit-hook-ensure-sops = pre_commit_hook_ensure_sops.__main__:main", 19 | ] 20 | }, 21 | install_requires=[ 22 | "ruamel.yaml", 23 | ], 24 | test_requires=[ 25 | "pytest", 26 | ], 27 | ) 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pre-commit-hook-ensure-sops 2 | 3 | A [pre-commit](https://pre-commit.com/) hook to ensure that users don't 4 | accidentally check-in unencrypted files into a repository that uses 5 | [sops](https://github.com/mozilla/sops) to safely store encrypted secrets. 6 | 7 | By default, any file with the word `secret` in its path is required to 8 | be encrypted with `sops`. This means any files under a directory 9 | named `secret` are also required to be encrypted. If you want to exempt 10 | specific files or directories from this requirement in your repository, 11 | use the `exclude` option in your `.pre-commit-config.yaml`. When pushing 12 | secrets to a repo, better safe than sorry :) 13 | 14 | ## Installation 15 | 16 | Add this to your `.pre-commit-config.yaml`: 17 | 18 | ```yaml 19 | - repo: https://github.com/yuvipanda/pre-commit-hook-ensure-sops 20 | rev: v1.0 21 | hooks: 22 | - id: sops-encryption 23 | # Uncomment to exclude all markdown files from encryption 24 | # exclude: *.\.md 25 | ``` 26 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for main functions. 3 | """ 4 | from contextlib import nullcontext 5 | from pathlib import Path 6 | 7 | import pytest 8 | from ruamel.yaml.parser import ParserError 9 | 10 | from pre_commit_hook_ensure_sops.__main__ import check_file 11 | 12 | VALID_SECRET = """ 13 | foo: ENC[AES256_GCM,data:9LiS,iv:B/Add+R3lTSx66Qrq8/+jFD2mok8GdD7R32uAf04+Ho=,tag:iyCZ1thFop63/2L+skFcdg==,type:str] 14 | sops: 15 | """ 16 | 17 | INVALID_SECRET = """ 18 | foo: this_is_not_encrypted_text 19 | sops: 20 | """ 21 | 22 | NO_SOPS_METADATA = """ 23 | foo: ENC[AES256_GCM,data:9LiS,iv:B/Add+R3lTSx66Qrq8/+jFD2mok8GdD7R32uAf04+Ho=,tag:iyCZ1thFop63/2L+skFcdg==,type:str] 24 | """ 25 | 26 | INVALID_YAML = """ 27 | this_is_not_valid_yaml 28 | """ 29 | 30 | 31 | @pytest.mark.parametrize( 32 | "text,is_valid", 33 | [ 34 | pytest.param(INVALID_SECRET, False, id="invalid_secret"), 35 | pytest.param(VALID_SECRET, True, id="valid_secret"), 36 | pytest.param(NO_SOPS_METADATA, False, id="no_sops_metadata"), 37 | pytest.param(INVALID_YAML, False, id="invalid_yaml"), 38 | ], 39 | ) 40 | def test_check_file_validity(text, is_valid, tmp_path: Path): 41 | """ 42 | Test check_file. 43 | 44 | Checks with known valid and invalid yaml texts and checks 45 | if an error is raised with invalid one. 46 | """ 47 | filepath = tmp_path / "test.yaml" 48 | filepath.write_text(text) 49 | filename = filepath.as_posix() 50 | result = check_file(filename=filename) 51 | assert result[0] == is_valid 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2022, Yuvi Panda 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /pre_commit_hook_ensure_sops/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Validate if given list of files are encrypted with sops. 4 | """ 5 | from argparse import ArgumentParser 6 | import json 7 | from ruamel.yaml import YAML 8 | from ruamel.yaml.parser import ParserError 9 | import sys 10 | 11 | yaml = YAML(typ='safe') 12 | 13 | 14 | def validate_enc(item): 15 | """ 16 | Validate given item is encrypted. 17 | 18 | All leaf values in a sops encrypted file must be strings that 19 | start with ENC[. We iterate through lists and dicts, checking 20 | only for leaf strings. Presence of any other data type (like 21 | bool, number, etc) also makes the file invalid except an empty 22 | string which would pass the encryption check. 23 | """ 24 | 25 | if isinstance(item, str): 26 | if item == "" or item.startswith('ENC['): 27 | return True 28 | elif isinstance(item, list): 29 | return all(validate_enc(i) for i in item) 30 | elif isinstance(item, dict): 31 | return all(validate_enc(i) for i in item.values()) 32 | else: 33 | return False 34 | 35 | def check_file(filename): 36 | """ 37 | Check if a file has been encrypted properly with sops. 38 | 39 | Returns a boolean indicating wether given file is valid or not, as well as 40 | a string with a human readable success / failure message. 41 | """ 42 | # All YAML is valid JSON *except* if it contains hard tabs, and the default go 43 | # JSON outputter uses hard tabs, and since sops is written in go it does the same. 44 | # So we can't just use a YAML loader here - we use a yaml one if it ends in 45 | # .yaml, but json otherwise 46 | if filename.endswith('.yaml'): 47 | loader_func = yaml.load 48 | else: 49 | loader_func = json.load 50 | # sops doesn't have a --verify (https://github.com/mozilla/sops/issues/437) 51 | # so we implement some heuristics, primarily to guard against unencrypted 52 | # files being checked in. 53 | with open(filename) as f: 54 | try: 55 | doc = loader_func(f) 56 | except ParserError: 57 | # All sops encrypted files are valid JSON or YAML 58 | return False, f"{filename}: Not valid JSON or YAML, is not properly encrypted" 59 | 60 | if 'sops' not in doc: 61 | # sops puts a `sops` key in the encrypted output. If it is not 62 | # present, very likely the file is not encrypted. 63 | return False, f"{filename}: sops metadata key not found in file, is not properly encrypted" 64 | 65 | invalid_keys = [] 66 | for k in doc: 67 | if k != 'sops': 68 | # Values under the `sops` key are not encrypted. 69 | if not validate_enc(doc[k]): 70 | # Collect all invalid keys so we can provide useful error message 71 | invalid_keys.append(k) 72 | 73 | if invalid_keys: 74 | return False, f"{filename}: Unencrypted values found nested under keys: {','.join(invalid_keys)}" 75 | 76 | return True, f"{filename}: Valid encryption" 77 | 78 | def main(): 79 | argparser = ArgumentParser() 80 | argparser.add_argument('filenames', nargs='+') 81 | 82 | args = argparser.parse_args() 83 | 84 | failed_messages = [] 85 | 86 | for f in args.filenames: 87 | is_valid, message = check_file(f) 88 | 89 | if not is_valid: 90 | failed_messages.append(message) 91 | 92 | if failed_messages: 93 | print('\n'.join(failed_messages)) 94 | return 1 95 | 96 | return 0 97 | 98 | if __name__ == '__main__': 99 | sys.exit(main()) 100 | --------------------------------------------------------------------------------