├── .coveragerc ├── .github └── workflows │ ├── ci.yml │ └── docs.yml ├── .gitignore ├── .gitmodules ├── .readthedocs.yml ├── .yamllint ├── AUTHORS.rst ├── CHANGELOG.rst ├── CONTRIBUTING.md ├── LICENSE.txt ├── README.md ├── docs ├── Makefile ├── _static │ └── .gitignore ├── authors.rst ├── changelog.rst ├── community.rst ├── conf.py ├── contributing.rst ├── index.rst ├── license.rst ├── requirements.txt └── rundown.rst ├── pyproject.toml ├── setup.cfg ├── setup.py ├── src └── ansible_sign │ ├── __init__.py │ ├── checksum │ ├── __init__.py │ ├── base.py │ └── differ │ │ ├── __init__.py │ │ ├── base.py │ │ └── distlib_manifest.py │ ├── cli.py │ └── signing │ ├── __init__.py │ ├── base.py │ └── gpg │ ├── __init__.py │ ├── signer.py │ └── verifier.py ├── tests ├── conftest.py ├── fixtures │ ├── checksum │ │ ├── invalid-checksum-1 │ │ │ ├── .ansible-sign │ │ │ │ └── sha256sum.txt │ │ │ ├── MANIFEST.in │ │ │ ├── dir │ │ │ │ └── hello2 │ │ │ └── hello1 │ │ ├── invalid-checksum-2 │ │ │ ├── .ansible-sign │ │ │ │ └── sha256sum.txt │ │ │ ├── MANIFEST.in │ │ │ ├── dir │ │ │ │ └── hello2 │ │ │ └── hello1 │ │ ├── manifest-files-added-not-in-manifest │ │ │ ├── .ansible-sign │ │ │ │ └── sha256sum.txt │ │ │ ├── MANIFEST.in │ │ │ ├── dir │ │ │ │ └── hello2 │ │ │ ├── evil.yaml │ │ │ ├── good.yml │ │ │ └── hello1 │ │ ├── manifest-files-added-removed │ │ │ ├── .ansible-sign │ │ │ │ └── sha256sum.txt │ │ │ ├── MANIFEST.in │ │ │ ├── dir │ │ │ │ └── hello2 │ │ │ ├── hello2 │ │ │ └── hello3 │ │ ├── manifest-files-added │ │ │ ├── .ansible-sign │ │ │ │ └── sha256sum.txt │ │ │ ├── MANIFEST.in │ │ │ ├── dir │ │ │ │ └── hello2 │ │ │ ├── hello1 │ │ │ ├── hello2 │ │ │ └── hello3 │ │ ├── manifest-files-changed │ │ │ ├── .ansible-sign │ │ │ │ └── sha256sum.txt │ │ │ ├── MANIFEST.in │ │ │ ├── dir │ │ │ │ └── hello2 │ │ │ └── hello1 │ │ ├── manifest-files-removed │ │ │ ├── .ansible-sign │ │ │ │ └── sha256sum.txt │ │ │ ├── MANIFEST.in │ │ │ └── dir │ │ │ │ └── hello2 │ │ ├── manifest-no-ansible-sign-dir │ │ │ ├── MANIFEST.in │ │ │ ├── dir │ │ │ │ └── hello2 │ │ │ └── hello1 │ │ ├── manifest-success │ │ │ ├── .ansible-sign │ │ │ │ └── sha256sum.txt │ │ │ ├── MANIFEST.in │ │ │ ├── dir │ │ │ │ └── hello2 │ │ │ └── hello1 │ │ ├── manifest-syntax-error │ │ │ └── MANIFEST.in │ │ ├── manifest-with-blank-lines-and-comments │ │ │ ├── .ansible-sign │ │ │ │ └── sha256sum.txt │ │ │ ├── MANIFEST.in │ │ │ ├── dir │ │ │ │ └── hello2 │ │ │ └── hello1 │ │ └── missing-manifest │ │ │ ├── .ansible-sign │ │ │ └── sha256sum.txt │ │ │ ├── dir │ │ │ └── hello2 │ │ │ └── hello1 │ ├── gpg │ │ ├── hao-signed-invalid │ │ │ ├── .ansible-sign │ │ │ │ ├── sha256sum.txt │ │ │ │ └── sha256sum.txt.sig │ │ │ ├── README.md │ │ │ └── hello_world.yml │ │ ├── hao-signed-missing-manifest │ │ │ ├── .ansible-sign │ │ │ │ ├── sha256sum.txt │ │ │ │ └── sha256sum.txt.sig │ │ │ ├── README.md │ │ │ └── hello_world.yml │ │ └── hao-signed │ │ │ ├── .ansible-sign │ │ │ ├── sha256sum.txt │ │ │ └── sha256sum.txt.sig │ │ │ ├── MANIFEST.in │ │ │ ├── README.md │ │ │ └── hello_world.yml │ └── gpgkeys │ │ └── hao_pubkey.txt ├── requirements.txt ├── test_checksum.py ├── test_cli.py ├── test_cli_pinentry.py └── test_gpg.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | # .coveragerc to control coverage.py 2 | [run] 3 | branch = True 4 | source = ansible_signatory 5 | # omit = bad_file.py 6 | 7 | [paths] 8 | source = 9 | src/ 10 | */site-packages/ 11 | 12 | [report] 13 | # Regexes for lines to exclude from consideration 14 | exclude_lines = 15 | # Have to re-enable the standard pragma 16 | pragma: no cover 17 | 18 | # Don't complain about missing debug-only code: 19 | def __repr__ 20 | if self\.debug 21 | 22 | # Don't complain if tests don't hit defensive assertion code: 23 | raise AssertionError 24 | raise NotImplementedError 25 | 26 | # Don't complain if non-runnable code isn't run: 27 | if 0: 28 | if __name__ == .__main__.: 29 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: ansible-sign tests 3 | on: # yamllint disable-line rule:truthy 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | python-tests: 9 | name: Python ${{ matrix.python }} on ${{ matrix.os }} 10 | runs-on: ${{ matrix.os }} 11 | timeout-minutes: 15 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | include: 16 | - python: "3.8" 17 | os: ubuntu-latest 18 | - python: "3.9" 19 | os: ubuntu-latest 20 | - python: "3.10" 21 | os: ubuntu-latest 22 | - python: "3.11" 23 | os: ubuntu-latest 24 | - python: "3.12" 25 | os: ubuntu-latest 26 | - python: "3.10" 27 | os: macos-latest 28 | steps: 29 | - uses: actions/checkout@v2 30 | with: 31 | submodules: recursive 32 | 33 | - name: Set up Python ${{ matrix.python }} 34 | uses: actions/setup-python@v2 35 | with: 36 | python-version: ${{ matrix.python }} 37 | 38 | - name: Install system packages (linux) 39 | if: matrix.os == 'ubuntu-latest' 40 | run: | 41 | sudo apt-get update && \ 42 | sudo apt-get -y install \ 43 | tmux \ 44 | ; 45 | 46 | - name: Install system packages (macos) 47 | if: matrix.os == 'macos-latest' 48 | run: | 49 | brew install \ 50 | tmux \ 51 | gpg \ 52 | ; 53 | which -a gpg 54 | gpg --version 55 | 56 | - name: Install tox 57 | run: pip install tox 58 | 59 | - name: Run tests 60 | run: tox -e py3 61 | 62 | - name: Ensure docs build 63 | run: tox -e docs 64 | linters: 65 | name: Linters 66 | runs-on: ubuntu-latest 67 | steps: 68 | - uses: actions/checkout@v2 69 | 70 | - name: Set up Python 3.8 71 | uses: actions/setup-python@v2 72 | with: 73 | python-version: "3.8" 74 | 75 | - name: Install tox 76 | run: pip install tox 77 | 78 | - name: Run linters 79 | run: tox -e lint 80 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: ansible-sign docs 3 | on: # yamllint disable-line rule:truthy 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | docs: 10 | name: Python ${{ matrix.python }} 11 | runs-on: ubuntu-latest 12 | timeout-minutes: 15 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | include: 17 | - python: "3.10" 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | with: 22 | submodules: recursive 23 | 24 | - name: Set up Python ${{ matrix.python }} 25 | uses: actions/setup-python@v2 26 | with: 27 | python-version: ${{ matrix.python }} 28 | 29 | - name: Install tox 30 | run: pip install tox 31 | 32 | - name: Show code-coverage docs 33 | run: | 34 | sed -i 's/term-missing/html/' setup.cfg 35 | tox 36 | mkdir -p docs/_build/html/coverage 37 | rm -v htmlcov/.gitignore 38 | mv -v htmlcov docs/_build/html/coverage 39 | 40 | - name: "Deploy 'em 🚀" 41 | uses: JamesIves/github-pages-deploy-action@v4 42 | with: 43 | folder: docs/_build/html/coverage/htmlcov 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Temporary and binary files 2 | *~ 3 | *.py[cod] 4 | *.so 5 | *.cfg 6 | !.isort.cfg 7 | !setup.cfg 8 | *.orig 9 | *.log 10 | *.pot 11 | __pycache__/* 12 | .cache/* 13 | .*.swp 14 | */.ipynb_checkpoints/* 15 | .DS_Store 16 | 17 | # Project files 18 | .ropeproject 19 | .project 20 | .pydevproject 21 | .settings 22 | .idea 23 | .vscode 24 | tags 25 | 26 | # Package files 27 | *.egg 28 | *.eggs/ 29 | .installed.cfg 30 | *.egg-info 31 | 32 | # Unittest and coverage 33 | htmlcov/* 34 | .coverage 35 | .coverage.* 36 | .tox 37 | junit*.xml 38 | coverage.xml 39 | .pytest_cache/ 40 | 41 | # Build and docs folder/files 42 | build/* 43 | dist/* 44 | sdist/* 45 | docs/api/* 46 | docs/_rst/* 47 | docs/_build/* 48 | cover/* 49 | MANIFEST 50 | 51 | # Per-project virtualenvs 52 | .venv*/ 53 | .conda*/ 54 | .python-version 55 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ansible/ansible-sign/6ac1f09778eb5d37d23f5fee046e4654bcfa4537/.gitmodules -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Build documentation in the docs/ directory with Sphinx 9 | sphinx: 10 | configuration: docs/conf.py 11 | 12 | # Build documentation with MkDocs 13 | # mkdocs: 14 | # configuration: mkdocs.yml 15 | 16 | # Optionally build your docs in additional formats such as PDF 17 | formats: 18 | - pdf 19 | 20 | python: 21 | version: 3.8 22 | install: 23 | - requirements: docs/requirements.txt 24 | - {path: ., method: pip} 25 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | --- 2 | # vim: syn=yaml 3 | extends: default 4 | rules: 5 | empty-lines: 6 | ignore: | 7 | tests/fixtures/gpg/hao-*/hello_world.yml 8 | document-start: 9 | ignore: | 10 | tests/fixtures/gpg/hao-*/hello_world.yml 11 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributors 3 | ============ 4 | 5 | * Rick Elrod 6 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Changelog 3 | ========= 4 | 5 | Version 1.0.0 6 | ============= 7 | 8 | - Initial library and CLI for ``ansible-sign``. See documentation for usage 9 | examples. Only the CLI is officially supported, and the API can change over 10 | time. We make no effort to provide backwards compatibility at the API level 11 | at this time. 12 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Hello! Want to contribute to `ansible-sign`? Good news - you're in the right place. 4 | 5 | ## Things to know prior to submitting code 6 | 7 | - All code and doc submissions are done through pull requests against the `main` branch. 8 | - Take care to make sure no merge commits are in the submission, and use `git rebase` vs `git merge` for this reason. 9 | - We ask all of our community members and contributors to adhere to the [Ansible code of conduct]. If you have questions, or need assistance, please reach out to our community team at [codeofconduct@ansible.com]. 10 | 11 | ## Setting up your development environment 12 | 13 | In this example we are using [virtualenvwrapper](https://virtualenvwrapper.readthedocs.io/en/latest/), but any virtual environment will do. 14 | 15 | ```bash 16 | $ pip install virtualenvwrapper # Follow installation instructions at https://virtualenvwrapper.readthedocs.io/en/latest/ 17 | $ mkvirtualenv ansible-sign 18 | $ pip install -e . 19 | ``` 20 | 21 | When done making changes, run: 22 | 23 | ``` 24 | $ deactivate 25 | ``` 26 | 27 | To reactivate the virtual environment: 28 | 29 | ``` 30 | $ workon ansible-sign 31 | ``` 32 | 33 | ## Linting and Unit Tests 34 | 35 | `tox` is used to run linters (`black`, `flake8` and `yamllint`) and tests. 36 | 37 | ``` 38 | $ pip install tox 39 | $ tox 40 | ``` 41 | 42 | [Ansible code of conduct]: http://docs.ansible.com/ansible/latest/community/code_of_conduct.html 43 | [codeofconduct@ansible.com]: mailto:codeofconduct@ansible.com 44 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Red Hat, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `ansible-sign` 2 | 3 | This is a library and auxiliary CLI tool for dealing with Ansible content 4 | verification. 5 | 6 | It does the following: 7 | 8 | - checksum manifest generation and validation (sha256sum) 9 | - GPG detached signature generation and validation (using python-gnupg) for 10 | content 11 | 12 | Note: The API (library) part of this package is not officially supported and 13 | might change as time goes on. CLI commands should be considered stable within 14 | major versions (the `X` of version `X.Y.Z`). 15 | 16 | Documentation can be found on [ansible-sign.readthedocs.io](https://ansible.readthedocs.io/projects/sign/en/latest/) 17 | including a 18 | [rundown/tutorial](https://ansible.readthedocs.io/projects/sign/en/latest/rundown.html) 19 | explaining how to use the CLI for basic project signing and verification. 20 | 21 | ## Community 22 | 23 | Need help or want to discuss the project? See our [Community guide](https://ansible.readthedocs.io/projects/sign/en/latest/community.html) join the conversation. 24 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | AUTODOCDIR = api 11 | 12 | # User-friendly check for sphinx-build 13 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $?), 1) 14 | $(error "The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from https://sphinx-doc.org/") 15 | endif 16 | 17 | .PHONY: help clean Makefile 18 | 19 | # Put it first so that "make" without argument is like "make help". 20 | help: 21 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 22 | 23 | clean: 24 | rm -rf $(BUILDDIR)/* $(AUTODOCDIR) 25 | 26 | # Catch-all target: route all unknown targets to Sphinx using the new 27 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 28 | %: Makefile 29 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 30 | -------------------------------------------------------------------------------- /docs/_static/.gitignore: -------------------------------------------------------------------------------- 1 | # Empty directory 2 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. _authors: 2 | .. include:: ../AUTHORS.rst 3 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. _changes: 2 | .. include:: ../CHANGELOG.rst 3 | -------------------------------------------------------------------------------- /docs/community.rst: -------------------------------------------------------------------------------- 1 | .. _community: 2 | 3 | ========= 4 | Community 5 | ========= 6 | 7 | We welcome your feedback, questions and ideas. Here's how to reach the community. 8 | 9 | Code of Conduct 10 | =============== 11 | 12 | All communication and interactions in the Ansible community are governed by the `Ansible code of conduct `_. Please read and abide by it! 13 | Reach out to our community team at `codeofconduct@ansible.com `_ if you have any questions or need assistance. 14 | 15 | Ansible Forum 16 | ============= 17 | 18 | Join the `Ansible Forum `_, the default communication platform for questions and help, development discussions, events, and much more. `Register `_ to join the community. Search by categories and tags to find interesting topics or start a new one; subscribe only to topics you need! 19 | 20 | * `Get Help `_: get help or help others. Please add appropriate tags if you start new discussions, for example `ansible-sign`. 21 | * `Posts tagged with 'ansible-sign' `_: subscribe to participate in project-related conversations. 22 | * `Bullhorn newsletter `_: used to announce releases and important changes. 23 | * `Social Spaces `_: gather and interact with fellow enthusiasts. 24 | * `News & Announcements `_: track project-wide announcements including social events. 25 | 26 | See `Navigating the Ansible forum `_ for some practical advice on finding your way around. 27 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # This file is execfile()d with the current directory set to its containing dir. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | # 7 | # All configuration values have a default; values that are commented out 8 | # serve to show the default. 9 | 10 | import os 11 | import sys 12 | import shutil 13 | 14 | # -- Path setup -------------------------------------------------------------- 15 | 16 | __location__ = os.path.dirname(__file__) 17 | 18 | # If extensions (or modules to document with autodoc) are in another directory, 19 | # add these directories to sys.path here. If the directory is relative to the 20 | # documentation root, use os.path.abspath to make it absolute, like shown here. 21 | sys.path.insert(0, os.path.join(__location__, "../src")) 22 | 23 | # -- Run sphinx-apidoc ------------------------------------------------------- 24 | # This hack is necessary since RTD does not issue `sphinx-apidoc` before running 25 | # `sphinx-build -b html . _build/html`. See Issue: 26 | # https://github.com/readthedocs/readthedocs.org/issues/1139 27 | # DON'T FORGET: Check the box "Install your project inside a virtualenv using 28 | # setup.py install" in the RTD Advanced Settings. 29 | # Additionally it helps us to avoid running apidoc manually 30 | 31 | try: # for Sphinx >= 1.7 32 | from sphinx.ext import apidoc 33 | except ImportError: 34 | from sphinx import apidoc 35 | 36 | output_dir = os.path.join(__location__, "api") 37 | module_dir = os.path.join(__location__, "../src/ansible_sign") 38 | try: 39 | shutil.rmtree(output_dir) 40 | except FileNotFoundError: 41 | pass 42 | 43 | try: 44 | import sphinx 45 | 46 | cmd_line = f"sphinx-apidoc --implicit-namespaces -f -o {output_dir} {module_dir}" 47 | 48 | args = cmd_line.split(" ") 49 | if tuple(sphinx.__version__.split(".")) >= ("1", "7"): 50 | # This is a rudimentary parse_version to avoid external dependencies 51 | args = args[1:] 52 | 53 | apidoc.main(args) 54 | except Exception as e: 55 | print("Running `sphinx-apidoc` failed!\n{}".format(e)) 56 | 57 | # -- General configuration --------------------------------------------------- 58 | 59 | # If your documentation needs a minimal Sphinx version, state it here. 60 | # needs_sphinx = '1.0' 61 | 62 | # Add any Sphinx extension module names here, as strings. They can be extensions 63 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 64 | extensions = [ 65 | "sphinx.ext.autodoc", 66 | "sphinx.ext.intersphinx", 67 | "sphinx.ext.todo", 68 | "sphinx.ext.autosummary", 69 | "sphinx.ext.viewcode", 70 | "sphinx.ext.coverage", 71 | "sphinx.ext.doctest", 72 | "sphinx.ext.ifconfig", 73 | "sphinx.ext.mathjax", 74 | "sphinx.ext.napoleon", 75 | "sphinx_ansible_theme", 76 | ] 77 | 78 | # Add any paths that contain templates here, relative to this directory. 79 | templates_path = ["_templates"] 80 | 81 | # The suffix of source filenames. 82 | source_suffix = ".rst" 83 | 84 | # The encoding of source files. 85 | # source_encoding = 'utf-8-sig' 86 | 87 | # The master toctree document. 88 | master_doc = "index" 89 | 90 | # General information about the project. 91 | project = "ansible-sign" 92 | copyright = "2022, Red Hat, Inc." 93 | 94 | # The version info for the project you're documenting, acts as replacement for 95 | # |version| and |release|, also used in various other places throughout the 96 | # built documents. 97 | # 98 | # version: The short X.Y version. 99 | # release: The full version, including alpha/beta/rc tags. 100 | # If you don’t need the separation provided between version and release, 101 | # just set them both to the same value. 102 | try: 103 | from ansible_sign import __version__ as version 104 | except ImportError: 105 | version = "" 106 | 107 | if not version or version.lower() == "unknown": 108 | version = os.getenv("READTHEDOCS_VERSION", "unknown") # automatically set by RTD 109 | 110 | release = version 111 | 112 | # The language for content autogenerated by Sphinx. Refer to documentation 113 | # for a list of supported languages. 114 | # language = None 115 | 116 | # There are two options for replacing |today|: either, you set today to some 117 | # non-false value, then it is used: 118 | # today = '' 119 | # Else, today_fmt is used as the format for a strftime call. 120 | # today_fmt = '%B %d, %Y' 121 | 122 | # List of patterns, relative to source directory, that match files and 123 | # directories to ignore when looking for source files. 124 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", ".venv"] 125 | 126 | # The reST default role (used for this markup: `text`) to use for all documents. 127 | # default_role = None 128 | 129 | # If true, '()' will be appended to :func: etc. cross-reference text. 130 | # add_function_parentheses = True 131 | 132 | # If true, the current module name will be prepended to all description 133 | # unit titles (such as .. function::). 134 | # add_module_names = True 135 | 136 | # If true, sectionauthor and moduleauthor directives will be shown in the 137 | # output. They are ignored by default. 138 | # show_authors = False 139 | 140 | # The name of the Pygments (syntax highlighting) style to use. 141 | pygments_style = "sphinx" 142 | 143 | # A list of ignored prefixes for module index sorting. 144 | # modindex_common_prefix = [] 145 | 146 | # If true, keep warnings as "system message" paragraphs in the built documents. 147 | # keep_warnings = False 148 | 149 | # If this is True, todo emits a warning for each TODO entries. The default is False. 150 | todo_emit_warnings = True 151 | 152 | 153 | # -- Options for HTML output ------------------------------------------------- 154 | 155 | # The theme to use for HTML and HTML Help pages. See the documentation for 156 | # a list of builtin themes. 157 | html_theme = "sphinx_ansible_theme" 158 | html_title = "Ansible Sign Documentation" 159 | 160 | # Theme options are theme-specific and customize the look and feel of a theme 161 | # further. For a list of options available for each theme, see the 162 | # documentation. 163 | 164 | html_theme_options = { 165 | "display_version": False, 166 | "titles_only": False, 167 | "documentation_home_url": "https://ansible.readthedocs.io/projects/sign/en/latest/", 168 | } 169 | 170 | # Add any paths that contain custom themes here, relative to this directory. 171 | # html_theme_path = [] 172 | 173 | # The name for this set of Sphinx documents. If None, it defaults to 174 | # " v documentation". 175 | # html_title = None 176 | 177 | # A shorter title for the navigation bar. Default is the same as html_title. 178 | # html_short_title = None 179 | 180 | # The name of an image file (relative to this directory) to place at the top 181 | # of the sidebar. 182 | # html_logo = "" 183 | 184 | # The name of an image file (within the static path) to use as favicon of the 185 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 186 | # pixels large. 187 | # html_favicon = None 188 | 189 | # Add any paths that contain custom static files (such as style sheets) here, 190 | # relative to this directory. They are copied after the builtin static files, 191 | # so a file named "default.css" will overwrite the builtin "default.css". 192 | html_static_path = ["_static"] 193 | 194 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 195 | # using the given strftime format. 196 | # html_last_updated_fmt = '%b %d, %Y' 197 | 198 | # If true, SmartyPants will be used to convert quotes and dashes to 199 | # typographically correct entities. 200 | # html_use_smartypants = True 201 | 202 | # Custom sidebar templates, maps document names to template names. 203 | # html_sidebars = {} 204 | 205 | # Additional templates that should be rendered to pages, maps page names to 206 | # template names. 207 | # html_additional_pages = {} 208 | 209 | # If false, no module index is generated. 210 | # html_domain_indices = True 211 | 212 | # If false, no index is generated. 213 | # html_use_index = True 214 | 215 | # If true, the index is split into individual pages for each letter. 216 | # html_split_index = False 217 | 218 | # If true, links to the reST sources are added to the pages. 219 | # html_show_sourcelink = True 220 | 221 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 222 | # html_show_sphinx = True 223 | 224 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 225 | # html_show_copyright = True 226 | 227 | # If true, an OpenSearch description file will be output, and all pages will 228 | # contain a tag referring to it. The value of this option must be the 229 | # base URL from which the finished HTML is served. 230 | # html_use_opensearch = '' 231 | 232 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 233 | # html_file_suffix = None 234 | 235 | # Output file base name for HTML help builder. 236 | htmlhelp_basename = "ansible-sign-doc" 237 | 238 | 239 | # -- Options for LaTeX output ------------------------------------------------ 240 | 241 | latex_elements = { 242 | # The paper size ("letterpaper" or "a4paper"). 243 | # "papersize": "letterpaper", 244 | # The font size ("10pt", "11pt" or "12pt"). 245 | # "pointsize": "10pt", 246 | # Additional stuff for the LaTeX preamble. 247 | # "preamble": "", 248 | } 249 | 250 | # Grouping the document tree into LaTeX files. List of tuples 251 | # (source start file, target name, title, author, documentclass [howto/manual]). 252 | latex_documents = [ 253 | ( 254 | "index", 255 | "user_guide.tex", 256 | "ansible-sign Documentation", 257 | "Red Hat, Inc.", 258 | "manual", 259 | ) 260 | ] 261 | 262 | # The name of an image file (relative to this directory) to place at the top of 263 | # the title page. 264 | # latex_logo = "" 265 | 266 | # For "manual" documents, if this is true, then toplevel headings are parts, 267 | # not chapters. 268 | # latex_use_parts = False 269 | 270 | # If true, show page references after internal links. 271 | # latex_show_pagerefs = False 272 | 273 | # If true, show URL addresses after external links. 274 | # latex_show_urls = False 275 | 276 | # Documents to append as an appendix to all manuals. 277 | # latex_appendices = [] 278 | 279 | # If false, no module index is generated. 280 | # latex_domain_indices = True 281 | 282 | # -- External mapping -------------------------------------------------------- 283 | python_version = ".".join(map(str, sys.version_info[0:2])) 284 | intersphinx_mapping = { 285 | "sphinx": ("https://www.sphinx-doc.org/en/master", None), 286 | "python": ("https://docs.python.org/" + python_version, None), 287 | "matplotlib": ("https://matplotlib.org", None), 288 | "numpy": ("https://numpy.org/doc/stable", None), 289 | "sklearn": ("https://scikit-learn.org/stable", None), 290 | "pandas": ("https://pandas.pydata.org/pandas-docs/stable", None), 291 | "scipy": ("https://docs.scipy.org/doc/scipy/reference", None), 292 | "setuptools": ("https://setuptools.pypa.io/en/stable/", None), 293 | "pyscaffold": ("https://pyscaffold.org/en/stable", None), 294 | } 295 | 296 | print(f"loading configurations for {project} {version} ...", file=sys.stderr) 297 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributing 3 | ============ 4 | 5 | .. note:: 6 | 7 | Need help or want to discuss the project? See the :ref:`Community guide` to join the conversation! 8 | 9 | See out `Contributing guide `_ to learn how to contribute to ``ansible-sign``! 10 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ================= 2 | ansible-sign 3 | ================= 4 | 5 | This is the documentation for the **ansible-sign** utility used for signing and 6 | verifying Ansible content. 7 | 8 | .. note:: 9 | 10 | Need help or want to discuss the project? See the :ref:`Community guide` to join the conversation! 11 | 12 | Contents 13 | ======== 14 | 15 | .. toctree:: 16 | :maxdepth: 2 17 | 18 | Overview 19 | Community 20 | Contributions & Help 21 | License 22 | Authors 23 | Changelog 24 | Rundown (CLI Tutorial) 25 | 26 | 27 | Indices and tables 28 | ================== 29 | 30 | * :ref:`genindex` 31 | * :ref:`modindex` 32 | * :ref:`search` 33 | -------------------------------------------------------------------------------- /docs/license.rst: -------------------------------------------------------------------------------- 1 | .. _license: 2 | 3 | ======= 4 | License 5 | ======= 6 | 7 | .. include:: ../LICENSE.txt 8 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | # Requirements file for ReadTheDocs, check .readthedocs.yml. 2 | # To build the module reference correctly, make sure every external package 3 | # under `install_requires` in `setup.cfg` is also listed here! 4 | sphinx>=3.2.1 5 | sphinx-ansible-theme == 0.10.1 6 | -------------------------------------------------------------------------------- /docs/rundown.rst: -------------------------------------------------------------------------------- 1 | ======================================= 2 | Rundown of ``ansible-sign`` (CLI) usage 3 | ======================================= 4 | 5 | For Ansible Automation Platform content developers (project maintainers), the 6 | primary and supported way of using **ansible-sign** is through the command-line 7 | interface that comes with it. 8 | 9 | The command-line interface aims to make it easy to use cryptographic technology 10 | like GPG to validate that specified files within a project have not been 11 | tampered with in any way. 12 | 13 | Though in the future other means of signing and validating might be supported, 14 | GPG is the only currently supported means of signing and validation. As such, the 15 | rest of this tutorial assumes the use of GPG. 16 | 17 | The process of creating a GPG public/private keypair for signing content is well 18 | documented online, such as in this `Red Hat "Enable Sysadmin" blog post`_. As 19 | such, we will assume that you have a valid GPG keypair already available and in 20 | your default GnuPG keyring. 21 | 22 | You can verify that you have a keypair with the following command: 23 | 24 | .. code-block:: shell 25 | :caption: Verifying that a valid secret GPG key exists for signing content 26 | 27 | $ gpg --list-secret-keys 28 | 29 | If the above command produces no output, or one line of output that says that a 30 | "trustdb" was created, then you do not have a secret key in your default 31 | keyring. In this case, refer to the aforementioned blog post to learn how to create a new keypair. 32 | 33 | If it produces output other than that, then you have a valid secret key 34 | and are ready to move on to 35 | :ref:`using ansible-sign`. 36 | 37 | Adding a GPG key to AWX or Ansible Automation Controller 38 | ======================================================== 39 | 40 | In the command line, run the following commands: 41 | 42 | .. code-block:: shell 43 | 44 | $ gpg --list-keys 45 | $ gpg --export --armour > my_public_key.asc 46 | 47 | #. In AWX/Automation Controller, click “Credentials" then the "Add" button 48 | #. Give the new credential a meaningful name (for example, "infrastructure team public GPG key") 49 | #. For "Credential Type" select "GPG Public Key" 50 | #. Click "Browse" to navigate to and select the file that you created earlier (``my_public_key.asc``) 51 | #. Finally, click the "Save" button to finish 52 | 53 | This credential can now be selected in "Project" settings. Once selected, content verification will automatically take place on future project syncs. 54 | 55 | Vist `the GnuPG documentation`_ for more information regarding GPG keys. 56 | For more information regarding generating a GPG keypair, visit the `Red Hat "Enable Sysadmin" blog post`_. 57 | 58 | .. _the GnuPG documentation: https://www.gnupg.org/documentation/index.html 59 | .. _Red Hat "Enable Sysadmin" blog post: https://www.redhat.com/sysadmin/creating-gpg-keypairs 60 | 61 | .. _ansible-sign-install: 62 | 63 | How to Access the ``ansible-sign`` CLI Utility 64 | ============================================== 65 | 66 | Run the following command to install ``ansible-sign``: 67 | 68 | .. code-block:: shell 69 | :caption: Installing ``ansible-sign`` 70 | 71 | $ pip install ansible-sign 72 | 73 | .. note:: 74 | 75 | An **alternative** approach to install ``ansible-sign`` is using the ``ansible-dev-tools`` package. 76 | `Ansible Development Tools (ADT) `_ is a single Python package that includes all necessary tools to 77 | set up a development environment, generate new collections, build and test the content consistently, resulting in robust automation. 78 | 79 | .. code-block:: shell 80 | 81 | # This also installs ansible-core if it is not already installed 82 | $ pip3 install ansible-dev-tools 83 | 84 | Once it’s installed, run: 85 | 86 | .. code-block:: shell 87 | :caption: Verify that ``ansible-sign`` was successfully installed. 88 | 89 | $ ansible-sign --version 90 | 91 | You should see output similar to the following (possibly with a different version number): 92 | 93 | .. code-block:: shell 94 | :caption: The output of ``ansible-sign --version`` 95 | 96 | ansible-sign 0.1 97 | 98 | Congratulations! You have successfully installed ``ansible-sign``! 99 | 100 | 101 | The Project Directory 102 | ===================== 103 | 104 | We will start with a simple Ansible project directory. The `Ansible 105 | documentation`_ goes into more sophisticated examples of project directory 106 | structures. 107 | 108 | In our sample project, we have a very simple structure. An ``inventory`` file, 109 | and two small playbooks under a ``playbooks`` directory. 110 | 111 | .. code-block:: shell 112 | :caption: Our sample project 113 | 114 | $ cd sample-project/ 115 | $ tree -a . 116 | . 117 | ├── inventory 118 | └── playbooks 119 | ├── get_uptime.yml 120 | └── hello.yml 121 | 122 | 1 directory, 3 files 123 | 124 | .. note:: 125 | 126 | Future commands that we run will assume that your Working Directory is the 127 | root of your project. ``ansible-sign project`` commands, as a rule, always 128 | take the project root directory as their last argument, thus we will simply 129 | use ``.`` to indicate the current Working Directory. 130 | 131 | Signing Content 132 | =============== 133 | 134 | The way that ``ansible-sign`` protects content from tampering is by taking 135 | checksums (sha256) of all of the secured files in the project, compiling those 136 | into a checksum manifest file, and then finally signing that manifest file. 137 | 138 | Thus, the first step toward signing content is to create a file that tells 139 | ``ansible-sign`` which files to protect. This file should be called 140 | ``MANIFEST.in`` and live in the project root directory. 141 | 142 | Internally, ``ansible-sign`` makes use of the ``distlib.manifest`` module of 143 | Python's distlib_ library, and thus ``MANIFEST.in`` must follow the syntax that 144 | this library specifies. The Python Packaging User Guide has an `explanation of 145 | the MANIFEST.in file directives`_. 146 | 147 | For our sample project, we will include two directives. Our ``MANIFEST.in`` will 148 | look like this: 149 | 150 | .. code-block:: 151 | :caption: ``MANIFEST.in`` 152 | 153 | include inventory 154 | recursive-include playbooks *.yml 155 | 156 | With this file in place, we can generate our checksum manifest file and sign 157 | it. These steps both happen in a single ``ansible-sign`` command. 158 | 159 | .. code-block:: 160 | :caption: Generating a checksum manifest file and signing it 161 | 162 | $ ansible-sign project gpg-sign . 163 | [OK ] GPG signing successful! 164 | [NOTE ] Checksum manifest: ./.ansible-sign/sha256sum.txt 165 | [NOTE ] GPG summary: signature created 166 | 167 | 168 | Congratulations, you've now signed your first project! 169 | 170 | Notice that the ``gpg-sign`` subcommand lives under the ``project`` 171 | subcommand. For signing project content, every command will start with 172 | ``ansible-sign project``. As noted above, as a rule, every ``ansible-sign 173 | project`` command takes the project root directory as its final argument. 174 | 175 | .. hint:: 176 | 177 | As mentioned earlier, ``ansible-sign`` by default makes use of your default 178 | keyring and looks for the first available secret key that it can find, to sign 179 | your project. You can specify a specific secret key to use with the 180 | ``--fingerprint`` option, or even a completely independent GPG home directory 181 | with the ``--gnupg-home`` option. 182 | 183 | .. note:: 184 | 185 | If you are using a desktop environment, GnuPG will automatically pop up a 186 | dialog asking for your secret key's passphrase. If this functionality does 187 | not work, or you are working without a desktop environment (e.g., via SSH), 188 | you can use the ``-p``/``--prompt-passphrase`` flag after ``gpg-sign`` in the 189 | above command, which will cause ``ansible-sign`` to prompt for the password 190 | instead. 191 | 192 | If we now look at the structure of the project directory, we'll notice that a 193 | new ``.ansible-sign`` directory has been created. This directory houses the 194 | checksum manifest and a detached GPG signature for it. 195 | 196 | .. code-block:: shell 197 | :caption: Our sample project after signing 198 | 199 | $ tree -a . 200 | . 201 | ├── .ansible-sign 202 | │   ├── sha256sum.txt 203 | │   └── sha256sum.txt.sig 204 | ├── inventory 205 | ├── MANIFEST.in 206 | └── playbooks 207 | ├── get_uptime.yml 208 | └── hello.yml 209 | 210 | .. _Ansible documentation: https://docs.ansible.com/ansible/latest/user_guide/sample_setup.html 211 | .. _distlib: https://pypi.org/project/distlib/ 212 | .. _explanation of the MANIFEST.in file directives: https://packaging.python.org/en/latest/guides/using-manifest-in/#manifest-in-commands 213 | 214 | 215 | Verifying Content 216 | ================= 217 | 218 | If you come in contact with a signed Ansible project and want to verify that it 219 | has not been altered, you can use ``ansible-sign`` to check both that the 220 | signature is valid and that the checksums of the files match what the checksum 221 | manifest says they should be. In particular, the ``ansible-sign project 222 | gpg-verify`` command can be used to automatically verify both of these 223 | conditions. 224 | 225 | .. code-block:: shell 226 | :caption: Verifying our sample project 227 | 228 | $ ansible-sign project gpg-verify . 229 | [OK ] GPG signature verification succeeded. 230 | [OK ] Checksum validation succeeded. 231 | 232 | 233 | .. hint:: 234 | 235 | Once again, by default ``ansible-sign`` makes use of your default GPG 236 | keyring to look for a matching public key. You can specify a keyring file 237 | with the ``--keyring`` option, or a different GPG home with the 238 | ``--gnugpg-home`` option. 239 | 240 | If verification fails for any reason, some information will be printed to help 241 | you debug the cause. More verbosity can be enabled by passing the global 242 | ``--debug`` flag, immediately after ``ansible-sign`` in your commands. 243 | 244 | Notes About Automation 245 | ====================== 246 | 247 | In environments with highly-trusted CI environments, it is possible to automate 248 | the signing process. For example, one might store their GPG private key in a 249 | GitHub Actions secret, and import that into GnuPG in the CI environment. One 250 | could then run through the signing workflow above within the normal CI 251 | workflow/container/environment. 252 | 253 | When signing a project using GPG, the environment variable 254 | ``ANSIBLE_SIGN_GPG_PASSPHRASE`` can be set to the passphrase of the signing 255 | key. This can be injected (and masked/secured) in a CI pipeline. 256 | 257 | ``ansible-sign`` will return with a different exit-code depending on the 258 | scenario at hand, both during signing and verification. This can also be useful 259 | in the context of CI and automation, as a CI environment can act differently 260 | based on the failure (for example, sending alerts for some errors but silently 261 | failing for others). 262 | 263 | These codes are used fairly consistently within the code, and can be considered 264 | stable: 265 | 266 | .. list-table:: Status codes that ``ansible-sign`` can exit with 267 | :widths: 15 35 50 268 | :header-rows: 1 269 | 270 | * - Exit code 271 | - Approximate meaning 272 | - Example scenarios 273 | * - 0 274 | - Success 275 | - * Signing was successful 276 | * Verification was successful 277 | * - 1 278 | - General failure 279 | - * The checksum manifest file contained a syntax error during verification 280 | * The signature file did not exist during verification 281 | * ``MANIFEST.in`` did not exist during signing 282 | * - 2 283 | - Checksum verification failure 284 | - * The checksum hashes calculated during verification differed from what 285 | was in the signed checksum manifest. (That is, a project file was 286 | changed but the signing process was not recompleted.) 287 | * - 3 288 | - Signature verification failure 289 | - * The signer's public key was not in the user's GPG keyring 290 | * The wrong GnuPG home directory or keyring file was specified 291 | * The signed checksum manifest file was modified in some way 292 | * - 4 293 | - Signing process failure 294 | - * The signer's private key was not found in the GPG keyring 295 | * The wrong GnuPG home directory or keyring file was specified 296 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | # AVOID CHANGING REQUIRES: IT WILL BE UPDATED BY PYSCAFFOLD! 3 | requires = ["setuptools>=46.1.0", "setuptools_scm[toml]>=5"] 4 | build-backend = "setuptools.build_meta" 5 | 6 | [tool.setuptools_scm] 7 | # For smarter version schemes and other configuration options, 8 | # check out https://github.com/pypa/setuptools_scm 9 | version_scheme = "no-guess-dev" 10 | 11 | [tool.black] 12 | line-length = 160 13 | fast = true 14 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | # This file is used to configure your project. 2 | # Read more about the various options under: 3 | # https://setuptools.pypa.io/en/latest/userguide/declarative_config.html 4 | # https://setuptools.pypa.io/en/latest/references/keywords.html 5 | 6 | [metadata] 7 | name = ansible-sign 8 | description = Ansible content validation library and CLI 9 | author = Rick Elrod 10 | author_email = relrod@redhat.com 11 | license = MIT 12 | license_files = LICENSE.txt 13 | long_description = file: README.md 14 | long_description_content_type = text/markdown; charset=UTF-8 15 | url = https://github.com/ansible/ansible-sign/ 16 | # Add here related links, for example: 17 | project_urls = 18 | Documentation = https://ansible-sign.readthedocs.io/en/latest/ 19 | Source = https://github.com/ansible/ansible-sign 20 | Tracker = https://github.com/ansible/ansible-sign/issues 21 | 22 | # Change if running only on Windows, Mac or Linux (comma-separated) 23 | platforms = any 24 | 25 | # Add here all kinds of additional classifiers as defined under 26 | # https://pypi.org/classifiers/ 27 | classifiers = 28 | Development Status :: 4 - Beta 29 | Programming Language :: Python 30 | 31 | 32 | [options] 33 | zip_safe = False 34 | packages = find_namespace: 35 | include_package_data = True 36 | package_dir = 37 | =src 38 | 39 | # Require a min/specific Python version (comma-separated conditions) 40 | # python_requires = >=3.8 41 | 42 | # Add here dependencies of your project (line-separated), e.g. requests>=2.2,<3.0. 43 | # Version specifiers like >=2.2,<3.0 avoid problems due to API changes in 44 | # new major versions. This works if the required packages follow Semantic Versioning. 45 | # For more information, check out https://semver.org/. 46 | install_requires = 47 | importlib-metadata; python_version<"3.8" 48 | python-gnupg 49 | distlib 50 | 51 | 52 | [options.packages.find] 53 | where = src 54 | exclude = 55 | tests 56 | 57 | [options.extras_require] 58 | # Add here additional requirements for extra features, to install with: 59 | # `pip install ansible-sign[PDF]` like: 60 | # PDF = ReportLab; RXP 61 | 62 | # Add here test requirements (semicolon/line-separated) 63 | testing = 64 | setuptools 65 | pytest 66 | pytest-cov 67 | 68 | [options.entry_points] 69 | console_scripts = 70 | ansible-sign = ansible_sign.cli:run 71 | 72 | [tool:pytest] 73 | # Specify command line options as you would do when invoking pytest directly. 74 | # e.g. --cov-report html (or xml) for html/xml output or --junitxml junit.xml 75 | # in order to write a coverage file that can be read by Jenkins. 76 | # CAUTION: --cov flags may prohibit setting breakpoints while debugging. 77 | # Comment those flags to avoid this pytest issue. 78 | addopts = 79 | --cov ansible_sign --cov-report term-missing 80 | --verbose 81 | norecursedirs = 82 | dist 83 | build 84 | .tox 85 | testpaths = tests 86 | # Use pytest markers to select/deselect specific tests 87 | # markers = 88 | # slow: mark tests as slow (deselect with '-m "not slow"') 89 | # system: mark end-to-end system tests 90 | 91 | [devpi:upload] 92 | # Options for the devpi: PyPI server and packaging tool 93 | # VCS export must be deactivated since we are using setuptools-scm 94 | no_vcs = 1 95 | formats = bdist_wheel 96 | 97 | [flake8] 98 | # Some sane defaults for the code style checker flake8 99 | max_line_length = 160 100 | extend_ignore = E203, W503 101 | # ^ Black-compatible 102 | # E203 and W503 have edge cases handled by black 103 | exclude = 104 | .tox 105 | build 106 | dist 107 | .eggs 108 | docs/conf.py 109 | 110 | [pyscaffold] 111 | # PyScaffold's parameters when the project was created. 112 | # This will be used when updating. Do not change! 113 | version = 4.3 114 | package = ansible_sign 115 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Setup file for ansible-sign. 3 | Use setup.cfg to configure your project. 4 | 5 | This file was generated with PyScaffold 4.3. 6 | PyScaffold helps you to put up the scaffold of your new Python project. 7 | Learn more under: https://pyscaffold.org/ 8 | """ 9 | 10 | from setuptools import setup 11 | 12 | if __name__ == "__main__": 13 | try: 14 | setup(use_scm_version={"version_scheme": "no-guess-dev"}) 15 | except: # noqa 16 | print( 17 | "\n\nAn error occurred while building the project, " 18 | "please ensure you have the most updated version of setuptools, " 19 | "setuptools_scm and wheel with:\n" 20 | " pip install -U setuptools setuptools_scm wheel\n\n" 21 | ) 22 | raise 23 | -------------------------------------------------------------------------------- /src/ansible_sign/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | if sys.version_info[:2] >= (3, 8): 4 | # TODO: Import directly (no need for conditional) when `python_requires = >= 3.8` 5 | from importlib.metadata import PackageNotFoundError, version # pragma: no cover 6 | else: 7 | from importlib_metadata import PackageNotFoundError, version # pragma: no cover 8 | 9 | try: 10 | # Change here if project is renamed and does not equal the package name 11 | dist_name = "ansible-sign" 12 | __version__ = version(dist_name) 13 | except PackageNotFoundError: # pragma: no cover 14 | __version__ = "unknown" 15 | finally: 16 | del version, PackageNotFoundError 17 | -------------------------------------------------------------------------------- /src/ansible_sign/checksum/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This package handles checksum validation for Ansible content. 3 | """ 4 | 5 | from .base import ChecksumFile, InvalidChecksumLine, ChecksumMismatch # noqa 6 | from .differ import DistlibManifestChecksumFileExistenceDiffer # noqa 7 | -------------------------------------------------------------------------------- /src/ansible_sign/checksum/base.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import os 3 | 4 | 5 | class InvalidChecksumLine(Exception): 6 | pass 7 | 8 | 9 | class NoDifferException(Exception): 10 | pass 11 | 12 | 13 | class ChecksumMismatch(Exception): 14 | pass 15 | 16 | 17 | class ChecksumFile: 18 | """ 19 | Slurp a checksum file and be able to check and compare its contents to a 20 | given root directory. Also: be able to write out a checksum file. 21 | 22 | We only allow sha256 for now, though supporting 512, etc. would be easy. 23 | """ 24 | 25 | def __init__(self, root, differ=None): 26 | self.root = root 27 | if differ is not None: 28 | self.differ = differ(root=self.root) 29 | else: 30 | from .differ.distlib_manifest import ( 31 | DistlibManifestChecksumFileExistenceDiffer, 32 | ) 33 | 34 | self.differ = DistlibManifestChecksumFileExistenceDiffer(root=self.root) 35 | 36 | @property 37 | def differ_warnings(self): 38 | """ 39 | A differ can store a set of warnings (as strings) in the_differ.warnings 40 | which we can propagate up here. This allows calling code to display any 41 | warnings found during diffing time. 42 | """ 43 | 44 | return self.differ.warnings 45 | 46 | @property 47 | def warnings(self): 48 | """ 49 | Right now this is just the same as differ_warnings. In the future it 50 | might include warnings that we differ in methods in this class as well. 51 | """ 52 | 53 | return self.differ_warnings 54 | 55 | def _parse_gnu_style(self, line): 56 | """ 57 | Attempt to parse a GNU style line checksum line, returning False if 58 | we are unable to. 59 | 60 | A GNU style line looks like this: 61 | f712979c4c5dfe739253908d122f5c87faa8b5de6f15ba7a1548ae028ff22d13 hello_world.yml 62 | 63 | Or maybe like this: 64 | f82da8b4f98a3d3125fbc98408911f65dbc8dc38c0f38e258ebe290a8ad3d3c0 *binary 65 | """ 66 | 67 | parts = line.split(" ", 1) 68 | if len(parts) != 2 or len(parts[0]) != 64: 69 | return False 70 | 71 | if len(parts[1]) < 2 or parts[1][0] not in (" ", "*"): 72 | return False 73 | 74 | shasum = parts[0] 75 | path = parts[1][1:] 76 | return (path, shasum) 77 | 78 | def parse(self, checksum_file_contents): 79 | """ 80 | Given a complete checksum manifest as a string, parse it and return a 81 | dict with the result, keyed on each filename or path. 82 | """ 83 | checksums = {} 84 | for idx, line in enumerate(checksum_file_contents.splitlines()): 85 | if not line.strip(): 86 | continue 87 | # parsed = self._parse_bsd_style(line) 88 | # if parsed is False: 89 | parsed = self._parse_gnu_style(line) 90 | if parsed is False: 91 | raise InvalidChecksumLine(f"Unparsable checksum, line {idx + 1}: {line}") 92 | path = parsed[0] 93 | shasum = parsed[1] 94 | if path in checksums: 95 | raise InvalidChecksumLine(f"Duplicate path in checksum, line {idx + 1}: {line}") 96 | checksums[path] = shasum 97 | return checksums 98 | 99 | def diff(self, paths): 100 | """ 101 | Given a collection of paths, use the differ to figure out which files 102 | (in reality) have been added/removed from the project root (or latest 103 | SCM tree). 104 | """ 105 | 106 | paths = set(paths) 107 | return self.differ.compare_filelist(paths) 108 | 109 | def generate_gnu_style(self): 110 | """ 111 | Using the root directory and 'differ' class given to the constructor, 112 | generate a GNU-style checksum manifest file. This is always generated 113 | from scratch by finding the list of relevant files in the root directory 114 | (by asking the differ), and calculating the checksum for each of them. 115 | 116 | The resulting list is always sorted by filename. 117 | """ 118 | lines = [] 119 | calculated = self.calculate_checksums_from_root(verifying=False) 120 | for path, checksum in sorted(calculated.items()): 121 | # *two* spaces here - it's important for compat with coreutils. 122 | lines.append(f"{checksum} {path}") 123 | return "\n".join(lines) + "\n" 124 | 125 | def calculate_checksum(self, path): 126 | shasum = hashlib.sha256() 127 | with open(path, "rb") as f: 128 | while True: 129 | chunk = f.read(65536) 130 | if not chunk: 131 | break 132 | shasum.update(chunk) 133 | return shasum.hexdigest() 134 | 135 | def calculate_checksums_from_root(self, verifying): 136 | """ 137 | Using the root of the project and the differ class passed to the 138 | constructor, iterate over all files in the project and calculate their 139 | checksums. Return a dictionary of the result, keyed on the filename. 140 | 141 | Just calling this is not enough in many cases- you want to ensure that 142 | the files in the checksum list are the same ones present in reality. 143 | diff() above does just that. Use that in combination with this method, 144 | or use verify() which does it for you. 145 | """ 146 | out = {} 147 | for path in self.differ.list_files(verifying=verifying): 148 | shasum = self.calculate_checksum(os.path.join(self.root, path)) 149 | out[path] = shasum 150 | return out 151 | 152 | def verify(self, parsed_manifest_dct, diff=True): 153 | """ 154 | Takes a parsed manifest file (e.g. using parse(), with paths as keys and 155 | checksums as values). 156 | 157 | Then calculates the current list of files in the project root. If paths 158 | have been added or removed, ChecksumMismatch is raised. 159 | 160 | Otherwise, each the checksum of file in the project root (and subdirs) 161 | is calculated and that result is checked to be equal to the parsed 162 | checksums passed in. 163 | """ 164 | 165 | if diff: 166 | # If there are any differences in existing paths, fail the check... 167 | differences = self.diff(parsed_manifest_dct.keys()) 168 | if differences["added"] or differences["removed"]: 169 | raise ChecksumMismatch(differences) 170 | 171 | recalculated = self.calculate_checksums_from_root(verifying=True) 172 | mismatches = set() 173 | for parsed_path, parsed_checksum in parsed_manifest_dct.items(): 174 | if recalculated[parsed_path] != parsed_checksum: 175 | mismatches.add(parsed_path) 176 | if mismatches: 177 | raise ChecksumMismatch(f"Checksum mismatch: {', '.join(mismatches)}") 178 | 179 | return True 180 | -------------------------------------------------------------------------------- /src/ansible_sign/checksum/differ/__init__.py: -------------------------------------------------------------------------------- 1 | from .distlib_manifest import DistlibManifestChecksumFileExistenceDiffer # noqa 2 | -------------------------------------------------------------------------------- /src/ansible_sign/checksum/differ/base.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import PurePath 3 | 4 | 5 | class ChecksumFileExistenceDiffer: 6 | """ 7 | When checking checksum files, it can be important to ensure not only that 8 | the files listed have correct hashes, but that no files were added that 9 | are not listed. 10 | 11 | This is particularly important in situations where files might get 12 | "wildcard-included" -- whereby an extra file slipping in could present a 13 | security risk. 14 | 15 | This class, and subclasses of it, provide an implementation that 16 | ChecksumFileValidator instances can use to list all "interesting" files that 17 | should be listed in the checksum file. 18 | """ 19 | 20 | # These are tuples of path elements, compared to the path's parts (as 21 | # presented by pathlib). 22 | ignored_paths = set( 23 | [ 24 | ".ansible-sign", 25 | ".ansible-sign/**", 26 | ] 27 | ) 28 | 29 | # Files that get added to the manifest in list_files() even if not 30 | # explicitly found by gather_files() 31 | always_added_files = set() 32 | 33 | # When gathering files, any warnings we encounter can be propagated up. 34 | # This is a place to store them. 35 | warnings = set() 36 | 37 | def __init__(self, root): 38 | self.root = root 39 | 40 | def gather_files(self, verifying=False): 41 | return set() 42 | 43 | def list_files(self, verifying): 44 | """ 45 | Return a (sorted, normalized) list of files. 46 | 47 | Individual differs can implement logic based on whether we are 48 | using this to generate a manifest or to verify one, and 'verifying' 49 | is what is used to toggle this logic. 50 | """ 51 | gathered = self.gather_files(verifying=verifying) 52 | files = set(os.path.normpath(f) for f in gathered) 53 | 54 | for path in files.copy(): 55 | for ignored_path in self.ignored_paths: 56 | if PurePath(path).match(ignored_path): 57 | files.remove(path) 58 | 59 | for path in self.always_added_files: 60 | if not os.path.exists(os.path.join(self.root, path)): 61 | raise FileNotFoundError(path) 62 | files.add(path) 63 | 64 | return sorted(files) 65 | 66 | def compare_filelist(self, checksum_paths): 67 | """ 68 | Given a set of paths (from a checksum file), see if files have since 69 | been added or removed from the root directory and any deeper 70 | directories. 71 | 72 | The given set of paths is used as the source of truth and additions 73 | and deletions are list with respect to it. 74 | """ 75 | 76 | real_paths = set(self.list_files(verifying=True)) 77 | out = {} 78 | out["added"] = sorted(real_paths - checksum_paths) 79 | out["removed"] = sorted(checksum_paths - real_paths) 80 | return out 81 | -------------------------------------------------------------------------------- /src/ansible_sign/checksum/differ/distlib_manifest.py: -------------------------------------------------------------------------------- 1 | from distlib.manifest import Manifest 2 | import errno 3 | import os 4 | 5 | from .base import ChecksumFileExistenceDiffer 6 | 7 | 8 | class DistlibManifestChecksumFileExistenceDiffer(ChecksumFileExistenceDiffer): 9 | """ 10 | Read in a MANIFEST.in file and process it. Use the results for comparing 11 | what is listed in the checksum file with what is "reality". 12 | """ 13 | 14 | always_added_files = set(["MANIFEST.in"]) 15 | 16 | def gather_files(self, verifying=False): 17 | files_set = set() 18 | 19 | manifest_path = os.path.join(self.root, "MANIFEST.in") 20 | 21 | if not os.path.exists(manifest_path): 22 | # open() would do this, but let us be explicit, the file must exist. 23 | raise FileNotFoundError(manifest_path, os.strerror(errno.ENOENT), manifest_path) 24 | 25 | with open(manifest_path, "r") as f: 26 | manifest_in = f.read() 27 | 28 | manifest = Manifest(self.root) 29 | lines = manifest_in.splitlines() 30 | 31 | if verifying: 32 | lines = ["global-include *"] + lines 33 | 34 | for line in lines: 35 | line = line.strip() 36 | 37 | # distlib.manifest bombs on empty lines. 38 | # It also doesn't appear to allow comments, so let's hack those in. 39 | if not line or line[0] == "#": 40 | continue 41 | 42 | manifest.process_directive(line) 43 | 44 | for path in manifest.files: 45 | files_set.add(os.path.relpath(path, start=self.root)) 46 | 47 | return files_set 48 | -------------------------------------------------------------------------------- /src/ansible_sign/cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from distlib.manifest import DistlibException 3 | import getpass 4 | import logging 5 | import os 6 | import sys 7 | 8 | from ansible_sign import __version__ 9 | from ansible_sign.checksum import ( 10 | ChecksumFile, 11 | ChecksumMismatch, 12 | InvalidChecksumLine, 13 | ) 14 | from ansible_sign.checksum.differ import DistlibManifestChecksumFileExistenceDiffer 15 | from ansible_sign.signing import GPGSigner, GPGVerifier 16 | 17 | __author__ = "Rick Elrod" 18 | __copyright__ = "(c) 2022 Red Hat, Inc." 19 | __license__ = "MIT" 20 | 21 | # This is relative to the project root passed in by the user at runtime. 22 | ANSIBLE_SIGN_DIR = ".ansible-sign" 23 | 24 | 25 | class AnsibleSignCLI: 26 | def __init__(self, args): 27 | self.logger = logging.getLogger(__name__) 28 | self.logger.debug("Parsing args: %s", str(args)) 29 | self.args = self.parse_args(args) 30 | logformat = "[%(asctime)s] %(levelname)s:%(name)s:%(message)s" 31 | logging.basicConfig(level=self.args.loglevel, stream=sys.stdout, format=logformat, datefmt="%Y-%m-%d %H:%M:%S") 32 | 33 | def run_command(self): 34 | """ 35 | parse_args() will set self.args.func() to the function we wish to 36 | execute, based on the subcommand the user ran. These 'action functions' 37 | will return the integer exit code with which we exit at the very end. 38 | 39 | Roughly: 40 | 0 = success 41 | 1 = error (e.g. file missing, permissions issue, couldn't parse checksum file, etc.) 42 | 2 = checksum verification failed 43 | 3 = signature verification failed 44 | 4 = signing failed 45 | """ 46 | return self.args.func() 47 | 48 | def parse_args(self, args): 49 | """ 50 | Parse command line parameters 51 | 52 | Args: 53 | args (List[str]): command line parameters as list of strings 54 | (for example ``["--help"]``). 55 | 56 | Returns: 57 | :obj:`argparse.Namespace`: command line parameters namespace 58 | """ 59 | 60 | parser = argparse.ArgumentParser(description="Signing and validation for Ansible content") 61 | parser.add_argument( 62 | "--version", 63 | action="version", 64 | version="ansible-sign {ver}".format(ver=__version__), 65 | ) 66 | parser.add_argument( 67 | "--debug", 68 | help="Print a bunch of debug info", 69 | action="store_const", 70 | dest="loglevel", 71 | const=logging.DEBUG, 72 | ) 73 | parser.add_argument( 74 | "--nocolor", 75 | help="Disable color output", 76 | required=False, 77 | dest="nocolor", 78 | default=True if len(os.environ.get("NO_COLOR", "")) else False, 79 | action="store_true", 80 | ) 81 | 82 | # Future-proofing for future content types. 83 | content_type_parser = parser.add_subparsers(required=True, dest="content_type", metavar="CONTENT_TYPE") 84 | 85 | project = content_type_parser.add_parser( 86 | "project", 87 | help="Act on an Ansible project directory", 88 | ) 89 | project_commands = project.add_subparsers(required=True, dest="command") 90 | 91 | # command: gpg-verify 92 | cmd_gpg_verify = project_commands.add_parser( 93 | "gpg-verify", 94 | help=("Perform signature validation AND checksum verification on the checksum manifest"), 95 | ) 96 | cmd_gpg_verify.set_defaults(func=self.gpg_verify) 97 | cmd_gpg_verify.add_argument( 98 | "--keyring", 99 | help=("The GPG keyring file to use to find the matching public key. (default: the user's default keyring)"), 100 | required=False, 101 | metavar="KEYRING", 102 | dest="keyring", 103 | default=None, 104 | ) 105 | cmd_gpg_verify.add_argument( 106 | "--gnupg-home", 107 | help=("A valid GnuPG home directory. (default: the GnuPG default, usually ~/.gnupg)"), 108 | required=False, 109 | metavar="GNUPG_HOME", 110 | dest="gnupg_home", 111 | default=None, 112 | ) 113 | cmd_gpg_verify.add_argument( 114 | "project_root", 115 | help="The directory containing the files being validated and verified", 116 | metavar="PROJECT_ROOT", 117 | ) 118 | 119 | # command: gpg-sign 120 | cmd_gpg_sign = project_commands.add_parser( 121 | "gpg-sign", 122 | help="Generate a checksum manifest and GPG sign it", 123 | ) 124 | cmd_gpg_sign.set_defaults(func=self.gpg_sign) 125 | cmd_gpg_sign.add_argument( 126 | "--fingerprint", 127 | help=("The GPG private key fingerprint to sign with. (default: First usable key in the user's keyring)"), 128 | required=False, 129 | metavar="PRIVATE_KEY", 130 | dest="fingerprint", 131 | default=None, 132 | ) 133 | cmd_gpg_sign.add_argument( 134 | "-p", 135 | "--prompt-passphrase", 136 | help="Prompt for a GPG key passphrase", 137 | required=False, 138 | dest="prompt_passphrase", 139 | default=False, 140 | action="store_true", 141 | ) 142 | cmd_gpg_sign.add_argument( 143 | "--gnupg-home", 144 | help=("A valid GnuPG home directory. (default: the GnuPG default, usually ~/.gnupg)"), 145 | required=False, 146 | metavar="GNUPG_HOME", 147 | dest="gnupg_home", 148 | default=None, 149 | ) 150 | cmd_gpg_sign.add_argument( 151 | "project_root", 152 | help="The directory containing the files being validated and verified", 153 | metavar="PROJECT_ROOT", 154 | ) 155 | return parser.parse_args(args) 156 | 157 | def _generate_checksum_manifest(self): 158 | differ = DistlibManifestChecksumFileExistenceDiffer 159 | checksum = ChecksumFile(self.args.project_root, differ=differ) 160 | try: 161 | manifest = checksum.generate_gnu_style() 162 | except FileNotFoundError as e: 163 | if os.path.islink(e.filename): 164 | self._error(f"Broken symlink found at {e.filename} -- this is not supported. Aborting.") 165 | return False 166 | 167 | if e.filename.endswith("/MANIFEST.in"): 168 | self._error("Could not find a MANIFEST.in file in the specified project.") 169 | self._note("If you are attempting to sign a project, please create this file.") 170 | self._note("See the ansible-sign documentation for more information.") 171 | return False 172 | raise e 173 | except DistlibException as e: 174 | self._error(f"An error was encountered while parsing MANIFEST.in: {e}") 175 | if self.args.loglevel != logging.DEBUG: 176 | self._note("You can use the --debug global flag to view the full traceback.") 177 | self.logger.debug(e, exc_info=e) 178 | return False 179 | for warning in checksum.warnings: 180 | self._warn(warning) 181 | self.logger.debug( 182 | "Full calculated checksum manifest (%s):\n%s", 183 | self.args.project_root, 184 | manifest, 185 | ) 186 | return manifest 187 | 188 | def _error(self, msg): 189 | if self.args.nocolor: 190 | print(f"[ERROR] {msg}") 191 | else: 192 | print(f"[\033[91mERROR\033[0m] {msg}") 193 | 194 | def _ok(self, msg): 195 | if self.args.nocolor: 196 | print(f"[OK ] {msg}") 197 | else: 198 | print(f"[\033[92mOK \033[0m] {msg}") 199 | 200 | def _note(self, msg): 201 | if self.args.nocolor: 202 | print(f"[NOTE ] {msg}") 203 | else: 204 | print(f"[\033[94mNOTE \033[0m] {msg}") 205 | 206 | def _warn(self, msg): 207 | if self.args.nocolor: 208 | print(f"[WARN ] {msg}") 209 | else: 210 | print(f"[\033[93mWARN \033[0m] {msg}") 211 | 212 | def validate_checksum(self): 213 | """ 214 | Validate a checksum manifest file. Print a pretty message and return an 215 | appropriate exit code. 216 | 217 | NOTE that this function does not actually check the path for existence, it 218 | leaves that to the caller (which in nearly all cases would need to do so 219 | anyway). This function will throw FileNotFoundError if the manifest does not 220 | exist. 221 | """ 222 | differ = DistlibManifestChecksumFileExistenceDiffer 223 | checksum = ChecksumFile(self.args.project_root, differ=differ) 224 | checksum_path = os.path.join(self.args.project_root, ".ansible-sign", "sha256sum.txt") 225 | checksum_file_contents = open(checksum_path, "r").read() 226 | 227 | try: 228 | manifest = checksum.parse(checksum_file_contents) 229 | except InvalidChecksumLine as e: 230 | self._error(f"Invalid line encountered in checksum manifest: {e}") 231 | return 1 232 | 233 | try: 234 | checksum.verify(manifest, diff=True) 235 | except ChecksumMismatch as e: 236 | self._error("Checksum validation failed.") 237 | self._error(str(e)) 238 | return 2 239 | except FileNotFoundError as e: 240 | if os.path.islink(e.filename): 241 | self._error(f"Broken symlink found at {e.filename} -- this is not supported. Aborting.") 242 | return 1 243 | 244 | if e.filename.endswith("MANIFEST.in"): 245 | self._error("Could not find a MANIFEST.in file in the specified project.") 246 | self._note("If you are attempting to verify a signed project, please ensure that the project directory includes this file after signing.") 247 | self._note("See the ansible-sign documentation for more information.") 248 | return 1 249 | except DistlibException as e: 250 | self._error(f"An error was encountered while parsing MANIFEST.in: {e}") 251 | if self.args.loglevel != logging.DEBUG: 252 | self._note("You can use the --debug global flag to view the full traceback.") 253 | self.logger.debug(e, exc_info=e) 254 | return 1 255 | 256 | for warning in checksum.warnings: 257 | self._warn(warning) 258 | 259 | self._ok("Checksum validation succeeded.") 260 | return 0 261 | 262 | def gpg_verify(self): 263 | signature_file = os.path.join(self.args.project_root, ".ansible-sign", "sha256sum.txt.sig") 264 | manifest_file = os.path.join(self.args.project_root, ".ansible-sign", "sha256sum.txt") 265 | 266 | if not os.path.exists(signature_file): 267 | self._error(f"Signature file does not exist: {signature_file}") 268 | return 1 269 | 270 | if not os.path.exists(manifest_file): 271 | self._error(f"Checksum manifest file does not exist: {manifest_file}") 272 | return 1 273 | 274 | if self.args.keyring is not None and not os.path.exists(self.args.keyring): 275 | self._error(f"Specified keyring file not found: {self.args.keyring}") 276 | return 1 277 | 278 | if self.args.gnupg_home is not None and not os.path.isdir(self.args.gnupg_home): 279 | self._error(f"Specified GnuPG home is not a directory: {self.args.gnupg_home}") 280 | return 1 281 | 282 | verifier = GPGVerifier( 283 | manifest_path=manifest_file, 284 | detached_signature_path=signature_file, 285 | gpg_home=self.args.gnupg_home, 286 | keyring=self.args.keyring, 287 | ) 288 | 289 | result = verifier.verify() 290 | 291 | if result.success is not True: 292 | self._error(result.summary) 293 | self._note("Re-run with the global --debug flag for more information.") 294 | self.logger.debug(result.extra_information) 295 | return 3 296 | 297 | self._ok(result.summary) 298 | 299 | # GPG verification is done and we are still here, so return based on 300 | # checksum validation now. 301 | return self.validate_checksum() 302 | 303 | def _write_file_or_print(self, dest, contents): 304 | if dest == "-": 305 | print(contents, end="") 306 | return 307 | 308 | outdir = os.path.dirname(dest) 309 | 310 | if len(outdir) > 0 and not os.path.isdir(outdir): 311 | self.logger.info("Creating output directory: %s", outdir) 312 | os.makedirs(outdir) 313 | 314 | with open(dest, "w") as f: 315 | f.write(contents) 316 | self.logger.info("Wrote to file: %s", dest) 317 | 318 | def gpg_sign(self): 319 | # Step 1: Manifest 320 | manifest_path = os.path.join(self.args.project_root, ".ansible-sign", "sha256sum.txt") 321 | checksum_file_contents = self._generate_checksum_manifest() 322 | if checksum_file_contents is False: 323 | return 1 324 | self._write_file_or_print(manifest_path, checksum_file_contents) 325 | 326 | # Step 2: Signing 327 | # Do they need a passphrase? 328 | passphrase = None 329 | if self.args.prompt_passphrase: 330 | self.logger.debug("Prompting for GPG key passphrase") 331 | passphrase = getpass.getpass("GPG Key Passphrase: ") 332 | elif "ANSIBLE_SIGN_GPG_PASSPHRASE" in os.environ: 333 | self.logger.debug("Taking GPG key passphrase from ANSIBLE_SIGN_GPG_PASSPHRASE env var") 334 | passphrase = os.environ["ANSIBLE_SIGN_GPG_PASSPHRASE"] 335 | else: 336 | os.environ["GPG_TTY"] = os.ttyname(sys.stdin.fileno()) 337 | 338 | signature_path = os.path.join(self.args.project_root, ".ansible-sign", "sha256sum.txt.sig") 339 | signer = GPGSigner( 340 | manifest_path=manifest_path, 341 | output_path=signature_path, 342 | privkey=self.args.fingerprint, 343 | passphrase=passphrase, 344 | gpg_home=self.args.gnupg_home, 345 | ) 346 | result = signer.sign() 347 | if result.success: 348 | self._ok("GPG signing successful!") 349 | retcode = 0 350 | else: 351 | self._error("GPG signing FAILED!") 352 | self._note("Re-run with the global --debug flag for more information.") 353 | retcode = 4 354 | 355 | self._note(f"Checksum manifest: {manifest_path}") 356 | self._note(f"GPG summary: {result.summary}") 357 | self.logger.debug(f"GPG Details: {result.extra_information}") 358 | return retcode 359 | 360 | 361 | def main(args): 362 | cli = AnsibleSignCLI(args) 363 | cli.logger.debug("Running requested command/passing to function") 364 | exitcode = cli.run_command() 365 | cli.logger.info("Script ends here, rc=%d", exitcode) 366 | return exitcode 367 | 368 | 369 | def run(): 370 | """Calls :func:`main` passing the CLI arguments extracted from :obj:`sys.argv` 371 | 372 | This function can be used as entry point to create console scripts with setuptools. 373 | """ 374 | return main(sys.argv[1:]) 375 | 376 | 377 | if __name__ == "__main__": 378 | run() 379 | -------------------------------------------------------------------------------- /src/ansible_sign/signing/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This package handles various ways of signing Ansible content. 3 | 4 | All verification methods contain two modules: 5 | 6 | 1) A verifier subclass of SignatureVerifier, which makes no assumptions about 7 | the verification strategy used. All it demands is the implementation of a 8 | 'verify' method. 9 | 10 | 2) A signer subclass of SignatureSigner, which similarly makes no assumptions 11 | leaving it to each subclass to implement sign() as it sees fit. 12 | """ 13 | 14 | from .gpg import GPGSigner, GPGVerifier # noqa 15 | 16 | # from .base import * 17 | -------------------------------------------------------------------------------- /src/ansible_sign/signing/base.py: -------------------------------------------------------------------------------- 1 | class SignatureVerificationResult: 2 | """Represents the result after performing signature verification.""" 3 | 4 | def __init__(self, success, summary, extra_information={}): 5 | self.success = success 6 | self.summary = summary 7 | self.extra_information = extra_information 8 | 9 | def __bool__(self): 10 | return self.success 11 | 12 | 13 | class SignatureVerifier: 14 | """ 15 | Represents a way of performing content verification. It doesn't make any 16 | assumptions about the kind of verification being done. 17 | """ 18 | 19 | def verify(self) -> SignatureVerificationResult: 20 | """ 21 | Does the actual verification. 22 | 23 | Returns an instance of SignatureVerificationResult. 24 | """ 25 | raise NotImplementedError("verify") 26 | 27 | 28 | class SignatureSigningResult: 29 | """Represents the result after performing signing.""" 30 | 31 | def __init__(self, success, summary, extra_information={}): 32 | self.success = success 33 | self.summary = summary 34 | self.extra_information = extra_information 35 | 36 | def __bool__(self): 37 | return self.success 38 | 39 | 40 | class SignatureSigner: 41 | """ 42 | Represents a way of signing content for later verification. This interface 43 | makes no assumptions about the kind of verification being done. 44 | """ 45 | 46 | def sign(self): 47 | """ 48 | Signs a file. 49 | """ 50 | raise NotImplementedError("sign") 51 | -------------------------------------------------------------------------------- /src/ansible_sign/signing/gpg/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This package handles GPG signing and validation for Ansible content. 3 | """ 4 | 5 | from .signer import GPGSigner # noqa 6 | from .verifier import GPGVerifier # noqa 7 | -------------------------------------------------------------------------------- /src/ansible_sign/signing/gpg/signer.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module handles GPG signature generation for Ansible content. It makes use 3 | of python-gnupg (which ultimately shells out to GPG). 4 | """ 5 | 6 | import gnupg 7 | 8 | from ansible_sign.signing.base import ( 9 | SignatureSigner, 10 | SignatureSigningResult, 11 | ) 12 | 13 | __author__ = "Rick Elrod" 14 | __copyright__ = "(c) 2022 Red Hat, Inc." 15 | __license__ = "MIT" 16 | 17 | 18 | class GPGSigner(SignatureSigner): 19 | def __init__( 20 | self, 21 | manifest_path, 22 | output_path, 23 | privkey=None, 24 | passphrase=None, 25 | gpg_home=None, 26 | ): 27 | super(GPGSigner, self).__init__() 28 | 29 | if manifest_path is None: 30 | raise RuntimeError("manifest_path must not be None") 31 | self.manifest_path = manifest_path 32 | 33 | if output_path is None: 34 | raise RuntimeError("output_path must not be None") 35 | self.output_path = output_path 36 | 37 | self.privkey = privkey 38 | self.passphrase = passphrase 39 | self.gpg_home = gpg_home 40 | 41 | def sign(self) -> SignatureSigningResult: 42 | # TODO: We currently use the default GPG home directory in the signing 43 | # case and assume the secret key exists in it. Is there a better way to 44 | # do this? 45 | gpg = gnupg.GPG(gnupghome=self.gpg_home) 46 | with open(self.manifest_path, "rb") as f: 47 | sign_result = gpg.sign_file( 48 | f, 49 | keyid=self.privkey, 50 | passphrase=self.passphrase, 51 | detach=True, 52 | output=self.output_path, 53 | ) 54 | 55 | extra_information = {} 56 | for k in ("stderr", "fingerprint", "hash_algo", "timestamp", "returncode"): 57 | if hasattr(sign_result, k): 58 | extra_information[k] = getattr(sign_result, k) 59 | 60 | return SignatureSigningResult( 61 | success=sign_result.returncode == 0 and sign_result.status is not None, 62 | summary=sign_result.status, 63 | extra_information=extra_information, 64 | ) 65 | -------------------------------------------------------------------------------- /src/ansible_sign/signing/gpg/verifier.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module handles GPG signature verification for Ansible content. It makes use 3 | of python-gnupg (which ultimately shells out to GPG). 4 | """ 5 | 6 | import gnupg 7 | import os 8 | 9 | from ansible_sign.signing.base import ( 10 | SignatureVerifier, 11 | SignatureVerificationResult, 12 | ) 13 | 14 | __author__ = "Rick Elrod" 15 | __copyright__ = "(c) 2022 Red Hat, Inc." 16 | __license__ = "MIT" 17 | 18 | 19 | class GPGVerifier(SignatureVerifier): 20 | def __init__(self, manifest_path, detached_signature_path, gpg_home=None, keyring=None): 21 | super(GPGVerifier, self).__init__() 22 | 23 | if manifest_path is None: 24 | raise RuntimeError("manifest_path must not be None") 25 | self.manifest_path = manifest_path 26 | 27 | if detached_signature_path is None: 28 | raise RuntimeError("detached_signature_path must not be None") 29 | self.detached_signature_path = detached_signature_path 30 | 31 | self.gpg_home = gpg_home 32 | self.keyring = keyring 33 | 34 | def verify(self) -> SignatureVerificationResult: 35 | if not os.path.exists(self.detached_signature_path): 36 | return SignatureVerificationResult( 37 | success=False, 38 | summary="The specified detached signature path does not exist.", 39 | ) 40 | 41 | extra = {} 42 | 43 | gpg = gnupg.GPG(gnupghome=self.gpg_home, keyring=self.keyring) 44 | 45 | with open(self.detached_signature_path, "rb") as sig: 46 | verified = gpg.verify_file(sig, self.manifest_path) 47 | 48 | if not verified: 49 | extra["stderr"] = verified.stderr 50 | return SignatureVerificationResult( 51 | success=False, 52 | summary="GPG signature verification failed.", 53 | extra_information=extra, 54 | ) 55 | 56 | extra["stderr"] = verified.stderr 57 | extra["fingerprint"] = verified.fingerprint 58 | extra["creation_date"] = verified.creation_date 59 | extra["status"] = verified.status 60 | extra["timestamp"] = verified.timestamp 61 | 62 | return SignatureVerificationResult( 63 | success=True, 64 | summary="GPG signature verification succeeded.", 65 | extra_information=extra, 66 | ) 67 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | Fixtures for ansible-sign tests 3 | """ 4 | 5 | import fileinput 6 | import gnupg 7 | import libtmux 8 | import os 9 | from pathlib import Path 10 | import pytest 11 | import shutil 12 | 13 | from ansible_sign.signing import GPGSigner 14 | 15 | 16 | if gnupg.__version__ >= "1.0": 17 | # https://stackoverflow.com/q/35028852/99834 18 | pytest.exit("Unsupported gnupg library found, repair it with: pip3 uninstall -y gnupg && pip3 install python-gnupg") 19 | 20 | 21 | @pytest.fixture 22 | def tmux_session(request): 23 | """ 24 | Create a tmux session for testing 25 | """ 26 | session = libtmux.Server().new_session( 27 | session_name=f"ansible-sign_{request.node.name}", 28 | kill_session=True, 29 | ) 30 | yield session 31 | session.kill_session() 32 | 33 | 34 | def _gpg_home_with_secret_key(tmp_path, no_protection=False): 35 | """ 36 | Creates a GPG home (in a temporary directory) and generates a private key 37 | inside of that GPG home. 38 | 39 | Returns the path to the GPG home. 40 | """ 41 | home = tmp_path / "gpg-home" 42 | home.mkdir() 43 | gpg = gnupg.GPG(gnupghome=home) 44 | key_params = gpg.gen_key_input( 45 | key_length=2048, 46 | name_real="TEMPORARY ansible-sign TEST key", 47 | name_comment="Generated by ansible-sign test fixture", 48 | name_email="foo@example.com", 49 | passphrase="doYouEvenPassphrase" if no_protection is False else None, 50 | no_protection=no_protection, 51 | ) 52 | gpg.gen_key(key_params) 53 | return home 54 | 55 | 56 | @pytest.fixture 57 | def gpg_home_with_secret_key(tmp_path): 58 | yield _gpg_home_with_secret_key(tmp_path) 59 | 60 | 61 | @pytest.fixture 62 | def gpg_home_with_secret_key_no_pass(tmp_path): 63 | yield _gpg_home_with_secret_key(tmp_path, no_protection=True) 64 | 65 | 66 | @pytest.fixture 67 | def gpg_home_with_hao_pubkey(tmp_path): 68 | home = tmp_path / "gpg-home-with-hao-pubkey" 69 | home.mkdir() 70 | gpg = gnupg.GPG(gnupghome=home) 71 | pubkey = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures", "gpgkeys", "hao_pubkey.txt"), "r").read() 72 | gpg.import_keys(pubkey) 73 | yield home 74 | 75 | 76 | @pytest.fixture 77 | def unsigned_project_with_checksum_manifest(tmp_path): 78 | """ 79 | Creates a project directory (at a temporary location) with a generated, but 80 | unsigned, checksum manifest, ready for signing. 81 | 82 | Uses the 'manifest-success' checksum fixture directory as its base. 83 | """ 84 | project = tmp_path / "project_root" 85 | shutil.copytree( 86 | os.path.join( 87 | os.path.dirname(os.path.abspath(__file__)), 88 | "fixtures", 89 | "checksum", 90 | "manifest-success", 91 | ), 92 | project, 93 | dirs_exist_ok=True, 94 | ) 95 | yield project 96 | 97 | 98 | @pytest.fixture 99 | def unsigned_project_with_broken_checksum_manifest(unsigned_project_with_checksum_manifest): 100 | """ 101 | Creates a project directory (at a temporary location) with a broken 102 | (syntactically invalid) checksum file. 103 | 104 | Uses the 'manifest-success' checksum fixture directory as its base and 105 | modifies the checksum manifest after copying. 106 | """ 107 | manifest = unsigned_project_with_checksum_manifest / ".ansible-sign" / "sha256sum.txt" 108 | with fileinput.input(files=manifest, inplace=True) as f: 109 | for idx, line in enumerate(f): 110 | line = line.strip() 111 | if idx == 0: 112 | print(line.replace(" ", "")) 113 | else: 114 | print(line) 115 | yield unsigned_project_with_checksum_manifest 116 | 117 | 118 | @pytest.fixture 119 | def unsigned_project_with_broken_symlink(unsigned_project_with_checksum_manifest): 120 | """ 121 | Creates a project directory (at a temporary location) with a broken 122 | symlink in the project root. This triggers a distlib.manifest bug and 123 | allows us to test our handling of it. 124 | """ 125 | symlink = unsigned_project_with_checksum_manifest / "broken-symlink.txt" 126 | symlink.symlink_to(Path("/does/not/exist/and/never/will")) 127 | yield unsigned_project_with_checksum_manifest 128 | 129 | 130 | @pytest.fixture 131 | def unsigned_project_with_modified_checksum_manifest(unsigned_project_with_checksum_manifest): 132 | """ 133 | Creates a project directory (at a temporary location) with a changed 134 | checksum file that contains wrong hashes. 135 | 136 | Uses the 'manifest-success' checksum fixture directory as its base and 137 | modifies the checksum manifest after copying. 138 | """ 139 | manifest = unsigned_project_with_checksum_manifest / ".ansible-sign" / "sha256sum.txt" 140 | with fileinput.input(files=manifest, inplace=True) as f: 141 | for idx, line in enumerate(f): 142 | line = line.strip() 143 | if idx % 2 == 0: 144 | # Change some of the checksum lines. 145 | print(line.replace("2", "3").replace("a", "b").replace("7", "a").replace("c", "2")) 146 | else: 147 | print(line) 148 | yield unsigned_project_with_checksum_manifest 149 | 150 | 151 | def _sign_project(gpg_home_with_secret_key, unsigned_project_with_checksum_manifest): 152 | """ 153 | GPG-sign a project. Usually the arguments are temp directories produced by 154 | other fixtures above. 155 | """ 156 | out = unsigned_project_with_checksum_manifest / ".ansible-sign" / "sha256sum.txt.sig" 157 | manifest_path = unsigned_project_with_checksum_manifest / ".ansible-sign" / "sha256sum.txt" 158 | signer = GPGSigner( 159 | manifest_path=manifest_path, 160 | output_path=out, 161 | passphrase="doYouEvenPassphrase", 162 | gpg_home=gpg_home_with_secret_key, 163 | ) 164 | result = signer.sign() 165 | assert result.success is True 166 | assert os.path.exists(out) 167 | 168 | # now signed 169 | return (unsigned_project_with_checksum_manifest, gpg_home_with_secret_key) 170 | 171 | 172 | @pytest.fixture 173 | def signed_project_and_gpg( 174 | gpg_home_with_secret_key, 175 | unsigned_project_with_checksum_manifest, 176 | ): 177 | """ 178 | Sign a project that has a valid manifest. 179 | """ 180 | yield _sign_project(gpg_home_with_secret_key, unsigned_project_with_checksum_manifest) 181 | 182 | 183 | @pytest.fixture 184 | def signed_project_broken_manifest( 185 | gpg_home_with_secret_key, 186 | unsigned_project_with_broken_checksum_manifest, 187 | ): 188 | """ 189 | Sign a project that has a broken manifest. 190 | """ 191 | yield _sign_project(gpg_home_with_secret_key, unsigned_project_with_broken_checksum_manifest) 192 | 193 | 194 | @pytest.fixture 195 | def signed_project_modified_manifest( 196 | gpg_home_with_secret_key, 197 | unsigned_project_with_modified_checksum_manifest, 198 | ): 199 | """ 200 | Sign a project that has a modified manifest. 201 | """ 202 | yield _sign_project(gpg_home_with_secret_key, unsigned_project_with_modified_checksum_manifest) 203 | 204 | 205 | @pytest.fixture 206 | def signed_project_missing_manifest( 207 | gpg_home_with_secret_key, 208 | unsigned_project_with_checksum_manifest, 209 | ): 210 | """ 211 | Sign a project that has a missing manifest. 212 | """ 213 | (project, gpghome) = _sign_project(gpg_home_with_secret_key, unsigned_project_with_checksum_manifest) 214 | manifest = project / ".ansible-sign" / "sha256sum.txt" 215 | os.remove(manifest) 216 | yield (project, gpghome) 217 | 218 | 219 | @pytest.fixture 220 | def signed_project_with_different_gpg_home( 221 | gpg_home_with_secret_key, 222 | gpg_home_with_hao_pubkey, 223 | unsigned_project_with_checksum_manifest, 224 | ): 225 | """ 226 | Sign a project but 'lose' the key it was signed with by returning an 227 | unrelated gnupg home directory. 228 | """ 229 | (project, gpghome) = _sign_project(gpg_home_with_secret_key, unsigned_project_with_checksum_manifest) 230 | yield (project, gpg_home_with_hao_pubkey) 231 | 232 | 233 | @pytest.fixture 234 | def signed_project_broken_manifest_in( 235 | gpg_home_with_secret_key, 236 | unsigned_project_with_checksum_manifest, 237 | ): 238 | """ 239 | Sign a project but then break its MANIFEST.in. 240 | """ 241 | (project, gpghome) = _sign_project(gpg_home_with_secret_key, unsigned_project_with_checksum_manifest) 242 | manifest_in = project / "MANIFEST.in" 243 | with open(manifest_in, "w") as f: 244 | f.write("invalid-directive foo bar\n") 245 | yield (project, gpghome) 246 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/invalid-checksum-1/.ansible-sign/sha256sum.txt: -------------------------------------------------------------------------------- 1 | dc920c7f31a4869fb9f94519a4a77f6c7c43c6c3e66b0e57a5bcda52e9b02ce3 dir/hello2 2 | 2a1b1ab320215205675234744dc03f028b46da4d94657bbb7dca7b1a3a25e91e hello1 3 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/invalid-checksum-1/MANIFEST.in: -------------------------------------------------------------------------------- 1 | include hello1 dir/** 2 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/invalid-checksum-1/dir/hello2: -------------------------------------------------------------------------------- 1 | this is file dir/hello2 2 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/invalid-checksum-1/hello1: -------------------------------------------------------------------------------- 1 | this is file hello1 2 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/invalid-checksum-2/.ansible-sign/sha256sum.txt: -------------------------------------------------------------------------------- 1 | dc920c7f31a4869fb9f94519a4a77f6c7c43c6c3e66b0e57a5bcda52e9b02ce dir/hello2 2 | 2a1b1ab320215205675234744dc03f028b46da4d94657bbb7dca7b1a3a25e91e hello1 3 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/invalid-checksum-2/MANIFEST.in: -------------------------------------------------------------------------------- 1 | include hello1 dir/** 2 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/invalid-checksum-2/dir/hello2: -------------------------------------------------------------------------------- 1 | this is file dir/hello2 2 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/invalid-checksum-2/hello1: -------------------------------------------------------------------------------- 1 | this is file hello1 2 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/manifest-files-added-not-in-manifest/.ansible-sign/sha256sum.txt: -------------------------------------------------------------------------------- 1 | d4b030df28d620426ab3c71b9988e197fe286049dd704095700f7d4a9f4c2aa7 MANIFEST.in 2 | dc920c7f31a4869fb9f94519a4a77f6c7c43c6c3e66b0e57a5bcda52e9b02ce3 dir/hello2 3 | e6348619c4e64aaebf4bf6850f82d69cdb831e946de9ae7afdf68b056a94c483 good.yml 4 | 2a1b1ab320215205675234744dc03f028b46da4d94657bbb7dca7b1a3a25e91e hello1 5 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/manifest-files-added-not-in-manifest/MANIFEST.in: -------------------------------------------------------------------------------- 1 | include hello* dir/** 2 | include *.yml 3 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/manifest-files-added-not-in-manifest/dir/hello2: -------------------------------------------------------------------------------- 1 | this is file dir/hello2 2 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/manifest-files-added-not-in-manifest/evil.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | this_file: evil 3 | evil: true 4 | go: away 5 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/manifest-files-added-not-in-manifest/good.yml: -------------------------------------------------------------------------------- 1 | --- 2 | hello: world 3 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/manifest-files-added-not-in-manifest/hello1: -------------------------------------------------------------------------------- 1 | this is file hello1 2 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/manifest-files-added-removed/.ansible-sign/sha256sum.txt: -------------------------------------------------------------------------------- 1 | 477025096c98b041e0412b0b01cee7cc7d811ce0496a97a42fe4256ec0a44e75 MANIFEST.in 2 | dc920c7f31a4869fb9f94519a4a77f6c7c43c6c3e66b0e57a5bcda52e9b02ce3 dir/hello2 3 | 2a1b1ab320215205675234744dc03f028b46da4d94657bbb7dca7b1a3a25e91e hello1 4 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/manifest-files-added-removed/MANIFEST.in: -------------------------------------------------------------------------------- 1 | include hello* dir/** 2 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/manifest-files-added-removed/dir/hello2: -------------------------------------------------------------------------------- 1 | this is file dir/hello2 2 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/manifest-files-added-removed/hello2: -------------------------------------------------------------------------------- 1 | oh no I was added but I am not in the checksum file 2 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/manifest-files-added-removed/hello3: -------------------------------------------------------------------------------- 1 | I was added too :( 2 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/manifest-files-added/.ansible-sign/sha256sum.txt: -------------------------------------------------------------------------------- 1 | 477025096c98b041e0412b0b01cee7cc7d811ce0496a97a42fe4256ec0a44e75 MANIFEST.in 2 | dc920c7f31a4869fb9f94519a4a77f6c7c43c6c3e66b0e57a5bcda52e9b02ce3 dir/hello2 3 | 2a1b1ab320215205675234744dc03f028b46da4d94657bbb7dca7b1a3a25e91e hello1 4 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/manifest-files-added/MANIFEST.in: -------------------------------------------------------------------------------- 1 | include hello* dir/** 2 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/manifest-files-added/dir/hello2: -------------------------------------------------------------------------------- 1 | this is file dir/hello2 2 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/manifest-files-added/hello1: -------------------------------------------------------------------------------- 1 | this is file hello1 2 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/manifest-files-added/hello2: -------------------------------------------------------------------------------- 1 | oh no I was added but I am not in the checksum file 2 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/manifest-files-added/hello3: -------------------------------------------------------------------------------- 1 | I was added too :( 2 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/manifest-files-changed/.ansible-sign/sha256sum.txt: -------------------------------------------------------------------------------- 1 | d2d1320f7f4fe3abafe92765732d2aa6c097e7adf05bbd53481777d4a1f0cdab MANIFEST.in 2 | dc920c7f31a4869fb9f94519a4a77f6c7c43c6c3e66b0e57a5bcda52e9b02ce3 dir/hello2 3 | 2a1b1ab320215205675234744dc03f028b46da4d94657bbb7dca7b1a3a25e91e hello1 4 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/manifest-files-changed/MANIFEST.in: -------------------------------------------------------------------------------- 1 | include hello1 dir/** 2 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/manifest-files-changed/dir/hello2: -------------------------------------------------------------------------------- 1 | this is file dir/hello2 2 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/manifest-files-changed/hello1: -------------------------------------------------------------------------------- 1 | this is file hello1 2 | 3 | but now it has another line in it. Two more lines, technically. 4 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/manifest-files-removed/.ansible-sign/sha256sum.txt: -------------------------------------------------------------------------------- 1 | 477025096c98b041e0412b0b01cee7cc7d811ce0496a97a42fe4256ec0a44e75 MANIFEST.in 2 | dc920c7f31a4869fb9f94519a4a77f6c7c43c6c3e66b0e57a5bcda52e9b02ce3 dir/hello2 3 | 2a1b1ab320215205675234744dc03f028b46da4d94657bbb7dca7b1a3a25e91e hello1 4 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/manifest-files-removed/MANIFEST.in: -------------------------------------------------------------------------------- 1 | include hello* dir/** 2 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/manifest-files-removed/dir/hello2: -------------------------------------------------------------------------------- 1 | this is file dir/hello2 2 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/manifest-no-ansible-sign-dir/MANIFEST.in: -------------------------------------------------------------------------------- 1 | include hello1 dir/** 2 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/manifest-no-ansible-sign-dir/dir/hello2: -------------------------------------------------------------------------------- 1 | this is file dir/hello2 2 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/manifest-no-ansible-sign-dir/hello1: -------------------------------------------------------------------------------- 1 | this is file hello1 2 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/manifest-success/.ansible-sign/sha256sum.txt: -------------------------------------------------------------------------------- 1 | d2d1320f7f4fe3abafe92765732d2aa6c097e7adf05bbd53481777d4a1f0cdab MANIFEST.in 2 | dc920c7f31a4869fb9f94519a4a77f6c7c43c6c3e66b0e57a5bcda52e9b02ce3 dir/hello2 3 | 2a1b1ab320215205675234744dc03f028b46da4d94657bbb7dca7b1a3a25e91e hello1 4 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/manifest-success/MANIFEST.in: -------------------------------------------------------------------------------- 1 | include hello1 dir/** 2 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/manifest-success/dir/hello2: -------------------------------------------------------------------------------- 1 | this is file dir/hello2 2 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/manifest-success/hello1: -------------------------------------------------------------------------------- 1 | this is file hello1 2 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/manifest-syntax-error/MANIFEST.in: -------------------------------------------------------------------------------- 1 | invalid-directive foo bar 2 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/manifest-with-blank-lines-and-comments/.ansible-sign/sha256sum.txt: -------------------------------------------------------------------------------- 1 | 57485fc1f9c2f3bf30d62a08e8215930240c56ac28a0138a97c33db37d31d452 MANIFEST.in 2 | dc920c7f31a4869fb9f94519a4a77f6c7c43c6c3e66b0e57a5bcda52e9b02ce3 dir/hello2 3 | 2a1b1ab320215205675234744dc03f028b46da4d94657bbb7dca7b1a3a25e91e hello1 4 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/manifest-with-blank-lines-and-comments/MANIFEST.in: -------------------------------------------------------------------------------- 1 | include hello1 2 | 3 | # This is a comment 4 | # 5 | # but the next line isn't 6 | include dir/** 7 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/manifest-with-blank-lines-and-comments/dir/hello2: -------------------------------------------------------------------------------- 1 | this is file dir/hello2 2 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/manifest-with-blank-lines-and-comments/hello1: -------------------------------------------------------------------------------- 1 | this is file hello1 2 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/missing-manifest/.ansible-sign/sha256sum.txt: -------------------------------------------------------------------------------- 1 | d2d1320f7f4fe3abafe92765732d2aa6c097e7adf05bbd53481777d4a1f0cdab MANIFEST.in 2 | dc920c7f31a4869fb9f94519a4a77f6c7c43c6c3e66b0e57a5bcda52e9b02ce3 dir/hello2 3 | 2a1b1ab320215205675234744dc03f028b46da4d94657bbb7dca7b1a3a25e91e hello1 4 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/missing-manifest/dir/hello2: -------------------------------------------------------------------------------- 1 | this is file dir/hello2 2 | -------------------------------------------------------------------------------- /tests/fixtures/checksum/missing-manifest/hello1: -------------------------------------------------------------------------------- 1 | this is file hello1 2 | -------------------------------------------------------------------------------- /tests/fixtures/gpg/hao-signed-invalid/.ansible-sign/sha256sum.txt: -------------------------------------------------------------------------------- 1 | 56654141810073915dc57dec6c629193a01a06def66f84c0dd378efa37d1738a README.md 2 | 1bd19910c18040efb7d978aa00d9a457d9fc80e29404659542e139b0b4b205cb hello_world.yml 3 | -------------------------------------------------------------------------------- /tests/fixtures/gpg/hao-signed-invalid/.ansible-sign/sha256sum.txt.sig: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ansible/ansible-sign/6ac1f09778eb5d37d23f5fee046e4654bcfa4537/tests/fixtures/gpg/hao-signed-invalid/.ansible-sign/sha256sum.txt.sig -------------------------------------------------------------------------------- /tests/fixtures/gpg/hao-signed-invalid/README.md: -------------------------------------------------------------------------------- 1 | # ansible-tower-samples 2 | Ansible Tower Playbook Samples 3 | -------------------------------------------------------------------------------- /tests/fixtures/gpg/hao-signed-invalid/hello_world.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Hello World Sample 3 | hosts: all 4 | tasks: 5 | - name: Hello Message 6 | debug: 7 | msg: "Hello World from a signed project! boo" 8 | -------------------------------------------------------------------------------- /tests/fixtures/gpg/hao-signed-missing-manifest/.ansible-sign/sha256sum.txt: -------------------------------------------------------------------------------- 1 | 8965debfe52792caed567fb96765855800644f1696d2899ca040565ad0a392b6 MANIFEST.in 2 | 56654141810073915dc57dec6c629193a01a06def66f84c0dd378efa37d1738a README.md 3 | 21e557a6fae9843018960a4edc73275509a1ed151ad96dc3ff127ded96c12a1d hello_world.yml 4 | -------------------------------------------------------------------------------- /tests/fixtures/gpg/hao-signed-missing-manifest/.ansible-sign/sha256sum.txt.sig: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP SIGNATURE----- 2 | 3 | iQIzBAABCAAdFiEE/olv0uIZnxrVzZ3ZZABXqvNHHrIFAmL1VKYACgkQZABXqvNH 4 | HrIzjg/+K9+jNg5yqoTvntB6TEbFoegiLjLUzc5Eq5sAq24mHVb0NW0TFLKtCT19 5 | /h4mHoSPSQraWtNpuzQ0/D1W+m8MBNW6zXKCqIFguRdZgYgd0Kp+E9uiCejjgR0u 6 | 6BgUg5cAjTQpEf0rSiP9jHGr++BDcuq7lYRx4S0IqYweHkRAxounmGv27haCb0y8 7 | VfI89+Wieg5AtaOpg5M2qLYqrliAxYoA0mmsQhZhM7GUjM3p4x2FN0S37nMq5Ixs 8 | XXRlTXC4DCFlW1WLckRFm8GF2gEbiTJJBPFaar+6JJcTdp1eRnzqr1TpCbOiC7z5 9 | 43tGWlf6QYTGHlndCdqy609fFPsgNaIpCKHxh8pSfavmvP5L5q7ymFCfiEN2+g+/ 10 | ISVGIdb6PT791OHlp9GRbXS2IzsFfieMYHXc0QttNxINL0bqPtqoZzI4q3fODUsI 11 | MAZoJGkEig0PVahvMOFK5YLwQ5oxXo2UOdb624sDaax8P4vpvQFPOM3XxRoMpx2d 12 | nZz2tTM/rmqd+8yjqn/H1COD/xgBypNl+n01QBkvuez7Ov+eN8U34e1P3PqFDJ3Z 13 | ZOy2VrI6gVwgxfmvK2XKhNOxvwVSwfpQ87aifUOhLhpO1h4peY8Qob+VLPBisIjg 14 | LtJKecEe+CT3ZAGc+mE6UYkCYrNZ/mnjKw0uTdjCRX80W092AO0= 15 | =9nKb 16 | -----END PGP SIGNATURE----- 17 | -------------------------------------------------------------------------------- /tests/fixtures/gpg/hao-signed-missing-manifest/README.md: -------------------------------------------------------------------------------- 1 | # ansible-tower-samples 2 | Ansible Tower Playbook Samples 3 | -------------------------------------------------------------------------------- /tests/fixtures/gpg/hao-signed-missing-manifest/hello_world.yml: -------------------------------------------------------------------------------- 1 | - name: Hello World Sample 2 | hosts: all 3 | tasks: 4 | - name: Hello Message 5 | debug: 6 | msg: "Hello World from a signed project!!!!" 7 | 8 | -------------------------------------------------------------------------------- /tests/fixtures/gpg/hao-signed/.ansible-sign/sha256sum.txt: -------------------------------------------------------------------------------- 1 | 8965debfe52792caed567fb96765855800644f1696d2899ca040565ad0a392b6 MANIFEST.in 2 | 56654141810073915dc57dec6c629193a01a06def66f84c0dd378efa37d1738a README.md 3 | 21e557a6fae9843018960a4edc73275509a1ed151ad96dc3ff127ded96c12a1d hello_world.yml 4 | -------------------------------------------------------------------------------- /tests/fixtures/gpg/hao-signed/.ansible-sign/sha256sum.txt.sig: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP SIGNATURE----- 2 | 3 | iQIzBAABCAAdFiEE/olv0uIZnxrVzZ3ZZABXqvNHHrIFAmL1VKYACgkQZABXqvNH 4 | HrIzjg/+K9+jNg5yqoTvntB6TEbFoegiLjLUzc5Eq5sAq24mHVb0NW0TFLKtCT19 5 | /h4mHoSPSQraWtNpuzQ0/D1W+m8MBNW6zXKCqIFguRdZgYgd0Kp+E9uiCejjgR0u 6 | 6BgUg5cAjTQpEf0rSiP9jHGr++BDcuq7lYRx4S0IqYweHkRAxounmGv27haCb0y8 7 | VfI89+Wieg5AtaOpg5M2qLYqrliAxYoA0mmsQhZhM7GUjM3p4x2FN0S37nMq5Ixs 8 | XXRlTXC4DCFlW1WLckRFm8GF2gEbiTJJBPFaar+6JJcTdp1eRnzqr1TpCbOiC7z5 9 | 43tGWlf6QYTGHlndCdqy609fFPsgNaIpCKHxh8pSfavmvP5L5q7ymFCfiEN2+g+/ 10 | ISVGIdb6PT791OHlp9GRbXS2IzsFfieMYHXc0QttNxINL0bqPtqoZzI4q3fODUsI 11 | MAZoJGkEig0PVahvMOFK5YLwQ5oxXo2UOdb624sDaax8P4vpvQFPOM3XxRoMpx2d 12 | nZz2tTM/rmqd+8yjqn/H1COD/xgBypNl+n01QBkvuez7Ov+eN8U34e1P3PqFDJ3Z 13 | ZOy2VrI6gVwgxfmvK2XKhNOxvwVSwfpQ87aifUOhLhpO1h4peY8Qob+VLPBisIjg 14 | LtJKecEe+CT3ZAGc+mE6UYkCYrNZ/mnjKw0uTdjCRX80W092AO0= 15 | =9nKb 16 | -----END PGP SIGNATURE----- 17 | -------------------------------------------------------------------------------- /tests/fixtures/gpg/hao-signed/MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-exclude .git * 2 | include README.md 3 | include hello_world.yml 4 | -------------------------------------------------------------------------------- /tests/fixtures/gpg/hao-signed/README.md: -------------------------------------------------------------------------------- 1 | # ansible-tower-samples 2 | Ansible Tower Playbook Samples 3 | -------------------------------------------------------------------------------- /tests/fixtures/gpg/hao-signed/hello_world.yml: -------------------------------------------------------------------------------- 1 | - name: Hello World Sample 2 | hosts: all 3 | tasks: 4 | - name: Hello Message 5 | debug: 6 | msg: "Hello World from a signed project!!!!" 7 | 8 | -------------------------------------------------------------------------------- /tests/fixtures/gpgkeys/hao_pubkey.txt: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP PUBLIC KEY BLOCK----- 2 | 3 | mQINBF9FX30BEADYbzekE8PPMDL49EcJrrpLfLYaZAV0PZqdHeT+oSFtJ75JxJTv 4 | K7s5tebynpH4/uz7tUhahUuTCtd0I3s/fWIkgoCDZuTTBxXG2P7TZgrqrvKLvuw2 5 | wV/36s4MbRX/Qak1zWrOvwx/veQf2s81wVHyndqpLQODjik3hpoco3F1VgQmqOpW 6 | Iq9pyk5UZGX4tL/AgPGfEOLvyNSUOXhiQcPDDFUYqc1huXwj4Ls2jRriVAAuKm4R 7 | AFQXNNqcR8w+d0soGDvfFcbUIHfz2xuqMbVjp78zo6MBB/Nt/1vKumYyuGfl7gmf 8 | J+5o0oLz87ZdoFaJSpM5u4+prgGvuDj6R1ARU4nHWySXyWKIimm3S1AiV0XSDRz2 9 | qUAq6IIygiWxykl4olzZPYIZpCqOwwCiVci1aPN5eYICYLacRQkNZkTobnie4Z+k 10 | SZjUKv3QOoHTRz6XZ1Jtk06Nmx8aisqnky09cs3baQpikHhPuHnomTb+2AP2KnRo 11 | mhiSVEo1+f9kxISgdT3R9beb625K2m/9u4U/BHjdSR1SwF1KhijCoXE419RSEoLs 12 | HbcsRL9cUKXHSonxorP6AaCDHX3YIaDote6S7sBhY6Z1+fA+3od4Hcd0YgBuX1tP 13 | vItJ1n7yD7Rr71YXr0QgoCkOatho7IFAXxCmBCm7lh04tweL2wRZszNC3wARAQAB 14 | tBpIYW8gTGl1IDxoYW9saUByZWRoYXQuY29tPokCTgQTAQgAOBYhBP6Jb9LiGZ8a 15 | 1c2d2WQAV6rzRx6yBQJfRV99AhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheAAAoJ 16 | EGQAV6rzRx6yZyUQAL3aUluPXkp8OI1nSxrE91Oobj6sACwaD0B3f6ox0xquu1ow 17 | Ti3b5WjTvxWXBBm4laGs5T7m2FLZBYjbrFJb5EvjGEN+1s1mdn5RNR2GSU4iCVOa 18 | nHEnAjrDJaOhYsqzlX1hFCQEmYFTPLKTfa/37Pxeh8Phv2Co+src+KH8Oy5XeYUj 19 | 7TNzv2+1TiWw5u2zTRqLk6T2FE8nLOyblb1Tw2eTQVEHbEU1tpZA9gXXO5XiP3xi 20 | nFeTu/wXa77aSAE1eZfdw2V3/6hsKNA4d8XTPeLyetAVeCdyY9F7dJ2xYwiHHTOm 21 | LlQKCQHutnqpRBx7Kaew5W2Q+MsCy4X+JsqA3JtLBLYiPxbwdii01hZ+kCmYKp+2 22 | gykvxWt13hAqM6uKzq8IWO7x1xv/31De0MBgcn5cN9nTchgiPtc35fGXoqB4aVd9 23 | v3WGc/GYBwX6Jzow/woE6fXW2b87UX6wsS0Rd146ZAO0sc/ONUlSkixZtg0WASjq 24 | 1Jj52t2KQeIqpQuRJdIuPvL9jnJWx+Q0BvrWVXZg1xe3Goz/y2FE1wjvY0Shtljg 25 | 5+Sc68XPH2AfIu6y/vrKf8i253eW+M3HVGoDJbLE/OBTEbFr+6NJg51Ua6p0nEyY 26 | 3QUtFDYvORGFKT4JzCWfTrdmDLDkCI0Cus3YuK4HsJX0hjUiYXwn9EnsZbjJuQIN 27 | BF9FX30BEADN9df4KGuvsgq2IyobrhwMvrAeMKicl5nILR7HoOvjeDLczLdpwHbJ 28 | gJSUHoQb77qsI+r8JXL/JkMDeTK95OIvDW0ToeKlyUGa+AbMoTplTd8mDWR5Ed4m 29 | UYraEbbhjqmxC0GoF8yl7hFaIq4CsjaMgr4Sxvggn7xP0x1xSYo0QFdvlXaBS2Vl 30 | 7rV7Qize25LG2PGrETNc8YEpuCoJyZHN05DuzoICtnc8JaXX1ZqnZ2yEp5+s3wzC 31 | fBHE44g15uBqZJB9R01or6GPgGmNK7Gfzh33L9egfuy6Em1lkLTqmYZ08j7mnFpv 32 | srwVe3sbt56MYJZMIPG4L3uhPLuNshixJ7nsg5lT19wRcjONKqHVrBqhvnmAsZl9 33 | vQmkKhpIi/KD/f5zTj7rGjOSCup9VbjsYYFmD1ouc86upYACzVmBmDFgV5S746J/ 34 | YrZw+iawkY6a7zCjpJKvjjRUqD5swdV94YyytkTndhlad+bsAOT5i2+560lXfBPj 35 | zmNkJgEK4B6eYAE54t6dmdcU0MY+CN9a+oYN3lTowYSUTp9AAZPZc+xQYXeFphfP 36 | fHktnBF7ae1E3vozVvWf1jnU8egZuVouj4Q6ugu9Fq69piQANMnOZISIhMnkrz9f 37 | QPSeDCw3iWJHNskkVS9V5sL9i3R0uVt2io3jSvc4cVcGbBS2epMfLwARAQABiQI2 38 | BBgBCAAgFiEE/olv0uIZnxrVzZ3ZZABXqvNHHrIFAl9FX30CGwwACgkQZABXqvNH 39 | HrISyhAA19Vmo79S4ZgI/ChmoW3q0PK3yk/qYo7a9Yh+B3jEqDpXR2qSHZA0QluS 40 | rJbo/ULcXXihQkMUzXV2NcO4i6GxHfRYOl9SvUSx2xpD0k8JAyEMNJ0aD1QAZOEL 41 | lToSKO+AGKlsrQbpdObuJmM6vQQOg63B2CQet0sOOPQO44J0wV9ZDQqqlHI4iIK6 42 | Z2u2U+Q5IdgiINh1nYyYimSlyorrKO5+9DbhLubMJrEfT9B3WbImt/jUBzC3YsPt 43 | GBt/ggYnUmCEsr8jGAMa0/e0nHyKIxQusm9TBoZNG+cXCzPWG2t/ISBqJWrigQgc 44 | N+ZaitYeBYihjG3KOQRV3d6k5qY6d8yFZWLXRUPwdxfv+ozHjrHePnDV5J18e1hH 45 | PcsL9pV7UEP2178RFKsvLZCAJ6m8LVg8lixr4DbaQINQ9089PKGhd5wjRL8NwLy+ 46 | TKy5k+AKPF7an4QlUCJoRl3SVbZ0B8EnUjUagaf4kzLXh9+MSJYXRCxKEem9tD3O 47 | b3dpFLwwNnnxIgjCJAPbZf7SuFLS8lKxI/tsgrWeRTUmeareSRwOt9h3DEwYfm9k 48 | kY9HhlApOmcB0zEYvi8x624kib0BO11v/YhLC8dIp6nslpFrSitiUDiGYoTujM+8 49 | b6hYawLKS5Uhy2ARNO7H4XJIIwZ5LFvwN6Gv7AUb9nrpp16Q+/k= 50 | =wsLv 51 | -----END PGP PUBLIC KEY BLOCK----- 52 | -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | libtmux 2 | pytest 3 | pytest-mock 4 | flake8 5 | yamllint 6 | black>=24.3.0 7 | -------------------------------------------------------------------------------- /tests/test_checksum.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | 4 | from ansible_sign.checksum import ChecksumFile, InvalidChecksumLine, ChecksumMismatch, DistlibManifestChecksumFileExistenceDiffer 5 | 6 | __author__ = "Rick Elrod" 7 | __copyright__ = "(c) 2022 Red Hat, Inc." 8 | __license__ = "MIT" 9 | 10 | 11 | FIXTURES_DIR = os.path.join( 12 | os.path.dirname(os.path.abspath(__file__)), 13 | "fixtures", 14 | "checksum", 15 | ) 16 | 17 | DUPLICATE_LINES_FIXTURE = """ 18 | 2a1b1ab320215205675234744dc03f028b46da4d94657bbb7dca7b1a3a25e91e tests/fixtures/checksum/directory-success/hello1 19 | 2a1b1ab320215205675234744dc03f028b46da4d94657bbb7dca7b1a3a25e91e tests/fixtures/checksum/directory-success/hello1 20 | """.strip() 21 | 22 | # Missing extra space 23 | INVALID_LINE_FIXTURE1 = """ 24 | 2a1b1ab320215205675234744dc03f028b46da4d94657bbb7dca7b1a3a25e91e tests/fixtures/checksum/directory-success/hello1 25 | """.strip() 26 | 27 | # Hash isn't 64 characters long 28 | INVALID_LINE_FIXTURE2 = """ 29 | 2a1b1ab320215205675234744dc03f028b46da4d94657bbb7dca7b1a3a25e91 tests/fixtures/checksum/directory-success/hello1 30 | """.strip() 31 | 32 | # Weird checksum file that for some reason has some blank lines in it. 33 | # The blank lines should just be skipped. 34 | # We should never generate such a file, but the parser handles it just to be 35 | # safe, and so we need this for coverage. 36 | BLANK_LINES_FIXTURE = """ 37 | 38 | d2d1320f7f4fe3abafe92765732d2aa6c097e7adf05bbd53481777d4a1f0cdab MANIFEST.in 39 | dc920c7f31a4869fb9f94519a4a77f6c7c43c6c3e66b0e57a5bcda52e9b02ce3 dir/hello2 40 | 41 | 2a1b1ab320215205675234744dc03f028b46da4d94657bbb7dca7b1a3a25e91e hello1 42 | 43 | 44 | """.strip() 45 | 46 | 47 | @pytest.mark.parametrize( 48 | "fixture", 49 | [ 50 | "manifest-success", 51 | "manifest-with-blank-lines-and-comments", 52 | ], 53 | ) 54 | def test_simple_gnu_generate(fixture): 55 | root = os.path.join( 56 | FIXTURES_DIR, 57 | fixture, 58 | ) 59 | checksum = ChecksumFile( 60 | root, 61 | differ=DistlibManifestChecksumFileExistenceDiffer, 62 | ) 63 | generated_manifest = checksum.generate_gnu_style() 64 | actual_manifest = open( 65 | os.path.join( 66 | root, 67 | ".ansible-sign", 68 | "sha256sum.txt", 69 | ), 70 | "r", 71 | ).read() 72 | assert generated_manifest == actual_manifest 73 | 74 | 75 | @pytest.mark.parametrize( 76 | "fixture, exc_substr", 77 | [ 78 | (DUPLICATE_LINES_FIXTURE, "Duplicate path in checksum, line 2"), 79 | (INVALID_LINE_FIXTURE1, "Unparsable checksum, line 1"), 80 | (INVALID_LINE_FIXTURE2, "Unparsable checksum, line 1"), 81 | ], 82 | ) 83 | def test_parse_invalid_manifests(fixture, exc_substr): 84 | checksum = ChecksumFile("/tmp", differ=None) 85 | with pytest.raises(InvalidChecksumLine) as exc: 86 | checksum.parse(fixture) 87 | assert exc_substr in str(exc) 88 | 89 | 90 | def test_parse_manifest_with_blank_lines(): 91 | checksum = ChecksumFile("/tmp", differ=None) 92 | parsed = checksum.parse(BLANK_LINES_FIXTURE) 93 | assert len(parsed) == 3 94 | assert parsed["MANIFEST.in"] == "d2d1320f7f4fe3abafe92765732d2aa6c097e7adf05bbd53481777d4a1f0cdab" 95 | assert parsed["dir/hello2"] == "dc920c7f31a4869fb9f94519a4a77f6c7c43c6c3e66b0e57a5bcda52e9b02ce3" 96 | assert parsed["hello1"] == "2a1b1ab320215205675234744dc03f028b46da4d94657bbb7dca7b1a3a25e91e" 97 | 98 | 99 | def test_parse_manifest_with_only_blank_lines(): 100 | checksum = ChecksumFile("/tmp", differ=None) 101 | parsed = checksum.parse("\n\n\n\n\n") 102 | assert len(parsed) == 0 103 | 104 | 105 | def test_parse_manifest_empty(): 106 | """ 107 | Don't throw on empty manifest, but return an empty dict. 108 | """ 109 | checksum = ChecksumFile("/tmp", differ=None) 110 | parsed = checksum.parse("") 111 | assert len(parsed) == 0 112 | 113 | 114 | @pytest.mark.parametrize( 115 | "fixture, diff_output", 116 | [ 117 | ( 118 | "files-added", 119 | "{'added': ['hello2', 'hello3'], 'removed': []}", 120 | ), 121 | ( 122 | "files-added-removed", 123 | "{'added': ['hello2', 'hello3'], 'removed': ['hello1']}", 124 | ), 125 | ( 126 | "files-removed", 127 | "{'added': [], 'removed': ['hello1']}", 128 | ), 129 | ( 130 | "files-changed", 131 | "Checksum mismatch: hello1", 132 | ), 133 | ( 134 | "success", 135 | True, 136 | ), 137 | ], 138 | ) 139 | def test_directory_diff( 140 | fixture, 141 | diff_output, 142 | ): 143 | root = os.path.join( 144 | FIXTURES_DIR, 145 | f"manifest-{fixture}", 146 | ) 147 | checksum = ChecksumFile( 148 | root, 149 | differ=DistlibManifestChecksumFileExistenceDiffer, 150 | ) 151 | actual_manifest = open( 152 | os.path.join( 153 | root, 154 | ".ansible-sign", 155 | "sha256sum.txt", 156 | ), 157 | "r", 158 | ).read() 159 | parsed_manifest = checksum.parse(actual_manifest) 160 | if diff_output is True: 161 | assert checksum.verify(parsed_manifest) 162 | else: 163 | with pytest.raises(ChecksumMismatch) as ex: 164 | checksum.verify(parsed_manifest) 165 | assert str(ex.value) == diff_output 166 | 167 | 168 | def test_manifest_evil_file_added(): 169 | """ 170 | Test a specific scenario: We wildcard *.yml but NOT *.yaml. 171 | We want to ensure an unexpected evil.yaml blocks validation. 172 | """ 173 | 174 | root = os.path.join( 175 | FIXTURES_DIR, 176 | "manifest-files-added-not-in-manifest", 177 | ) 178 | checksum = ChecksumFile( 179 | root, 180 | differ=DistlibManifestChecksumFileExistenceDiffer, 181 | ) 182 | actual_manifest = open( 183 | os.path.join( 184 | root, 185 | ".ansible-sign", 186 | "sha256sum.txt", 187 | ), 188 | "r", 189 | ).read() 190 | parsed_manifest = checksum.parse(actual_manifest) 191 | with pytest.raises(ChecksumMismatch): 192 | checksum.verify(parsed_manifest) 193 | 194 | 195 | def test_missing_manifest(): 196 | root = os.path.join(FIXTURES_DIR, "missing-manifest") 197 | checksum = ChecksumFile( 198 | root, 199 | differ=DistlibManifestChecksumFileExistenceDiffer, 200 | ) 201 | 202 | with pytest.raises(FileNotFoundError) as ex: 203 | checksum.verify({}) 204 | 205 | assert "missing-manifest/MANIFEST.in" in str(ex) 206 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import pytest 4 | 5 | from ansible_sign.cli import main 6 | 7 | __author__ = "Rick Elrod" 8 | __copyright__ = "(c) 2022 Red Hat, Inc." 9 | __license__ = "MIT" 10 | IS_GITHUB_ACTION_MACOS = sys.platform == "darwin" and os.environ.get("CI", "false") == "true" 11 | 12 | 13 | @pytest.mark.parametrize( 14 | "args, exp_stdout_substr, exp_stderr_substr, exp_rc", 15 | [ 16 | ( 17 | [ 18 | "--debug", 19 | "--nocolor", 20 | "project", 21 | "gpg-sign", 22 | "tests/fixtures/checksum/missing-manifest", 23 | ], 24 | "If you are attempting to sign a project, please create this file", 25 | "", 26 | 1, 27 | ), 28 | ( 29 | [ 30 | "--debug", 31 | "--nocolor", 32 | "project", 33 | "gpg-sign", 34 | "tests/fixtures/checksum/manifest-syntax-error", 35 | ], 36 | "An error was encountered while parsing MANIFEST.in: unknown action 'invalid-directive'", 37 | "", 38 | 1, 39 | ), 40 | ( 41 | [ 42 | "--debug", 43 | "--nocolor", 44 | "project", 45 | "gpg-verify", 46 | "tests/fixtures/checksum/manifest-success", 47 | ], 48 | "Signature file does not exist", 49 | "", 50 | 1, 51 | ), 52 | ( 53 | [ 54 | "--debug", 55 | "--nocolor", 56 | "project", 57 | "gpg-verify", 58 | "--gnupg-home=/dir/that/does/not/exist/321", 59 | "tests/fixtures/gpg/hao-signed", 60 | ], 61 | "Specified GnuPG home is not a directory:", 62 | "", 63 | 1, 64 | ), 65 | ], 66 | ) 67 | def test_main(capsys, args, exp_stdout_substr, exp_stderr_substr, exp_rc): 68 | """ 69 | Test the CLI, making no assumptions about the environment, such as having a 70 | GPG keypair, or even a GPG home directory." 71 | """ 72 | rc = main(args) 73 | captured = capsys.readouterr() 74 | assert exp_stdout_substr in captured.out 75 | assert exp_stderr_substr in captured.err 76 | 77 | if rc is None: 78 | rc = 0 79 | 80 | assert rc == exp_rc 81 | 82 | 83 | @pytest.mark.parametrize( 84 | "args, exp_stdout_substr, exp_stderr_substr, exp_rc", 85 | [ 86 | ( 87 | [ 88 | "--debug", 89 | "--nocolor", 90 | "project", 91 | "gpg-verify", 92 | "--keyring={gpghome}/pubring.kbx", 93 | "tests/fixtures/gpg/hao-signed-missing-manifest", 94 | ], 95 | "ensure that the project directory includes this file after", 96 | "", 97 | 1, 98 | ), 99 | ( 100 | [ 101 | "--debug", 102 | "--nocolor", 103 | "project", 104 | "gpg-verify", 105 | "--gnupg-home={gpghome}", 106 | "tests/fixtures/gpg/hao-signed-missing-manifest", 107 | ], 108 | "ensure that the project directory includes this file after", 109 | "", 110 | 1, 111 | ), 112 | ( 113 | [ 114 | "--debug", 115 | "--nocolor", 116 | "project", 117 | "gpg-verify", 118 | "--gnupg-home={gpghome}", 119 | "--keyring=/file/does/not/exist", 120 | "tests/fixtures/gpg/hao-signed-missing-manifest", 121 | ], 122 | "Specified keyring file not found:", 123 | "", 124 | 1, 125 | ), 126 | ], 127 | ) 128 | def test_main_with_pubkey_in_keyring(capsys, gpg_home_with_hao_pubkey, args, exp_stdout_substr, exp_stderr_substr, exp_rc): 129 | """ 130 | Test the CLI assuming that there is (only) a public key in the keyring. 131 | """ 132 | interpolation = {"gpghome": gpg_home_with_hao_pubkey} 133 | interpolated_args = [arg.format(**interpolation) for arg in args] 134 | rc = main(interpolated_args) 135 | captured = capsys.readouterr() 136 | assert exp_stdout_substr in captured.out 137 | assert exp_stderr_substr in captured.err 138 | 139 | if rc is None: 140 | rc = 0 141 | 142 | assert rc == exp_rc 143 | 144 | 145 | @pytest.mark.parametrize( 146 | "project_fixture, exp_stdout_substr, exp_stderr_substr, exp_rc", 147 | [ 148 | pytest.param( 149 | "signed_project_and_gpg", 150 | "GPG signature verification succeeded", 151 | "", 152 | 0, 153 | id="valid checksum file and signature", 154 | marks=pytest.mark.xfail(IS_GITHUB_ACTION_MACOS, reason="https://github.com/ansible/ansible-sign/issues/51"), 155 | ), 156 | pytest.param( 157 | "signed_project_broken_manifest", 158 | "Invalid line encountered in checksum manifest", 159 | "", 160 | 1, 161 | id="valid signature but broken checksum file", 162 | marks=pytest.mark.xfail(IS_GITHUB_ACTION_MACOS, reason="https://github.com/ansible/ansible-sign/issues/51"), 163 | ), 164 | pytest.param("signed_project_missing_manifest", "Checksum manifest file does not exist:", "", 1, id="missing checksum file entirely"), 165 | pytest.param( 166 | "signed_project_modified_manifest", 167 | "Checksum validation failed.", 168 | "", 169 | 2, 170 | id="checksum file with wrong hashes", 171 | marks=pytest.mark.xfail(IS_GITHUB_ACTION_MACOS, reason="https://github.com/ansible/ansible-sign/issues/51"), 172 | ), 173 | pytest.param("signed_project_with_different_gpg_home", "Re-run with the global --debug flag", "", 3, id="matching pubkey does not exist in gpg home"), 174 | pytest.param( 175 | "signed_project_broken_manifest_in", 176 | "An error was encountered while parsing MANIFEST.in: unknown action 'invalid-directive'", 177 | "", 178 | 1, 179 | id="broken MANIFEST.in after signing", 180 | marks=pytest.mark.xfail(IS_GITHUB_ACTION_MACOS, reason="https://github.com/ansible/ansible-sign/issues/51"), 181 | ), 182 | ], 183 | ) 184 | def test_gpg_verify_manifest_scenario(capsys, request, project_fixture, exp_stdout_substr, exp_stderr_substr, exp_rc): 185 | """ 186 | Test `ansible-sign project gpg-verify` given different project directory 187 | scenarios (fixtures). 188 | """ 189 | (project_root, gpg_home) = request.getfixturevalue(project_fixture) 190 | keyring = os.path.join(gpg_home, "pubring.kbx") 191 | args = [ 192 | "--debug", 193 | "--nocolor", 194 | "project", 195 | "gpg-verify", 196 | f"--keyring={keyring}", 197 | str(project_root), 198 | ] 199 | rc = main(args) 200 | captured = capsys.readouterr() 201 | assert exp_stdout_substr in captured.out 202 | assert exp_stderr_substr in captured.err 203 | 204 | if rc is None: 205 | rc = 0 206 | 207 | assert rc == exp_rc 208 | 209 | 210 | @pytest.mark.parametrize( 211 | "use_passphrase", 212 | ("mock", "env_var", False), 213 | ids=[ 214 | "GPG signing with key that requires passphrase (mocked stdin)", 215 | "GPG signing with key that requires passphrase (via env var)", 216 | "GPG signing with key that does NOT require passphrase", 217 | ], 218 | ) 219 | def test_gpg_sign_with_gnupg_home(capsys, mocker, request, unsigned_project_with_checksum_manifest, use_passphrase): 220 | if use_passphrase: 221 | gpg_home = request.getfixturevalue("gpg_home_with_secret_key") 222 | else: 223 | gpg_home = request.getfixturevalue("gpg_home_with_secret_key_no_pass") 224 | 225 | project_root = unsigned_project_with_checksum_manifest 226 | args = [ 227 | "project", 228 | "gpg-sign", 229 | f"--gnupg-home={gpg_home}", 230 | ] 231 | 232 | # If testing with pass-phrase use built in passphrase prompt. 233 | # We'll mock this return value below. 234 | if use_passphrase == "mock": 235 | args.append("--prompt-passphrase") 236 | elif use_passphrase == "env_var": 237 | os.environ["ANSIBLE_SIGN_GPG_PASSPHRASE"] = "doYouEvenPassphrase" 238 | 239 | # Final argument, the project root. 240 | args.append(str(project_root)) 241 | 242 | if use_passphrase == "mock": 243 | m = mocker.patch("getpass.getpass", return_value="doYouEvenPassphrase") 244 | else: 245 | # We mock getpass() even if we don't use --prompt-passphrase, this lets us 246 | # assert that we don't call it when we don't mean to. 247 | m = mocker.patch("getpass.getpass", return_value="INCORRECT") 248 | 249 | rc = main(args) 250 | captured = capsys.readouterr() 251 | assert "GPG signing successful!" in captured.out 252 | assert "GPG summary: signature created" in captured.out 253 | assert rc in (None, 0) 254 | 255 | if use_passphrase == "mock": 256 | m.assert_called_once() 257 | else: 258 | m.assert_not_called() 259 | 260 | 261 | def test_gpg_sign_with_broken_symlink(capsys, unsigned_project_with_broken_symlink, gpg_home_with_secret_key_no_pass): 262 | """ 263 | Test that we show a warning when there's a broken symlink in the project 264 | directory. This works around a distlib.manifest bug, but tests our handling 265 | of it. 266 | """ 267 | project_root = str(unsigned_project_with_broken_symlink) 268 | args = ["project", "gpg-sign", f"--gnupg-home={gpg_home_with_secret_key_no_pass}", project_root] 269 | rc = main(args) 270 | captured = capsys.readouterr() 271 | assert "Broken symlink found at" in captured.out 272 | assert rc == 1 273 | 274 | 275 | def test_main_color(capsys, signed_project_and_gpg): 276 | """ 277 | Test the CLI's handling of disabling color output: 278 | 1) via global --nocolor flag 279 | 2) via NO_COLOR env-var 280 | """ 281 | 282 | (project_root, gpg_home) = signed_project_and_gpg 283 | args = ["project", "gpg-verify", f"--gnupg-home={gpg_home}", str(project_root)] 284 | args_nocolor = ["--nocolor", "project", "gpg-verify", f"--gnupg-home={gpg_home}", str(project_root)] 285 | 286 | no_color_expected = "[OK ]" 287 | color_expected = "[\033[92mOK \033[0m]" 288 | 289 | # normal -- we should have color here 290 | rc = main(args) 291 | captured = capsys.readouterr() 292 | assert color_expected in captured.out 293 | assert rc in (0, None) 294 | 295 | # --nocolor -- should disable color 296 | rc = main(args_nocolor) 297 | captured = capsys.readouterr() 298 | assert no_color_expected in captured.out 299 | assert rc in (0, None) 300 | 301 | # env var with --nocolor -- should *really* disable color 302 | os.environ["NO_COLOR"] = "foo" 303 | rc = main(args_nocolor) 304 | captured = capsys.readouterr() 305 | assert no_color_expected in captured.out 306 | assert rc in (0, None) 307 | 308 | # env var without --nocolor -- should still disable color 309 | # and it should not matter what $NO_COLOR is set to 310 | no_color_values = ( 311 | "foo", 312 | "0", 313 | "False", 314 | "false", 315 | "true", 316 | "1", 317 | "_", 318 | "$", 319 | ) 320 | for value in no_color_values: 321 | os.environ["NO_COLOR"] = value 322 | rc = main(args) 323 | captured = capsys.readouterr() 324 | assert no_color_expected in captured.out 325 | 326 | # but if it's empty, ignore it 327 | os.environ["NO_COLOR"] = "" 328 | rc = main(args) 329 | captured = capsys.readouterr() 330 | assert rc in (0, None) 331 | assert color_expected in captured.out 332 | -------------------------------------------------------------------------------- /tests/test_cli_pinentry.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import sys 3 | import time 4 | 5 | __author__ = "Rick Elrod" 6 | __copyright__ = "(c) 2022 Red Hat, Inc." 7 | __license__ = "MIT" 8 | 9 | 10 | # On MacOS the is a dialog popup asking for password, not a console prompt. 11 | @pytest.mark.skipif(sys.platform == "darwin", reason="Interactive test not working on MacOS") 12 | def test_pinentry_simple(tmux_session, gpg_home_with_secret_key, unsigned_project_with_checksum_manifest): 13 | """Test that we can sign a file with a pinentry program.""" 14 | home = gpg_home_with_secret_key 15 | window = tmux_session.new_window(window_name="test_pinentry_simple") 16 | pane = window.attached_pane 17 | pane.resize_pane(height=24, width=80) 18 | pane.send_keys("unset HISTFILE") 19 | pane.send_keys("killall gpg-agent") 20 | pane.send_keys("unset ANSIBLE_SIGN_GPG_PASSPHRASE") 21 | pane.send_keys(f"cd {unsigned_project_with_checksum_manifest}") 22 | pane.send_keys(f"ansible-sign project gpg-sign --gnupg-home {home} .") 23 | time.sleep(2) # Give the pinentry prompt time to show up. 24 | cmd = pane.cmd("capture-pane", "-p") 25 | assert cmd.returncode == 0 26 | out = "\n".join(cmd.stdout) 27 | assert "Passphrase: _" in out 28 | pane.send_keys("doYouEvenPassphrase") 29 | time.sleep(2) # Give time for returning to ansible-sign and signing to finish. 30 | cmd = pane.cmd("capture-pane", "-p") 31 | assert cmd.returncode == 0 32 | out = "\n".join(cmd.stdout) 33 | assert "GPG signing successful!" in out 34 | -------------------------------------------------------------------------------- /tests/test_gpg.py: -------------------------------------------------------------------------------- 1 | import gnupg 2 | import os 3 | import pytest 4 | 5 | from ansible_sign.signing import GPGSigner, GPGVerifier 6 | 7 | __author__ = "Rick Elrod" 8 | __copyright__ = "(c) 2022 Red Hat, Inc." 9 | __license__ = "MIT" 10 | 11 | 12 | FIXTURES_DIR = os.path.join( 13 | os.path.dirname(os.path.abspath(__file__)), 14 | "fixtures", 15 | ) 16 | 17 | 18 | @pytest.mark.parametrize( 19 | "directory, expected", 20 | [ 21 | ("hao-signed", True), 22 | ("hao-signed-invalid", False), 23 | ], 24 | ) 25 | def test_gpg_simple_verify(tmp_path, directory, expected): 26 | gpg = gnupg.GPG(gnupghome=tmp_path) 27 | pubkey = open(os.path.join(FIXTURES_DIR, "gpgkeys", "hao_pubkey.txt"), "r").read() 28 | gpg.import_keys(pubkey) 29 | 30 | manifest_path = os.path.join(FIXTURES_DIR, "gpg", directory, ".ansible-sign", "sha256sum.txt") 31 | signature_path = os.path.join(FIXTURES_DIR, "gpg", directory, ".ansible-sign", "sha256sum.txt.sig") 32 | 33 | verifier = GPGVerifier( 34 | manifest_path=manifest_path, 35 | detached_signature_path=signature_path, 36 | gpg_home=tmp_path, 37 | ) 38 | 39 | result = verifier.verify() 40 | assert result.success is expected 41 | assert bool(result) is expected 42 | 43 | 44 | def test_gpg_simple_sign( 45 | gpg_home_with_secret_key, 46 | unsigned_project_with_checksum_manifest, 47 | ): 48 | out = unsigned_project_with_checksum_manifest / ".ansible-sign" / "sha256sum.txt.sig" 49 | manifest_path = unsigned_project_with_checksum_manifest / ".ansible-sign" / "sha256sum.txt" 50 | signer = GPGSigner( 51 | manifest_path=manifest_path, 52 | output_path=out, 53 | passphrase="doYouEvenPassphrase", 54 | gpg_home=gpg_home_with_secret_key, 55 | ) 56 | result = signer.sign() 57 | assert result.success is True 58 | assert bool(result) 59 | assert os.path.exists(out) 60 | 61 | 62 | def test_gpg_sign_verify_end_to_end(signed_project_and_gpg): 63 | project_root = signed_project_and_gpg[0] 64 | gpg_home = signed_project_and_gpg[1] 65 | 66 | manifest_path = project_root / ".ansible-sign" / "sha256sum.txt" 67 | signature_path = project_root / ".ansible-sign" / "sha256sum.txt.sig" 68 | 69 | verifier = GPGVerifier( 70 | manifest_path=manifest_path, 71 | detached_signature_path=signature_path, 72 | gpg_home=gpg_home, 73 | ) 74 | result = verifier.verify() 75 | assert result.success is True 76 | assert bool(result) 77 | 78 | 79 | def test_gpg_none_manifest(): 80 | with pytest.raises(RuntimeError) as ex: 81 | GPGSigner( 82 | manifest_path=None, 83 | output_path="/tmp/this-file-should-not-exist", 84 | passphrase="doYouEvenPassphrase", 85 | gpg_home="/tmp", 86 | ) 87 | assert "manifest_path must not be None" in str(ex) 88 | 89 | 90 | def test_gpg_none_output_path(): 91 | with pytest.raises(RuntimeError) as ex: 92 | GPGSigner( 93 | manifest_path="/tmp/this-file-should-not-exist", 94 | output_path=None, 95 | passphrase="doYouEvenPassphrase", 96 | gpg_home="/tmp", 97 | ) 98 | assert "output_path must not be None" in str(ex) 99 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | minversion = 3.24 3 | envlist = py3,lint,docs 4 | isolated_build = True 5 | 6 | 7 | [testenv] 8 | description = Invoke pytest to run automated tests 9 | deps = 10 | -r {toxinidir}/tests/requirements.txt 11 | setenv = 12 | TOXINIDIR = {toxinidir} 13 | passenv = 14 | CI 15 | GITHUB_* 16 | HOME 17 | SETUPTOOLS_* 18 | extras = 19 | testing 20 | allowlist_externals = 21 | rm 22 | mkdir 23 | commands = 24 | rm -rf /tmp/ansible-sign-pytest 25 | mkdir /tmp/ansible-sign-pytest 26 | pytest --basetemp /tmp/ansible-sign-pytest --color=yes {posargs} 27 | 28 | 29 | [testenv:lint] 30 | description = Run code linters 31 | basepython = python3.8 32 | commands= 33 | black --check . 34 | flake8 --version 35 | flake8 docs src tests 36 | yamllint --version 37 | yamllint -s . 38 | 39 | 40 | [testenv:{build,clean}] 41 | description = 42 | build: Build the package in isolation according to PEP517, see https://github.com/pypa/build 43 | clean: Remove old distribution files and temporary build artifacts (./build and ./dist) 44 | # https://setuptools.pypa.io/en/stable/build_meta.html#how-to-use-it 45 | skip_install = True 46 | changedir = {toxinidir} 47 | deps = 48 | build: build[virtualenv] 49 | passenv = 50 | SETUPTOOLS_* 51 | commands = 52 | clean: python -c 'import shutil; [shutil.rmtree(p, True) for p in ("build", "dist", "docs/_build")]' 53 | clean: python -c 'import pathlib, shutil; [shutil.rmtree(p, True) for p in pathlib.Path("src").glob("*.egg-info")]' 54 | build: python -m build {posargs} 55 | 56 | 57 | [testenv:{docs,doctests,linkcheck}] 58 | description = 59 | docs: Invoke sphinx-build to build the docs 60 | doctests: Invoke sphinx-build to run doctests 61 | linkcheck: Check for broken links in the documentation 62 | passenv = 63 | SETUPTOOLS_* 64 | setenv = 65 | DOCSDIR = {toxinidir}/docs 66 | BUILDDIR = {toxinidir}/docs/_build 67 | docs: BUILD = html 68 | doctests: BUILD = doctest 69 | linkcheck: BUILD = linkcheck 70 | deps = 71 | -r {toxinidir}/docs/requirements.txt 72 | # ^ requirements.txt shared with Read The Docs 73 | commands = 74 | sphinx-build --color -b {env:BUILD} -d "{env:BUILDDIR}/doctrees" "{env:DOCSDIR}" "{env:BUILDDIR}/{env:BUILD}" {posargs} 75 | 76 | 77 | [testenv:publish] 78 | description = 79 | Publish the package you have been developing to a package index server. 80 | By default, it uses testpypi. If you really want to publish your package 81 | to be publicly accessible in PyPI, use the `-- --repository pypi` option. 82 | skip_install = True 83 | changedir = {toxinidir} 84 | passenv = 85 | # See: https://twine.readthedocs.io/en/latest/ 86 | TWINE_USERNAME 87 | TWINE_PASSWORD 88 | TWINE_REPOSITORY 89 | deps = twine 90 | commands = 91 | python -m twine check dist/* 92 | python -m twine upload {posargs:--repository {env:TWINE_REPOSITORY:testpypi}} dist/* 93 | --------------------------------------------------------------------------------