├── .github └── workflows │ ├── publish.yaml │ └── test.yaml ├── .gitignore ├── .readthedocs.yaml ├── .travis.yml ├── .vscode └── launch.json ├── AUTHORS.rst ├── CONTRIBUTING.rst ├── HISTORY.rst ├── LICENSE.txt ├── MANIFEST.in ├── Makefile ├── README.rst ├── VERSION ├── bandit.yaml ├── bin └── psec ├── docs ├── .gitignore ├── Makefile ├── _static │ └── .gitignore ├── advanced.rst ├── authors.rst ├── conf.py ├── contributing.rst ├── history.rst ├── index.rst ├── installation.rst ├── license.rst ├── make.bat ├── readme.rst ├── reference.rst ├── requirements.txt └── usage.rst ├── poetry.lock ├── poetry.toml ├── psec ├── __init__.py ├── __main__.py ├── about.py ├── app.py ├── cli │ ├── __init__.py │ ├── environments │ │ ├── __init__.py │ │ ├── create.py │ │ ├── default.py │ │ ├── delete.py │ │ ├── list.py │ │ ├── path.py │ │ ├── rename.py │ │ └── tree.py │ ├── groups │ │ ├── __init__.py │ │ ├── create.py │ │ ├── delete.py │ │ ├── list.py │ │ ├── path.py │ │ └── show.py │ ├── init.py │ ├── run.py │ ├── secrets │ │ ├── __init__.py │ │ ├── backup.py │ │ ├── create.py │ │ ├── delete.py │ │ ├── describe.py │ │ ├── find.py │ │ ├── generate.py │ │ ├── get.py │ │ ├── path.py │ │ ├── restore.py │ │ ├── send.py │ │ ├── set.py │ │ ├── show.py │ │ └── tree.py │ ├── ssh.py │ ├── template.py │ └── utils │ │ ├── __init__.py │ │ ├── myip.py │ │ ├── netblock.py │ │ ├── set_aws_credentials.py │ │ ├── tfbackend.py │ │ ├── tfoutput.py │ │ └── yaml_to_json.py ├── exceptions.py ├── google_oauth2.py ├── secrets_environment │ ├── __init__.py │ ├── factory │ │ └── __init__.py │ └── handlers │ │ ├── __init__.py │ │ ├── boolean.py │ │ ├── crypt_6.py │ │ ├── password.py │ │ ├── sha256_digest.py │ │ ├── string.py │ │ ├── token_base64.py │ │ ├── token_hex.py │ │ ├── token_urlsafe.py │ │ └── uuid4.py └── utils.py ├── pylintrc ├── pyproject.toml ├── requirements-dev.txt ├── requirements.txt ├── secrets.d └── hypriot.json ├── setup.cfg ├── test-environment.bash ├── tests ├── .gitignore ├── 00_usage.bats ├── __init__.py ├── gosecure.json ├── runtime_00_initialization.bats ├── runtime_05_environments.bats ├── runtime_10_groups.bats ├── runtime_20_secrets.bats ├── runtime_30_utils.bats ├── runtime_40_run.bats ├── secrets.d │ ├── consul.json │ ├── hypriot.json │ ├── jenkins.json │ ├── myapp.json │ ├── oauth.json │ └── trident.json ├── test_exceptions.py ├── test_groups.py ├── test_helper.bash ├── test_secrets.py ├── test_utils.py └── yamlsecrets │ └── secrets.d │ ├── jenkins.yml │ ├── myapp.yml │ ├── oauth.yml │ └── trident.yml └── tox.ini /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*.*.*' 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-22.04 14 | env: 15 | PY_COLORS: 1 16 | PYTHON_VERSION: '3.12.6' 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 0 22 | 23 | - name: Set up Python 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: ${{env.PYTHON_VERSION}} 27 | 28 | - name: Install and configure Poetry 29 | uses: snok/install-poetry@v1 30 | with: 31 | version: 1.8.3 32 | 33 | - name: Install poetry dependencies 34 | run: poetry install --no-root --with=dev --with=test 35 | 36 | - name: Add Dynamic Versioning Plugin 37 | run: | 38 | poetry self add poetry-dynamic-versioning[plugin] 39 | 40 | - name: Update the version 41 | run: | 42 | poetry dynamic-versioning 43 | echo "VERSION=$(poetry version --short)" 44 | 45 | - name: Package project 46 | run: make twine-check 47 | 48 | - name: Store the distribution packages 49 | uses: actions/upload-artifact@v4 50 | with: 51 | name: python-package-distributions 52 | path: dist/ 53 | 54 | # [1-build-publish-workflow] 55 | test-pypi-publish: 56 | name: Publish release candidate artifacts to test PyPI 57 | if: contains(github.ref, 'rc') == true 58 | runs-on: ubuntu-22.04 59 | needs: 60 | - build 61 | environment: 62 | name: testpypi 63 | url: https://test.pypi.org/p/python_secrets 64 | permissions: 65 | id-token: write 66 | steps: 67 | - name: Download all the dists 68 | uses: actions/download-artifact@v4 69 | with: 70 | name: python-package-distributions 71 | path: dist/ 72 | 73 | - name: Publish release candidate distribution to TestPyPI 74 | uses: pypa/gh-action-pypi-publish@release/v1 75 | with: 76 | repository-url: https://test.pypi.org/legacy/ 77 | skip-existing: true 78 | 79 | pypi-publish: 80 | name: Publish release artifacts to PyPI 81 | if: contains(github.ref, 'rc') == false 82 | runs-on: ubuntu-22.04 83 | needs: 84 | - build 85 | environment: 86 | name: pypi 87 | url: https://pypi.org/p/python_secrets 88 | permissions: 89 | id-token: write 90 | steps: 91 | - name: Download all the dists 92 | uses: actions/download-artifact@v4 93 | with: 94 | name: python-package-distributions 95 | path: dist/ 96 | 97 | - name: Publish distribution to PyPI 98 | uses: pypa/gh-action-pypi-publish@release/v1 99 | # ![1-build-publish-workflow] 100 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | # .github/workflows/test.yml 2 | name: Test 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | - develop 9 | - 'feature/*' 10 | - 'hotfix/*' 11 | pull_request: 12 | 13 | jobs: 14 | test: 15 | runs-on: ubuntu-22.04 16 | env: 17 | PY_COLORS: 1 18 | TOX_PARALLEL_NO_SPINNER: 1 19 | PYTHON_VERSION: '3.12.6' 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v4 24 | with: 25 | fetch-depth: 0 26 | 27 | - name: Set up Python 28 | uses: actions/setup-python@v5 29 | with: 30 | python-version: ${{env.PYTHON_VERSION}} 31 | 32 | - name: Set up Conda 33 | uses: conda-incubator/setup-miniconda@v3 34 | with: 35 | auto-activate-base: true 36 | python-version: ${{env.PYTHON_VERSION}} 37 | auto-update-conda: true 38 | 39 | - name: Install and configure Poetry 40 | uses: snok/install-poetry@v1 41 | with: 42 | version: 1.8.3 43 | 44 | - name: Install poetry dependencies 45 | run: poetry install --no-root --with=dev --with=test 46 | 47 | - name: Install remaining dependencies 48 | run: | 49 | conda config --set always_yes yes --set changeps1 no 50 | make bats-libraries 51 | # Useful for debugging any issues with conda 52 | conda info -a 53 | 54 | - name: Add Dynamic Versioning Plugin 55 | run: | 56 | poetry self add poetry-dynamic-versioning[plugin] 57 | 58 | - name: Update the version 59 | run: | 60 | poetry dynamic-versioning 61 | echo "VERSION=$(poetry version --short)" 62 | 63 | # [1-test-workflow] 64 | - name: Run tests 65 | run: make test 66 | # ![1-test-workflow] 67 | 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # psec related temp directories and files 2 | known_hosts/ 3 | fingerprints/ 4 | 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | venv/ 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | # poetry-dynamic-versioning file 31 | psec/_version.py 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # PyCharm 40 | .idea 41 | .editorconfig 42 | 43 | # VSCode 44 | .vscode/ 45 | 46 | # Installer logs 47 | pip-log.txt 48 | pip-delete-this-directory.txt 49 | 50 | # Unit test / coverage reports 51 | htmlcov/ 52 | .tox/ 53 | .coverage 54 | .coverage.* 55 | .cache 56 | nosetests.xml 57 | coverage.xml 58 | *,cover 59 | .hypothesis/ 60 | 61 | # Translations 62 | *.mo 63 | *.pot 64 | 65 | # Django stuff: 66 | *.log 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # python_secrets 75 | .python_secrets_environment 76 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 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: 9 | os: ubuntu-22.04 10 | tools: 11 | python: "3.12" 12 | 13 | post_create_environment: 14 | # Install poetry 15 | - pip install poetry 16 | # Tell poetry to not use a virtual environment 17 | - poetry config virtualenvs.create false 18 | post_install: 19 | # Install dependencies 20 | - poetry install --with docs 21 | 22 | # Build documentation in the docs/ directory with Sphinx 23 | sphinx: 24 | configuration: docs/conf.py 25 | 26 | formats: 27 | - pdf 28 | 29 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # Config file for automatic testing at travis-ci.org 2 | # This file will be regenerated if you run travis_pypi_setup.py 3 | 4 | language: python 5 | 6 | #virtualenv: 7 | # system_site_packages: true 8 | 9 | matrix: 10 | include: 11 | - python: 3.6 12 | dist: bionic 13 | env: TOXENV=py36 14 | - python: 3.7 15 | dist: bionic 16 | env: TOXENV=py37 17 | - python: 3.8 18 | dist: bionic 19 | env: TOXENV=py38 20 | - python: 3.8 21 | dist: bionic 22 | env: TOXENV=bandit 23 | - python: 3.8 24 | dist: bionic 25 | env: TOXENV=pep8 26 | - python: 3.8 27 | dist: bionic 28 | env: TOXENV=pypi 29 | 30 | 31 | branches: 32 | only: 33 | - master 34 | - develop 35 | 36 | # command to install dependencies, e.g. pip install -r requirements.txt --use-mirrors 37 | install: 38 | - wget https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh -O miniconda.sh 39 | - bash miniconda.sh -b -p $HOME/miniconda 40 | - export PATH=$HOME/miniconda/bin:$PATH 41 | - source "$HOME/miniconda/etc/profile.d/conda.sh" 42 | - hash -r 43 | - conda config --set always_yes yes --set changeps1 no 44 | - conda update --yes conda # Update CONDA without command line prompt 45 | - conda install -c conda-forge --yes tox 46 | # Useful for debugging any issues with conda 47 | - conda info -a 48 | 49 | # command to run tests, e.g. python setup.py test 50 | script: 51 | - make test 52 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Development Lead 6 | ---------------- 7 | 8 | * Dave Dittrich 9 | 10 | Contributors 11 | ------------ 12 | 13 | None yet. Why not be the first? 14 | 15 | Support 16 | ------- 17 | 18 | Tools used in rendering this package: 19 | 20 | * Cookiecutter_ 21 | * `cookiecutter-pypackage`_ 22 | 23 | Development of this program was supported in part under an Open Source 24 | Development Grant from the Comcast Innovation Fund. 25 | 26 | .. _Cookiecutter: https://github.com/audreyr/cookiecutter 27 | .. _`cookiecutter-pypackage`: https://github.com/audreyr/cookiecutter-pypackage 28 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributing 3 | ============ 4 | 5 | Contributions are welcome, and they are greatly appreciated! Every 6 | little bit helps, and credit will always be given. 7 | 8 | You can contribute in many ways: 9 | 10 | Types of Contributions 11 | ---------------------- 12 | 13 | Report Bugs 14 | ~~~~~~~~~~~ 15 | 16 | Report bugs at this repository's GitHub issues page (https://github.com/davedittrich/python_secrets/issues). [1]_ 17 | 18 | If you are reporting a bug, please include: 19 | 20 | * Your operating system name and version. 21 | * Any details about your local setup that might be helpful in troubleshooting. 22 | * Output using the ``--debug`` and ``-vvv`` flags. 23 | * Detailed steps to reproduce the bug. 24 | 25 | Fix Bugs 26 | ~~~~~~~~ 27 | 28 | Look through the GitHub issues [1]_ for bugs. Anything tagged with **bug** 29 | is open to whoever wants to implement it. 30 | 31 | Implement Features 32 | ~~~~~~~~~~~~~~~~~~ 33 | 34 | Look through the GitHub issues [1]_ for features. Anything tagged with **feature** 35 | is open to whoever wants to implement it. 36 | 37 | Write Documentation 38 | ~~~~~~~~~~~~~~~~~~~ 39 | 40 | ``python_secrets``, like pretty much every open source project, could always use 41 | more user-friendly documentation. That includes this official ``python_secrets`` 42 | documentation, docstrings in source code, and around the web in blog posts, 43 | articles, and such. 44 | 45 | Submit Feedback 46 | ~~~~~~~~~~~~~~~ 47 | 48 | The best way to send feedback is to file an issue at https://github.com/davedittrich/python_secrets/issues. 49 | 50 | If you are proposing a feature: 51 | 52 | * Explain in detail how it would work. 53 | * Keep the scope as narrow as possible, to make it easier to implement the feature. 54 | * Remember that this is a volunteer-driven project, and that contributions 55 | (i.e., pull requests) *are always welcome*. ;) 56 | 57 | Get Started! 58 | ------------ 59 | 60 | Ready to contribute? Here's how to set up `python_secrets` for local development. 61 | 62 | #. Fork the `python_secrets` repo on GitHub. 63 | 64 | #. Clone your fork locally:: 65 | 66 | $ git clone git@github.com:your_name_here/python_secrets.git 67 | 68 | #. Ensure Bats is ready to use for testing. Bats assertion libraries 69 | are assumed to be installed in Git cloned repositories at the same 70 | directory level as the ``python_secrets`` repository:: 71 | 72 | $ git clone https://github.com/ztombol/bats-support.git 73 | $ git clone https://github.com/jasonkarns/bats-assert-1.git 74 | 75 | #. Install your local copy into a virtualenv. Assuming you have 76 | virtualenvwrapper installed, this is how you set up your fork for 77 | local development:: 78 | 79 | $ mkvirtualenv python_secrets 80 | $ cd python_secrets/ 81 | $ make install 82 | 83 | #. Create a branch for local development:: 84 | 85 | $ git checkout -b name-of-your-bugfix-or-feature 86 | 87 | Now you can make your changes locally. 88 | 89 | #. When you're done making changes, check that your changes pass 90 | ``flake8`` and ``bandit`` (security) tests, including testing 91 | other Python versions with ``tox``:: 92 | 93 | $ make test 94 | 95 | To get ``flake8`` and ``tox``, just ``python -m pip install`` them 96 | into your virtualenv. 97 | 98 | #. Commit your changes and push your branch to GitHub:: 99 | 100 | $ git add . 101 | $ git commit -m "Your detailed description of your changes." 102 | $ git push origin name-of-your-bugfix-or-feature 103 | 104 | #. Submit a pull request through the GitHub website. 105 | 106 | Pull Request Guidelines 107 | ----------------------- 108 | 109 | Before you submit a pull request, check that it meets these guidelines: 110 | 111 | #. The pull request should include tests. 112 | 113 | #. If the pull request adds functionality, the docs should be updated. Put 114 | your new functionality into a function with a docstring, and add the 115 | feature to the list of changes in ``HISTORY.rst`` and documentation on use 116 | in ``README.rst``, ``docs/usage.rst``, and ``parser.epilog`` for CLI 117 | commands. 118 | 119 | #. The pull request should work for the versions of Python defined in ``tox.ini`` 120 | and ``.travis.yml``. Check 121 | https://travis-ci.org/davedittrich/python_secrets/pull_requests 122 | and make sure that the tests pass for all supported Python versions. 123 | 124 | Tips 125 | ---- 126 | 127 | To run a subset of Python unit tests:: 128 | 129 | $ python -m unittest tests.test_secrets 130 | 131 | To run a subset of Bats tests:: 132 | 133 | $ bats tests/secrets.bats 134 | 135 | 136 | .. [1] https://github.com/davedittrich/python_secrets/issues 137 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018--2021 Dave Dittrich . 2 | All rights reserved. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include CONTRIBUTING.rst 3 | include HISTORY.rst 4 | include LICENSE.txt 5 | include README.rst 6 | include requirements.txt 7 | 8 | prune tests/libs 9 | 10 | recursive-exclude * __pycache__ 11 | recursive-exclude * *.py[co] 12 | 13 | recursive-include docs *.rst conf.py Makefile make.bat 14 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for python_secrets 2 | 3 | SHELL=bash 4 | VERSION=$(shell cat VERSION) 5 | PROJECT:=$(shell basename `pwd`) 6 | # Install poetry into conda environment 7 | export POETRY_HOME:=$(CONDA_PREFIX) 8 | POETRY_INSTALL_URL=https://raw.githubusercontent.com/python-poetry/install.python-poetry.org/refs/heads/main/install-poetry.py 9 | POETRY_VERSION=1.8.3 10 | 11 | .PHONY: default 12 | default: all 13 | 14 | .PHONY: all 15 | all: install 16 | 17 | .PHONY: help 18 | help: 19 | @echo 'usage: make [VARIABLE=value] [target [target..]]' 20 | @echo '' 21 | @echo 'build - build project packages' 22 | @echo 'twine-check - run "twine check"' 23 | @echo 'clean - remove build artifacts' 24 | @echo 'spotless - deep clean' 25 | @echo 'test - generic target for both "test-tox" and "test-bats"' 26 | @echo 'test-tox - run tox tests' 27 | @echo 'test-bats - run Bats unit tests' 28 | @echo 'test-bats-runtime - run Bats runtime integration/system tests' 29 | @echo 'release - produce a pypi production release' 30 | @echo 'release-test - produce a pypi test release' 31 | @echo 'release-prep - final documentation preparations for release' 32 | @echo 'install - build project with Poetry and install with Pip' 33 | @echo 'update-packages - update dependencies with Poetry' 34 | @echo 'docs-tests - generate bats test output for documentation' 35 | @echo 'docs-help - generate "psec help" output for documentation' 36 | @echo 'docs - build Sphinx docs' 37 | 38 | #HELP test - run 'tox' for testing 39 | .PHONY: test 40 | test: test-tox 41 | @echo '[+] test: All tests passed' 42 | 43 | # [Makefile-test-tox] 44 | # The following target rules are optimized by splitting up `tox` tests so they 45 | # fail early on syntax and security checks before running more lengthy unit 46 | # tests against Python versions (with coverage reporting). This is designed to 47 | # more easily focus on code quality first and foremost. 48 | .PHONY: test-tox 49 | test-tox: 50 | @if [ -f .python_secrets_environment ]; then (echo '[!] Remove .python_secrets_environment prior to testing'; exit 1); fi 51 | touch docs/psec_help.txt 52 | @# See also comment in tox.ini file. 53 | tox run -m static 54 | tox run -m tests 55 | # ![Makefile-test-tox] 56 | 57 | .PHONY: test-bats 58 | test-bats: bats-libraries 59 | @if [ "$(TRAVIS)" != "true" ]; then \ 60 | if ! type bats 2>/dev/null >/dev/null; then \ 61 | echo "[-] Skipping bats tests"; \ 62 | else \ 63 | source test-environment.bash; \ 64 | echo "[+] Running bats tests: $(shell cd tests && echo [0-9][0-9]*.bats)"; \ 65 | PYTHONWARNINGS="ignore" bats --tap tests/[0-9][0-9]*.bats && \ 66 | echo '[+] test-bats: All tests passed'; \ 67 | fi \ 68 | fi 69 | 70 | .PHONY: test-bats-runtime 71 | test-bats-runtime: bats-libraries 72 | @echo "[+] Running bats runtime tests: $(shell cd tests && echo runtime_[0-9][0-9]*.bats)"; \ 73 | (source test-environment.bash; \ 74 | PYTHONWARNINGS="ignore" bats --tap tests/runtime_[0-9][0-9]*.bats && \ 75 | echo '[+] test-bats-runtime: All tests passed') 76 | 77 | .PHONY: no-diffs 78 | no-diffs: 79 | @echo 'Checking Git for uncommitted changes' 80 | git diff --quiet HEAD 81 | 82 | #HELP release - package and upload a release to pypi 83 | .PHONY: release 84 | release: clean docs build twine-check 85 | (cd dist && twine upload $$(cat .LATEST_*) -r pypi) 86 | 87 | #HELP release-prep - final documentation preparations for release 88 | .PHONY: release-prep 89 | release-prep: install clean build docs-help docs-tests 90 | @echo 'Check in help text docs and HISTORY.rst?' 91 | 92 | #HELP release-test - upload to "testpypi" 93 | .PHONY: release-test 94 | release-test: clean test docs-tests docs twine-check 95 | $(MAKE) no-diffs 96 | (cd dist && twine upload $$(cat .LATEST_*) -r testpypi) 97 | 98 | #HELP build - build project packages 99 | .PHONY: build 100 | build: 101 | @rm -f dist/.LATEST_TARGZ dist/.LATEST_WHEEL 102 | poetry build 103 | @(cd dist && ls -t *.tar.gz 2>/dev/null | head -n 1 > .LATEST_TARGZ) 104 | @(cd dist && ls -t *.whl 2>/dev/null | head -n 1 > .LATEST_WHEEL) 105 | 106 | #HELP twine-check 107 | .PHONY: twine-check 108 | twine-check: build 109 | (cd dist && twine check $$(cat .LATEST_*)) 110 | 111 | #HELP clean - remove build artifacts 112 | .PHONY: clean 113 | clean: clean-docs 114 | rm -f psec/_version.py 115 | rm -rf dist build *.egg-info 116 | find . -name '*.pyc' -delete 117 | 118 | .PHONY: clean-docs 119 | clean-docs: 120 | cd docs && make clean 121 | 122 | .PHONY: spotless 123 | spotless: clean 124 | rm -rf htmlcov 125 | rm -rf .tox/ 126 | python -m pip uninstall -y $(PROJECT) 127 | 128 | #HELP install - build project with Poetry and install with Pip' 129 | .PHONY: i 130 | .PHONY: install 131 | i install: clean build 132 | (cd dist && python -m pip install $$(cat .LATEST_WHEEL) --force-reinstall) 133 | 134 | #HELP uninstall - uninstall project package from active virtualenv' 135 | .PHONY: uninstall 136 | uninstall: 137 | python -m pip uninstall python_secrets 138 | 139 | # dittrich 2024-10-08 Assuming use of conda environments right now... 140 | 141 | .PHONY: install-poetry 142 | install-poetry: 143 | @if [[ "$(shell poetry --version 2>/dev/null)" =~ "$(POETRY_VERSION)" ]]; then \ 144 | echo "[+] poetry version $(POETRY_VERSION) is already installed"; \ 145 | else \ 146 | (curl -sSL $(POETRY_INSTALL_URL) | python - --version $(POETRY_VERSION)); \ 147 | poetry self add "poetry-dynamic-versioning[plugin]"; \ 148 | fi 149 | 150 | .PHONY: uninstall-poetry 151 | uninstall-poetry: 152 | curl -sSL $(POETRY_INSTALL_URL) | python - --version $(POETRY_VERSION) --uninstall 153 | 154 | #HELP update-packages - update dependencies with Poetry 155 | .PHONY: update-packages 156 | update-packages: 157 | poetry update 158 | 159 | #HELP docs-tests - generate bats test output for documentation 160 | .PHONY: docs-tests 161 | PR=pr --omit-header --omit-pagination --page-width 80 162 | docs-tests: 163 | $(MAKE) -B docs/test-tox.txt 164 | $(MAKE) -B docs/test-bats.txt 165 | $(MAKE) -B docs/test-bats-runtime.txt 166 | 167 | docs/test-tox.txt: 168 | (echo '$$ make test-tox' && $(MAKE) test-tox) |\ 169 | $(PR) | tee docs/test-tox.txt 170 | 171 | docs/test-bats.txt: 172 | $(MAKE) test-bats | $(PR) | tee docs/test-bats.txt 173 | 174 | docs/test-bats-runtime.txt: 175 | (echo '$$ make test-bats-runtime' && $(MAKE) test-bats-runtime) |\ 176 | $(PR) | tee docs/test-bats-runtime.txt 177 | 178 | #HELP docs - build Sphinx docs (NOT INTEGRATED YET FROM OPENSTACK CODE BASE) 179 | .PHONY: docs 180 | docs: docs/psec_help.txt 181 | cd docs && make html 182 | 183 | docs/psec_help.txt: install 184 | PYTHONPATH=$(shell pwd) python -m psec help | tee docs/psec_help.txt 185 | 186 | #HELP examples - produce some example output for docs 187 | .PHONY: examples 188 | examples: 189 | @PYTHONPATH=$(shell pwd) python -m psec --help 190 | 191 | # Git submodules and subtrees are both a huge PITA. This is way simpler. 192 | 193 | .PHONY: bats-libraries 194 | bats-libraries: bats bats-support bats-assert 195 | 196 | bats: 197 | @[ -d tests/libs/bats ] || \ 198 | (mkdir -p tests/libs/bats; git clone http://github.com/sstephenson/bats tests/libs/bats) 199 | 200 | 201 | bats-support: 202 | @[ -d tests/libs/bats-support ] || \ 203 | (mkdir -p tests/libs/bats-support; git clone https://github.com/ztombol/bats-support tests/libs/bats-support) 204 | 205 | bats-assert: 206 | @[ -d tests/libs/bats-assert ] || \ 207 | (mkdir -p tests/libs/bats-assert; git clone https://github.com/ztombol/bats-assert tests/libs/bats-assert) 208 | 209 | #EOF 210 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 24.10.12 2 | -------------------------------------------------------------------------------- /bandit.yaml: -------------------------------------------------------------------------------- 1 | skips: 2 | - B101 3 | - B110 4 | -------------------------------------------------------------------------------- /bin/psec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # This is a wrapper with the short name 'psec' to provide a script 5 | # for installation by 'pip', which is required by 'pipsi' (as it does 6 | # doesn't use console_scripts entry points, just script.) It is 7 | # almost exactly the same thing that 'pip' will create, so 8 | # effectively a NOP. 9 | 10 | import re 11 | import sys 12 | 13 | from psec.__main__ import main 14 | 15 | if __name__ == '__main__': 16 | sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0]) 17 | sys.exit(main()) 18 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | psec_help.txt 2 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(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 http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/python_secrets.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/python_secrets.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/python_secrets" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/python_secrets" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/_static/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davedittrich/python_secrets/74d6d2f977d92dbc6946c441e5cbce0b986e6802/docs/_static/.gitignore -------------------------------------------------------------------------------- /docs/advanced.rst: -------------------------------------------------------------------------------- 1 | .. _section_advanced: 2 | 3 | ============== 4 | Advanced Usage 5 | ============== 6 | 7 | Managing SSH public keys 8 | ------------------------ 9 | 10 | There is a fundamental problem with using Secure Shell (SSH) access to remote 11 | systems, which is dealing with validation of previously unseen host public 12 | keys. The vulnerability here, as described by SSH.com, is a `MAN-IN-THE-MIDDLE 13 | ATTACK`_. 14 | 15 | This problem is bad enough with manually installed computers, either virtual 16 | machines or bare-metal servers. In a cloud environment, the problem is 17 | exacerbated because every newly instantiated vitual machine may get its own new 18 | IP address, domain name, public and private key pairs. To retrieve the public 19 | key and/or determine the hashes of the public keys in order to validate them, 20 | you must determine the new IP addresses (or DNS names) of every node in the 21 | stack in order to remotely log into the instances using SSH, which requires 22 | validating the hashes of the SSH public keys... see the problem? 23 | 24 | There is a better way, which is to retrieve the public keys and/or fingerprints 25 | using the cloud provider's portal console output feature (or the debug output 26 | of a program like Terraform). ``psec`` has sub-commands that parse the console 27 | output log to extract the keys and then use Ansible to ensure they are present 28 | in the system's ``known_hosts`` file for all to use immediately. (There is an 29 | inverse command to remove these keys when the instance is going to be destroyed 30 | and its IP address changed and SSH keys never to be seen again). 31 | 32 | This asciicast shows all of the steps involved in instantiating a new cloud 33 | instance, extracting and storing its SSH public keys, immediately using SSH 34 | without having to validate the key, removing the key, and destroying the 35 | instance. 36 | 37 | .. image:: https://asciinema.org/a/245120.svg 38 | :target: https://asciinema.org/a/245120?autoplay=1 39 | :align: center 40 | :alt: Managing SSH host public keys 41 | :width: 835px 42 | 43 | .. 44 | 45 | .. _MAN-IN-THE-MIDDLE ATTACK: https://www.ssh.com/attack/man-in-the-middle 46 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../HISTORY.rst 2 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. python_secrets documentation master file, created by 2 | sphinx-quickstart on Tue Jul 9 22:26:36 2013. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | .. meta:: 7 | :description: Python CLI for managing secrets in open source code projects 8 | :robots: index, follow 9 | :keywords: python, secrets, python_secrets, psec 10 | 11 | 12 | psec 13 | ==== 14 | 15 | Contents: 16 | 17 | .. toctree:: 18 | :maxdepth: 2 19 | 20 | readme 21 | installation 22 | usage 23 | advanced 24 | reference 25 | contributing 26 | authors 27 | history 28 | license 29 | 30 | Indices and tables 31 | ================== 32 | 33 | * :ref:`genindex` 34 | * :ref:`modindex` 35 | * :ref:`search` 36 | 37 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Installation 3 | ============ 4 | 5 | Install ``psec`` using the ``pip`` module of Python: 6 | 7 | .. code-block:: console 8 | 9 | $ python -V 10 | Python 3.12.6 11 | $ python -m pip install python_secrets 12 | 13 | .. 14 | 15 | .. image:: https://asciinema.org/a/201502.png 16 | :target: https://asciinema.org/a/201502?autoplay=1 17 | :align: center 18 | :alt: Installation of python_secrets 19 | :width: 835px 20 | 21 | .. 22 | 23 | For best results, use a Python ``virtualenv`` to avoid problems due to 24 | the system Python not conforming to the version dependency. User's with 25 | Mac OS X systems and Windows systems may want to use ``miniconda`` to 26 | standardize management of your virtual environments across those two 27 | operating systems as well as Linux. 28 | 29 | If you are not doing a lot of Python development and just want to use 30 | ``psec`` for managing secrets in your open source projects (or as part 31 | of an open source project that depends on ``psec`` for configuration 32 | files that include secrets) there are some simpler options that 33 | transparently handle the virtual environment creation for you. The 34 | ``pipx`` program is easy to install as a PyPI package, or with 35 | Homebrew on the Mac (see https://pipxproject.github.io/pipx/ for 36 | installation instructions). 37 | -------------------------------------------------------------------------------- /docs/license.rst: -------------------------------------------------------------------------------- 1 | License 2 | ======= 3 | 4 | .. literalinclude:: ../LICENSE.txt 5 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /docs/reference.rst: -------------------------------------------------------------------------------- 1 | .. _api_reference_toplevel: 2 | 3 | API Reference 4 | ============= 5 | 6 | Version 7 | ------- 8 | 9 | .. py:data:: psec.__version__ 10 | 11 | Current python_secrets version. 12 | 13 | psec.google_oauth2 14 | ------------------ 15 | 16 | .. automodule:: psec.google_oauth2 17 | :members: 18 | :undoc-members: 19 | :special-members: 20 | :noindex: 21 | 22 | 23 | psec.secrets_environment 24 | ------------------------ 25 | 26 | .. automodule:: psec.secrets_environment 27 | :members: 28 | :undoc-members: 29 | :special-members: 30 | :noindex: 31 | 32 | psec.utils 33 | ---------- 34 | 35 | .. automodule:: psec.utils 36 | :members: 37 | :undoc-members: 38 | :special-members: 39 | :noindex: 40 | 41 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | -e . 3 | -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | create = false 3 | prefer-active-python = true 4 | -------------------------------------------------------------------------------- /psec/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from importlib.metadata import ( 4 | version, 5 | PackageNotFoundError, 6 | ) 7 | 8 | __author__ = 'Dave Dittrich' 9 | __email__ = 'dave.dittrich@gmail.com' 10 | __release__ = '24.10.12' 11 | 12 | try: 13 | from psec._version import ( 14 | __version__, 15 | __version_tuple__, 16 | ) 17 | except ModuleNotFoundError: 18 | __version__ = __release__ 19 | __version_tuple__ = tuple(__version__.split('.')) 20 | 21 | if __version__ in ['0.0.0', '0.1.0']: 22 | try: 23 | __version__ = version("python-secrets") 24 | except PackageNotFoundError: 25 | __version__ = __release__ 26 | 27 | __all__ = [ 28 | '__author__', 29 | '__email__', 30 | '__release__', 31 | '__version__', 32 | '__version_tuple__', 33 | ] 34 | 35 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 36 | -------------------------------------------------------------------------------- /psec/__main__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Modular secrets and settings management app. 5 | 6 | Generic modular configuration file manager. 7 | 8 | """ 9 | 10 | # Standard imports 11 | import sys 12 | 13 | # Local imports 14 | from psec import __version__ 15 | from psec.app import PythonSecretsApp 16 | 17 | # Register handlers to ensure parser arguments are available. 18 | from psec.secrets_environment.handlers import * # noqa pylint: disable=wildcard-import, unused-wildcard-import 19 | 20 | 21 | def main(argv=None): 22 | """ 23 | Command line interface for the ``psec`` program. 24 | """ 25 | if argv is None: 26 | argv = sys.argv[1:] 27 | myapp = PythonSecretsApp( 28 | namespace='psec', 29 | docs_url='https://python-secrets.readthedocs.io/en/latest/usage.html', 30 | version=__version__, 31 | ) 32 | return myapp.run(argv) 33 | 34 | 35 | if __name__ == '__main__': 36 | sys.exit(main(sys.argv[1:])) 37 | 38 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 39 | -------------------------------------------------------------------------------- /psec/about.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | """ 4 | About the ``psec`` (``python_secrets``) CLI. 5 | """ 6 | 7 | import logging 8 | import os 9 | 10 | from collections import OrderedDict 11 | from pathlib import Path 12 | 13 | from cliff.show import ShowOne 14 | 15 | from psec import __version__ 16 | from psec.utils import is_secrets_basedir 17 | 18 | 19 | UNDEF = '' 20 | 21 | 22 | class About(ShowOne): 23 | """ 24 | About the ``psec`` (``python_secrets``) CLI. 25 | 26 | Prints out selected environment and internal state information 27 | useful for better situational awareness of how ``psec`` will 28 | behave when testing, etc.:: 29 | 30 | $ psec about 31 | +-----------------------------+-------------------------------------------------------------------------------+ 32 | | Field | Value | 33 | +-----------------------------+-------------------------------------------------------------------------------+ 34 | | env:BROWSER | safari | 35 | | env:CLIFF_FIT_WIDTH | 1 | 36 | | env:CONDA_DEFAULT_ENV | psec | 37 | | env:D2_ENVIRONMENT | python_secrets | 38 | | env:D2_LOGFILE | | 39 | | env:D2_SECRETS_BASEDIR | /tmp/.psecsecrets | 40 | | env:D2_SECRETS_BASENAME | | 41 | | env:D2_NO_REDACT | | 42 | | environment | python_secrets | 43 | | executable_path | /usr/local/Caskroom/miniconda/base/envs/psec/lib/python3.8/site-packages/psec | 44 | | secrets_basedir | /tmp/.psecsecrets | 45 | | secrets_basedir_initialized | True | 46 | | version | 21.2.1.dev8+gcbfdd3a.d20210623 | 47 | +-----------------------------+-------------------------------------------------------------------------------+ 48 | """ # noqa 49 | 50 | log = logging.getLogger(__name__) 51 | 52 | def get_parser(self, prog_name): 53 | parser = super().get_parser(prog_name) 54 | return parser 55 | 56 | def take_action(self, parsed_args): 57 | columns = [] 58 | data = [] 59 | info = OrderedDict({ 60 | "env:BROWSER": os.getenv('BROWSER', UNDEF), 61 | "env:CLIFF_FIT_WIDTH": os.getenv('CLIFF_FIT_WIDTH', UNDEF), 62 | "env:CONDA_DEFAULT_ENV": os.getenv('CONDA_DEFAULT_ENV', UNDEF), 63 | "env:D2_ENVIRONMENT": os.getenv('D2_ENVIRONMENT', UNDEF), 64 | "env:D2_LOGFILE": os.getenv('D2_LOGFILE', UNDEF), 65 | "env:D2_SECRETS_BASEDIR": os.getenv('D2_SECRETS_BASEDIR', UNDEF), 66 | "env:D2_SECRETS_BASENAME": os.getenv('D2_SECRETS_BASENAME', UNDEF), 67 | "env:D2_NO_REDACT": os.getenv('D2_NO_REDACT', UNDEF), 68 | "environment": self.app.environment, 69 | "executable_path": Path(__file__).parent.absolute(), 70 | "secrets_basedir": self.app.secrets_basedir, 71 | "secrets_basedir_initialized": is_secrets_basedir(self.app.secrets_basedir), # noqa 72 | "version": __version__ 73 | }) 74 | for key, value in info.items(): 75 | columns.append(key) 76 | data.append(value) 77 | return columns, data 78 | 79 | 80 | # EOF 81 | -------------------------------------------------------------------------------- /psec/cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davedittrich/python_secrets/74d6d2f977d92dbc6946c441e5cbce0b986e6802/psec/cli/__init__.py -------------------------------------------------------------------------------- /psec/cli/environments/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 5 | -------------------------------------------------------------------------------- /psec/cli/environments/create.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Create environment(s). 5 | """ 6 | 7 | import logging 8 | 9 | from cliff.command import Command 10 | from psec.secrets_environment import SecretsEnvironment 11 | from psec.utils import get_default_environment 12 | 13 | 14 | class EnvironmentsCreate(Command): 15 | """ 16 | Create environment(s). 17 | 18 | Empty environments can be created as needed, one at a time or several at 19 | once. Specify the names on the command line as arguments:: 20 | 21 | $ psec environments create development testing production 22 | [+] environment directory /Users/dittrich/.secrets/development created 23 | [+] environment directory /Users/dittrich/.secrets/testing created 24 | [+] environment directory /Users/dittrich/.secrets/production created 25 | 26 | In some special circumstances, it may be necessary to have one set of 27 | identical secrets that have different environment names. If this happens, 28 | you can create an alias (see also the ``environments list`` command):: 29 | 30 | $ psec environments create --alias evaluation testing 31 | 32 | To make it easier to bootstrap an open source project, where the use may 33 | not be intimately familiar with all necessary secrets and settings, you can 34 | make their life easier by preparing an empty set of secret descriptions 35 | that will help prompt the user to set them. You can do this following these 36 | steps: 37 | 38 | #. Create a template secrets environment directory that contains 39 | just the secrets definitions. This example uses the template 40 | found in the `davedittrich/goSecure`_ repository (directory 41 | https://github.com/davedittrich/goSecure/tree/master/secrets). 42 | 43 | #. Use this template to clone a secrets environment, which will 44 | initially be empty:: 45 | 46 | $ psec environments create test --clone-from ~/git/goSecure/secrets 47 | [+] new password variable "gosecure_app_password" is unset 48 | [+] new string variable "gosecure_client_ssid" is unset 49 | [+] new string variable "gosecure_client_ssid" is unset 50 | [+] new string variable "gosecure_client_psk" is unset 51 | [+] new password variable "gosecure_pi_password" is unset 52 | [+] new string variable "gosecure_pi_pubkey" is unset 53 | [+] environment directory /Users/dittrich/.secrets/test created 54 | 55 | .. _davedittrich/goSecure: https://github.com/davedittrich/goSecure 56 | 57 | Note: Directory and file permissions on cloned environments will prevent 58 | ``other`` from having read/write/execute permissions (i.e., ``o-rwx`` in 59 | terms of the ``chmod`` command.) 60 | """ 61 | 62 | logger = logging.getLogger(__name__) 63 | 64 | def get_parser(self, prog_name): 65 | parser = super().get_parser(prog_name) 66 | how = parser.add_mutually_exclusive_group(required=False) 67 | default_environment = get_default_environment() 68 | how.add_argument( 69 | '-A', '--alias', 70 | action='store', 71 | dest='alias', 72 | default=None, 73 | help='Environment to alias' 74 | ) 75 | how.add_argument( 76 | '-C', '--clone-from', 77 | action='store', 78 | dest='clone_from', 79 | default=None, 80 | help='Environment directory to clone from' 81 | ) 82 | parser.add_argument( 83 | '--force', 84 | action='store_true', 85 | dest='force', 86 | default=False, 87 | help='Create secrets base directory' 88 | ) 89 | parser.add_argument( 90 | 'env', 91 | nargs='*', 92 | default=[default_environment] 93 | ) 94 | return parser 95 | 96 | def take_action(self, parsed_args): 97 | secrets_basedir = self.app.secrets_basedir 98 | if parsed_args.alias is not None: 99 | if len(parsed_args.env) != 1: 100 | raise RuntimeError( 101 | '[-] --alias requires one source environment') 102 | se = SecretsEnvironment( 103 | environment=parsed_args.alias, 104 | secrets_basedir=secrets_basedir, 105 | create_root=parsed_args.force, 106 | ) 107 | se.environment_create( 108 | source=parsed_args.env[0], 109 | alias=True 110 | ) 111 | if se.environment_exists(): 112 | self.logger.info( 113 | "[+] environment '%s' aliased to '%s'", 114 | parsed_args.alias, 115 | parsed_args.env[0] 116 | ) 117 | else: 118 | raise RuntimeError('[-] creating environment failed') 119 | else: 120 | # Default to app environment identifier 121 | if len(parsed_args.env) == 0: 122 | parsed_args.env = list(self.app.environment) 123 | for environment in parsed_args.env: 124 | se = SecretsEnvironment( 125 | environment=environment, 126 | secrets_basedir=secrets_basedir, 127 | create_root=True, 128 | ) 129 | se.environment_create(source=parsed_args.clone_from) 130 | self.logger.info( 131 | "[+] environment '%s' created (%s)", 132 | environment, 133 | se.get_environment_path() 134 | ) 135 | if parsed_args.clone_from: 136 | se.read_secrets(from_descriptions=True) 137 | se.write_secrets() 138 | 139 | 140 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 141 | -------------------------------------------------------------------------------- /psec/cli/environments/default.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import logging 4 | 5 | # TODO(dittrich): https://github.com/Mckinsey666/bullet/issues/2 6 | # Workaround until bullet has Windows missing 'termios' fix. 7 | try: 8 | from bullet import Bullet 9 | except ModuleNotFoundError: 10 | pass 11 | 12 | from cliff.command import Command 13 | from sys import stdin 14 | 15 | from psec.utils import ( 16 | clear_saved_default_environment, 17 | get_default_environment, 18 | get_environment_paths, 19 | get_saved_default_environment, 20 | save_default_environment, 21 | ) 22 | 23 | 24 | class EnvironmentsDefault(Command): 25 | """ 26 | Manage default environment via file in cwd. 27 | 28 | If no default is explicitly set, the default that would be applied is 29 | returned:: 30 | 31 | $ cd ~/git/psec 32 | $ psec environments default 33 | [+] default environment is "psec" 34 | 35 | When listing environments, the default environment that would be implicitly 36 | used will be identified:: 37 | 38 | $ psec environments list 39 | +-------------+---------+ 40 | | Environment | Default | 41 | +-------------+---------+ 42 | | development | No | 43 | | testing | No | 44 | | production | No | 45 | +-------------+---------+ 46 | 47 | The following shows setting and unsetting the default:: 48 | 49 | $ psec environments default testing 50 | [+] default environment set to "testing" 51 | $ psec environments default 52 | testing 53 | $ psec environments list 54 | +-------------+---------+ 55 | | Environment | Default | 56 | +-------------+---------+ 57 | | development | No | 58 | | testing | Yes | 59 | | production | No | 60 | +-------------+---------+ 61 | $ psec environments default --unset-default 62 | [+] default environment unset 63 | """ 64 | 65 | logger = logging.getLogger(__name__) 66 | 67 | def get_parser(self, prog_name): 68 | parser = super().get_parser(prog_name) 69 | what = parser.add_mutually_exclusive_group(required=False) 70 | what.add_argument( 71 | '--set', 72 | action='store_true', 73 | dest='set', 74 | default=False, 75 | help="Set localized environment default" 76 | ) 77 | what.add_argument( 78 | '--unset', 79 | action='store_true', 80 | dest='unset', 81 | default=False, 82 | help="Unset localized environment default" 83 | ) 84 | parser.add_argument( 85 | 'environment', 86 | nargs='?', 87 | default=None 88 | ) 89 | return parser 90 | 91 | def take_action(self, parsed_args): 92 | if parsed_args.unset: 93 | if parsed_args.environment is not None: 94 | raise RuntimeError("[-] '--unset' does not take an argument") 95 | if clear_saved_default_environment(): 96 | self.logger.info('[+] explicit default environment unset') 97 | else: 98 | self.logger.info('[+] no default environment was set') 99 | elif parsed_args.set: 100 | # If it is not possible to interactively ask for environment, 101 | # just raise an exception. 102 | if ( 103 | parsed_args.environment is None and not 104 | (stdin.isatty() and 'Bullet' in globals()) 105 | ): 106 | raise RuntimeError('[-] no environment specified') 107 | # Otherwise, let's prompt for an environment for better UX! 108 | if parsed_args.environment is not None: 109 | choice = parsed_args.environment 110 | else: 111 | basedir = self.app.secrets.get_secrets_basedir() 112 | environments = [ 113 | env_path.name 114 | for env_path in get_environment_paths(basedir=basedir) 115 | ] 116 | choices = [''] + sorted(environments) 117 | cli = Bullet(prompt="\nChose a new default environment:", 118 | choices=choices, 119 | indent=0, 120 | align=2, 121 | margin=1, 122 | shift=0, 123 | bullet="→", 124 | pad_right=5) 125 | choice = cli.launch() 126 | # Having second thoughts, eh? 127 | if choice == "": 128 | self.logger.info('[-] cancelled setting default') 129 | if save_default_environment(choice): 130 | self.logger.info( 131 | "[+] default environment explicitly set to '%s'", 132 | choice 133 | ) 134 | elif parsed_args.environment is not None: 135 | print(parsed_args.environment) 136 | else: 137 | # No environment specified; show current setting. 138 | env_string = get_saved_default_environment() 139 | if env_string is not None: 140 | if self.app_args.verbose_level > 1: 141 | self.logger.info( 142 | "[+] default environment explicitly set to '%s'", 143 | env_string 144 | ) 145 | else: 146 | # No explicit saved default. 147 | env_string = get_default_environment() 148 | if self.app_args.verbose_level > 1: 149 | self.logger.info( 150 | "[+] default environment is implicitly '%s'", 151 | env_string 152 | ) 153 | print(env_string) 154 | 155 | 156 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 157 | -------------------------------------------------------------------------------- /psec/cli/environments/delete.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import logging 4 | import shutil 5 | import sys 6 | from sys import stdin 7 | 8 | # TODO(dittrich): https://github.com/Mckinsey666/bullet/issues/2 9 | # Workaround until bullet has Windows missing 'termios' fix. 10 | try: 11 | from bullet import Bullet 12 | from bullet import Input 13 | from bullet import colors 14 | except ModuleNotFoundError: 15 | pass 16 | 17 | from cliff.command import Command 18 | from psec.secrets_environment import SecretsEnvironment 19 | from psec.utils import ( 20 | get_environment_paths, 21 | atree, 22 | ) 23 | 24 | 25 | class EnvironmentsDelete(Command): 26 | """ 27 | Delete environment. 28 | 29 | Deleting an environment requires explicitly naming the environment 30 | to delete and confirmation from the user. This is done in one of 31 | two ways: by prompting the user to confirm the environment to delete, 32 | or by requiring the ``--force`` option flag be set along with the name. 33 | 34 | When this command is run in a terminal shell (i.e., with a TTY), 35 | the user will be asked to type the name again to confirm the operation:: 36 | 37 | $ psec environments delete testenv 38 | Type the name 'testenv' to confirm: testenv 39 | [+] deleted directory path '/Users/dittrich/.secrets/testenv' 40 | 41 | 42 | If no TTY is present (i.e., a shell script running in the background), 43 | an exception is raised that includes the files that will be deleted 44 | and explaining how to force the deletion:: 45 | 46 | $ psec environments delete testenv 47 | [-] must use '--force' flag to delete an environment. 48 | [-] the following will be deleted: 49 | /Users/dittrich/.secrets/testenv 50 | ├── secrets.d 51 | │ ├── ansible.json 52 | │ ├── ca.json 53 | │ ├── consul.json 54 | │ ├── do.json 55 | │ ├── jenkins.json 56 | │ ├── opendkim.json 57 | │ ├── rabbitmq.json 58 | │ └── trident.json 59 | └── token.json 60 | 61 | 62 | The ``--force`` flag will allow deletion of the environment:: 63 | 64 | $ psec environments delete --force testenv 65 | [+] deleted directory path /Users/dittrich/.secrets/testenv 66 | """ 67 | 68 | logger = logging.getLogger(__name__) 69 | 70 | def get_parser(self, prog_name): 71 | parser = super().get_parser(prog_name) 72 | parser.add_argument( 73 | '--force', 74 | action='store_true', 75 | dest='force', 76 | default=False, 77 | help='Mandatory confirmation' 78 | ) 79 | parser.add_argument( 80 | 'environment', 81 | nargs='?', 82 | default=None 83 | ) 84 | return parser 85 | 86 | def take_action(self, parsed_args): 87 | choice = None 88 | if parsed_args.environment is not None: 89 | choice = parsed_args.environment 90 | elif stdin.isatty() and 'Bullet' in globals(): 91 | # Give user a chance to choose. 92 | environments = [ 93 | fpath.name for fpath in get_environment_paths( 94 | basedir=self.app.secrets_basedir 95 | ) 96 | ] 97 | choices = [''] + sorted(environments) 98 | cli = Bullet(prompt="\nSelect environment to delete:", 99 | choices=choices, 100 | indent=0, 101 | align=2, 102 | margin=1, 103 | shift=0, 104 | bullet="→", 105 | pad_right=5) 106 | choice = cli.launch() 107 | if choice == "": 108 | self.logger.info('[-] cancelled deleting environment') 109 | return 110 | else: 111 | # Can't involve user in getting a choice. 112 | sys.exit('[-] no environment specified to delete') 113 | # Environment chosen. Now do we need to confirm? 114 | e = SecretsEnvironment(choice) 115 | env_path = e.get_environment_path() 116 | if not parsed_args.force: 117 | if not stdin.isatty(): 118 | output = atree( 119 | env_path, 120 | outfile=None, 121 | print_files=True 122 | ) 123 | raise RuntimeError( 124 | "[-] must use '--force' flag to delete an environment.\n" 125 | "[-] the following will be deleted: \n" 126 | f"{''.join([line for line in output])}" 127 | ) 128 | else: 129 | prompt = f"Type the name '{choice}' to confirm: " 130 | cli = Input(prompt, 131 | default="", 132 | word_color=colors.foreground["yellow"]) 133 | confirm = cli.launch() 134 | if confirm != choice: 135 | self.logger.info('[-] cancelled deleting environment') 136 | return 137 | # We have confirmation or --force. Now safe to delete. 138 | # TODO(dittrich): Use safe_delete_file over file list 139 | if env_path.is_symlink(): 140 | env_path.unlink() 141 | self.logger.info("[+] deleted alias '%s'", env_path) 142 | else: 143 | shutil.rmtree(env_path) 144 | self.logger.info("[+] deleted directory path '%s'", env_path) 145 | 146 | 147 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 148 | -------------------------------------------------------------------------------- /psec/cli/environments/list.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import logging 4 | import os 5 | import sys 6 | 7 | from cliff.lister import Lister 8 | from psec.utils import ( 9 | get_default_environment, 10 | get_environment_paths, 11 | is_valid_environment, 12 | ) 13 | 14 | 15 | class EnvironmentsList(Lister): 16 | """ 17 | List the current environments. 18 | 19 | You can get a list of all available environments at any time, including 20 | which one would be the default used by sub-commands:: 21 | 22 | $ psec environments list 23 | +-------------+---------+ 24 | | Environment | Default | 25 | +-------------+---------+ 26 | | development | No | 27 | | testing | No | 28 | | production | No | 29 | +-------------+---------+ 30 | 31 | 32 | To see which environments are aliases, use the ``--aliasing`` option:: 33 | 34 | $ psec -v environments create --alias evaluation testing 35 | $ psec environments list --aliasing 36 | +-------------+---------+----------+ 37 | | Environment | Default | AliasFor | 38 | +-------------+---------+----------+ 39 | | development | No | | 40 | | evaluation | No | testing | 41 | | testing | No | | 42 | | production | No | | 43 | +-------------+---------+----------+ 44 | 45 | 46 | If there are any older environments that contain ``.yml`` files for storing 47 | secrets or definitions, they will be called out when you list environments. 48 | (Adding ``-v`` will explicitly list the names of files that are found if 49 | you wish to see them.):: 50 | 51 | $ psec environments list 52 | [!] environment 'algo' needs conversion (see 'psec utils yaml-to-json --help') 53 | [!] environment 'hypriot' needs conversion (see 'psec utils yaml-to-json --help') 54 | [!] environment 'kali-packer' needs conversion (see 'psec utils yaml-to-json --help') 55 | +-------------------------+---------+ 56 | | Environment | Default | 57 | +-------------------------+---------+ 58 | | attack_range | No | 59 | | attack_range_local | No | 60 | | flash | No | 61 | | python_secrets | Yes | 62 | +-------------------------+---------+ 63 | """ # noqa 64 | 65 | logger = logging.getLogger(__name__) 66 | 67 | def get_parser(self, prog_name): 68 | parser = super().get_parser(prog_name) 69 | parser.add_argument( 70 | '--aliasing', 71 | action='store_true', 72 | dest='aliasing', 73 | default=False, 74 | help='Include aliasing' 75 | ) 76 | return parser 77 | 78 | def take_action(self, parsed_args): 79 | default_env = get_default_environment() 80 | self.logger.debug( 81 | "[+] using secrets basedir '%s'", 82 | self.app.secrets_basedir, 83 | ) 84 | columns = (['Environment', 'Default']) 85 | if parsed_args.aliasing: 86 | columns.append('AliasFor') 87 | data = list() 88 | for env_path in get_environment_paths(basedir=self.app.secrets_basedir): # noqa 89 | if is_valid_environment( 90 | env_path, 91 | self.app_args.verbose_level, 92 | ): 93 | is_default = ( 94 | "Yes" if env_path.name == default_env 95 | else "No" 96 | ) 97 | if not parsed_args.aliasing: 98 | item = (env_path.name, is_default) 99 | else: 100 | try: 101 | alias_for = os.path.basename(os.readlink(env_path)) 102 | except OSError: 103 | alias_for = '' 104 | item = (env_path.name, is_default, alias_for) 105 | data.append(item) 106 | if len(data) == 0: 107 | sys.exit(1) 108 | return columns, data 109 | 110 | 111 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 112 | -------------------------------------------------------------------------------- /psec/cli/environments/path.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import logging 4 | import os 5 | 6 | from cliff.command import Command 7 | from psec.secrets_environment import SecretsEnvironment 8 | 9 | 10 | class EnvironmentsPath(Command): 11 | """ 12 | Return path to files and directories for environment. 13 | 14 | Provides the full absolute path to the environment directory for the 15 | environment and any specified subdirectories:: 16 | 17 | $ psec environments path 18 | /Users/dittrich/.secrets/psec 19 | $ psec environments path -e goSecure 20 | /Users/dittrich/.secrets/goSecure 21 | 22 | Using the ``--exists`` option will just exit with return code ``0`` when 23 | the environment directory exists, or ``1`` if it does not, and no path is 24 | printed on stdout. 25 | 26 | To append subdirectory components, provide them as arguments and they will 27 | be concatenated with the appropriate OS path separator:: 28 | 29 | $ psec environments path -e goSecure configs 30 | /Users/dittrich/.secrets/goSecure/configs 31 | 32 | To ensure the directory path specified by command line arguments is present 33 | in the file system, use the ``--create`` option. 34 | 35 | Using the ``--tmpdir`` option will return the path to the temporary 36 | directory for the environment. If the environment's directory already 37 | exists, the temporary directory will be also be created so it is ready for 38 | use. If the environment directory does not already exist, the program will 39 | exit with an error message. Again, the ``--create`` changes this behavior 40 | and the missing directory path will be created. 41 | """ 42 | 43 | logger = logging.getLogger(__name__) 44 | 45 | def get_parser(self, prog_name): 46 | parser = super().get_parser(prog_name) 47 | parser.add_argument( 48 | '--create', 49 | action='store_true', 50 | dest='create', 51 | default=False, 52 | help='Create the directory path if it does not yet exist' 53 | ) 54 | parser.add_argument( 55 | '--exists', 56 | action='store_true', 57 | dest='exists', 58 | default=False, 59 | help=('Check to see if environment exists and ' 60 | 'return exit code (0==exists, 1==not)') 61 | ) 62 | parser.add_argument( 63 | '--json', 64 | action='store_true', 65 | dest='json', 66 | default=False, 67 | help='Output in JSON (e.g., for Terraform external data source)' 68 | ) 69 | parser.add_argument( 70 | '--tmpdir', 71 | action='store_true', 72 | dest='tmpdir', 73 | default=False, 74 | help='Create and/or return tmpdir for this environment' 75 | ) 76 | parser.add_argument( 77 | 'subdir', 78 | nargs='*', 79 | default=None 80 | ) 81 | return parser 82 | 83 | def _print(self, item, use_json=False): 84 | """Output item, optionally using JSON""" 85 | if use_json: 86 | import json 87 | res = {'path': item} 88 | print(json.dumps(res)) 89 | else: 90 | print(item) 91 | 92 | def take_action(self, parsed_args): 93 | environment = self.app.options.environment 94 | e = SecretsEnvironment(environment) 95 | if parsed_args.tmpdir: 96 | if not e.environment_exists() and not parsed_args.create: 97 | return (f"[-] environment '{str(e)}' does not exist; " 98 | "use '--create' to create it") 99 | tmpdir = e.get_tmpdir_path(create_path=parsed_args.create) 100 | self._print(tmpdir, parsed_args.json) 101 | else: 102 | base_path = e.get_environment_path() 103 | subdir = parsed_args.subdir 104 | full_path = base_path if subdir is None \ 105 | else os.path.join(base_path, *subdir) 106 | if not os.path.exists(full_path) and parsed_args.create: 107 | mode = 0o700 108 | os.makedirs(full_path, mode) 109 | if self.app_args.verbose_level > 1: 110 | self.logger.info("[+] created %s", full_path) 111 | if parsed_args.exists: 112 | # Just check existance and return result 113 | exists = os.path.exists(full_path) 114 | if self.app_args.verbose_level > 1: 115 | status = "exists" if exists else "does not exist" 116 | self.logger.info( 117 | "[+] environment path '%s' %s", 118 | full_path, 119 | status 120 | ) 121 | return 0 if exists else 1 122 | else: 123 | self._print(full_path, parsed_args.json) 124 | 125 | 126 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 127 | -------------------------------------------------------------------------------- /psec/cli/environments/rename.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import logging 4 | import os 5 | 6 | from cliff.command import Command 7 | from psec.secrets_environment import SecretsEnvironment 8 | 9 | 10 | class EnvironmentsRename(Command): 11 | """ 12 | Rename environment. 13 | 14 | Just like `mv`, renames an environment from the name specified by the 15 | first argument to that of the second argument:: 16 | 17 | $ psec environments list 18 | +----------------+---------+ 19 | | Environment | Default | 20 | +----------------+---------+ 21 | | old | No | 22 | +----------------+---------+ 23 | $ psec environments rename new old 24 | [-] source environment "new" does not exist 25 | $ psec environments rename old new 26 | [+] environment "old" renamed to "new" 27 | $ psec environments list 28 | +----------------+---------+ 29 | | Environment | Default | 30 | +----------------+---------+ 31 | | new | No | 32 | +----------------+---------+ 33 | """ 34 | 35 | logger = logging.getLogger(__name__) 36 | 37 | def get_parser(self, prog_name): 38 | parser = super().get_parser(prog_name) 39 | parser.add_argument( 40 | 'source', 41 | nargs=1, 42 | default=None, 43 | help='environment to rename' 44 | ) 45 | parser.add_argument( 46 | 'dest', 47 | nargs=1, 48 | default=None, 49 | help='new environment name' 50 | ) 51 | return parser 52 | 53 | def take_action(self, parsed_args): 54 | basedir = self.app.secrets.get_secrets_basedir() 55 | source = parsed_args.source[0] 56 | source_path = os.path.join(basedir, source) 57 | dest = parsed_args.dest[0] 58 | dest_path = os.path.join(basedir, dest) 59 | if source is None: 60 | raise RuntimeError('[-] no source name provided') 61 | if dest is None: 62 | raise RuntimeError('[-] no destination name provided') 63 | if not SecretsEnvironment( 64 | environment=source).environment_exists(): 65 | raise RuntimeError( 66 | f"[-] source environment '{source}' does not exist") 67 | if SecretsEnvironment( 68 | environment=dest).environment_exists(): 69 | raise RuntimeError( 70 | f"[-] destination environment '{dest}' already exist") 71 | os.rename(source_path, dest_path) 72 | self.logger.info( 73 | "[+] environment '%s' renamed to '%s'", 74 | source, 75 | dest 76 | ) 77 | 78 | 79 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 80 | -------------------------------------------------------------------------------- /psec/cli/environments/tree.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import logging 4 | import sys 5 | 6 | from cliff.command import Command 7 | from psec.secrets_environment import SecretsEnvironment 8 | from psec.utils import atree 9 | 10 | 11 | class EnvironmentsTree(Command): 12 | """ 13 | Output tree listing of files/directories in environment. 14 | 15 | The ``environments tree`` command produces output similar to the Unix ``tree`` 16 | command:: 17 | 18 | $ psec -e d2 environments tree 19 | /Users/dittrich/.secrets/d2 20 | ├── backups 21 | │ ├── black.secretsmgmt.tk 22 | │ │ ├── letsencrypt_2018-04-06T23:36:58PDT.tgz 23 | │ │ └── letsencrypt_2018-04-25T16:32:20PDT.tgz 24 | │ ├── green.secretsmgmt.tk 25 | │ │ ├── letsencrypt_2018-04-06T23:45:49PDT.tgz 26 | │ │ └── letsencrypt_2018-04-25T16:32:20PDT.tgz 27 | │ ├── purple.secretsmgmt.tk 28 | │ │ ├── letsencrypt_2018-04-25T16:32:20PDT.tgz 29 | │ │ ├── trident_2018-01-31T23:38:48PST.tar.bz2 30 | │ │ └── trident_2018-02-04T20:05:33PST.tar.bz2 31 | │ └── red.secretsmgmt.tk 32 | │ ├── letsencrypt_2018-04-06T23:45:49PDT.tgz 33 | │ └── letsencrypt_2018-04-25T16:32:20PDT.tgz 34 | ├── dittrich.asc 35 | ├── keys 36 | │ └── opendkim 37 | │ └── secretsmgmt.tk 38 | │ ├── 201801.private 39 | │ ├── 201801.txt 40 | │ ├── 201802.private 41 | │ └── 201802.txt 42 | ├── secrets.d 43 | │ ├── ca.json 44 | │ ├── consul.json 45 | │ ├── jenkins.json 46 | │ ├── rabbitmq.json 47 | │ ├── trident.json 48 | │ ├── vncserver.json 49 | │ └── zookeper.json 50 | ├── secrets.json 51 | └── vault_password.txt 52 | 53 | To just see the directory structure and not files, add the ``--no-files`` option:: 54 | 55 | $ psec -e d2 environments tree --no-files 56 | /Users/dittrich/.secrets/d2 57 | ├── backups 58 | │ ├── black.secretsmgmt.tk 59 | │ ├── green.secretsmgmt.tk 60 | │ ├── purple.secretsmgmt.tk 61 | │ └── red.secretsmgmt.tk 62 | ├── keys 63 | │ └── opendkim 64 | │ └── secretsmgmt.tk 65 | └── secrets.d 66 | 67 | """ # noqa 68 | 69 | logger = logging.getLogger(__name__) 70 | 71 | def get_parser(self, prog_name): 72 | parser = super().get_parser(prog_name) 73 | parser.add_argument( 74 | '--no-files', 75 | action='store_true', 76 | dest='no_files', 77 | default=False, 78 | help='Do not include files in listing' 79 | ) 80 | parser.add_argument( 81 | 'environment', 82 | nargs='?', 83 | default=None 84 | ) 85 | return parser 86 | 87 | def take_action(self, parsed_args): 88 | environment = parsed_args.environment 89 | if environment is None: 90 | environment = self.app.options.environment 91 | e = SecretsEnvironment(environment=environment) 92 | e.requires_environment() 93 | print_files = bool(parsed_args.no_files is False) 94 | atree(e.get_environment_path(), 95 | print_files=print_files, 96 | outfile=sys.stdout) 97 | 98 | 99 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 100 | -------------------------------------------------------------------------------- /psec/cli/groups/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 4 | -------------------------------------------------------------------------------- /psec/cli/groups/create.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import logging 4 | import os 5 | 6 | from cliff.command import Command 7 | from psec.secrets_environment import SecretsEnvironment 8 | 9 | 10 | class GroupsCreate(Command): 11 | """ 12 | Create a secrets descriptions group. 13 | 14 | Secrets and variables are described in files in a drop-in style directory 15 | ending in ``.d``. This forms 'groups' that organize secrets and variables 16 | by purpose, by open source tool, etc. This command creates a new group 17 | descriptions file in the selected environment. 18 | 19 | When integrating a new open source tool or project with an existing tool or 20 | project, you can create a new group in the current environment and clone 21 | its secrets descriptions from pre-existing definitions. This does not copy 22 | any values, just the descriptions, allowing you to manage the values 23 | independently of other projects using a different environment:: 24 | 25 | $ psec groups create newgroup --clone-from ~/git/goSecure/secrets.d/gosecure.json 26 | [+] created new group 'newgroup' 27 | $ psec groups list 28 | +----------+-------+ 29 | | Group | Items | 30 | +----------+-------+ 31 | | jenkins | 1 | 32 | | myapp | 4 | 33 | | newgroup | 12 | 34 | | trident | 2 | 35 | +----------+-------+ 36 | 37 | Note: Directory and file permissions on cloned groups will prevent 38 | ``other`` from having read/write/execute permissions (i.e., ``o-rwx`` 39 | in terms of the ``chmod`` command.) 40 | """ # noqa 41 | 42 | logger = logging.getLogger(__name__) 43 | 44 | def get_parser(self, prog_name): 45 | parser = super().get_parser(prog_name) 46 | parser.add_argument( 47 | '-C', '--clone-from', 48 | action='store', 49 | dest='clone_from', 50 | default=None, 51 | help='Group descriptions file to clone from' 52 | ) 53 | parser.add_argument( 54 | 'arg', 55 | nargs='?', 56 | default=None 57 | ) 58 | return parser 59 | 60 | def take_action(self, parsed_args): 61 | se = self.app.secrets 62 | # Creating a new group in an empty environment that exists is OK. 63 | se.requires_environment(path_only=True) 64 | se.read_secrets_descriptions() 65 | # A cloned group can inherit its name from file, otherwise a 66 | # name is required. 67 | if parsed_args.arg is None and parsed_args.clone_from is None: 68 | raise RuntimeError( 69 | '[-] no group name or group description source specified') 70 | group = parsed_args.arg 71 | groups = se.get_groups() 72 | clone_from = parsed_args.clone_from 73 | # Default is to create a new empty group 74 | descriptions = dict() 75 | if clone_from is not None: 76 | # Are we cloning from a file? 77 | if clone_from.endswith('.json'): 78 | if not os.path.isfile(clone_from): 79 | raise RuntimeError( 80 | "[-] group description file " 81 | f"'{clone_from}' does not exist") 82 | if group is None: 83 | group = os.path.splitext( 84 | os.path.basename(clone_from))[0] 85 | descriptions = se.read_descriptions(infile=clone_from) 86 | else: 87 | # Must be cloning from an environment, but which group? 88 | if group is None: 89 | raise RuntimeError( 90 | "[-] please specify which group from environment " 91 | f"'{parsed_args.clone_from}' you want to clone") 92 | clonefrom_se = SecretsEnvironment(environment=clone_from) 93 | if group not in clonefrom_se.get_groups(): 94 | raise RuntimeError( 95 | f"[-] group '{group}' does not exist in " 96 | f"environment '{clone_from}'") 97 | descriptions = clonefrom_se.read_descriptions(group=group) 98 | if len(descriptions) > 0: 99 | se.check_duplicates(descriptions) 100 | if group in groups: 101 | raise RuntimeError(f"[-] group '{group}' already exists") 102 | self.logger.info("[+] creating new group '%s'", group) 103 | se.write_descriptions( 104 | data=descriptions, 105 | group=group) 106 | 107 | 108 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 109 | -------------------------------------------------------------------------------- /psec/cli/groups/delete.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import logging 4 | import os 5 | 6 | # TODO(dittrich): https://github.com/Mckinsey666/bullet/issues/2 7 | # Workaround until bullet has Windows missing 'termios' fix. 8 | try: 9 | from bullet import Bullet 10 | from bullet import Input 11 | from bullet import colors 12 | except ModuleNotFoundError: 13 | pass 14 | from cliff.command import Command 15 | from psec.utils import safe_delete_file 16 | from sys import stdin 17 | 18 | 19 | class GroupsDelete(Command): 20 | """ 21 | Delete a secrets descriptions group. 22 | 23 | Deletes a group of secrets and variables by removing them from 24 | the secrets environment and deleting their descriptions file. 25 | 26 | If the ``--force`` option is not specified, you will be prompted 27 | to confirm the group name before it is deleted. 28 | """ 29 | 30 | logger = logging.getLogger(__name__) 31 | 32 | def get_parser(self, prog_name): 33 | parser = super().get_parser(prog_name) 34 | parser.add_argument( 35 | '--force', 36 | action='store_true', 37 | dest='force', 38 | default=False, 39 | help='Mandatory confirmation' 40 | ) 41 | parser.add_argument( 42 | 'group', 43 | nargs='?', 44 | default=None 45 | ) 46 | return parser 47 | 48 | def take_action(self, parsed_args): 49 | se = self.app.secrets 50 | se.requires_environment() 51 | se.read_secrets_descriptions() 52 | group = parsed_args.group 53 | groups = se.get_groups() 54 | choice = None 55 | 56 | if parsed_args.group is not None: 57 | choice = parsed_args.group 58 | elif not (stdin.isatty() and 'Bullet' in globals()): 59 | # Can't involve user in getting a choice. 60 | raise RuntimeError('[-] no group specified to delete') 61 | else: 62 | # Give user a chance to choose. 63 | choices = [''] + sorted(groups) 64 | cli = Bullet(prompt="\nSelect group to delete:", 65 | choices=choices, 66 | indent=0, 67 | align=2, 68 | margin=1, 69 | shift=0, 70 | bullet="→", 71 | pad_right=5) 72 | choice = cli.launch() 73 | if choice == "": 74 | self.logger.info('[-] cancelled deleting group') 75 | return 76 | 77 | # Group chosen. Now do we need to confirm? 78 | if not parsed_args.force: 79 | if not stdin.isatty(): 80 | raise RuntimeError( 81 | '[-] must use "--force" flag to delete a group.') 82 | else: 83 | prompt = f"Type the name '{choice}' to confirm: " 84 | cli = Input(prompt, 85 | default="", 86 | word_color=colors.foreground["yellow"]) 87 | confirm = cli.launch() 88 | if confirm != choice: 89 | self.logger.info('[-] cancelled deleting group') 90 | return 91 | 92 | group_file = se.get_descriptions_path(group=group) 93 | if not os.path.exists(group_file): 94 | raise RuntimeError( 95 | f"[-] group file '{group_file}' does not exist") 96 | # Delete secrets from group. 97 | secrets = se.get_items_from_group(choice) 98 | for secret in secrets: 99 | se.delete_secret(secret) 100 | # Delete group descriptions. 101 | safe_delete_file(group_file) 102 | self.logger.info( 103 | "[+] deleted secrets group '%s' (%s)", 104 | choice, 105 | group_file 106 | ) 107 | 108 | 109 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 110 | -------------------------------------------------------------------------------- /psec/cli/groups/list.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import logging 4 | import sys 5 | 6 | from cliff.lister import Lister 7 | 8 | 9 | class GroupsList(Lister): 10 | """ 11 | Show a list of secrets groups. 12 | 13 | The names of the groups and number of items are printed by default:: 14 | 15 | $ psec groups list 16 | +---------+-------+ 17 | | Group | Items | 18 | +---------+-------+ 19 | | jenkins | 1 | 20 | | myapp | 4 | 21 | | trident | 2 | 22 | +---------+-------+ 23 | """ 24 | 25 | logger = logging.getLogger(__name__) 26 | 27 | # def get_parser(self, prog_name): 28 | # parser = super().get_parser(prog_name) 29 | # return parser 30 | 31 | def take_action(self, parsed_args): 32 | se = self.app.secrets 33 | se.requires_environment(path_only=True) 34 | se.read_secrets_descriptions() 35 | columns = ('Group', 'Items') 36 | items = {} 37 | for g in se.get_groups(): 38 | items[g] = se.get_items_from_group(g) 39 | data = [(k, len(v)) for k, v in items.items()] 40 | if len(data) == 0: 41 | sys.exit(1) 42 | return columns, data 43 | 44 | 45 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 46 | -------------------------------------------------------------------------------- /psec/cli/groups/path.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import logging 4 | 5 | from cliff.command import Command 6 | 7 | from psec.secrets_environment import SecretsEnvironment 8 | from psec.utils import get_default_environment 9 | 10 | 11 | class GroupsPath(Command): 12 | """ 13 | Return path to secrets descriptions (groups) directory. 14 | 15 | :: 16 | 17 | $ psec groups path 18 | /Users/dittrich/.secrets/psec/secrets.d 19 | """ 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | def get_parser(self, prog_name): 24 | parser = super().get_parser(prog_name) 25 | default_environment = get_default_environment() 26 | parser.add_argument( 27 | 'environment', 28 | nargs='?', 29 | default=default_environment 30 | ) 31 | return parser 32 | 33 | def take_action(self, parsed_args): 34 | e = SecretsEnvironment(environment=parsed_args.environment) 35 | print(e.get_descriptions_path()) 36 | 37 | 38 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 39 | -------------------------------------------------------------------------------- /psec/cli/groups/show.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import logging 4 | import sys 5 | 6 | from cliff.lister import Lister 7 | 8 | 9 | class GroupsShow(Lister): 10 | """ 11 | Show a list of secrets in a group. 12 | 13 | Show the group name and number of items in the group for one or more 14 | groups:: 15 | 16 | $ psec groups show trident myapp 17 | +---------+-----------------------+ 18 | | Group | Variable | 19 | +---------+-----------------------+ 20 | | trident | trident_sysadmin_pass | 21 | | trident | trident_db_pass | 22 | | myapp | myapp_pi_password | 23 | | myapp | myapp_app_password | 24 | | myapp | myapp_client_psk | 25 | | myapp | myapp_client_ssid | 26 | +---------+-----------------------+ 27 | """ 28 | 29 | logger = logging.getLogger(__name__) 30 | 31 | def get_parser(self, prog_name): 32 | parser = super().get_parser(prog_name) 33 | parser.add_argument( 34 | 'group', 35 | nargs='*', 36 | default=None 37 | ) 38 | return parser 39 | 40 | def take_action(self, parsed_args): 41 | se = self.app.secrets 42 | se.requires_environment() 43 | se.read_secrets_descriptions() 44 | columns = ('Group', 'Variable') 45 | data = [] 46 | for group in parsed_args.group: 47 | for item in se.get_items_from_group(group): 48 | data.append((group, item)) 49 | if len(data) == 0: 50 | sys.exit(1) 51 | return columns, data 52 | 53 | 54 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 55 | -------------------------------------------------------------------------------- /psec/cli/init.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Initialize a psec secrets base directory. 5 | """ 6 | 7 | import logging 8 | 9 | from cliff.command import Command 10 | 11 | from psec.utils import ( 12 | get_default_secrets_basedir, 13 | secrets_basedir_create, 14 | ) 15 | 16 | 17 | class Init(Command): 18 | """ 19 | Initialize a psec secrets base directory. 20 | 21 | The `psec` program stores secrets and variables for environments in their 22 | own subdirectory trees beneath a top level directory root referred to as the 23 | "secrets base directory" (`secrets_basedir`). This directory tree should not 24 | be a "normal" file system directory that includes arbitrary files and 25 | directories, but rather a special location dedicated to *only* storing 26 | secrets environments and related files. 27 | 28 | For added security, you can root this directory tree within an encrypted 29 | USB-connected disk device, SD card, or other external or remote file system 30 | that is only mounted when needed. This ensures sensitive data that are not 31 | being actively used are left encrypted in storage. The `D2_SECRETS_BASEDIR` 32 | environment variable or `-d` option allow you to specify the directory to use. 33 | 34 | To attempt to prevent accidentally storing secrets in directories that 35 | are already storing normal files or directories, a special marker file must 36 | be present. The `init` command ensures that this secrets base directory is 37 | created and marked by the presence of that special file. Until this is done, 38 | some `psec` commands may report the base directory is not found (if it 39 | does not exist) or is not valid (if it does exist, but does not contain 40 | the special marker file):: 41 | 42 | $ psec -d /tmp/foo/does/not/exist environments list 43 | [-] directory '/tmp/foo/does/not/exist' does not exist 44 | """ # noqa 45 | 46 | logger = logging.getLogger(__name__) 47 | 48 | def get_parser(self, prog_name): 49 | basedir = getattr(self.app, 'secrets_basedir', None) 50 | if basedir is None: 51 | basedir = get_default_secrets_basedir() 52 | parser = super().get_parser(prog_name) 53 | parser.add_argument( 54 | 'basedir', 55 | nargs='?', 56 | default=basedir 57 | ) 58 | return parser 59 | 60 | def take_action(self, parsed_args): 61 | secrets_basedir = parsed_args.basedir 62 | secrets_basedir_create(basedir=secrets_basedir) 63 | if self.app_args.verbose_level > 0: 64 | self.logger.info( 65 | "[+] directory '%s' is enabled for secrets storage", 66 | secrets_basedir 67 | ) 68 | 69 | 70 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 71 | -------------------------------------------------------------------------------- /psec/cli/run.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Run a command line as a sub-shell. 5 | """ 6 | 7 | import logging 8 | import shlex 9 | from subprocess import call # nosec 10 | 11 | from cliff.command import Command 12 | 13 | # NOTE: Calling subprocess.call() with shell=True can have security 14 | # implications. To prevent user-supplied data from injecting commands 15 | # into shell command lines, pass arguments as a list and ensure they 16 | # are properly quoted. 17 | 18 | 19 | class Run(Command): 20 | """ 21 | Run a command line as a sub-shell. 22 | 23 | The ``run`` subcommand is used to run a command line in a sub-shell similar 24 | to using ``bash -c``. 25 | 26 | When used with the ``--elapsed`` option, you get more readable elapsed time 27 | information than with the ``time`` command:: 28 | 29 | $ psec --elapsed run sleep 3 30 | [+] elapsed time 00:00:03.01 31 | 32 | When combined with the ``-E`` option to export a ``psec`` environment's 33 | secrets and variables into the process environment, the command sub-shell 34 | (and every shell that is subsequently forked & exec'd) will inherit these 35 | environment variables. Programs like Ansible and Terraform can then access 36 | the values by reference rather than requiring hard-coding values or passing 37 | values on the command line. You can even run a shell program like ``bash`` 38 | or ``byobu`` in a nested shell to change default values for interactive 39 | sessions. 40 | 41 | Secrets and variables may be exported with their name, or may have an 42 | additional environment variable name (for programs that expect a particular 43 | prefix, like ``TF_`` for Terraform variables) as seen here:: 44 | 45 | $ psec secrets show myapp_pi_password --no-redact 46 | +-------------------+---------------------------+------------------+ 47 | | Variable | Value | Export | 48 | +-------------------+---------------------------+------------------+ 49 | | myapp_pi_password | GAINS.ranged.ENGULF.wound | DEMO_pi_password | 50 | +-------------------+---------------------------+------------------+ 51 | 52 | Without the ``-E`` option the export variable is not set:: 53 | 54 | $ psec run -- bash -c 'env | grep DEMO_pi_password' 55 | $ psec run -- bash -c 'echo The demo password is ${DEMO_pi_password:-not set}' 56 | The demo password is not set 57 | 58 | With the ``-E`` option it is set and the sub-shell can expand it:: 59 | 60 | $ psec -E run -- bash -c 'env | grep DEMO_pi_password' 61 | DEMO_pi_password=GAINS.ranged.ENGULF.wound 62 | $ psec run -- bash -c 'echo The demo password is ${DEMO_pi_password:-not set}' 63 | The demo password is GAINS.ranged.ENGULF.wound 64 | 65 | NOTE: The ``--`` you see in these examples is necessary to ensure that 66 | command line parsing by the shell to construct the argument vector for 67 | passing to the ``psec`` program is stopped so that the options meant for 68 | the sub-command are passed to it properly for parsing. Failing to add the 69 | ``--`` may result in a strange parsing error message, or unexpected 70 | behavior when the command line you typed is not parsed the way you assumed 71 | it would be:: 72 | 73 | $ psec run bash -c 'env | grep DEMO_pi_password' 74 | usage: psec run [-h] [arg [arg ...]] 75 | psec run: error: unrecognized arguments: -c env | grep DEMO_pi_password 76 | 77 | You may use ``--elapsed`` without an environment if you do not need to 78 | export variables, but when the ``-e`` option is present an environment must 79 | exist or you will get an error. 80 | 81 | If no arguments are specified, this ``--help`` text is output. 82 | """ # noqa 83 | 84 | logger = logging.getLogger(__name__) 85 | 86 | def get_parser(self, prog_name): 87 | parser = super().get_parser(prog_name) 88 | parser.add_argument( 89 | 'arg', 90 | nargs='*', 91 | help='Command arguments', 92 | default=['psec', 'run', '--help']) 93 | return parser 94 | 95 | def take_action(self, parsed_args): 96 | se = self.app.secrets 97 | cmd = " ".join( 98 | [ 99 | shlex.quote(a.encode('unicode-escape').decode()) 100 | for a in parsed_args.arg 101 | ] 102 | ) 103 | if self.app_args.export_env_vars: 104 | se.requires_environment() 105 | se.read_secrets_and_descriptions() 106 | return call(cmd, shell=True) # nosec 107 | 108 | 109 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 110 | -------------------------------------------------------------------------------- /psec/cli/secrets/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | 'secrets' subcommands and related classes. 5 | 6 | Author: Dave Dittrich 7 | URL: https://python_secrets.readthedocs.org. 8 | """ 9 | 10 | 11 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 12 | -------------------------------------------------------------------------------- /psec/cli/secrets/backup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Back up just secrets and descriptions. 5 | """ 6 | 7 | import contextlib 8 | import datetime 9 | import logging 10 | import os 11 | import tarfile 12 | 13 | from cliff.command import Command 14 | 15 | 16 | @contextlib.contextmanager 17 | def cd(path): 18 | """Change directory.""" 19 | old_path = os.getcwd() 20 | os.chdir(path) 21 | try: 22 | yield 23 | finally: 24 | os.chdir(old_path) 25 | 26 | 27 | class SecretsBackup(Command): 28 | """ 29 | Back up just secrets and descriptions. 30 | 31 | Creates a backup (``tar`` format) of the secrets.json file 32 | and all description files. 33 | """ 34 | 35 | logger = logging.getLogger(__name__) 36 | 37 | # def get_parser(self, prog_name): 38 | # parser = super().get_parser(prog_name) 39 | # return parser 40 | 41 | def take_action(self, parsed_args): 42 | secrets = self.app.secrets 43 | secrets.requires_environment() 44 | backups_dir = os.path.join( 45 | secrets.get_environment_path(), 46 | "backups") 47 | if not os.path.exists(backups_dir): 48 | os.mkdir(backups_dir, mode=0o700) 49 | elif not os.path.isdir(backups_dir): 50 | raise RuntimeError(f"[-] {backups_dir} is not a directory") 51 | 52 | # '2020-03-01T06:11:16.572992+00:00' 53 | iso8601_string = datetime.datetime.utcnow().replace( 54 | tzinfo=datetime.timezone.utc).isoformat().replace(":", "") 55 | backup_name = f"{secrets.environment}_{iso8601_string}.tgz" 56 | backup_path = os.path.join(backups_dir, backup_name) 57 | 58 | # Change directory to allow relative paths in tar file, 59 | # then force relative paths (there has to be a better way... 60 | # just not right now.) 61 | env_path = secrets.get_environment_path() + os.path.sep 62 | with cd(env_path): 63 | with tarfile.open(backup_path, "w:gz") as tf: 64 | tf.add( 65 | secrets.get_secrets_file_path().replace(env_path, "", 1)) 66 | tf.add( 67 | secrets.get_descriptions_path().replace(env_path, "", 1)) 68 | 69 | self.logger.info("[+] created backup '%s'", backup_path) 70 | 71 | 72 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 73 | -------------------------------------------------------------------------------- /psec/cli/secrets/delete.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Delete secrets and their definitions. 5 | """ 6 | 7 | import logging 8 | import os 9 | 10 | from sys import stdin 11 | 12 | from cliff.command import Command 13 | from psec.utils import safe_delete_file 14 | 15 | # TODO(dittrich): https://github.com/Mckinsey666/bullet/issues/2 16 | # Workaround until bullet has Windows missing 'termios' fix. 17 | try: 18 | from bullet import Input 19 | from bullet import colors 20 | except ModuleNotFoundError: 21 | pass 22 | 23 | 24 | class SecretsDelete(Command): 25 | """ 26 | Delete secrets and their definitions. 27 | 28 | Deletes one or more secrets and their definitions from an environment. 29 | Unless the ``--force`` flag is specified, you will be prompted to type in 30 | the variable name again to ensure you really want to remove all trace of it 31 | from the environment:: 32 | 33 | $ psec secrets delete --group myapp myapp_client_psk myapp_client_ssid 34 | Type the name 'myapp_client_psk' to confirm: myapp_client_psk 35 | Type the name 'myapp_client_ssid' to confirm: myapp_client_ssid 36 | 37 | If you delete all of the variable descriptions remaining in a group, the 38 | group file will be deleted. 39 | 40 | The ``--mirror-locally`` option will manage a local copy of the 41 | descriptions file. Use this if you are eliminating a variable from a 42 | project while editing files in the root of the source repository. 43 | 44 | KNOWN LIMITATION: You must specify the group with the ``--group`` option 45 | currently and are restricted to deleting variables from one group at a 46 | time. 47 | """ # noqa 48 | 49 | # TODO(dittrich): address the known limitation 50 | 51 | logger = logging.getLogger(__name__) 52 | 53 | def get_parser(self, prog_name): 54 | parser = super().get_parser(prog_name) 55 | parser.add_argument( 56 | '-g', '--group', 57 | action='store', 58 | dest='group', 59 | default=None, 60 | help='Group from which to delete the secret(s)' 61 | ) 62 | parser.add_argument( 63 | '--force', 64 | action='store_true', 65 | dest='force', 66 | default=False, 67 | help='Mandatory confirmation' 68 | ) 69 | parser.add_argument( 70 | '--mirror-locally', 71 | action='store_true', 72 | dest='mirror_locally', 73 | default=False, 74 | help='Mirror definitions locally' 75 | ) 76 | parser.add_argument( 77 | 'arg', 78 | nargs='*', 79 | default=None 80 | ) 81 | return parser 82 | 83 | def take_action(self, parsed_args): 84 | se = self.app.secrets 85 | se.requires_environment() 86 | se.read_secrets_and_descriptions() 87 | group = parsed_args.group 88 | groups = se.get_groups() 89 | # Default to using a group with the same name as the environment, 90 | # for projects that require a group of "global" variables. 91 | if group is None: 92 | group = str(self.app.secrets) 93 | if group not in groups: 94 | raise RuntimeError( 95 | ( 96 | f"[!] group '{group}' does not exist in " 97 | f"environment '{str(se)}'" 98 | ) 99 | if parsed_args.group is not None else 100 | "[!] please specify a group with '--group'" 101 | ) 102 | descriptions = se.read_descriptions(group=group) 103 | variables = [item['Variable'] for item in descriptions] 104 | args = parsed_args.arg 105 | for arg in args: 106 | if arg not in variables: 107 | raise RuntimeError( 108 | f"[!] variable '{arg}' does not exist in group '{group}'") 109 | if not parsed_args.force: 110 | if not stdin.isatty(): 111 | raise RuntimeError( 112 | "[-] must use '--force' flag to delete a secret") 113 | else: 114 | prompt = f"Type the name '{arg}' to confirm: " 115 | cli = Input(prompt, 116 | default="", 117 | word_color=colors.foreground["yellow"]) 118 | confirm = cli.launch() 119 | if confirm != arg: 120 | self.logger.info('[-] cancelled deleting secret') 121 | return 122 | descriptions = [ 123 | item for item in descriptions 124 | if item['Variable'] != arg 125 | ] 126 | se.delete_secret(arg) 127 | if len(descriptions) == 0: 128 | paths = [se.get_descriptions_path(group=group)] 129 | if parsed_args.mirror_locally: 130 | paths.append( 131 | se.get_descriptions_path( 132 | root=os.getcwd(), 133 | group=group, 134 | ) 135 | ) 136 | for path in paths: 137 | safe_delete_file(path) 138 | self.logger.info( 139 | "[+] deleted empty group '%s' (%s)", group, path) 140 | else: 141 | se.write_descriptions( 142 | data=descriptions, 143 | group=group, 144 | mirror_to=os.getcwd() if parsed_args.mirror_locally else None) 145 | se.write_secrets() 146 | 147 | 148 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 149 | -------------------------------------------------------------------------------- /psec/cli/secrets/describe.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Describe supported secret types. 5 | """ 6 | 7 | import logging 8 | 9 | from cliff.lister import Lister 10 | from psec.secrets_environment import SECRET_TYPES 11 | 12 | 13 | class SecretsDescribe(Lister): 14 | """ 15 | Describe supported secret types. 16 | 17 | To get descriptions for a subset of secrets, specify their names as the 18 | arguments:: 19 | 20 | $ psec secrets describe jenkins_admin_password 21 | +------------------------+---------+----------+--------------------------------------+---------+ 22 | | Variable | Group | Type | Prompt | Options | 23 | +------------------------+---------+----------+--------------------------------------+---------+ 24 | | jenkins_admin_password | jenkins | password | Password for Jenkins 'admin' account | * | 25 | +------------------------+---------+----------+--------------------------------------+---------+ 26 | 27 | If you instead want to get descriptions of all secrets in one or more 28 | groups, use the ``--group`` option and specify the group names as the 29 | arguments. 30 | 31 | To instead see the values and exported environment variables associated 32 | with secrets, use the ``secrets show`` command instead. 33 | """ # noqa 34 | 35 | logger = logging.getLogger(__name__) 36 | 37 | # Note: Not totally DRY. Replicates some logic from SecretsShow() 38 | 39 | def get_parser(self, prog_name): 40 | parser = super().get_parser(prog_name) 41 | parser.add_argument( 42 | '--undefined', 43 | action='store_true', 44 | dest='undefined', 45 | default=False, 46 | help='Only show variables that are not yet defined' 47 | ) 48 | what = parser.add_mutually_exclusive_group(required=False) 49 | what.add_argument( 50 | '-g', '--group', 51 | dest='args_group', 52 | action="store_true", 53 | default=False, 54 | help='Arguments are groups to list' 55 | ) 56 | what.add_argument( 57 | '-t', '--types', 58 | dest='types', 59 | action="store_true", 60 | default=False, 61 | help='Describe types' 62 | ) 63 | parser.add_argument( 64 | 'arg', 65 | nargs='*', 66 | default=None 67 | ) 68 | return parser 69 | 70 | def take_action(self, parsed_args): 71 | se = self.app.secrets 72 | if parsed_args.types: 73 | columns = [k.title() for k in SECRET_TYPES[0].keys()] 74 | data = [[v for k, v in i.items()] for i in SECRET_TYPES] 75 | else: 76 | se.requires_environment() 77 | se.read_secrets_and_descriptions() 78 | variables = [] 79 | if parsed_args.args_group: 80 | if len(parsed_args.arg) == 0: 81 | raise RuntimeError('[-] no group specified') 82 | for g in parsed_args.arg: 83 | try: 84 | variables.extend([ 85 | v for v 86 | in se.get_items_from_group(g) 87 | ]) 88 | except KeyError as e: 89 | raise RuntimeError( 90 | f"[-] group {str(e)} does not exist" 91 | ) 92 | else: 93 | variables = parsed_args.arg \ 94 | if len(parsed_args.arg) > 0 \ 95 | else [k for k, v in se.items()] 96 | columns = ( 97 | 'Variable', 'Group', 'Type', 'Prompt', 'Options', 'Help' 98 | ) 99 | data = ( 100 | [ 101 | ( 102 | k, 103 | se.get_group(k), 104 | se.get_secret_type(k), 105 | se.get_prompt(k), 106 | se.get_options(k), 107 | se.get_help(k) 108 | ) 109 | for k, v in se.items() 110 | if (k in variables and 111 | (not parsed_args.undefined or 112 | (parsed_args.undefined and v in [None, '']))) 113 | ] 114 | ) 115 | return columns, data 116 | 117 | 118 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 119 | -------------------------------------------------------------------------------- /psec/cli/secrets/find.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Find defined secrets in environments. 5 | """ 6 | 7 | # External imports 8 | import logging 9 | import sys 10 | 11 | from pathlib import Path 12 | 13 | # External imports 14 | from cliff.lister import Lister 15 | 16 | # Local imports 17 | from psec.secrets_environment import SecretsEnvironment 18 | from psec.utils import get_environment_paths 19 | 20 | 21 | class SecretsFind(Lister): 22 | """ 23 | Find defined secrets in environments. 24 | 25 | Searches through all environments in the secrets base directory and 26 | lists those that contain the variable(s) with names matching the 27 | search terms. You can search for secrets by value instead using 28 | the `--value` option flag. Example:: 29 | 30 | $ psec secrets find tanzanite_admin 31 | [+] searching secrets base directory /Users/dittrich/.secrets 32 | +-------------+-----------+----------------------------+ 33 | | Environment | Group | Variable | 34 | +-------------+-----------+----------------------------+ 35 | | tanzanite | tanzanite | tanzanite_admin_user_email | 36 | | tanzanite | tanzanite | tanzanite_admin_password | 37 | | tzdocker | tanzanite | tanzanite_admin_user_email | 38 | | tzdocker | tanzanite | tanzanite_admin_password | 39 | | tztest | tanzanite | tanzanite_admin_user_email | 40 | | tztest | tanzanite | tanzanite_admin_password | 41 | +-------------+-----------+----------------------------+ 42 | 43 | """ 44 | 45 | logger = logging.getLogger(__name__) 46 | 47 | def get_parser(self, prog_name): 48 | parser = super().get_parser(prog_name) 49 | parser.add_argument( 50 | '--group', 51 | action='store', 52 | dest='group', 53 | default=None, 54 | help='Limit searches to this specific group' 55 | ) 56 | parser.add_argument( 57 | '--value', 58 | action='store_true', 59 | default=False, 60 | help='The search term is the value to find (not the variable name)' 61 | ) 62 | parser.add_argument( 63 | 'arg', 64 | nargs='*', 65 | default=None 66 | ) 67 | return parser 68 | 69 | def take_action(self, parsed_args): 70 | data = list() 71 | columns = ['Environment', 'Group', 'Variable'] 72 | self.logger.info( 73 | '[+] searching secrets base directory %s', 74 | self.app.secrets_basedir 75 | ) 76 | for env_path in get_environment_paths( 77 | basedir=self.app.secrets_basedir, 78 | ): 79 | environment = Path(env_path).name 80 | se = SecretsEnvironment( 81 | environment=environment, 82 | secrets_basedir=self.app.secrets_basedir, 83 | ) 84 | se.read_secrets_and_descriptions(ignore_errors=True) 85 | matching = list() 86 | if parsed_args.value: 87 | matching = [ 88 | key for key, value in se.Variable.items() 89 | for arg in parsed_args.arg 90 | if arg == value 91 | ] 92 | else: 93 | matching = [ 94 | key for key, value in se.Variable.items() 95 | for arg in parsed_args.arg 96 | if arg in key 97 | ] 98 | for match in matching: 99 | data.append([environment, se.Group.get(match), match]) 100 | if len(data) < 1: 101 | args = ','.join([f"'{arg}'" for arg in parsed_args.arg]) 102 | something_something = ( 103 | "with value" 104 | if parsed_args.value 105 | else "matching name" 106 | ) 107 | sys.exit(f"[-] no secrets found {something_something} {args}") 108 | return columns, data 109 | 110 | # vim: set ts=4 sw=4 tw=0 et : 111 | -------------------------------------------------------------------------------- /psec/cli/secrets/generate.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Generate values for secrets. 5 | """ 6 | 7 | import logging 8 | 9 | from cliff.command import Command 10 | 11 | # Register handlers to ensure parser arguments are available. 12 | # FIXME: Doing this to enable 'make docs' to work properly. 13 | from psec.secrets_environment.factory import SecretFactory 14 | from psec.secrets_environment.handlers import * # noqa 15 | 16 | 17 | class SecretsGenerate(Command): 18 | """ 19 | Generate values for secrets. 20 | 21 | Sets variables by generating values according to the ``Type`` definition 22 | for each variable. 23 | 24 | If you include the ``--from-options`` flag, string variables will also be 25 | set according to their default value as described in the help output for 26 | the ``secrets set`` command. This allows as many variables as possible to 27 | be set with a single command (rather than requiring the user to do both 28 | ``secrets set`` and ``secrets generate`` as two separate steps. 29 | 30 | To affect only a subset of secrets, specify their names as the arguments to 31 | this command. If no secrets are specified, all secrets will be affected. 32 | """ 33 | 34 | logger = logging.getLogger(__name__) 35 | 36 | def get_parser(self, prog_name): 37 | parser = super().get_parser(prog_name) 38 | parser.add_argument( 39 | '--from-options', 40 | action='store_true', 41 | dest='from_options', 42 | default=False, 43 | help='Set variables from first available option' 44 | ) 45 | parser.add_argument( 46 | '-U', '--unique', 47 | action='store_true', 48 | dest='unique', 49 | default=False, 50 | help='Generate unique values for each type of secret' 51 | ) 52 | try: 53 | secret_factory = self.app.secret_factory 54 | except AttributeError: 55 | secret_factory = SecretFactory() 56 | # FIXME: Cliff automatic document generation fails here. 57 | parser = secret_factory.add_parser_arguments(parser) 58 | parser.add_argument( 59 | 'arg', 60 | nargs='*', 61 | default=None 62 | ) 63 | return parser 64 | 65 | def take_action(self, parsed_args): 66 | se = self.app.secrets 67 | se.requires_environment() 68 | se.read_secrets_and_descriptions() 69 | # If no secrets specified, default to all secrets 70 | to_change = parsed_args.arg \ 71 | if len(parsed_args.arg) > 0 \ 72 | else [k for k, v in se.items()] 73 | cached_result = {} 74 | for secret in to_change: 75 | secret_type = se.get_secret_type(secret) 76 | # >> Issue: [B105:hardcoded_password_string] Possible hardcoded password: 'string' # noqa 77 | # Severity: Low Confidence: Medium 78 | # Location: psec/secrets/generate.py:142 79 | # More Info: https://bandit.readthedocs.io/en/latest/plugins/b105_hardcoded_password_string.html # noqa 80 | # 142 if parsed_args.from_options and secret_type == 'string': # noqa 81 | if secret_type is None: 82 | raise TypeError( 83 | f"[-] secret '{secret}' " 84 | "has no type definition") 85 | default_value = se.get_default_value(secret) 86 | if parsed_args.from_options and default_value: 87 | value = default_value 88 | else: 89 | handler = self.app.secret_factory.get_handler(secret_type) 90 | value = cached_result.get( 91 | secret_type, 92 | handler.generate_secret( 93 | **dict(parsed_args._get_kwargs()) 94 | ) 95 | ) 96 | if not parsed_args.unique and handler.is_generable(): 97 | cached_result[secret_type] = value 98 | if value is not None: 99 | self.logger.debug( 100 | "[+] generated %s for %s", secret_type, secret) 101 | se.set_secret(secret, value) 102 | 103 | 104 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 105 | -------------------------------------------------------------------------------- /psec/cli/secrets/get.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Get value associated with a secret. 5 | """ 6 | 7 | import logging 8 | import os 9 | 10 | from cliff.command import Command 11 | 12 | 13 | class SecretsGet(Command): 14 | """ 15 | Gets the value associated with a secret and returns it on 16 | standard output. This is used for inline command substitution 17 | in cases where a single value is needed:: 18 | 19 | $ echo "Jenkins admin password: $(psec secrets get jenkins_admin_password)" 20 | Jenkins admin password: OZONE.negate.TIPTOP.ocean 21 | 22 | To get values for more than one secret, use `secrets show` 23 | with one of the formatting options allowing you to parse 24 | the results or otherwise use them as a group. 25 | """ # noqa 26 | 27 | logger = logging.getLogger(__name__) 28 | 29 | def get_parser(self, prog_name): 30 | parser = super().get_parser(prog_name) 31 | parser.add_argument( 32 | '-C', '--content', 33 | action='store_true', 34 | dest='content', 35 | default=False, 36 | help='Get content if secret is a file path' 37 | ) 38 | parser.add_argument( 39 | 'secret', 40 | nargs=1, 41 | default=None 42 | ) 43 | return parser 44 | 45 | def take_action(self, parsed_args): 46 | se = self.app.secrets 47 | se.requires_environment() 48 | se.read_secrets_and_descriptions() 49 | if parsed_args.secret is not None: 50 | value = se.get_secret( 51 | parsed_args.secret.pop(), allow_none=True) 52 | if not parsed_args.content: 53 | print(value) 54 | else: 55 | if os.path.exists(value): 56 | with open(value, 'r') as f: 57 | content = f.read().replace('\n', '') 58 | print(content) 59 | 60 | 61 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 62 | -------------------------------------------------------------------------------- /psec/cli/secrets/path.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Return path to secrets file. 5 | """ 6 | 7 | import logging 8 | 9 | from cliff.command import Command 10 | from psec.secrets_environment import SecretsEnvironment 11 | 12 | 13 | class SecretsPath(Command): 14 | """ 15 | Return path to secrets file. 16 | 17 | If no arguments are present, the path to the secrets for the default 18 | environment is returned. If you want to get the secrets path for a specific 19 | environment, specify it as the argument to this command. 20 | """ 21 | 22 | logger = logging.getLogger(__name__) 23 | 24 | def get_parser(self, prog_name): 25 | try: 26 | default = self.app_args.environment 27 | except AttributeError: 28 | default = None 29 | parser = super().get_parser(prog_name) 30 | parser.add_argument( 31 | 'environment', 32 | nargs='?', 33 | default=default 34 | ) 35 | return parser 36 | 37 | def take_action(self, parsed_args): 38 | e = SecretsEnvironment(environment=parsed_args.environment) 39 | print(e.get_secrets_file_path()) 40 | 41 | 42 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 43 | -------------------------------------------------------------------------------- /psec/cli/secrets/restore.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Restore secrets and descriptions from a backup file. 5 | """ 6 | 7 | import logging 8 | import os 9 | import tarfile 10 | 11 | from sys import stdin 12 | 13 | from cliff.command import Command 14 | # TODO(dittrich): https://github.com/Mckinsey666/bullet/issues/2 15 | # Workaround until bullet has Windows missing 'termios' fix. 16 | try: 17 | from bullet import Bullet 18 | except ModuleNotFoundError: 19 | pass 20 | 21 | 22 | class SecretsRestore(Command): 23 | """ 24 | Restore secrets and descriptions from a backup file. 25 | """ 26 | 27 | # TODO(dittrich): Finish documenting command. 28 | 29 | logger = logging.getLogger(__name__) 30 | 31 | def get_parser(self, prog_name): 32 | parser = super().get_parser(prog_name) 33 | parser.add_argument( 34 | 'backup', 35 | nargs='?', 36 | default=None 37 | ) 38 | return parser 39 | 40 | def take_action(self, parsed_args): 41 | secrets = self.app.secrets 42 | secrets.requires_environment() 43 | backups_dir = os.path.join( 44 | secrets.get_environment_path(), 45 | "backups") 46 | backups = [fn for fn in 47 | os.listdir(backups_dir) 48 | if fn.endswith('.tgz')] 49 | if parsed_args.backup is not None: 50 | choice = parsed_args.backup 51 | elif not (stdin.isatty() and 'Bullet' in globals()): 52 | # Can't involve user in getting a choice. 53 | raise RuntimeError('[-] no backup specified for restore') 54 | else: 55 | # Give user a chance to choose. 56 | choices = [''] + sorted(backups) 57 | cli = Bullet(prompt="\nSelect a backup from which to restore:", 58 | choices=choices, 59 | indent=0, 60 | align=2, 61 | margin=1, 62 | shift=0, 63 | bullet="→", 64 | pad_right=5) 65 | choice = cli.launch() 66 | if choice == "": 67 | self.logger.info('cancelled restoring from backup') 68 | return 69 | backup_path = os.path.join(backups_dir, choice) 70 | with tarfile.open(backup_path, "r:gz") as tf: 71 | # Only select intended files. See warning re: Tarfile.extractall() 72 | # in https://docs.python.org/3/library/tarfile.html 73 | allowed_prefixes = ['secrets.json', 'secrets.d/'] 74 | names = [fn for fn in tf.getnames() 75 | if any(fn.startswith(prefix) 76 | for prefix in allowed_prefixes 77 | if '../' not in fn) 78 | ] 79 | env_path = secrets.get_environment_path() 80 | for name in names: 81 | tf.extract(name, path=env_path) 82 | self.logger.info('[+] restored backup %s to %s', backup_path, env_path) 83 | 84 | 85 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 86 | -------------------------------------------------------------------------------- /psec/cli/secrets/send.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Send secrets using GPG encrypted email. 5 | """ 6 | 7 | # Standard imports 8 | import logging 9 | import textwrap 10 | 11 | # External imports 12 | from cliff.command import Command 13 | 14 | # Local imports 15 | from psec import __version__ 16 | from psec.google_oauth2 import GoogleSMTP 17 | 18 | 19 | class SecretsSend(Command): 20 | """ 21 | Send secrets using GPG encrypted email. 22 | 23 | Recipients for the secrets are specified as ``USERNAME@EMAIL.ADDRESS`` 24 | strings and/or ``VARIABLE`` references. 25 | """ 26 | 27 | logger = logging.getLogger(__name__) 28 | refresh_token = None 29 | 30 | def get_parser(self, prog_name): 31 | parser = super().get_parser(prog_name) 32 | parser.add_argument( 33 | '-T', '--refresh-token', 34 | action='store_true', 35 | dest='refresh_token', 36 | default=False, 37 | help='Refresh Google API Oauth2 token and exit' 38 | ) 39 | parser.add_argument( 40 | '--test-smtp', 41 | action='store_true', 42 | dest='test_smtp', 43 | default=False, 44 | help='Test Oauth2 SMTP authentication and exit' 45 | ) 46 | parser.add_argument( 47 | '-H', '--smtp-host', 48 | action='store', 49 | dest='smtp_host', 50 | default='localhost', 51 | help='SMTP host' 52 | ) 53 | parser.add_argument( 54 | '-U', '--smtp-username', 55 | action='store', 56 | dest='smtp_username', 57 | default=None, 58 | help='SMTP authentication username' 59 | ) 60 | parser.add_argument( 61 | '-F', '--from', 62 | action='store', 63 | dest='smtp_sender', 64 | default='noreply@nowhere', 65 | help='Sender address' 66 | ) 67 | parser.add_argument( 68 | '-S', '--subject', 69 | action='store', 70 | dest='smtp_subject', 71 | default='For Your Information', 72 | help='Subject line' 73 | ) 74 | parser.add_argument( 75 | 'arg', 76 | nargs='*', 77 | default=None 78 | ) 79 | return parser 80 | 81 | def take_action(self, parsed_args): 82 | se = self.app.secrets 83 | se.requires_environment() 84 | se.read_secrets_and_descriptions() 85 | username = ( 86 | parsed_args.smtp_username 87 | if parsed_args.smtp_username is not None 88 | else se.get_secret('google_oauth_username') 89 | ) 90 | orig_refresh_token = None 91 | self.refresh_token = se.get_secret( 92 | 'google_oauth_refresh_token', 93 | allow_none=True 94 | ) 95 | if parsed_args.refresh_token: 96 | orig_refresh_token = self.refresh_token 97 | self.logger.debug('[+] refreshing Google Oauth2 token') 98 | else: 99 | self.logger.debug('[+] sending secrets') 100 | googlesmtp = GoogleSMTP( 101 | username=username, 102 | client_id=se.get_secret( 103 | 'google_oauth_client_id'), 104 | client_secret=se.get_secret( 105 | 'google_oauth_client_secret'), 106 | refresh_token=self.refresh_token, 107 | gpg_encrypt=True, 108 | verbose=self.app_args.verbose_level > 1 109 | ) 110 | if parsed_args.refresh_token: 111 | new_refresh_token = googlesmtp.get_authorization()[0] 112 | if new_refresh_token != orig_refresh_token: 113 | se.set_secret( 114 | 'google_oauth_refresh_token', 115 | new_refresh_token 116 | ) 117 | return None 118 | if parsed_args.test_smtp: 119 | auth_string, expires_in = googlesmtp.refresh_authorization() # pylint: disable=unused-variable # noqa 120 | googlesmtp.test_smtp( 121 | googlesmtp.generate_oauth2_string( 122 | base64_encode=True 123 | ) 124 | ) 125 | return None 126 | recipients = [] 127 | variables = [] 128 | for arg in parsed_args.arg: 129 | if "@" in arg: 130 | recipients.append(arg) 131 | else: 132 | if se.get_secret(arg): 133 | variables.append(arg) 134 | secrets_sent = "\n".join( 135 | [ 136 | f"{v}='{se.get_secret(v)}'" 137 | for v in variables 138 | ] 139 | ) 140 | message = ( 141 | f"The following secret{'' if len(variables) == 1 else 's'} " 142 | f"{'is' if len(variables) == 1 else 'are'} " 143 | "being shared with you:\n\n" 144 | f"{secrets_sent}\n\n" 145 | ) 146 | # https://stackoverflow.com/questions/33170016/how-to-use-django-1-8-5-orm-without-creating-a-django-project/46050808#46050808 # noqa 147 | addendum = textwrap.dedent( 148 | f"""\ 149 | Sent using psec version {__version__} 150 | https://pypi.org/project/python-secrets/ 151 | https://github.com/davedittrich/python_secrets 152 | """ 153 | ) 154 | for recipient in recipients: 155 | msg = googlesmtp.create_msg( 156 | parsed_args.smtp_sender, 157 | recipient, 158 | parsed_args.smtp_subject, 159 | text_message=message, 160 | addendum=addendum, 161 | ) 162 | googlesmtp.send_mail( 163 | parsed_args.smtp_sender, 164 | recipient, 165 | msg, 166 | ) 167 | self.logger.info("[+] sent secrets to %s", recipient) 168 | 169 | 170 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 171 | -------------------------------------------------------------------------------- /psec/cli/secrets/show.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | List the contents of the secrets file or definitions. 5 | """ 6 | 7 | import logging 8 | import os 9 | 10 | from cliff.lister import Lister 11 | 12 | from psec.exceptions import SecretNotFoundError 13 | from psec.utils import redact 14 | 15 | 16 | class SecretsShow(Lister): 17 | """ 18 | List the contents of the secrets file or definitions. 19 | 20 | The ``secrets show`` command is used to see variables, their values, and 21 | exported environment variables to help in using them in your code, shell 22 | scripts, etc. To see more metadata-ish information, such as their group, 23 | type, etc., use the ``secrets describe`` command instead. 24 | 25 | To get show a subset of secrets, specify their names as arguments. If you 26 | instead want to show all secrets in one or more groups, use the ``--group`` 27 | option and specify the group names as arguments, or to show all secrets of 28 | one or more types, use the ``--type`` option:: 29 | 30 | $ psec secrets show 31 | +------------------------+----------+------------------------+ 32 | | Variable | Value | Export | 33 | +------------------------+----------+------------------------+ 34 | | jenkins_admin_password | REDACTED | jenkins_admin_password | 35 | | myapp_app_password | REDACTED | DEMO_app_password | 36 | | myapp_client_psk | REDACTED | DEMO_client_ssid | 37 | | myapp_client_ssid | REDACTED | DEMO_client_ssid | 38 | | myapp_pi_password | REDACTED | DEMO_pi_password | 39 | | trident_db_pass | REDACTED | trident_db_pass | 40 | | trident_sysadmin_pass | REDACTED | trident_sysadmin_pass | 41 | +------------------------+----------+------------------------+ 42 | 43 | Visually finding undefined variables in a very long list can be difficult. 44 | You can show just undefined variables with the ``--undefined`` option. 45 | """ # noqa 46 | 47 | logger = logging.getLogger(__name__) 48 | 49 | # Note: Not totally DRY. Replicates some logic from SecretsDescribe() 50 | 51 | def get_parser(self, prog_name): 52 | # Sorry for the double-negative, but it works better 53 | # this way for the user as a flag and to have a default 54 | # of redacting (so they need to turn it off) 55 | do_redact = not ( 56 | os.getenv('D2_NO_REDACT', "FALSE").upper() 57 | in ["true".upper(), "1", "yes".upper()] 58 | ) 59 | parser = super().get_parser(prog_name) 60 | parser.add_argument( 61 | '-C', '--no-redact', 62 | action='store_false', 63 | dest='redact', 64 | default=do_redact, 65 | help='Do not redact values in output' 66 | ) 67 | parser.add_argument( 68 | '-p', '--prompts', 69 | dest='args_prompts', 70 | action="store_true", 71 | default=False, 72 | help='Include prompts' 73 | ) 74 | parser.add_argument( 75 | '--undefined', 76 | action='store_true', 77 | dest='undefined', 78 | default=False, 79 | help='Only show variables that are not yet defined' 80 | ) 81 | what = parser.add_mutually_exclusive_group(required=False) 82 | what.add_argument( 83 | '-g', '--group', 84 | dest='args_group', 85 | action="store_true", 86 | default=False, 87 | help='Arguments are groups to show' 88 | ) 89 | what.add_argument( 90 | '-t', '--type', 91 | dest='args_type', 92 | action="store_true", 93 | default=False, 94 | help='Arguments are types to show' 95 | ) 96 | parser.add_argument( 97 | 'arg', 98 | nargs='*', 99 | default=None 100 | ) 101 | return parser 102 | 103 | def take_action(self, parsed_args): 104 | se = self.app.secrets 105 | se.requires_environment() 106 | se.read_secrets_and_descriptions() 107 | variables = [] 108 | all_items = [k for k, v in se.items()] 109 | if parsed_args.args_group: 110 | if len(parsed_args.arg) == 0: 111 | raise RuntimeError('[-] no group(s) specified') 112 | for g in parsed_args.arg: 113 | try: 114 | variables.extend( 115 | [ 116 | v for v 117 | in se.get_items_from_group(g) 118 | ] 119 | ) 120 | except KeyError as e: 121 | raise RuntimeError( 122 | f"[-] group '{str(e)}' does not exist") 123 | elif parsed_args.args_type: 124 | if len(parsed_args.arg) == 0: 125 | raise RuntimeError('[-] no type(s) specified') 126 | variables = [ 127 | k for k, v 128 | in se.Type.items() 129 | if v in parsed_args.arg 130 | ] 131 | else: 132 | for v in parsed_args.arg: 133 | if v not in all_items: 134 | # Validate requested variables exist. 135 | raise SecretNotFoundError(secret=v) 136 | variables = parsed_args.arg \ 137 | if len(parsed_args.arg) > 0 \ 138 | else [k for k, v in se.items()] 139 | columns = ('Variable', 'Value', 'Export') 140 | data = ([(k, 141 | redact(v, parsed_args.redact), 142 | se.get_secret_export(k)) 143 | for k, v in se.items() 144 | if (k in variables and 145 | (not parsed_args.undefined or 146 | (parsed_args.undefined and v in [None, ''])))]) 147 | return columns, data 148 | 149 | 150 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 151 | -------------------------------------------------------------------------------- /psec/cli/secrets/tree.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Output tree listing of groups and secrets in environment. 4 | """ 5 | 6 | import logging 7 | import sys 8 | 9 | from cliff.command import Command 10 | from psec.secrets_environment import SecretsEnvironment 11 | from psec.utils import secrets_tree 12 | 13 | 14 | class SecretsTree(Command): 15 | """ 16 | Output tree listing of groups and secrets in environment. 17 | 18 | The ``secrets tree`` command produces output similar to the Unix ``tree`` 19 | command. This gives you a visual overview of the groupings of secrets in 20 | the target environment:: 21 | 22 | $ psec secrets tree my_environment 23 | my_environment 24 | ├── myapp 25 | │ ├── myapp_app_password 26 | │ ├── myapp_client_psk 27 | │ ├── myapp_client_ssid 28 | │ ├── myapp_ondemand_wifi 29 | │ └── myapp_pi_password 30 | └── oauth 31 | ├── google_oauth_client_id 32 | ├── google_oauth_client_secret 33 | ├── google_oauth_refresh_token 34 | └── google_oauth_username 35 | """ 36 | 37 | logger = logging.getLogger(__name__) 38 | 39 | def get_parser(self, prog_name): 40 | parser = super().get_parser(prog_name) 41 | parser.add_argument( 42 | 'environment', 43 | nargs='?', 44 | default=None 45 | ) 46 | return parser 47 | 48 | def take_action(self, parsed_args): 49 | environment = parsed_args.environment 50 | if environment is None: 51 | environment = self.app.options.environment 52 | e = SecretsEnvironment(environment=environment) 53 | e.requires_environment() 54 | e.read_secrets_and_descriptions() 55 | secrets_tree(e, outfile=sys.stdout) 56 | 57 | 58 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 59 | -------------------------------------------------------------------------------- /psec/cli/template.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import logging 4 | 5 | from cliff.command import Command 6 | from jinja2 import (Environment, FileSystemLoader, 7 | StrictUndefined, Undefined, 8 | make_logging_undefined, select_autoescape) 9 | 10 | 11 | class Template(Command): 12 | """ 13 | Template file(s). 14 | 15 | For information on the Jinja2 template engine and how to 16 | use it, see http://jinja.pocoo.org 17 | 18 | To assist debugging, use ``--check-defined`` to check that 19 | all required variables are defined. 20 | """ 21 | 22 | logger = logging.getLogger(__name__) 23 | 24 | def get_parser(self, prog_name): 25 | parser = super().get_parser(prog_name) 26 | parser.add_argument( 27 | '--check-defined', 28 | action='store_true', 29 | dest='check_defined', 30 | default=False, 31 | help="Just check for undefined variables" 32 | ) 33 | parser.add_argument( 34 | '--no-env', 35 | action='store_true', 36 | dest='no_env', 37 | default=False, 38 | help="Do not require and load an environment" 39 | ) 40 | parser.add_argument( 41 | 'source', 42 | nargs="?", 43 | help="input Jinja2 template source", 44 | default=None 45 | ) 46 | parser.add_argument( 47 | 'dest', 48 | nargs="?", 49 | help="templated output destination ('-' for stdout)", 50 | default=None 51 | ) 52 | return parser 53 | 54 | def take_action(self, parsed_args): 55 | se = self.app.secrets 56 | if parsed_args.no_env: 57 | template_vars = dict() 58 | else: 59 | se.requires_environment() 60 | se.read_secrets_and_descriptions() 61 | template_vars = se.items() 62 | template_loader = FileSystemLoader('.') 63 | base = Undefined if parsed_args.check_defined is True \ 64 | else StrictUndefined 65 | LoggingUndefined = make_logging_undefined( 66 | logger=self.logger, 67 | base=base) 68 | template_env = Environment( 69 | loader=template_loader, 70 | autoescape=select_autoescape( 71 | disabled_extensions=('txt',), 72 | default_for_string=True, 73 | default=True, 74 | ), 75 | undefined=LoggingUndefined) 76 | template = template_env.get_template(parsed_args.source) 77 | output_text = template.render(template_vars) 78 | if parsed_args.check_defined is False: 79 | if parsed_args.dest == "-": 80 | print(output_text) 81 | else: 82 | with open(parsed_args.dest, 'w') as f: 83 | f.writelines(output_text) 84 | 85 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 86 | -------------------------------------------------------------------------------- /psec/cli/utils/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | 'utils' subcommands and related classes. 5 | 6 | Author: Dave Dittrich 7 | 8 | URL: https://python_secrets.readthedocs.org. 9 | """ 10 | 11 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 12 | -------------------------------------------------------------------------------- /psec/cli/utils/myip.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Standard imports 4 | import textwrap 5 | 6 | # External imports 7 | import ipaddress 8 | import logging 9 | 10 | from cliff.command import Command 11 | from cliff.lister import Lister 12 | from psec.utils import ( 13 | get_myip, 14 | get_myip_methods, 15 | get_netblock, 16 | myip_methods, 17 | ) 18 | 19 | 20 | logger = logging.getLogger(__name__) 21 | # NOTE(dittrich): To be DRY, we want to define this warning once, but 22 | # to ensure cliff formatting is not messed up, we need to append this 23 | # text to the epilog with the string '+' operator. 24 | QUOTA_WARNING = textwrap.dedent(""" 25 | WARNING: Any of the sites used by this command may limit the number 26 | of queries allowed from a given source in a given period of time and 27 | may temporarily block or reject attempts to use their service beyond 28 | the quota limit. 29 | """) 30 | 31 | 32 | class MyIP(Command): 33 | r""" 34 | Get currently active internet routable IPv4 address. 35 | 36 | Return the routable IP address of the host running this script using one of 37 | several publicly available free methods typically using HTTPS or DNS. 38 | 39 | The ``--cidr`` option expresses the IP address as a CIDR block to use in 40 | setting up firewall rules for this specific IP address. 41 | 42 | The ``--netblock`` option follows this lookup with another lookup using 43 | WHOIS to get the network provider's address range(s), in CIDR notation, to 44 | help with creating firewall rules that can work around dynamic addressing. 45 | This is not the most secure way to grant network access as it allows any 46 | customer using the same provider to also communicate through the firewall, 47 | but you have to admit that it is better than ``allow ANY``! ¯\_(ツ)_/¯ 48 | 49 | To see a table of the methods, use ``utils myip methods``. 50 | 51 | KNOWN LIMITATION: Some of the methods may not fully support IPv6 at this 52 | point. If you find one that doesn't work, try a different one. 53 | 54 | See also: 55 | https://linuxize.com/post/how-to-find-ip-address-linux/ 56 | https://dev.to/adityathebe/a-handy-way-to-know-your-public-ip-address-with-dns-servers-4nmn 57 | """ # noqa 58 | 59 | # TODO(dittrich): Add environment variable defining preferred method 60 | 61 | logger = logging.getLogger(__name__) 62 | 63 | def __init__(self, app, app_args, cmd_name=None): 64 | super().__init__(app, app_args, cmd_name=cmd_name) 65 | 66 | def get_parser(self, prog_name): 67 | parser = super().get_parser(prog_name) 68 | default_method = 'random' 69 | choices = get_myip_methods(include_random=True) 70 | parser.add_argument( 71 | '-M', '--method', 72 | action='store', 73 | dest='method', 74 | choices=choices, 75 | # type=lambda m: None if m == 'random' else m, 76 | default=default_method, 77 | help='Method to use for determining IP address' 78 | ) 79 | what = parser.add_mutually_exclusive_group(required=False) 80 | what.add_argument( 81 | '-C', '--cidr', 82 | action='store_true', 83 | dest='cidr', 84 | default=False, 85 | help='Express the IP address as a CIDR block' 86 | ) 87 | what.add_argument( 88 | '-N', '--netblock', 89 | action='store_true', 90 | dest='netblock', 91 | default=False, 92 | help='Return network CIDR block(s) for IP from WHOIS' 93 | ) 94 | parser.epilog = QUOTA_WARNING 95 | return parser 96 | 97 | def take_action(self, parsed_args): 98 | interface = ipaddress.ip_interface( 99 | get_myip(method=parsed_args.method)) 100 | if parsed_args.cidr: 101 | print(str(interface.with_prefixlen)) 102 | elif parsed_args.netblock: 103 | print(get_netblock(ip=interface.ip)) 104 | else: 105 | print(str(interface.ip)) 106 | 107 | 108 | class MyIPMethods(Lister): 109 | """ 110 | Show methods for obtaining routable IP address. 111 | 112 | Provides the details of the methods coded into this app for 113 | obtaining this host's routable IP address:: 114 | 115 | $ psec utils myip methods 116 | +-----------+-------+--------------------------------------------------------+ 117 | | Method | Type | Source | 118 | +-----------+-------+--------------------------------------------------------+ 119 | | akamai | dns | dig +short @ns1-1.akamaitech.net ANY whoami.akamai.net | 120 | | amazon | https | https://checkip.amazonaws.com | 121 | | google | dns | dig +short @ns1.google.com TXT o-o.myaddr.l.google.com | 122 | | icanhazip | https | https://icanhazip.com/ | 123 | | infoip | https | https://api.infoip.io/ip | 124 | | opendns_h | https | https://diagnostic.opendns.com/myip | 125 | | opendns_r | dns | dig +short @resolver1.opendns.com myip.opendns.com -4 | 126 | | tnx | https | https://tnx.nl/ip | 127 | +-----------+-------+--------------------------------------------------------+ 128 | 129 | It can be used for looping in tests, etc. like this:: 130 | 131 | $ for method in $(psec utils myip methods -f value -c Method) 132 | > do 133 | > echo "$method: $(psec utils myip --method $method)" 134 | > done 135 | akamai: 93.184.216.34 136 | amazon: 93.184.216.34 137 | google: 93.184.216.34 138 | icanhazip: 93.184.216.34 139 | infoip: 93.184.216.34 140 | opendns_h: 93.184.216.34 141 | opendns_r: 93.184.216.34 142 | tnx: 93.184.216.34 143 | """ # noqa 144 | 145 | logger = logging.getLogger(__name__) 146 | 147 | def get_parser(self, prog_name): 148 | parser = super().get_parser(prog_name) 149 | parser.add_argument( 150 | 'method', 151 | nargs='*', 152 | default=None 153 | ) 154 | parser.epilog = QUOTA_WARNING 155 | return parser 156 | 157 | def take_action(self, parsed_args): 158 | columns = ('Method', 'Type', 'Source') 159 | data = [] 160 | methods = (parsed_args.method 161 | if len(parsed_args.method) > 0 162 | else myip_methods.keys()) 163 | 164 | for method, mechanics in sorted(myip_methods.items()): 165 | if method in methods: 166 | data.append(( 167 | method, 168 | mechanics['func'](), 169 | mechanics['arg'] 170 | )) 171 | return columns, data 172 | 173 | 174 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 175 | -------------------------------------------------------------------------------- /psec/cli/utils/netblock.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import logging 4 | 5 | from cliff.command import Command 6 | from psec.utils import ( 7 | get_myip, 8 | get_netblock, 9 | ) 10 | 11 | 12 | class Netblock(Command): 13 | """ 14 | Get network CIDR block(s) for IP from WHOIS lookup. 15 | 16 | Look up the network address blocks serving the specified IP address(es) 17 | using the Python ``IPWhois`` module. 18 | 19 | https://pypi.org/project/ipwhois/ 20 | 21 | If no arguments are given, the routable address of the host on which 22 | ``psec`` is being run will be determined and used as the default. 23 | """ 24 | 25 | logger = logging.getLogger(__name__) 26 | 27 | def __init__(self, app, app_args, cmd_name=None): 28 | super().__init__(app, app_args, cmd_name=cmd_name) 29 | 30 | def get_parser(self, prog_name): 31 | parser = super().get_parser(prog_name) 32 | parser.add_argument( 33 | 'ip', 34 | nargs='*', 35 | default=[], 36 | help='IP address to use' 37 | ) 38 | return parser 39 | 40 | def take_action(self, parsed_args): 41 | if len(parsed_args.ip) == 0: 42 | # TODO(dittrich): Just use random for now 43 | # until refactoring out the choice method. 44 | parsed_args.ip.append(get_myip(method='random')) 45 | for ip in parsed_args.ip: 46 | print(get_netblock(ip=ip)) 47 | 48 | 49 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 50 | -------------------------------------------------------------------------------- /psec/cli/utils/set_aws_credentials.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import logging 4 | import os 5 | 6 | 7 | from cliff.command import Command 8 | from configobj import ConfigObj 9 | 10 | 11 | AWS_CONFIG_FILE = os.path.join(os.path.expanduser('~'), '.aws', 'credentials') 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class SetAWSCredentials(Command): 16 | """ 17 | Set credentials from saved secrets for use by AWS CLI. 18 | 19 | This command directly manipulates the AWS CLI "credentials" INI-style file. 20 | The AWS CLI does not support non-interactive manipulation of the 21 | credentials file, so this hack is used to do this. Be aware that this might 22 | cause some problems (though it shouldn't, since the file is so simple):: 23 | 24 | [default] 25 | aws_access_key_id = [ Harm to Ongoing Matter ] 26 | aws_secret_access_key = [ HOM ] 27 | \n 28 | For simple use cases, you will not need to switch between different users. 29 | The default is to use the AWS convention of ``default`` as seen in the 30 | example above. If you do need to support multiple users, the ``--user`` 31 | option will allow you to specify the user. 32 | 33 | See also: 34 | 35 | * https://aws.amazon.com/cli/ 36 | * https://docs.aws.amazon.com/cli/latest/userguide/cli-config-files.html 37 | """ # noqa 38 | 39 | logger = logging.getLogger(__name__) 40 | 41 | def get_parser(self, prog_name): 42 | parser = super().get_parser(prog_name) 43 | parser.add_argument( 44 | '-U', '--user', 45 | action='store', 46 | dest='user', 47 | default='default', 48 | help='IAM User who owns credentials' 49 | ) 50 | return parser 51 | 52 | def take_action(self, parsed_args): 53 | se = self.app.secrets 54 | se.requires_environment() 55 | se.read_secrets_and_descriptions() 56 | required_vars = ['aws_access_key_id', 'aws_secret_access_key'] 57 | config = ConfigObj(AWS_CONFIG_FILE) 58 | for v in required_vars: 59 | try: 60 | cred = se.get_secret(v) 61 | except Exception as err: # noqa 62 | raise 63 | config[parsed_args.user][v] = cred 64 | config.write() 65 | 66 | 67 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 68 | -------------------------------------------------------------------------------- /psec/cli/utils/tfbackend.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Enable Terraform backend support. 5 | """ 6 | 7 | import logging 8 | import os 9 | import textwrap 10 | 11 | from cliff.command import Command 12 | from psec.secrets_environment import SecretsEnvironment 13 | 14 | 15 | class TfBackend(Command): 16 | """ 17 | Enable Terraform backend support. 18 | 19 | Enables the Terraform "backend support" option to move the file ``terraform.tfstate`` 20 | (which can contain many secrets) out of the current working directory and into the 21 | current environment directory path. 22 | """ # noqa 23 | 24 | # TODO(dittrich): Finish documenting this 25 | 26 | logger = logging.getLogger(__name__) 27 | 28 | def get_parser(self, prog_name): 29 | parser = super().get_parser(prog_name) 30 | parser.add_argument( 31 | '--path', 32 | action='store_true', 33 | dest='path', 34 | default=False, 35 | help='Print path and exit' 36 | ) 37 | # tfstate = None 38 | # try: 39 | # tfstate = os.path.join(self.app.secrets.get_environment_path(), 40 | # "terraform.tfstate") 41 | # except AttributeError: 42 | # pass 43 | return parser 44 | 45 | def take_action(self, parsed_args): 46 | e = SecretsEnvironment(environment=self.app.options.environment) 47 | tmpdir = e.get_tmpdir_path() 48 | backend_file = os.path.join(os.getcwd(), 'tfbackend.tf') 49 | tfstate_file = os.path.join(tmpdir, 'terraform.tfstate') 50 | backend_text = textwrap.dedent( 51 | f"""terraform {{ 52 | backend "local" {{ 53 | path = "{tfstate_file}" 54 | }} 55 | }} 56 | """ 57 | ) 58 | 59 | if parsed_args.path: 60 | self.logger.debug('[+] showing terraform state file path') 61 | print(tfstate_file) 62 | else: 63 | self.logger.debug('[+] setting up terraform backend') 64 | if os.path.exists(backend_file): 65 | self.logger.debug("[+] updating '%s'", backend_file) 66 | else: 67 | self.logger.debug("[+] creating '%s'", backend_file) 68 | with open(backend_file, 'w') as f: 69 | f.write(backend_text) 70 | 71 | 72 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 73 | -------------------------------------------------------------------------------- /psec/cli/utils/tfoutput.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import json 4 | import logging 5 | import os 6 | import shlex 7 | import subprocess # nosec 8 | 9 | from cliff.lister import Lister 10 | from pathlib import Path 11 | 12 | 13 | # The TfOutput Lister assumes `terraform output` structured as 14 | # shown here: 15 | # 16 | # $ terraform output -state=xgt/terraform.tfstate 17 | # xgt = { 18 | # instance_user = ec2-user 19 | # privatekey_path = /home/dittrich/.ssh/xgt.pem 20 | # public_dns = ec2-52-27-37-238.us-west-2.compute.amazonaws.com 21 | # public_ip = 52.27.37.238 22 | # spot_bid_state = [active] 23 | # spot_bid_status = [fulfilled] 24 | # spot_instance_id = [i-06590cf97d79bdfd9] 25 | # } 26 | # 27 | # $ terraform output -state=xgt/terraform.tfstate -json 28 | # { 29 | # "xgt": { 30 | # "sensitive": false, 31 | # "type": "map", 32 | # "value": { 33 | # "instance_user": "ec2-user", 34 | # "privatekey_path": "/home/dittrich/.ssh/xgt.pem", 35 | # "public_dns": "ec2-52-27-37-238.us-west-2.compute.amazonaws.com", 36 | # "public_ip": "52.27.37.238", 37 | # "spot_bid_state": [ 38 | # "active" 39 | # ], 40 | # "spot_bid_status": [ 41 | # "fulfilled" 42 | # ], 43 | # "spot_instance_id": [ 44 | # "i-06590cf97d79bdfd9" 45 | # ] 46 | # } 47 | # } 48 | # } 49 | # 50 | # Pulumi output: 51 | # $ pulumi stack output --json 52 | # { 53 | # "instance_id": "i-06a01c878aa51b66c", 54 | # "instance_user": "ec2-user", 55 | # "privatekey_path": "/home/dittrich/.ssh/xgt.pem", 56 | # "public_dns": "ec2-34-220-229-93.us-west-2.compute.amazonaws.com", 57 | # "public_ip": "34.220.229.93", 58 | # "subnet_id": "subnet-0e642669", 59 | # "vpc_id": "vpc-745d6b13" 60 | # } 61 | 62 | 63 | class TfOutput(Lister): 64 | """ 65 | Retrieve current ``terraform output`` results. 66 | 67 | If the ``tfstate`` argument is not provided, this command will attempt to 68 | search for a ``terraform.tfstate`` file in (1) the active environment's 69 | secrets storage directory (see ``environments path``), or (2) the current 70 | working directory. The former is documented preferred location for storing 71 | this file, since it will contain secrets that *should not* be stored in a 72 | source repository directory to avoid potential leaking of those secrets:: 73 | 74 | $ psec environments path 75 | /Users/dittrich/.secrets/psec 76 | """ 77 | 78 | logger = logging.getLogger(__name__) 79 | 80 | def get_parser(self, prog_name): 81 | parser = super().get_parser(prog_name) 82 | tfstate = None 83 | try: 84 | tfstate = os.path.join(self.app.secrets.get_tmpdir_path(), 85 | "terraform.tfstate") 86 | except AttributeError: 87 | pass 88 | parser.add_argument( 89 | 'tfstate', 90 | nargs='?', 91 | default=tfstate, 92 | help='Path to Terraform state file' 93 | ) 94 | return parser 95 | 96 | def take_action(self, parsed_args): 97 | se = self.app.secrets 98 | columns = ('Variable', 'Value') 99 | data = list() 100 | tfstate = parsed_args.tfstate 101 | if tfstate is None: 102 | base = 'terraform.tfstate' 103 | tfstate = se.get_environment_path() / base 104 | if not os.path.exists(tfstate): 105 | tfstate = Path(os.getcwd()) / base 106 | if not os.path.exists(tfstate): 107 | raise RuntimeError('[-] no terraform state file specified') 108 | if not os.path.exists(tfstate): 109 | raise RuntimeError(f"[-] file does not exist: '{tfstate}'") 110 | if self.app_args.verbose_level > 1: 111 | # NOTE(dittrich): Not DRY, but spend time fixing later. 112 | self.logger.info( 113 | ' '.join(['/usr/local/bin/terraform', 114 | 'output', 115 | f'-state={tfstate}', 116 | '-json']) 117 | ) 118 | p = subprocess.Popen( 119 | [ 120 | '/usr/local/bin/terraform', 121 | 'output', 122 | f'-state={shlex.quote(tfstate)}', 123 | '-json' 124 | ], 125 | env=dict(os.environ), 126 | stdout=subprocess.PIPE, 127 | stderr=subprocess.PIPE, 128 | shell=False # nosec 129 | ) 130 | jout, err = p.communicate() 131 | dout = json.loads(jout.decode('UTF-8')) 132 | for prefix in dout.keys(): 133 | for k, v in dout[prefix]['value'].items(): 134 | data.append([f"{prefix}_{k}", v]) 135 | return columns, data 136 | 137 | 138 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 139 | -------------------------------------------------------------------------------- /psec/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | ``psec`` exception classes. 5 | """ 6 | 7 | 8 | class PsecBaseException(Exception): 9 | """Base class for psec exceptions""" 10 | # This base class uses the __doc__ string of subclasses as the default 11 | # message. To customize the message, pass it to ``__init__()`` as 12 | # the ``msg`` argument. 13 | # 14 | # Subclasses are responsible for adding any additional information by 15 | # overloading the own ``__str__()`` method. 16 | # 17 | # Don't raise this exception directly. 18 | # 19 | def __init__(self, *args, msg=None, **kwargs): 20 | super().__init__(msg or self.__doc__, *args, **kwargs) 21 | self.msg = self.__doc__ 22 | 23 | def __str__(self): 24 | return str(self.msg) 25 | 26 | 27 | class PsecEnvironmentError(PsecBaseException): 28 | """Environment error""" 29 | # Subclasses should pass the environment name as the 30 | # ``environment`` keyword argument to ``__init__()``. 31 | # 32 | def __init__(self, *args, **kwargs): 33 | self.environment = kwargs.pop('environment', None) 34 | super().__init__(*args, **kwargs) 35 | 36 | def __str__(self): 37 | addendum = ( 38 | f": {self.environment}" 39 | if self.environment is not None 40 | else '' 41 | ) 42 | return str(self.__doc__ + addendum) 43 | 44 | 45 | class PsecEnvironmentAlreadyExistsError(PsecEnvironmentError): 46 | """Environment already exists""" 47 | 48 | 49 | class PsecEnvironmentNotFoundError(PsecEnvironmentError): 50 | """Environment does not exist""" 51 | 52 | 53 | class BasedirError(PsecBaseException): 54 | """Base directory error""" 55 | # Subclasses should pass the environment name as the 56 | # ``basedir`` keyword argument to ``__init__()``. 57 | # 58 | def __init__(self, *args, **kwargs): 59 | self.basedir = kwargs.pop('basedir', None) 60 | super().__init__(*args, **kwargs) 61 | 62 | def __str__(self): 63 | addendum = ( 64 | f": {self.basedir}" 65 | if self.basedir is not None 66 | else '' 67 | ) 68 | return str(self.__doc__ + addendum) 69 | 70 | 71 | class BasedirNotFoundError(BasedirError): 72 | """Basedir does not exist""" 73 | 74 | 75 | class InvalidBasedirError(BasedirError): 76 | """Basedir is not valid""" 77 | 78 | 79 | class SecretsError(PsecBaseException): 80 | """Secrets exception base class""" 81 | # Subclasses should pass the variable name as the 82 | # ``secret`` keyword argument to ``__init__()``. 83 | # 84 | def __init__(self, *args, **kwargs): 85 | self.secret = kwargs.pop('secret', None) 86 | super().__init__(*args, **kwargs) 87 | 88 | def __str__(self): 89 | addendum = ( 90 | f": {self.secret}" 91 | if self.secret is not None 92 | else '' 93 | ) 94 | return str(self.__doc__ + addendum) 95 | 96 | 97 | class SecretNotFoundError(SecretsError): 98 | """Secret not found""" 99 | 100 | 101 | class SecretTypeNotFoundError(SecretsError): 102 | """Secret type not found""" 103 | 104 | 105 | class DescriptionsError(PsecBaseException): 106 | """Secrets descriptions exception base class""" 107 | 108 | 109 | class InvalidDescriptionsError(DescriptionsError): 110 | """Invalid secrets descriptions""" 111 | 112 | 113 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 114 | -------------------------------------------------------------------------------- /psec/secrets_environment/factory/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Secrets factory. 4 | """ 5 | 6 | # Standard imports 7 | import logging 8 | from abc import ( 9 | ABC, 10 | abstractmethod, 11 | ) 12 | from collections import OrderedDict 13 | from inspect import getdoc 14 | 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | # pylint: disable=missing-function-docstring 20 | 21 | 22 | class SecretFactory: 23 | """ 24 | Factory class for generating secrets. 25 | """ 26 | 27 | class_map = {} 28 | 29 | # def _get_secret_class(self, class_name): 30 | # """ 31 | # Return secret class. 32 | # """ 33 | # secret_class = self.class_map.get('class_name') 34 | # if not secret_class: 35 | # raise SecretTypeNotFoundError(class_name) 36 | # return secret_class 37 | 38 | @classmethod 39 | def register_handler(cls, secret_type): 40 | def wrapper(secret_class): 41 | cls.class_map[secret_type] = secret_class 42 | return secret_class 43 | return wrapper 44 | 45 | @classmethod 46 | def get_handler(cls, secret_type): 47 | return cls.class_map[secret_type]() 48 | 49 | @classmethod 50 | def get_handler_classes(cls): 51 | return [ 52 | secret_class for secret_class 53 | in cls.class_map.values() 54 | ] 55 | 56 | @classmethod 57 | def add_parser_arguments(cls, parser): 58 | for secret_class in cls.get_handler_classes(): 59 | secret_class().add_parser_arguments(parser) 60 | return parser 61 | 62 | @classmethod 63 | def describe_secret_classes(cls): 64 | return [ 65 | secret_class().describe() 66 | for secret_class in cls.get_handler_classes() 67 | ] 68 | 69 | 70 | class SecretHandler(ABC): 71 | """ 72 | Abstract secrets class. 73 | """ 74 | @abstractmethod 75 | def generate_secret(self, **kwargs): 76 | raise NotImplementedError 77 | 78 | def add_parser_arguments(self, parser): 79 | """ 80 | Override this method with argparse arguments specific 81 | to the secret type as necessary. 82 | """ 83 | return parser 84 | 85 | def is_generable(self): 86 | result = None 87 | try: 88 | result = self.generate_secret() 89 | except NotImplementedError: 90 | return False 91 | except RuntimeError: 92 | return True 93 | return result not in ['', None] 94 | 95 | def describe(self): 96 | return OrderedDict( 97 | { 98 | 'Type': self.__module__.rsplit('.', maxsplit=1)[-1], 99 | 'Description': getdoc(self), 100 | 'Generable': self.is_generable() 101 | } 102 | ) 103 | 104 | 105 | # vim: set ts=4 sw=4 tw=0 et : 106 | -------------------------------------------------------------------------------- /psec/secrets_environment/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Secrets handlers. 5 | """ 6 | 7 | from pathlib import Path 8 | 9 | 10 | handlers_dir = Path(__file__).parent 11 | # Derive list of supported secret types from files in this directory. 12 | handlers = sorted( 13 | [ 14 | str(item.stem) 15 | for item in handlers_dir.iterdir() 16 | if str(item.stem)[0] not in ['.', '_'] 17 | ] # noqa 18 | ) 19 | 20 | __all__ = handlers 21 | 22 | # vim: set ts=4 sw=4 tw=0 et : 23 | -------------------------------------------------------------------------------- /psec/secrets_environment/handlers/boolean.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Boolean class. 4 | """ 5 | 6 | # Standard imports 7 | 8 | # Local imports 9 | from ..factory import ( 10 | SecretFactory, 11 | SecretHandler, 12 | ) 13 | 14 | 15 | @SecretFactory.register_handler(__name__.split('.')[-1]) 16 | class Boolean_c(SecretHandler): 17 | """ 18 | Boolean string (`true` or `false`) 19 | """ 20 | 21 | def generate_secret(self, **kwargs) -> str: 22 | """ 23 | Cannot generate boolean strings. 24 | """ 25 | return None 26 | 27 | 28 | # vim: set ts=4 sw=4 tw=0 et : 29 | -------------------------------------------------------------------------------- /psec/secrets_environment/handlers/crypt_6.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | crypt_6 secret class. 4 | """ 5 | 6 | # Standard imports 7 | import crypt 8 | 9 | # Local imports 10 | from ..factory import ( 11 | SecretFactory, 12 | SecretHandler, 13 | ) 14 | 15 | 16 | @SecretFactory.register_handler(__name__) 17 | class Crypt_6_c(SecretHandler): 18 | """ 19 | crypt() style SHA512 ("$6$") digest 20 | """ 21 | 22 | def generate_secret( 23 | self, 24 | unique=False, 25 | password=None, 26 | salt=None, 27 | **kwargs, 28 | ): 29 | """ 30 | Generate a crypt() style SHA512 ("$6$") digest 31 | """ 32 | if password is None: 33 | raise RuntimeError("[-] 'password' is not defined") 34 | if salt is None: 35 | salt = crypt.mksalt(crypt.METHOD_SHA512) 36 | return crypt.crypt(password, salt) 37 | 38 | 39 | # vim: set ts=4 sw=4 tw=0 et : 40 | -------------------------------------------------------------------------------- /psec/secrets_environment/handlers/password.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | XKCD password class. 4 | """ 5 | 6 | # Standard imports 7 | import secrets 8 | 9 | # External imports 10 | from xkcdpass import xkcd_password as xp 11 | from xkcdpass.xkcd_password import CASE_METHODS 12 | 13 | # Local imports 14 | from ..factory import ( 15 | SecretFactory, 16 | SecretHandler, 17 | ) 18 | from psec.utils import natural_number 19 | 20 | 21 | # XKCD password defaults 22 | # See: https://www.unix-ninja.com/p/your_xkcd_passwords_are_pwned 23 | WORDS = 4 24 | MIN_WORDS_LENGTH = 3 25 | MAX_WORDS_LENGTH = 6 26 | MIN_ACROSTIC_LENGTH = 3 27 | MAX_ACROSTIC_LENGTH = 6 28 | DELIMITER = '.' 29 | 30 | 31 | @SecretFactory.register_handler(__name__.split('.')[-1]) 32 | class XKCD_Password_c(SecretHandler): 33 | """Simple (xkcd) password string""" 34 | 35 | def __init__(self): 36 | self.last_result = None 37 | 38 | def add_parser_arguments(self, parser): 39 | parser.add_argument( 40 | '--min-words-length', 41 | action='store', 42 | type=natural_number, 43 | dest='min_words_length', 44 | default=MIN_WORDS_LENGTH, 45 | help='Minimum word length for XKCD words list' 46 | ) 47 | parser.add_argument( 48 | '--max-words-length', 49 | action='store', 50 | type=natural_number, 51 | dest='max_words_length', 52 | default=MAX_WORDS_LENGTH, 53 | help='Maximum word length for XKCD words list' 54 | ) 55 | parser.add_argument( 56 | '--min-acrostic-length', 57 | action='store', 58 | type=natural_number, 59 | dest='min_acrostic_length', 60 | default=MIN_ACROSTIC_LENGTH, 61 | help='Minimum length of acrostic word for XKCD password' 62 | ) 63 | parser.add_argument( 64 | '--max-acrostic-length', 65 | action='store', 66 | type=natural_number, 67 | dest='max_acrostic_length', 68 | default=MAX_ACROSTIC_LENGTH, 69 | help='Maximum length of acrostic word for XKCD password' 70 | ) 71 | parser.add_argument( 72 | '--acrostic', 73 | action='store', 74 | dest='acrostic', 75 | default=None, 76 | help='Acrostic word for XKCD password' 77 | ) 78 | parser.add_argument( 79 | '--delimiter', 80 | action='store', 81 | dest='delimiter', 82 | default=DELIMITER, 83 | help='Delimiter for XKCD password' 84 | ) 85 | parser.add_argument( 86 | "-C", "--case", 87 | dest="case", 88 | type=str, 89 | metavar="CASE", 90 | choices=list(CASE_METHODS.keys()), default="alternating", 91 | help=( 92 | 'Choose the method for setting the case of each ' 93 | 'word in the passphrase. ' 94 | f"Choices: {list(CASE_METHODS.keys())}" 95 | ) 96 | ) 97 | return parser 98 | 99 | def generate_secret(self, **kwargs): 100 | """ 101 | Generate an XKCD-style password string. 102 | 103 | For a note about the security issues with XKCD passwords, 104 | see: https://www.unix-ninja.com/p/your_xkcd_passwords_are_pwned 105 | """ 106 | unique = kwargs.get('unique', False) 107 | case = kwargs.get('case', 'lower') 108 | acrostic = kwargs.get('acrostic', None) 109 | numwords = kwargs.get('numwords', WORDS) 110 | delimiter = kwargs.get('delimiter', DELIMITER) 111 | min_words_length = kwargs.get('min_words_length', MIN_WORDS_LENGTH) 112 | max_words_length = kwargs.get('max_words_length', MAX_WORDS_LENGTH) 113 | min_acrostic_length = kwargs.get( 114 | 'min_acrostic_length', 115 | MIN_ACROSTIC_LENGTH, 116 | ) 117 | max_acrostic_length = kwargs.get( 118 | 'max_acrostic_length', 119 | MAX_ACROSTIC_LENGTH, 120 | ) 121 | if numwords not in range(min_acrostic_length, max_acrostic_length): 122 | raise ValueError( 123 | "'numwords' must be between " 124 | f"{min_acrostic_length} and {max_acrostic_length}" 125 | ) 126 | wordfile = kwargs.get('wordfile', None) 127 | 128 | if not unique and self.last_result: 129 | return self.last_result 130 | 131 | # Create a wordlist from the default wordfile. 132 | if wordfile is None: 133 | wordfile = xp.locate_wordfile() 134 | mywords = xp.generate_wordlist( 135 | wordfile=wordfile, 136 | min_length=min_words_length, 137 | max_length=max_words_length) 138 | if acrostic is None: 139 | # Chose a random word for acrostic with length 140 | # equal to desired number of words. 141 | acrostic = secrets.choice( 142 | xp.generate_wordlist( 143 | wordfile=wordfile, 144 | min_length=numwords, 145 | max_length=numwords) 146 | ) 147 | # Create a password with acrostic word 148 | password = xp.generate_xkcdpassword( 149 | mywords, 150 | numwords=numwords, 151 | acrostic=acrostic, 152 | case=case, 153 | delimiter=delimiter, 154 | ) 155 | if not unique: 156 | self.last_result = password 157 | return password 158 | 159 | 160 | # vim: set ts=4 sw=4 tw=0 et : 161 | -------------------------------------------------------------------------------- /psec/secrets_environment/handlers/sha256_digest.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | DIGEST-SHA256 secret class. 4 | """ 5 | 6 | # Standard imports 7 | import base64 8 | import hashlib 9 | 10 | # Local imports 11 | from ..factory import ( 12 | SecretFactory, 13 | SecretHandler, 14 | ) 15 | 16 | 17 | @SecretFactory.register_handler(__name__.split('.')[-1]) 18 | class DIGEST_SHA256_c(SecretHandler): 19 | """ 20 | DIGEST-SHA256 (user:pass) digest 21 | """ 22 | 23 | def generate_secret(self, user=None, credential=None, **kwargs) -> str: 24 | """ 25 | Generate a DIGEST-SHA256 (user:pass) digest 26 | """ 27 | if user is None: 28 | raise RuntimeError('[-] user is not defined') 29 | if credential is None: 30 | raise RuntimeError('[-] credential is not defined') 31 | return base64.b64encode( 32 | hashlib.sha256( 33 | user + ":" + credential 34 | ).digest() 35 | ).strip() 36 | 37 | 38 | # vim: set ts=4 sw=4 tw=0 et : 39 | -------------------------------------------------------------------------------- /psec/secrets_environment/handlers/string.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | String secret class. 4 | """ 5 | from ..factory import ( 6 | SecretFactory, 7 | SecretHandler, 8 | ) 9 | 10 | 11 | @SecretFactory.register_handler(__name__.split('.')[-1]) 12 | class String_c(SecretHandler): 13 | """Arbitrary string""" 14 | 15 | def generate_secret(self, **kwargs) -> str: 16 | """ 17 | Strings are not generated. 18 | """ 19 | return '' 20 | 21 | 22 | # vim: set ts=4 sw=4 tw=0 et : 23 | -------------------------------------------------------------------------------- /psec/secrets_environment/handlers/token_base64.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | BASE64 encoded token class 4 | """ 5 | 6 | # Standard imports 7 | import base64 8 | import secrets 9 | 10 | # Local imports 11 | from ..factory import ( 12 | SecretFactory, 13 | SecretHandler, 14 | ) 15 | 16 | 17 | DEFAULT_SIZE = 32 18 | 19 | 20 | @SecretFactory.register_handler(__name__.split('.')[-1]) 21 | class BASE64_Token_c(SecretHandler): 22 | """ 23 | Random byte string 24 | """ 25 | 26 | def generate_secret( 27 | self, 28 | unique=False, 29 | size=DEFAULT_SIZE, 30 | **kwargs, 31 | ) -> str: 32 | """ 33 | Generate BASE64 encoded token of 'size' bytes. 34 | """ 35 | return str( 36 | base64.b64encode( 37 | secrets.token_bytes(nbytes=size) 38 | ), 39 | encoding='utf-8' 40 | ) 41 | 42 | 43 | # vim: set ts=4 sw=4 tw=0 et : 44 | -------------------------------------------------------------------------------- /psec/secrets_environment/handlers/token_hex.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Hex token secret class. 4 | """ 5 | 6 | # Standard imports 7 | import secrets 8 | 9 | # Local imports 10 | from ..factory import ( 11 | SecretFactory, 12 | SecretHandler, 13 | ) 14 | 15 | 16 | @SecretFactory.register_handler(__name__.split('.')[-1]) 17 | class Token_Hex32_c(SecretHandler): 18 | """ 19 | 32-bit hexadecimal token 20 | """ 21 | 22 | def generate_secret(self, nbytes=32, **kwargs) -> str: 23 | """ 24 | Generate a 32-bit hexadecimal token. 25 | """ 26 | return secrets.token_hex(nbytes=nbytes) 27 | 28 | 29 | # vim: set ts=4 sw=4 tw=0 et : 30 | -------------------------------------------------------------------------------- /psec/secrets_environment/handlers/token_urlsafe.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Hex token secret class. 4 | """ 5 | 6 | # Standard imports 7 | import secrets 8 | 9 | # Local imports 10 | from ..factory import ( 11 | SecretFactory, 12 | SecretHandler, 13 | ) 14 | 15 | 16 | @SecretFactory.register_handler(__name__.split('.')[-1]) 17 | class Token_URLsafe_c(SecretHandler): 18 | """ 19 | 32-bit URL-safe token 20 | """ 21 | 22 | def generate_secret(self, nbytes=32, **kwargs) -> str: 23 | """ 24 | Generate a 32-bit URL-safe token. 25 | """ 26 | return secrets.token_urlsafe(nbytes=nbytes) 27 | 28 | 29 | # vim: set ts=4 sw=4 tw=0 et : 30 | -------------------------------------------------------------------------------- /psec/secrets_environment/handlers/uuid4.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | UUID4 secret class. 4 | """ 5 | 6 | # Standard imports 7 | import uuid 8 | 9 | # Local imports 10 | from ..factory import ( 11 | SecretFactory, 12 | SecretHandler, 13 | ) 14 | 15 | 16 | @SecretFactory.register_handler(__name__.split('.')[-1]) 17 | class UUID4_c(SecretHandler): 18 | """ 19 | UUID4 token 20 | """ 21 | 22 | def generate_secret(self, **kwargs) -> str: 23 | """ 24 | Generate a UUID4 string. 25 | """ 26 | return str(uuid.uuid4()) 27 | 28 | 29 | # vim: set ts=4 sw=4 tw=0 et : 30 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | 3 | # Enable the message, report, category or checker with the given id(s). You can 4 | # either give multiple identifier separated by comma (,) or put this option 5 | # multiple time. 6 | #enable= 7 | 8 | # Disable the message, report, category or checker with the given id(s). You 9 | # can either give multiple identifier separated by comma (,) or put this option 10 | # multiple time (only on the command line, not in the configuration file where 11 | # it should appear only once). 12 | disable=C0103,C0301,C0302 13 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | 3 | [build-system] 4 | requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"] 5 | build-backend = "poetry_dynamic_versioning.backend" 6 | 7 | [tool.poetry-dynamic-versioning] 8 | enable = true 9 | vcs = "git" 10 | style = "pep440" 11 | latest-tag = true 12 | metadata = true 13 | pattern = "((?P\\d+)!)?(?P\\d+(\\.\\d+)*)([-._]?((?P[a-zA-Z]+)[-._]?(?P\\d+)?))?" 14 | format-jinja = """ 15 | {%- if distance == 0 -%} 16 | {{ serialize_pep440(base, stage, revision) }} 17 | {%- elif revision is not none -%} 18 | {{ serialize_pep440(base, stage, revision + 1, dev=distance, metadata=[commit]) }} 19 | {%- else -%} 20 | {{ serialize_pep440(bump_version(base), stage, revision, dev=distance, metadata=[commit]) }} 21 | {%- endif -%} 22 | """ 23 | 24 | [tool.poetry-dynamic-versioning.files."psec/_version.py"] 25 | persistent-substitution = true 26 | initial-content = """ 27 | # These version placeholders will be replaced later during substitution. 28 | __version__ = "0.0.0" 29 | __version_tuple__ = (0, 0, 0) 30 | """ 31 | 32 | [tool.poetry] 33 | name = "python-secrets" 34 | package-mode = true 35 | version = "0.1.0" 36 | description = "Python CLI for decoupling secrets (passwords, API keys, etc.) from source code" 37 | license = "Apache-2.0" 38 | authors = [ 39 | "Dave Dittrich " 40 | ] 41 | maintainers = [ 42 | "Dave Dittrich " 43 | ] 44 | readme = "README.rst" 45 | homepage = "https://github.com/davedittrich/python_secrets" 46 | repository = "https://github.com/davedittrich/python_secrets" 47 | documentation = "https://github.com/davedittrich/python_secrets" 48 | keywords = [ 49 | "cliff", 50 | "cli", 51 | "secrets", 52 | "environment", 53 | ] 54 | classifiers = [ 55 | "Development Status :: 5 - Production/Stable", 56 | "Environment :: Console", 57 | "Intended Audience :: Developers", 58 | "Intended Audience :: End Users/Desktop", 59 | "Intended Audience :: Information Technology", 60 | "Intended Audience :: Other Audience", 61 | "Intended Audience :: Science/Research", 62 | "Intended Audience :: System Administrators", 63 | "Natural Language :: English", 64 | "Operating System :: POSIX", 65 | "Operating System :: MacOS", 66 | "Operating System :: OS Independent", 67 | "Operating System :: Microsoft :: Windows", 68 | "Operating System :: Unix", 69 | "Programming Language :: Python :: 3", 70 | "Programming Language :: Python :: 3.10", 71 | "Programming Language :: Python :: 3.11", 72 | "Programming Language :: Python :: 3.12", 73 | "Programming Language :: Unix Shell", 74 | "Topic :: Security", 75 | "Topic :: Software Development", 76 | "Topic :: Software Development :: Build Tools", 77 | "Topic :: Software Development :: Libraries :: Python Modules", 78 | "Topic :: System :: Installation/Setup", 79 | "Topic :: System :: Systems Administration", 80 | "Topic :: Utilities" 81 | ] 82 | include = [ 83 | { path = "psec/_version.py" }, 84 | ] 85 | packages = [ 86 | {include = "psec"} 87 | ] 88 | 89 | [tool.poetry.scripts] 90 | psec = "psec.__main__:main" 91 | 92 | [tool.poetry.plugins.psec] 93 | about = "psec.about:About" 94 | environments_create = "psec.cli.environments.create:EnvironmentsCreate" 95 | environments_default = "psec.cli.environments.default:EnvironmentsDefault" 96 | environments_delete = "psec.cli.environments.delete:EnvironmentsDelete" 97 | environments_list = "psec.cli.environments.list:EnvironmentsList" 98 | environments_path = "psec.cli.environments.path:EnvironmentsPath" 99 | environments_rename = "psec.cli.environments.rename:EnvironmentsRename" 100 | environments_tree = "psec.cli.environments.tree:EnvironmentsTree" 101 | init = "psec.cli.init:Init" 102 | groups_create = "psec.cli.groups.create:GroupsCreate" 103 | groups_delete = "psec.cli.groups.delete:GroupsDelete" 104 | groups_list = "psec.cli.groups.list:GroupsList" 105 | groups_path = "psec.cli.groups.path:GroupsPath" 106 | groups_show = "psec.cli.groups.show:GroupsShow" 107 | run = "psec.cli.run:Run" 108 | secrets_backup = "psec.cli.secrets.backup:SecretsBackup" 109 | secrets_create = "psec.cli.secrets.create:SecretsCreate" 110 | secrets_delete = "psec.cli.secrets.delete:SecretsDelete" 111 | secrets_describe = "psec.cli.secrets.describe:SecretsDescribe" 112 | secrets_find = "psec.cli.secrets.find:SecretsFind" 113 | secrets_generate = "psec.cli.secrets.generate:SecretsGenerate" 114 | secrets_get = "psec.cli.secrets.get:SecretsGet" 115 | secrets_path = "psec.cli.secrets.path:SecretsPath" 116 | secrets_restore = "psec.cli.secrets.restore:SecretsRestore" 117 | secrets_send = "psec.cli.secrets.send:SecretsSend" 118 | secrets_set = "psec.cli.secrets.set:SecretsSet" 119 | secrets_show = "psec.cli.secrets.show:SecretsShow" 120 | secrets_tree = "psec.cli.secrets.tree:SecretsTree" 121 | ssh_config = "psec.cli.ssh:SSHConfig" 122 | ssh_known-hosts_add = "psec.cli.ssh:SSHKnownHostsAdd" 123 | ssh_known-hosts_extract = "psec.cli.ssh:SSHKnownHostsExtract" 124 | ssh_known-hosts_remove = "psec.cli.ssh:SSHKnownHostsRemove" 125 | template = "psec.cli.template:Template" 126 | utils_myip = "psec.cli.utils.myip:MyIP" 127 | utils_myip_methods = "psec.cli.utils.myip:MyIPMethods" 128 | utils_netblock = "psec.cli.utils.netblock:Netblock" 129 | utils_set-aws-credentials = "psec.cli.utils.set_aws_credentials:SetAWSCredentials" 130 | utils_tfstate_backend = "psec.cli.utils.tfbackend:TfBackend" 131 | utils_tfstate_output = "psec.cli.utils.tfoutput:TfOutput" 132 | utils_yaml-to-json = "psec.cli.utils.yaml_to_json:YAMLToJSON" 133 | 134 | [tool.poetry.dependencies] 135 | python = ">=3.10,<4.0" 136 | ansible = "^10.4.0" 137 | anytree = "^2.12.1" 138 | beautifulsoup4 = "^4.12.3" 139 | bullet = "^2.2.0" 140 | cliff = "^4.7.0" 141 | configobj = "^5.0.8" 142 | gnupg = "^2.3.1" 143 | ipwhois = "^1.2.0" 144 | jinja2 = "^3.1.4" 145 | lxml = "^5.3.0" 146 | pexpect = "^4.9.0" 147 | poetry = ">=1.8.3" 148 | poetry-dynamic-versioning = "^1.4.1" 149 | psutil = "^6.0.0" 150 | requests = ">=2.31.0" 151 | xkcdpass ="^1.19.9" 152 | 153 | [tool.poetry.group.docs] 154 | optional = true 155 | 156 | [tool.poetry.group.docs.dependencies] 157 | sphinx = ">=4.0" 158 | sphinx-autobuild = ">=2021.0" 159 | sphinx-rtd-theme = ">=1.0" 160 | 161 | [tool.poetry.group.dev] 162 | optional = true 163 | 164 | [tool.poetry.group.dev.dependencies] 165 | tox = "^4.8.1" 166 | bandit = "^1.7.9" 167 | ruff = "^0.6.9" 168 | 169 | [tool.poetry.group.test] 170 | optional = true 171 | 172 | [tool.poetry.group.test.dependencies] 173 | pytest = "^8.3.3" 174 | pytest-cov = "^5.0.0" 175 | pytest-cookies = "^0.6.1" 176 | twine = "^5.1.1" 177 | 178 | [tool.pytest.ini_options] 179 | addopts = [ 180 | "--import-mode=importlib", 181 | ] 182 | pythonpath = "psec" 183 | 184 | # EOF 185 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | docutils==0.15 2 | flake8>=3.8.3 3 | bandit>=1.1.0 4 | twine 5 | setuptools>=42 6 | setuptools_scm 7 | sphinx 8 | pip>=20.2.2 9 | pytest 10 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | setuptools>=42 2 | setuptools_scm 3 | chardet 4 | click 5 | cliff 6 | anytree 7 | beautifulsoup4 8 | bullet 9 | configobj 10 | configparser 11 | ipwhois 12 | jinja2 13 | lxml 14 | gnupg 15 | mccabe 16 | pyflakes 17 | pexpect 18 | Pygments 19 | property_manager 20 | psutil 21 | requests 22 | sphinx 23 | tox 24 | yamlreader 25 | xkcdpass>=1.19.3 26 | -------------------------------------------------------------------------------- /secrets.d/hypriot.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Variable": "hypriot_user", 4 | "Type": "string", 5 | "Prompt": "User name for primary account", 6 | "Options": "pirate,*" 7 | }, 8 | { 9 | "Variable": "hypriot_password", 10 | "Type": "password", 11 | "Prompt": "Password for primary account" 12 | }, 13 | { 14 | "Variable": "hypriot_hostname", 15 | "Type": "string", 16 | "Prompt": "Host name for your RPi", 17 | "Options": "hypriot,*" 18 | }, 19 | { 20 | "Variable": "hypriot_eth0_addr", 21 | "Type": "string", 22 | "Prompt": "Static IP address for eth0 interface", 23 | "Options": "192.168.50.1,*" 24 | }, 25 | { 26 | "Variable": "hypriot_eth0_netmask", 27 | "Type": "string", 28 | "Prompt": "Netmask for eth0 interface", 29 | "Options": "255.255.255.0,*" 30 | }, 31 | { 32 | "Variable": "hypriot_client_psk", 33 | "Type": "string", 34 | "Prompt": "Pre-shared key for HypriotOS client WiFi AP" 35 | }, 36 | { 37 | "Variable": "hypriot_client_ssid", 38 | "Type": "string", 39 | "Prompt": "SSID for HypriotOS client WiFi AP" 40 | }, 41 | { 42 | "Variable": "hypriot_pubkey", 43 | "Type": "string", 44 | "Prompt": "SSH public key for accessing primary account" 45 | }, 46 | { 47 | "Variable": "hypriot_locale", 48 | "Type": "string", 49 | "Prompt": "Locale", 50 | "Options": "en_US.UTF-8,*" 51 | }, 52 | { 53 | "Variable": "hypriot_timezone", 54 | "Type": "string", 55 | "Prompt": "Timezone", 56 | "Options": "America/Los_Angeles,*" 57 | }, 58 | { 59 | "Variable": "hypriot_wifi_country", 60 | "Type": "string", 61 | "Prompt": "WiFi country code", 62 | "Options": "US,*" 63 | }, 64 | { 65 | "Variable": "hypriot_keyboard_model", 66 | "Type": "string", 67 | "Prompt": "Keyboard model", 68 | "Options": "pc105,*" 69 | }, 70 | { 71 | "Variable": "hypriot_keyboard_layout", 72 | "Type": "string", 73 | "Prompt": "Keyboard layout", 74 | "Options": "us,*" 75 | }, 76 | { 77 | "Variable": "hypriot_keyboard_options", 78 | "Type": "string", 79 | "Prompt": "Keyboard options", 80 | "Options": "ctrl:swapcaps,*" 81 | } 82 | ] 83 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 24.10.12 3 | commit = False 4 | tag = False 5 | 6 | [bumpversion:file:VERSION] 7 | 8 | [bumpversion:file:README.rst] 9 | 10 | [bumpversion:file:psec/__init__.py] 11 | 12 | [bdist_wheel] 13 | universal = 1 14 | -------------------------------------------------------------------------------- /test-environment.bash: -------------------------------------------------------------------------------- 1 | # Source this file to mimic test and VSCode debugging launch settings. 2 | # That's what tests/test_helper.sh does as well. 3 | 4 | # Put all test environments in /tmp to avoid messing with any 5 | # real environments. This directory is also used in the 6 | # .vscode/launch.json file for more consistent testing. 7 | export D2_ENVIRONMENT="psectest" 8 | export D2_SECRETS_BASEDIR="/tmp/.psecsecrets" 9 | -------------------------------------------------------------------------------- /tests/.gitignore: -------------------------------------------------------------------------------- 1 | libs/ 2 | -------------------------------------------------------------------------------- /tests/00_usage.bats: -------------------------------------------------------------------------------- 1 | load test_helper 2 | 3 | # See definition of PSEC in test_helpers.bash for why "main" is used 4 | # in tests. 5 | 6 | setup() { 7 | true 8 | } 9 | 10 | teardown() { 11 | true 12 | } 13 | 14 | 15 | @test "'psec help' can load all entry points" { 16 | run $PSEC help 2>&1 17 | refute_output --partial "Traceback" 18 | refute_output --partial "Could not load EntryPoint" 19 | } 20 | 21 | @test "'psec --version' works" { 22 | run $PSEC --version 23 | refute_output --partial "main" 24 | assert_output --partial "psec" 25 | refute_output --partial "0.0.0" 26 | } 27 | 28 | @test "'psec --help' shows usage" { 29 | run $PSEC --help 30 | assert_output --partial 'usage: ' 31 | assert_output --partial 'options:' 32 | } 33 | 34 | # vim: set ts=4 sw=4 tw=0 et : 35 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /tests/gosecure.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Variable": "gosecure_pi_password", 4 | "Type": "password", 5 | "Prompt": "Password for 'pi' user account" 6 | }, 7 | { 8 | "Variable": "gosecure_app_password", 9 | "Type": "password", 10 | "Prompt": "Password for goSecure web app admin account" 11 | }, 12 | { 13 | "Variable": "gosecure_client_psk", 14 | "Type": "string", 15 | "Prompt": "Pre-shared key for goSecure client WiFi AP" 16 | }, 17 | { 18 | "Variable": "gosecure_client_ssid", 19 | "Type": "string", 20 | "Prompt": "SSID for goSecure client WiFi AP" 21 | }, 22 | { 23 | "Variable": "gosecure_vpn_client_id", 24 | "Type": "string", 25 | "Prompt": "Default VPN client ID" 26 | }, 27 | { 28 | "Variable": "gosecure_vpn_client_psk", 29 | "Type": "token_hex", 30 | "Prompt": "Default VPN client pre-shared key" 31 | }, 32 | { 33 | "Variable": "gosecure_pi_pubkey", 34 | "Type": "string", 35 | "Prompt": "SSH public key for accessing 'pi' account" 36 | }, 37 | { 38 | "Variable": "gosecure_pi_locale", 39 | "Type": "string", 40 | "Prompt": "Locale", 41 | "Options": "en_US.UTF-8,*" 42 | }, 43 | { 44 | "Variable": "gosecure_pi_timezone", 45 | "Type": "string", 46 | "Prompt": "Timezone", 47 | "Options": "America/Los_Angeles,*" 48 | }, 49 | { 50 | "Variable": "gosecure_pi_wifi_country", 51 | "Type": "string", 52 | "Prompt": "WiFi country code", 53 | "Options": "US,*" 54 | }, 55 | { 56 | "Variable": "gosecure_pi_keyboard_model", 57 | "Type": "string", 58 | "Prompt": "Keyboard model", 59 | "Options": "pc105,*" 60 | }, 61 | { 62 | "Variable": "gosecure_pi_keyboard_layout", 63 | "Type": "string", 64 | "Prompt": "Keyboard layout", 65 | "Options": "us,*" 66 | } 67 | ] 68 | -------------------------------------------------------------------------------- /tests/runtime_00_initialization.bats: -------------------------------------------------------------------------------- 1 | load test_helper 2 | 3 | setup() { 4 | remove_basedir 5 | } 6 | 7 | teardown() { 8 | remove_basedir 9 | } 10 | 11 | @test "Running 'psec environments list' before 'psec init' fails" { 12 | run $PSEC environments list 2>&1 &1 20 | assert_success 21 | assert_output --partial "is enabled for secrets storage" 22 | [ -f ${D2_SECRETS_BASEDIR}/.psec ] 23 | } 24 | 25 | @test "'psec init' twice does not cause errors" { 26 | run $PSEC -q init 27 | run $PSEC init -v 2>&1 28 | assert_success 29 | assert_output --partial "is enabled for secrets storage" 30 | } 31 | 32 | @test "'psec -v --init environments create testenv ...' works { 33 | run $PSEC --init environments create testenv --clone-from tests/secrets.d 1>&2 34 | assert_success 35 | assert_output --partial "does not exist" 36 | assert_output --partial "environment 'testenv' created" 37 | [ -f ${D2_SECRETS_BASEDIR}/.psec ] 38 | } 39 | 40 | # vim: set ts=4 sw=4 tw=0 et : 41 | -------------------------------------------------------------------------------- /tests/runtime_10_groups.bats: -------------------------------------------------------------------------------- 1 | load test_helper 2 | 3 | export OAUTH_COUNT=$(grep -c Variable tests/secrets.d/oauth.json) 4 | export JENKINS_COUNT=$(grep -c Variable tests/secrets.d/jenkins.json) 5 | 6 | setup() { 7 | run $PSEC --init environments create --clone-from tests/secrets.d 1>&2 8 | } 9 | 10 | teardown() { 11 | run $PSEC environments delete ${D2_ENVIRONMENT} --force 1>&2 12 | remove_basedir 13 | } 14 | 15 | @test "'psec groups path' returns ${D2_SECRETS_BASEDIR}/${D2_ENVIRONMENT}/secrets.d" { 16 | run $PSEC groups path 17 | assert_success 18 | assert_output "${D2_SECRETS_BASEDIR}/${D2_ENVIRONMENT}/secrets.d" 19 | } 20 | 21 | @test "'psec groups list' contains 'jenkins' and 'oauth'" { 22 | run $PSEC groups list 23 | assert_success 24 | assert_output --partial jenkins 25 | assert_output --partial oauth 26 | } 27 | 28 | @test "'psec groups show nosuchgroup' fails" { 29 | run $PSEC groups show nosuchgroup 30 | assert_failure 31 | assert_output "" 32 | } 33 | 34 | @test "'psec groups show jenkins' contains $JENKINS_COUNT item(s)" { 35 | bash -c "$PSEC -q groups show jenkins -f csv" >&2 36 | run bash -c "$PSEC -q groups show jenkins -f csv | grep -c jenkins" 37 | assert_success 38 | assert_output "$JENKINS_COUNT" 39 | } 40 | 41 | @test "'psec groups show oauth' contains $OAUTH_COUNT item(s)" { 42 | bash -c "$PSEC -q groups show oauth -f csv" >&2 43 | run bash -c "$PSEC -q groups show oauth -f csv | grep -c oauth" 44 | assert_success 45 | assert_output "$OAUTH_COUNT" 46 | } 47 | 48 | @test "'psec groups create emptygroup' creates an empty group" { 49 | run $PSEC groups create emptygroup 50 | assert_success 51 | assert_output --partial 'creating' 52 | run echo $($PSEC -q groups path)/emptygroup.json >&2 53 | [ -f $($PSEC -q groups path)/emptygroup.json ] 54 | run $PSEC groups show emptygroup -f csv 55 | assert_failure 56 | assert_output '' 57 | } 58 | 59 | @test "'psec groups create emptygroup' twice fails" { 60 | run $PSEC groups create emptygroup 61 | run $PSEC groups create emptygroup 62 | assert_failure 63 | assert_output --partial 'already exists' 64 | } 65 | 66 | @test "'psec groups create --clone-from tests/gosecure.json' works" { 67 | run $PSEC groups create --clone-from tests/gosecure.json 68 | assert_success 69 | assert_output --partial 'creating' 70 | [ -f $($PSEC -q groups path)/gosecure.json ] 71 | } 72 | 73 | @test "'psec groups create newgroup --clone-from tests/gosecure.json' works" { 74 | run $PSEC groups create newgroup --clone-from tests/gosecure.json 75 | assert_success 76 | assert_output --partial 'creating' 77 | [ ! -f $($PSEC groups path)/gosecure.json ] 78 | [ -f $($PSEC groups path)/newgroup.json ] 79 | } 80 | 81 | @test "'psec groups create newgroup --clone-from tests/gosecure.json' twice fails" { 82 | run $PSEC groups create newgroup --clone-from tests/gosecure.json 83 | assert_success 84 | assert_output --partial 'creating' 85 | run $PSEC groups create newgroup --clone-from tests/gosecure.json 86 | assert_failure 87 | assert_output --partial "already exists" 88 | } 89 | 90 | @test "'psec groups create --clone-from nosuchenv' fails" { 91 | run $PSEC groups create --clone-from nosuchenv 92 | assert_failure 93 | assert_output --partial "please specify which group" 94 | } 95 | 96 | @test "'psec groups delete oauth --force' works" { 97 | run $PSEC groups delete oauth --force 98 | assert_success 99 | assert_output --partial "deleted secrets group 'oauth'" 100 | } 101 | 102 | @test "'psec groups delete oauth' without TTY fails" { 103 | run $PSEC groups delete oauth /dev/null | wc -l | sed 's/ *//g' 6 | } 7 | 8 | export TEST_FILES_COUNT="$(files_count tests/yamlsecrets/secrets.d '*.yml')" 9 | export KEEP_DIR="${BATS_TMPDIR}/bats_keep" 10 | export DONOTKEEP_DIR="${BATS_TMPDIR}/bats_donotkeep" 11 | 12 | # TODO(dittrich): Some odd bug in bats-core v1.2.1 causes failures 13 | # for all defined tests: 14 | # 15 | # $ bats tests/runtime_30_utils.bats 16 | # bats warning: Executed 0 instead of expected 4 tests 17 | # 18 | # Had to back off to using v1.2.0 following steps defined in: 19 | # https://zoltanaltfatter.com/2017/09/07/Install-a-specific-version-of-formula-with-homebrew/ 20 | 21 | # Ensure cleanup on interrupt 22 | trap "rm -rf ${KEEP_DIR} ${DONOTKEEP_DIR}" EXIT INT TERM QUIT 23 | 24 | setup() { 25 | for DIR in ${KEEP_DIR} ${DONOTKEEP_DIR}; do 26 | rm -rf ${DIR} 27 | mkdir -p ${DIR} 28 | cp tests/yamlsecrets/secrets.d/*.yml ${DIR}/ 29 | done 30 | run $PSEC --init environments create $D2_ENVIRONMENT --clone-from tests/secrets.d 1>&2 31 | } 32 | 33 | teardown() { 34 | rm -rf ${KEEP_DIR} ${DONOTKEEP_DIR} 35 | remove_basedir 36 | } 37 | 38 | @test "Setting up ${KEEP_DIR} worked" { 39 | assert_equal "$(files_count ${DONOTKEEP_DIR} '*.yml')" "${TEST_FILES_COUNT}" 40 | } 41 | 42 | @test "'psec utils yaml-to-json ${KEEP_DIR}/jenkins.yml' works" { 43 | run $PSEC utils yaml-to-json ${KEEP_DIR}/jenkins.yml 44 | assert_output '[ 45 | { 46 | "Variable": "jenkins_admin_password", 47 | "Type": "password", 48 | "Prompt": "Password for Jenkins \"admin\" account" 49 | } 50 | ]' 51 | assert_success 52 | } 53 | 54 | @test "'psec utils yaml-to-json --convert ${DONOTKEEP_DIR}/jenkins.yml' works" { 55 | run $PSEC utils yaml-to-json --convert ${DONOTKEEP_DIR}/jenkins.yml 56 | assert_output --partial '[+] converting' 57 | assert_success 58 | [ -f ${DONOTKEEP_DIR}/jenkins.json ] 59 | [ ! -f ${DONOTKEEP_DIR}/jenkins.yml ] 60 | } 61 | 62 | @test "'psec utils yaml-to-json --convert --keep-original ${KEEP_DIR}/jenkins.yml' works" { 63 | run $PSEC utils yaml-to-json --convert --keep-original ${KEEP_DIR}/jenkins.yml 64 | assert_output --partial '[+] converting' 65 | assert_success 66 | [ -f ${KEEP_DIR}/jenkins.json ] 67 | [ -f ${KEEP_DIR}/jenkins.yml ] 68 | } 69 | 70 | @test "'psec utils yaml-to-json' from stdin works" { 71 | run bash -c "cat ${KEEP_DIR}/oauth.yml | $PSEC utils yaml-to-json -" 72 | assert_output --partial ' 73 | { 74 | "Variable": "google_oauth_username", 75 | "Type": "string", 76 | "Prompt": "Google OAuth2 username" 77 | }' 78 | assert_success 79 | [ ! -f ${KEEP_DIR}/oauth.json ] 80 | [ -f ${KEEP_DIR}/oauth.yml ] 81 | } 82 | 83 | @test "'psec utils yaml-to-json --convert ${DONOTKEEP_DIR}' works" { 84 | run $PSEC utils yaml-to-json --convert ${DONOTKEEP_DIR} 85 | assert_success 86 | assert_equal "$(files_count ${DONOTKEEP_DIR} '*.json')" "${TEST_FILES_COUNT}" 87 | assert_equal "$(files_count ${DONOTKEEP_DIR} '*.yml')" "0" 88 | } 89 | 90 | @test "'psec utils yaml-to-json --convert --keep-original ${KEEP_DIR}' works" { 91 | run $PSEC utils yaml-to-json --convert --keep-original ${KEEP_DIR} 92 | assert_success 93 | assert_equal "$(files_count ${KEEP_DIR} '*.json')" "${TEST_FILES_COUNT}" 94 | assert_equal "$(files_count ${KEEP_DIR} '*.yml')" "${TEST_FILES_COUNT}" 95 | } 96 | 97 | 98 | # vim: set ts=4 sw=4 tw=0 et : 99 | -------------------------------------------------------------------------------- /tests/runtime_40_run.bats: -------------------------------------------------------------------------------- 1 | load test_helper 2 | 3 | setup() { 4 | ensure_basedir 5 | run $PSEC --init environments create $D2_ENVIRONMENT --clone-from tests/secrets.d 1>&2 6 | } 7 | 8 | teardown() { 9 | remove_basedir 10 | } 11 | 12 | @test "'psec --umask -1 run ...' fails" { 13 | run $PSEC --umask -1 run umask 1>&2 14 | assert_failure 15 | } 16 | 17 | @test "'psec --umask 007 run ...' fails" { 18 | run $PSEC --umask 077 run umask 1>&2 19 | assert_failure 20 | } 21 | 22 | @test "'psec --umask 0o7777 run ...' fails" { 23 | run $PSEC --umask 0o7777 run umask 1>&2 24 | assert_failure 25 | } 26 | 27 | @test "'psec --umask 0o007 succeeds'" { 28 | run $PSEC -e testenv --umask 0o007 run umask 1>&2 29 | assert_output "0007" 30 | } 31 | 32 | @test "'psec --umask 0o777 succeeds'" { 33 | run $PSEC -e testenv --umask 0o777 run umask 1>&2 34 | assert_output "0777" 35 | } 36 | 37 | @test "'psec -E run -- bash -c env' exports PYTHON_SECRETS_ENVIRONMENT" { 38 | # Nesting processes to really, really, prove environment variable is passed. 39 | run $PSEC -E run -- bash -c "(env | grep PYTHON_SECRETS_ENVIRONMENT)" 40 | assert_success 41 | assert_output --partial PYTHON_SECRETS_ENVIRONMENT 42 | } 43 | 44 | @test "'psec -E run sleep 1' succeeds" { 45 | run $PSEC -E run sleep 1 2>&1 46 | assert_success 47 | } 48 | 49 | @test "'psec --elapsed run sleep 1' succeeds" { 50 | run $PSEC --elapsed run sleep 1 2>&1 51 | assert_success 52 | assert_output --partial elapsed 53 | } 54 | 55 | @test "'psec -e NOSUCHENVIRONMENT --elapsed run sleep 1' succeeds" { 56 | run $PSEC -e NOSUCHENVIRONMENT --elapsed run sleep 1 2>&1 57 | assert_success 58 | assert_output --partial elapsed 59 | } 60 | 61 | @test "'psec -E -e NOSUCHENVIRONMENT --elapsed run sleep 1' fails" { 62 | run $PSEC -E -e NOSUCHENVIRONMENT --elapsed run sleep 1 2>&1 63 | assert_failure 64 | assert_output --partial 'does not exist' 65 | } 66 | 67 | # vim: set ts=4 sw=4 tw=0 et : 68 | -------------------------------------------------------------------------------- /tests/secrets.d/consul.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Variable": "consul_key", 4 | "Type": "token_base64", 5 | "Prompt": "Key for Consul cluster" 6 | } 7 | ] 8 | -------------------------------------------------------------------------------- /tests/secrets.d/hypriot.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Variable": "hypriot_user", 4 | "Type": "string", 5 | "Prompt": "User name for primary account", 6 | "Options": "pirate,*" 7 | }, 8 | { 9 | "Variable": "hypriot_password", 10 | "Type": "password", 11 | "Prompt": "Password for primary account" 12 | }, 13 | { 14 | "Variable": "hypriot_hostname", 15 | "Type": "string", 16 | "Prompt": "Host name for your RPi", 17 | "Options": "hypriot,*" 18 | }, 19 | { 20 | "Variable": "hypriot_eth0_addr", 21 | "Type": "string", 22 | "Prompt": "Static IP address for eth0 interface", 23 | "Options": "192.168.50.1,*" 24 | }, 25 | { 26 | "Variable": "hypriot_eth0_netmask", 27 | "Type": "string", 28 | "Prompt": "Netmask for eth0 interface", 29 | "Options": "255.255.255.0,*" 30 | }, 31 | { 32 | "Variable": "hypriot_client_psk", 33 | "Type": "string", 34 | "Prompt": "Pre-shared key for HypriotOS client WiFi AP" 35 | }, 36 | { 37 | "Variable": "hypriot_client_ssid", 38 | "Type": "string", 39 | "Prompt": "SSID for HypriotOS client WiFi AP" 40 | }, 41 | { 42 | "Variable": "hypriot_pubkey", 43 | "Type": "string", 44 | "Prompt": "SSH public key for accessing primary account" 45 | }, 46 | { 47 | "Variable": "hypriot_locale", 48 | "Type": "string", 49 | "Prompt": "Locale", 50 | "Options": "en_US.UTF-8,*" 51 | }, 52 | { 53 | "Variable": "hypriot_timezone", 54 | "Type": "string", 55 | "Prompt": "Timezone", 56 | "Options": "America/Los_Angeles,*" 57 | }, 58 | { 59 | "Variable": "hypriot_wifi_country", 60 | "Type": "string", 61 | "Prompt": "WiFi country code", 62 | "Options": "US,*" 63 | }, 64 | { 65 | "Variable": "hypriot_keyboard_model", 66 | "Type": "string", 67 | "Prompt": "Keyboard model", 68 | "Options": "pc105,*" 69 | }, 70 | { 71 | "Variable": "hypriot_keyboard_layout", 72 | "Type": "string", 73 | "Prompt": "Keyboard layout", 74 | "Options": "us,*" 75 | }, 76 | { 77 | "Variable": "hypriot_keyboard_options", 78 | "Type": "string", 79 | "Prompt": "Keyboard options", 80 | "Options": "ctrl:swapcaps,*" 81 | } 82 | ] 83 | -------------------------------------------------------------------------------- /tests/secrets.d/jenkins.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Variable": "jenkins_admin_password", 4 | "Type": "password", 5 | "Prompt": "Password for Jenkins 'admin' account" 6 | } 7 | ] 8 | -------------------------------------------------------------------------------- /tests/secrets.d/myapp.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Variable": "myapp_pi_password", 4 | "Type": "password", 5 | "Prompt": "Password for myapp 'pi' user account", 6 | "Export": "DEMO_pi_password" 7 | }, 8 | { 9 | "Variable": "myapp_app_password", 10 | "Type": "password", 11 | "Prompt": "Password for myapp web app", 12 | "Export": "DEMO_app_password" 13 | }, 14 | { 15 | "Variable": "myapp_client_psk", 16 | "Type": "string", 17 | "Prompt": "Pre-shared key for myapp client WiFi AP", 18 | "Options": "*", 19 | "Export": "DEMO_client_psk" 20 | }, 21 | { 22 | "Variable": "myapp_client_ssid", 23 | "Type": "string", 24 | "Prompt": "SSID for myapp client WiFi AP", 25 | "Options": "myapp_ssid,*", 26 | "Export": "DEMO_client_ssid" 27 | }, 28 | { 29 | "Variable": "myapp_ondemand_wifi", 30 | "Type": "boolean", 31 | "Prompt": "'Connect on demand' when connected to wifi", 32 | "Options": "true,false", 33 | "Export": "DEMO_ondemand_wifi" 34 | }, 35 | { 36 | "Variable": "myapp_optional_setting", 37 | "Type": "boolean", 38 | "Prompt": "Optionally do something", 39 | "Options": "false,true", 40 | "Export": "DEMO_options_setting" 41 | } 42 | ] 43 | -------------------------------------------------------------------------------- /tests/secrets.d/oauth.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Variable": "google_oauth_username", 4 | "Type": "string", 5 | "Prompt": "Google OAuth2 username" 6 | }, 7 | { 8 | "Variable": "google_oauth_client_id", 9 | "Type": "string", 10 | "Prompt": "Google OAuth2 client id" 11 | }, 12 | { 13 | "Variable": "google_oauth_client_secret", 14 | "Type": "string", 15 | "Prompt": "Google OAuth2 client secret" 16 | }, 17 | { 18 | "Variable": "google_oauth_refresh_token", 19 | "Type": "string", 20 | "Prompt": "Google OAuth2 refresh token" 21 | } 22 | ] 23 | -------------------------------------------------------------------------------- /tests/secrets.d/trident.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Variable": "trident_sysadmin_pass", 4 | "Type": "password", 5 | "Prompt": "Password for Trident sysadmin account" 6 | }, 7 | { 8 | "Variable": "trident_db_pass", 9 | "Type": "password", 10 | "Prompt": "Password for Trident postgres database" 11 | } 12 | ] 13 | -------------------------------------------------------------------------------- /tests/test_exceptions.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | test_exceptions 5 | --------------- 6 | 7 | Tests for exceptions. 8 | """ 9 | 10 | import pytest 11 | import unittest 12 | 13 | 14 | from psec import exceptions as exc 15 | 16 | 17 | environment_exceptions = [ 18 | exc.PsecEnvironmentAlreadyExistsError, 19 | exc.PsecEnvironmentNotFoundError, 20 | ] 21 | basedir_exceptions = [ 22 | exc.BasedirNotFoundError, 23 | exc.InvalidBasedirError, 24 | ] 25 | secret_exceptions = [ 26 | exc.SecretNotFoundError, 27 | ] 28 | exceptions = ( 29 | environment_exceptions 30 | + basedir_exceptions 31 | + secret_exceptions 32 | + [exc.DescriptionsError] 33 | ) 34 | 35 | 36 | @pytest.mark.parametrize('exc', exceptions) 37 | class Test_Exceptions(object): 38 | 39 | def test_exception_with_no_msg(self, exc): 40 | with pytest.raises(exc) as exc_info: 41 | raise exc() 42 | assert exc_info.type is exc 43 | assert str(exc_info.value.args[0]) == exc.__doc__ 44 | 45 | def test_base_exception_with_msg(self, exc): 46 | with pytest.raises(exc) as exc_info: 47 | raise exc(msg='MESSAGE') 48 | assert exc_info.type is exc 49 | assert str(exc_info.value.args[0]) == 'MESSAGE' 50 | 51 | 52 | @pytest.mark.parametrize('exc', environment_exceptions) 53 | class Test_Environment_Exceptions(object): 54 | 55 | def test_environment_exceptions(self, exc): 56 | with pytest.raises(exc) as exc_info: 57 | raise exc(environment='ENVIRONMENT') 58 | assert exc_info.type is exc 59 | assert str(exc_info.value.environment) == 'ENVIRONMENT' 60 | 61 | 62 | @pytest.mark.parametrize('exc', basedir_exceptions) 63 | class Test_Basedir_Exceptions(object): 64 | 65 | def test_basedir_exceptions(self, exc): 66 | with pytest.raises(exc) as exc_info: 67 | raise exc(basedir='BASEDIR') 68 | assert exc_info.type is exc 69 | assert str(exc_info.value.basedir) == 'BASEDIR' 70 | 71 | 72 | @pytest.mark.parametrize("exc", secret_exceptions) 73 | class Test_Secret_Exceptions(object): 74 | 75 | def test_secret_exceptions(self, exc): 76 | with pytest.raises(exc) as exc_info: 77 | raise exc(secret='SECRET') 78 | assert exc_info.type is exc 79 | assert str(exc_info.value.secret) == 'SECRET' 80 | 81 | 82 | if __name__ == '__main__': 83 | import sys 84 | sys.exit(unittest.main()) 85 | 86 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 87 | -------------------------------------------------------------------------------- /tests/test_groups.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | test_psec.groups 6 | ---------------- 7 | 8 | Tests for `psec.groups` module. 9 | """ 10 | 11 | import unittest 12 | import os 13 | import sys 14 | 15 | # from unittest.mock import patch 16 | 17 | HOST = 'example.com' 18 | HOME = os.path.expanduser('~') 19 | TESTENV = 'pytest' 20 | SECRETS_SUBDIR = 'pytest' 21 | KEYS_SUBDIR = 'keys' 22 | 23 | 24 | def groups_dir(env=None, basedir=None): 25 | if env is not None: 26 | env_str = str(env) 27 | else: 28 | env = os.getenv('D2_ENVIRONMENT', None) 29 | cwd = os.getcwd() 30 | default_file = os.path.join(cwd, '.python_secrets_environment') 31 | if os.path.exists(default_file): 32 | with open(default_file, 'r') as f: 33 | env_str = f.read().strip() 34 | else: 35 | env_str = os.path.basename(cwd) 36 | basedir = os.getenv('D2_SECRETS_BASEDIR', None) 37 | if basedir is None: 38 | basedir = os.path.join( 39 | HOME, 40 | 'secrets' if sys.platform.startswith('win') else '.secrets') 41 | return os.path.join(basedir, env_str) 42 | 43 | 44 | # TODO(dittrich): Finish tests for groups 45 | 46 | class Test_Groups(unittest.TestCase): 47 | @unittest.skip("Finish tests for groups") 48 | def test_skip_groups(self): 49 | pass 50 | 51 | 52 | if __name__ == '__main__': 53 | sys.exit(unittest.main()) 54 | 55 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 56 | -------------------------------------------------------------------------------- /tests/test_helper.bash: -------------------------------------------------------------------------------- 1 | export OS=$(uname -s) 2 | # Sets D2_ENVIRONMENT and D2_SECRETS_BASEDIR environment variables. 3 | # source test-environment.bash 4 | export D2_ENVIRONMENT="batstest" 5 | export D2_SECRETS_BASEDIR="/tmp/.secrets_bats$$" 6 | export PYTHONPATH=$(pwd) 7 | export PSEC="python -m psec.__main__ --debug" 8 | 9 | # By default, cleans up the standard environment. Also use to 10 | # clean up any alternative environments created during testing 11 | # by specifying them as arguments when calling the function. 12 | 13 | function ensure_basedir() { 14 | if [ ! -f "${D2_SECRETS_BASEDIR}" ]; then 15 | mkdir -p "${D2_SECRETS_BASEDIR}" 16 | chmod 700 "${D2_SECRETS_BASEDIR}" 17 | touch "${D2_SECRETS_BASEDIR}/.psec" 18 | chmod 600 "${D2_SECRETS_BASEDIR}/.psec" 19 | fi 20 | } 21 | 22 | function remove_basedir() { 23 | if [ -d "${D2_SECRETS_BASEDIR}" ]; then 24 | rm -rf "${D2_SECRETS_BASEDIR}" 25 | fi 26 | } 27 | 28 | 29 | function clean_environments() { 30 | BASEDIR="${D2_SECRETS_BASEDIR:?warning - not set}" 31 | ENVPATH="${BASEDIR}${D2_ENVIRONMENT:?warning - not set}" 32 | for environment in $* 33 | do 34 | rm -rf ${BASEDIR}/${environment} 35 | done 36 | } 37 | 38 | load 'libs/bats-support/load' 39 | load 'libs/bats-assert/load' 40 | 41 | # vim: set ts=4 sw=4 tw=0 et : 42 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | test_psec.utils 5 | --------------- 6 | 7 | Tests for `psec.utils` module. 8 | """ 9 | 10 | import psec.utils 11 | import unittest 12 | 13 | 14 | class Test_Utils(unittest.TestCase): 15 | 16 | def setUp(self): 17 | self.lst = [ 18 | {'Variable': 'jenkins_admin_password', 'Type': 'password'}, 19 | {'Variable': 'ca_rootca_password', 'Type': 'password'}, 20 | ] 21 | 22 | def tearDown(self): 23 | pass 24 | 25 | def test_redact_false(self): 26 | assert psec.utils.redact("foo", False) == "foo" 27 | 28 | def test_redact_true(self): 29 | assert psec.utils.redact("foo", True) == "REDACTED" 30 | 31 | def test_find_present(self): 32 | assert psec.utils.find(self.lst, 33 | 'Variable', 34 | 'ca_rootca_password') == 1 35 | 36 | def test_find_absent(self): 37 | assert psec.utils.find(self.lst, 38 | 'Variable', 39 | 'something_not_there') is None 40 | 41 | 42 | if __name__ == '__main__': 43 | import sys 44 | sys.exit(unittest.main()) 45 | 46 | # vim: set fileencoding=utf-8 ts=4 sw=4 tw=0 et : 47 | -------------------------------------------------------------------------------- /tests/yamlsecrets/secrets.d/jenkins.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - Variable: jenkins_admin_password 4 | Type: password 5 | Prompt: 'Password for Jenkins "admin" account' 6 | 7 | # vim: ft=ansible : 8 | -------------------------------------------------------------------------------- /tests/yamlsecrets/secrets.d/myapp.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - Variable: myapp_pi_password 4 | Type: password 5 | Prompt: 'Password for myapp "pi" user account' 6 | Export: DEMO_pi_password 7 | 8 | - Variable: myapp_app_password 9 | Type: password 10 | Prompt: 'Password for myapp web app' 11 | Export: DEMO_app_password 12 | 13 | - Variable: myapp_client_psk 14 | Type: string 15 | Prompt: 'Pre-shared key for myapp client WiFi AP' 16 | Export: DEMO_client_ssid 17 | 18 | - Variable: myapp_client_ssid 19 | Type: string 20 | Prompt: 'SSID for myapp client WiFi AP' 21 | Export: DEMO_client_ssid 22 | 23 | - Variable: myapp_ondemand_wifi 24 | Type: boolean 25 | Prompt: '"Connect on demand" when connected to wifi' 26 | Export: DEMO_ondemand_wifi 27 | 28 | # vim: ft=ansible : 29 | -------------------------------------------------------------------------------- /tests/yamlsecrets/secrets.d/oauth.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - Variable: google_oauth_username 4 | Type: string 5 | Prompt: 'Google OAuth2 username' 6 | 7 | - Variable: google_oauth_client_id 8 | Type: string 9 | Prompt: 'Google OAuth2 client id' 10 | 11 | - Variable: google_oauth_client_secret 12 | Type: string 13 | Prompt: 'Google OAuth2 client secret' 14 | 15 | - Variable: google_oauth_refresh_token 16 | Type: string 17 | Prompt: 'Google OAuth2 refresh token' 18 | 19 | # vim: ft=yaml : 20 | -------------------------------------------------------------------------------- /tests/yamlsecrets/secrets.d/trident.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - Variable: trident_sysadmin_pass 4 | Prompt: 'Password for Trident sysadmin account' 5 | Type: password 6 | 7 | - Variable: trident_db_pass 8 | Prompt: 'Password for Trident postgres database' 9 | Type: password 10 | 11 | # vim: ft=ansible : 12 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | clean 4 | pep8 5 | bandit 6 | docs 7 | py310 8 | py311 9 | py312 10 | bats 11 | pypi 12 | report 13 | labels = 14 | tests = py310,py311,py312,pypi 15 | static = pep8,bandit,docs 16 | # In practice, you can optimize by first running static tests 17 | # 'pep8,bandit,docs' and only after those succeed go on to 18 | # run the remaining tests. E.g., 19 | # $ tox -e pep8,bandit,docs && tox -e py310,py311,py312,pypi 20 | # or 21 | # $ tox run -m static && tox run -m tests 22 | skip_missing_interpreters = true 23 | #skipsdists = true 24 | requires = 25 | tox>=4 26 | poetry==1.8.3 27 | pytest 28 | virtualenv>20.2 29 | 30 | [testenv] 31 | deps = 32 | pytest 33 | pytest-cov 34 | depends = 35 | {py310,py311,py312}: clean 36 | report: py310,py311,py312 37 | allowlist_externals = 38 | coverage 39 | ruff 40 | make 41 | poetry 42 | pytest 43 | distribute = false 44 | usedevelop = false 45 | skip_install = true 46 | setenv = 47 | VIRTUAL_ENV={envdir} 48 | PYTHONPATH={toxinidir}:{toxinidir}/psec 49 | commands_pre = 50 | poetry install --no-root --with=dev --with=test 51 | poetry self add poetry-dynamic-versioning[plugin] 52 | commands = 53 | pytest --version 54 | pytest tests/ --import-mode importlib --cov=psec --cov-append --cov-report=term-missing 55 | make DOT_LOCAL={envdir} PYTHON={envpython} test-bats 56 | 57 | [testenv:report] 58 | deps = coverage 59 | skip_install = true 60 | #allowlist_externals = coverage 61 | commands = 62 | coverage report 63 | coverage html 64 | 65 | [testenv:clean] 66 | deps = coverage 67 | skip_install = true 68 | #allowlist_externals = 69 | # coverage 70 | # poetry 71 | commands = coverage erase 72 | 73 | [testenv:pypi] 74 | deps = twine 75 | #allowlist_externals = make 76 | commands = make twine-check 77 | 78 | [testenv:pep8] 79 | #allowlist_externals = 80 | # ruff 81 | # poetry 82 | deps = 83 | #commands = ruff psec tests docs/conf.py 84 | commands = 85 | ruff check -v --extend-exclude tests/libs/ tests/ docs/conf.py 86 | 87 | [testenv:bandit] 88 | ; Run security linter 89 | commands = bandit -c bandit.yaml -r psec -x tests -n5 90 | 91 | [testenv:docs] 92 | #allowlist_externals = 93 | # make 94 | # poetry 95 | commands = 96 | poetry install --with=docs 97 | make clean install 98 | sphinx-build -b html docs docs/_build 99 | 100 | [testenv:bats] 101 | ; Run bats unit tests 102 | ; Deal with this by requiring docutils==0.15: 103 | ; # Traceback (most recent call last): 104 | ; # File "/Users/dittrich/git/python_secrets/.tox/bats/lib/python3.7/site-packages/cliff/help.py", line 43, in __call__ 105 | ; # factory = ep.load() 106 | ; # File "/Users/dittrich/git/python_secrets/.tox/bats/lib/python3.7/site-packages/pkg_resources/__init__.py", line 2444, in load 107 | ; # self.require(*args, **kwargs) 108 | ; # File "/Users/dittrich/git/python_secrets/.tox/bats/lib/python3.7/site-packages/pkg_resources/__init__.py", line 2467, in require 109 | ; # items = working_set.resolve(reqs, env, installer, extras=self.extras) 110 | ; # File "/Users/dittrich/git/python_secrets/.tox/bats/lib/python3.7/site-packages/pkg_resources/__init__.py", line 792, in resolve 111 | ; # raise VersionConflict(dist, req).with_context(dependent_req) 112 | ; # pkg_resources.ContextualVersionConflict: (docutils 0.16 (/Users/dittrich/git/python_secrets/.tox/bats/lib/python3.7/site-packages), Requirement.parse('docutils<0.16,>=0.10'), {'botocore'}) 113 | #allowlist_externals = 114 | # make 115 | # poetry 116 | commands = make test-bats 117 | 118 | # EOF 119 | --------------------------------------------------------------------------------