mirror of
https://github.com/enpaul/vault2vault.git
synced 2025-06-07 16:43:25 +00:00
Compare commits
29 Commits
Author | SHA1 | Date | |
---|---|---|---|
0bb654c2e2 | |||
8e621138e9 | |||
de4ff0031f | |||
50a5481108 | |||
3586a4e277 | |||
|
45ab9addb3 | ||
90e4a32753 | |||
3dc062c849 | |||
fdad46a945 | |||
96bd80db6e | |||
fcaac8ca43 | |||
9c6486ce55 | |||
c3fe7bdef9 | |||
98d1bf3e8e | |||
d11af1658d | |||
29243223fe | |||
a98dd16358 | |||
226c717684 | |||
b55af77051 | |||
4550a73404 | |||
bdb62993a2 | |||
3f6f5cf7e0 | |||
2f75180623 | |||
c729414b03 | |||
ba6b71687e | |||
d61d2cb1a1 | |||
c7c2a87ebb | |||
8e9df58f43 | |||
9943dd112c |
15
.github/scripts/setup-env.sh
vendored
15
.github/scripts/setup-env.sh
vendored
@ -4,31 +4,34 @@
|
|||||||
# to create a repeatable local environment for tests to be run in. The python env
|
# to create a repeatable local environment for tests to be run in. The python env
|
||||||
# this script creates can be accessed at the location defined by the CI_VENV variable
|
# this script creates can be accessed at the location defined by the CI_VENV variable
|
||||||
# below.
|
# below.
|
||||||
|
#
|
||||||
|
# POETRY_VERSION can be set to install a specific version of Poetry
|
||||||
|
|
||||||
set -e;
|
set -e;
|
||||||
|
|
||||||
CI_CACHE=$HOME/.cache;
|
CI_CACHE=$HOME/.cache;
|
||||||
POETRY_VERSION=1.1.12;
|
INSTALL_POETRY_VERSION="${POETRY_VERSION:-1.3.2}";
|
||||||
|
|
||||||
mkdir --parents "$CI_CACHE";
|
mkdir --parents "$CI_CACHE";
|
||||||
|
|
||||||
command -v python;
|
command -v python;
|
||||||
python --version;
|
python3.10 --version;
|
||||||
|
|
||||||
curl --location https://install.python-poetry.org \
|
curl --location https://install.python-poetry.org \
|
||||||
--output "$CI_CACHE/install-poetry.py" \
|
--output "$CI_CACHE/install-poetry.py" \
|
||||||
--silent \
|
--silent \
|
||||||
--show-error;
|
--show-error;
|
||||||
python "$CI_CACHE/install-poetry.py" \
|
python "$CI_CACHE/install-poetry.py" \
|
||||||
--version "$POETRY_VERSION" \
|
--version "$INSTALL_POETRY_VERSION" \
|
||||||
--yes;
|
--yes;
|
||||||
poetry --version --no-ansi;
|
poetry --version --no-ansi;
|
||||||
poetry run pip --version;
|
poetry run pip --version;
|
||||||
|
|
||||||
poetry install \
|
poetry install \
|
||||||
--quiet \
|
--sync \
|
||||||
--remove-untracked \
|
--no-ansi \
|
||||||
--no-ansi;
|
--no-root \
|
||||||
|
--only ci;
|
||||||
|
|
||||||
poetry env info;
|
poetry env info;
|
||||||
poetry run tox --version;
|
poetry run tox --version;
|
||||||
|
42
.github/workflows/ci.yaml
vendored
42
.github/workflows/ci.yaml
vendored
@ -5,14 +5,16 @@ on:
|
|||||||
types: ["opened", "synchronize"]
|
types: ["opened", "synchronize"]
|
||||||
push:
|
push:
|
||||||
branches: ["devel"]
|
branches: ["devel"]
|
||||||
|
env:
|
||||||
|
POETRY_VERSION: 1.4.1
|
||||||
jobs:
|
jobs:
|
||||||
Test:
|
Test:
|
||||||
|
name: Python ${{ matrix.python.version }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
|
fail-fast: true
|
||||||
matrix:
|
matrix:
|
||||||
python:
|
python:
|
||||||
- version: "3.6"
|
|
||||||
toxenv: py36
|
|
||||||
- version: "3.7"
|
- version: "3.7"
|
||||||
toxenv: py37
|
toxenv: py37
|
||||||
- version: "3.8"
|
- version: "3.8"
|
||||||
@ -21,15 +23,24 @@ jobs:
|
|||||||
toxenv: py39
|
toxenv: py39
|
||||||
- version: "3.10"
|
- version: "3.10"
|
||||||
toxenv: py310
|
toxenv: py310
|
||||||
|
- version: "3.11"
|
||||||
|
toxenv: py311
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Install Python 3.10
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: "3.10"
|
||||||
|
|
||||||
- name: Install Python ${{ matrix.python.version }}
|
- name: Install Python ${{ matrix.python.version }}
|
||||||
uses: actions/setup-python@v1
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python.version }}
|
python-version: ${{ matrix.python.version }}
|
||||||
|
|
||||||
- name: Configure Job Cache
|
- name: Configure Job Cache
|
||||||
uses: actions/cache@v2
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.cache/pip
|
~/.cache/pip
|
||||||
@ -39,38 +50,49 @@ jobs:
|
|||||||
# will be invalidated, and thus all packages will be redownloaded, if the
|
# will be invalidated, and thus all packages will be redownloaded, if the
|
||||||
# lockfile is updated
|
# lockfile is updated
|
||||||
key: ${{ runner.os }}-${{ matrix.python.toxenv }}-${{ hashFiles('**/poetry.lock') }}
|
key: ${{ runner.os }}-${{ matrix.python.toxenv }}-${{ hashFiles('**/poetry.lock') }}
|
||||||
|
|
||||||
- name: Configure Path
|
- name: Configure Path
|
||||||
run: echo "$HOME/.local/bin" >> $GITHUB_PATH
|
run: echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
- name: Configure Environment
|
- name: Configure Environment
|
||||||
run: .github/scripts/setup-env.sh
|
run: .github/scripts/setup-env.sh
|
||||||
|
|
||||||
- name: Run Toxenv ${{ matrix.python.toxenv }}
|
- name: Run Toxenv ${{ matrix.python.toxenv }}
|
||||||
run: poetry run tox -e ${{ matrix.python.toxenv }}
|
run: poetry run tox -e ${{ matrix.python.toxenv }}
|
||||||
|
|
||||||
Check:
|
Check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
- name: Install Python 3.8
|
|
||||||
uses: actions/setup-python@v1
|
- name: Install Python 3.10
|
||||||
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: 3.8
|
python-version: "3.10"
|
||||||
|
|
||||||
- name: Configure Job Cache
|
- name: Configure Job Cache
|
||||||
uses: actions/cache@v2
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.cache/pip
|
~/.cache/pip
|
||||||
~/.cache/pypoetry/cache
|
~/.cache/pypoetry/cache
|
||||||
~/.poetry
|
~/.poetry
|
||||||
# Hardcoded 'py38' slug here lets this cache piggyback on the 'py38' cache
|
# Hardcoded 'py310' slug here lets this cache piggyback on the 'py310' cache
|
||||||
# that is generated for the tests above
|
# that is generated for the tests above
|
||||||
key: ${{ runner.os }}-py38-${{ hashFiles('**/poetry.lock') }}
|
key: ${{ runner.os }}-py310-${{ hashFiles('**/poetry.lock') }}
|
||||||
|
|
||||||
- name: Configure Path
|
- name: Configure Path
|
||||||
run: echo "$HOME/.local/bin" >> $GITHUB_PATH
|
run: echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
- name: Configure Environment
|
- name: Configure Environment
|
||||||
run: .github/scripts/setup-env.sh
|
run: .github/scripts/setup-env.sh
|
||||||
|
|
||||||
- name: Run Static Analysis Checks
|
- name: Run Static Analysis Checks
|
||||||
run: poetry run tox -e static
|
run: poetry run tox -e static
|
||||||
|
|
||||||
- name: Run Static Analysis Checks (Tests)
|
- name: Run Static Analysis Checks (Tests)
|
||||||
run: poetry run tox -e static-tests
|
run: poetry run tox -e static-tests
|
||||||
|
|
||||||
- name: Run Security Checks
|
- name: Run Security Checks
|
||||||
run: poetry run tox -e security
|
run: poetry run tox -e security
|
||||||
|
@ -11,7 +11,6 @@
|
|||||||
# --disable=W"
|
# --disable=W"
|
||||||
disable=logging-fstring-interpolation
|
disable=logging-fstring-interpolation
|
||||||
,logging-format-interpolation
|
,logging-format-interpolation
|
||||||
,bad-continuation
|
|
||||||
,line-too-long
|
,line-too-long
|
||||||
,ungrouped-imports
|
,ungrouped-imports
|
||||||
,typecheck
|
,typecheck
|
||||||
|
28
CHANGELOG.md
28
CHANGELOG.md
@ -2,6 +2,34 @@
|
|||||||
|
|
||||||
See also: [Github Release Page](https://github.com/enpaul/vault2vault/releases).
|
See also: [Github Release Page](https://github.com/enpaul/vault2vault/releases).
|
||||||
|
|
||||||
|
## Version 0.1.3
|
||||||
|
|
||||||
|
View this release on: [Github](https://github.com/enpaul/vault2vault/releases/tag/0.1.3),
|
||||||
|
[PyPI](https://pypi.org/project/vault2vault/0.1.3/)
|
||||||
|
|
||||||
|
- Fix incorrect encoding specification when opening password files. Contributed by
|
||||||
|
[brycelowe](https://github.com/brycelowe) (#2)
|
||||||
|
|
||||||
|
## Version 0.1.2
|
||||||
|
|
||||||
|
View this release on: [Github](https://github.com/enpaul/vault2vault/releases/tag/0.1.2),
|
||||||
|
[PyPI](https://pypi.org/project/vault2vault/0.1.2/)
|
||||||
|
|
||||||
|
- Add user documentation
|
||||||
|
- Add project road map
|
||||||
|
- Fix incorrect and missing docstrings for internal functions
|
||||||
|
|
||||||
|
## Version 0.1.1
|
||||||
|
|
||||||
|
View this release on: [Github](https://github.com/enpaul/vault2vault/releases/tag/0.1.1),
|
||||||
|
[PyPI](https://pypi.org/project/vault2vault/0.1.1/)
|
||||||
|
|
||||||
|
- Fix bug causing stack trace when the same vaulted block appears in a YAML file more than
|
||||||
|
once
|
||||||
|
- Fix bug where the `--ignore-undecryptable` option was not respected for vaulted
|
||||||
|
variables in YAML files
|
||||||
|
- Update logging messages and levels to improve verbose output
|
||||||
|
|
||||||
## Version 0.1.0
|
## Version 0.1.0
|
||||||
|
|
||||||
View this release on: [Github](https://github.com/enpaul/vault2vault/releases/tag/0.1.0),
|
View this release on: [Github](https://github.com/enpaul/vault2vault/releases/tag/0.1.0),
|
||||||
|
@ -27,9 +27,10 @@ Examples of unacceptable behavior include:
|
|||||||
- The use of sexualized language or imagery, and sexual attention or advances of any kind
|
- The use of sexualized language or imagery, and sexual attention or advances of any kind
|
||||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
- Public or private harassment
|
- Public or private harassment
|
||||||
- Publishing others' private information, such as a physical or email address, without their
|
- Publishing others' private information, such as a physical or email address, without
|
||||||
explicit permission
|
their explicit permission
|
||||||
- Other conduct which could reasonably be considered inappropriate in a professional setting
|
- Other conduct which could reasonably be considered inappropriate in a professional
|
||||||
|
setting
|
||||||
|
|
||||||
## Enforcement Responsibilities
|
## Enforcement Responsibilities
|
||||||
|
|
||||||
@ -52,8 +53,8 @@ offline event.
|
|||||||
## Enforcement
|
## Enforcement
|
||||||
|
|
||||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the
|
||||||
community leaders responsible for enforcement at \[INSERT CONTACT METHOD\]. All
|
community leaders responsible for enforcement at \[INSERT CONTACT METHOD\]. All complaints
|
||||||
complaints will be reviewed and investigated promptly and fairly.
|
will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
All community leaders are obligated to respect the privacy and security of the reporter of
|
All community leaders are obligated to respect the privacy and security of the reporter of
|
||||||
any incident.
|
any incident.
|
||||||
@ -105,8 +106,8 @@ toward or disparagement of classes of individuals.
|
|||||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0,
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0,
|
||||||
available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||||
|
|
||||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
Community Impact Guidelines were inspired by
|
||||||
enforcement ladder](https://github.com/mozilla/diversity).
|
[Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity).
|
||||||
|
|
||||||
For answers to common questions about this code of conduct, see the FAQ at
|
For answers to common questions about this code of conduct, see the FAQ at
|
||||||
https://www.contributor-covenant.org/faq. Translations are available at
|
https://www.contributor-covenant.org/faq. Translations are available at
|
||||||
|
4
Makefile
4
Makefile
@ -30,10 +30,10 @@ source: ## Build Python source distribution package
|
|||||||
poetry build --format sdist
|
poetry build --format sdist
|
||||||
|
|
||||||
test: ## Run the project testsuite(s)
|
test: ## Run the project testsuite(s)
|
||||||
poetry run tox --recreate
|
poetry run tox --recreate --parallel
|
||||||
|
|
||||||
dev: ## Create the local dev environment
|
dev: ## Create the local dev environment
|
||||||
poetry install
|
poetry install --with dev --extras ansible --sync
|
||||||
poetry run pre-commit install
|
poetry run pre-commit install
|
||||||
|
|
||||||
publish: test wheel source ## Build and upload to pypi (requires $PYPI_API_KEY be set)
|
publish: test wheel source ## Build and upload to pypi (requires $PYPI_API_KEY be set)
|
||||||
|
143
README.md
143
README.md
@ -10,27 +10,29 @@ but works recursively on encrypted files and in-line variables
|
|||||||
[](https://www.python.org)
|
[](https://www.python.org)
|
||||||
[](https://github.com/psf/black)
|
[](https://github.com/psf/black)
|
||||||
|
|
||||||
⚠️ **This project is alpha software and is under active development** ⚠️
|
⚠️ **This project is beta software and is under active development** ⚠️
|
||||||
|
|
||||||
- [What is this?](#what-is-this)
|
- [What is this?](#what-is-this)
|
||||||
- [Installing](#installing)
|
- [Installing](#installing)
|
||||||
- [Using](#using)
|
- [Usage](#usage)
|
||||||
|
- [Recovering from a failed migration](#recovering-from-a-failed-migration)
|
||||||
|
- [Roadmap](#roadmap)
|
||||||
- [Developing](#developer-documentation)
|
- [Developing](#developer-documentation)
|
||||||
|
|
||||||
## What is this?
|
## What is this?
|
||||||
|
|
||||||
If you use [Ansible Vault](https://docs.ansible.com/ansible/latest/user_guide/vault.html)
|
If you use [Ansible Vault](https://docs.ansible.com/ansible/latest/user_guide/vault.html)
|
||||||
then you may have encountered the problem of needing to role your vault password. Maybe
|
then you may have encountered the problem of needing to roll your vault password. Maybe
|
||||||
you found it written down on a sticky note, maybe a coworker who knows it left the
|
you found it written down on a sticky note, maybe a coworker who knows it left the
|
||||||
company, maybe you accidentally typed it into Slack when you thought the focus was on your
|
company, maybe you accidentally typed it into Slack when you thought the focus was on your
|
||||||
terminal. Whatever, these things happen.
|
terminal. Whatever, these things happen.
|
||||||
|
|
||||||
The builtin tool Ansible provides,
|
The built-in tool Ansible provides,
|
||||||
[`ansible-vault rekey`](https://docs.ansible.com/ansible/latest/cli/ansible-vault.html#rekey),
|
[`ansible-vault rekey`](https://docs.ansible.com/ansible/latest/cli/ansible-vault.html#rekey),
|
||||||
works suffers from two main drawbacks: first, it only works on vault encrypted files and
|
suffers from two main drawbacks: first, it only works on vault encrypted files and not on
|
||||||
not on vault encrypted YAML data; and second, it only works on a single vault encrypted
|
vault encrypted YAML data; and second, it only works on a single vault encrypted file at a
|
||||||
file at a time. To rekey everything in a large project you'd need to write a script that
|
time. To rekey everything in a large project you'd need to write a script that recursively
|
||||||
goes through every file and rekeys everything in every format it can find.
|
goes through every file and rekeys every encrypted file and YAML variable all at once.
|
||||||
|
|
||||||
This is that script.
|
This is that script.
|
||||||
|
|
||||||
@ -55,47 +57,110 @@ install `vault2vault` using [PipX](https://pypa.github.io/pipx/) and the `ansibl
|
|||||||
pipx install vault2vault[ansible]
|
pipx install vault2vault[ansible]
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note: vault2vault requires an Ansible installation to function. If you are installing to a standalone virtual environment (like with PipX) then you must install it with the `ansible` extra to ensure a version of Ansible is available to the application.**
|
> Note: vault2vault requires an Ansible installation to function. If you are installing to
|
||||||
|
> a standalone virtual environment (like with PipX) then you must install it with the
|
||||||
|
> `ansible` extra to ensure a version of Ansible is available to the application.\*\*
|
||||||
|
|
||||||
## Using
|
## Usage
|
||||||
|
|
||||||
These docs are pretty sparse, largely because this project is still under active design
|
> Note: the full command reference is available by running `vault2vault --help`
|
||||||
and redevelopment. Here are the command line options:
|
|
||||||
|
|
||||||
```
|
Vault2Vault works with files in any arbitrary directory structures, so there is no need to
|
||||||
> vault2vault --help
|
have your Ansible project(s) structured in a specific way for the tool to work. The
|
||||||
usage: vault2vault [-h] [--version] [--interactive] [-v] [-b] [-i VAULT_ID] [--ignore-undecryptable] [--old-pass-file OLD_PASS_FILE]
|
simplest usage of Vault2Vault is by passing the path to your Ansible project directory to
|
||||||
[--new-pass-file NEW_PASS_FILE]
|
the command:
|
||||||
[paths ...]
|
|
||||||
|
|
||||||
Recursively rekey ansible-vault encrypted files and in-line variables
|
```bash
|
||||||
|
vault2vault ./my-ansible-project/
|
||||||
positional arguments:
|
|
||||||
paths Paths to search for Ansible Vault encrypted content
|
|
||||||
|
|
||||||
options:
|
|
||||||
-h, --help show this help message and exit
|
|
||||||
--version Show program version and exit
|
|
||||||
--interactive Step through files and variables interactively, prompting for confirmation before making each change
|
|
||||||
-v, --verbose Increase verbosity; can be repeated
|
|
||||||
-b, --backup Write a backup of every file to be modified, suffixed with '.bak'
|
|
||||||
-i VAULT_ID, --vault-id VAULT_ID
|
|
||||||
Limit rekeying to encrypted secrets with the specified Vault ID
|
|
||||||
--ignore-undecryptable
|
|
||||||
Ignore any file or variable that is not decryptable with the provided vault secret instead of raising an error
|
|
||||||
--old-pass-file OLD_PASS_FILE
|
|
||||||
Path to a file with the old vault password to decrypt secrets with
|
|
||||||
--new-pass-file NEW_PASS_FILE
|
|
||||||
Path to a file with the new vault password to rekey secrets with
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Please report any bugs or issues you encounter on
|
The tool will prompt for the current vault password and the new vault password and then
|
||||||
[Github](https://github.com/enpaul/vault2vault/issues).
|
process every file under the provided path. You can also specify multiple paths and
|
||||||
|
they'll all be processed together:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vault2vault \
|
||||||
|
./my-ansible-project/playbooks/ \
|
||||||
|
./my-ansible-project/host_vars/ \
|
||||||
|
./my-ansible-project/group_vars/
|
||||||
|
```
|
||||||
|
|
||||||
|
To skip the interactive password prompts you can put the password in a file and have the
|
||||||
|
tool read it in at runtime. The `--old-pass-file` and `--new-pass-file` parameters work
|
||||||
|
the same way as the `--vault-password-file` option from the `ansible` command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vault2vault ./my-ansible-project/ \
|
||||||
|
--old-pass-file=./oldpass.txt \
|
||||||
|
--new-pass-file=./newpass.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
If you use multiple vault passwords in your project and want to roll them you'll need to
|
||||||
|
run `vault2vault` once for each password you want to change. By default, `vault2vault`
|
||||||
|
will fail with an error if it encounters vaulted data that it cannot decrypt with the
|
||||||
|
provided current vault password. To change this behavior and instead just ignore any
|
||||||
|
vaulted data that can't be decrypted (like, for example, if you have data encrypted with
|
||||||
|
multiple vault passwords) you can pass the `--ignore-undecryptable` flag to turn the
|
||||||
|
errors into warnings.
|
||||||
|
|
||||||
|
> Please report any bugs or issues you encounter on
|
||||||
|
> [Github](https://github.com/enpaul/vault2vault/issues).
|
||||||
|
|
||||||
|
### Recovering from a failed migration
|
||||||
|
|
||||||
|
This tool is still pretty early in it's development, and to be honest it hooks into
|
||||||
|
Ansible's functionality in some fragile ways. I've tested as best I can to ensure it
|
||||||
|
covers as many edge cases as possible, but there is still the chance that you might get
|
||||||
|
partway through a password migration and then have the tool fail out, leaving half of your
|
||||||
|
data successfully rekeyed and the other half not.
|
||||||
|
|
||||||
|
In the spirit of the
|
||||||
|
[Unix philosophy](https://hackaday.com/2018/09/10/doing-one-thing-well-the-unix-philosophy/)
|
||||||
|
this tool does not include any built-in way to recover from this state. However, it can be
|
||||||
|
done very effectively using a version control tool.
|
||||||
|
|
||||||
|
If you are using Git to track your project files then you can use the command
|
||||||
|
`git reset --hard` to restore all files to the state of the currently checked out commit.
|
||||||
|
This does have the side effect of erasing any other un-committed work in the repository,
|
||||||
|
so it's recommended to always have a clean working tree when using Vault2Vault.
|
||||||
|
|
||||||
|
If you are not using a version control system to track your project files then you can
|
||||||
|
create a temporary Git repository to use in the event of a migration failure:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd my-project/
|
||||||
|
|
||||||
|
# Initialize the new repository
|
||||||
|
git init
|
||||||
|
|
||||||
|
# Add and commit all your existing files to the git tree
|
||||||
|
git add .
|
||||||
|
git commit -m "initial commit"
|
||||||
|
|
||||||
|
# Run vault migrations
|
||||||
|
vault2vault ...
|
||||||
|
|
||||||
|
# If no recovery is necessary, delete the git repository data
|
||||||
|
rm -rf .git
|
||||||
|
```
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
This project is considered feature complete as of the
|
||||||
|
[0.1.1](https://github.com/enpaul/vault2vault/releases/tag/0.1.1) release. As a result the
|
||||||
|
roadmap focuses on stability and user experience ahead of a 1.0 release.
|
||||||
|
|
||||||
|
- [ ] Reimplement core vaulted data processing function to enable multithreading
|
||||||
|
- [ ] Implement multithreading for performance in large environments
|
||||||
|
- [ ] Add unit tests
|
||||||
|
- [ ] Add integration tests
|
||||||
|
- [ ] Redesign logging messages to improve clarity and consistency
|
||||||
|
|
||||||
## Developer Documentation
|
## Developer Documentation
|
||||||
|
|
||||||
All project contributors and participants are expected to adhere to the
|
All project contributors and participants are expected to adhere to the
|
||||||
[Contributor Covenant Code of Conduct, v2](CODE_OF_CONDUCT.md) ([external link](https://www.contributor-covenant.org/version/2/0/code_of_conduct/)).
|
[Contributor Covenant Code of Conduct, v2](CODE_OF_CONDUCT.md)
|
||||||
|
([external link](https://www.contributor-covenant.org/version/2/0/code_of_conduct/)).
|
||||||
|
|
||||||
The `devel` branch has the latest (and potentially unstable) changes. The stable releases
|
The `devel` branch has the latest (and potentially unstable) changes. The stable releases
|
||||||
are tracked on [Github](https://github.com/enpaul/vault2vault/releases),
|
are tracked on [Github](https://github.com/enpaul/vault2vault/releases),
|
||||||
|
3415
poetry.lock
generated
3415
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "vault2vault"
|
name = "vault2vault"
|
||||||
version = "0.1.0"
|
version = "0.1.3"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Ethan Paul <24588726+enpaul@users.noreply.github.com>"]
|
authors = ["Ethan Paul <24588726+enpaul@users.noreply.github.com>"]
|
||||||
description = "Recursively rekey ansible-vault encrypted files and in-line variables"
|
description = "Recursively rekey ansible-vault encrypted files and in-line variables"
|
||||||
@ -12,7 +12,7 @@ packages = [
|
|||||||
keywords = ["ansible", "vault", "playbook", "yaml", "password"]
|
keywords = ["ansible", "vault", "playbook", "yaml", "password"]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 3 - Alpha",
|
"Development Status :: 4 - Beta",
|
||||||
"Environment :: Console",
|
"Environment :: Console",
|
||||||
"Framework :: Ansible",
|
"Framework :: Ansible",
|
||||||
"Intended Audience :: Developers",
|
"Intended Audience :: Developers",
|
||||||
@ -22,11 +22,11 @@ classifiers = [
|
|||||||
"Natural Language :: English",
|
"Natural Language :: English",
|
||||||
"Operating System :: OS Independent",
|
"Operating System :: OS Independent",
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
"Programming Language :: Python :: 3.6",
|
|
||||||
"Programming Language :: Python :: 3.7",
|
"Programming Language :: Python :: 3.7",
|
||||||
"Programming Language :: Python :: 3.8",
|
"Programming Language :: Python :: 3.8",
|
||||||
"Programming Language :: Python :: 3.9",
|
"Programming Language :: Python :: 3.9",
|
||||||
"Programming Language :: Python :: 3.10",
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
"Programming Language :: Python :: Implementation :: CPython"
|
"Programming Language :: Python :: Implementation :: CPython"
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -37,30 +37,43 @@ vault2vault = "vault2vault:main"
|
|||||||
ansible = ["ansible-core"]
|
ansible = ["ansible-core"]
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = "^3.6.1"
|
python = "^3.7"
|
||||||
"ruamel.yaml" = "^0.17.16"
|
"ruamel.yaml" = "^0.17.16"
|
||||||
ansible-core = {version = "^2.11.5", optional = true}
|
ansible-core = {version = "^2.11.5", optional = true}
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
bandit = "^1.6.2"
|
black = {version = "^23.1.0", python = "^3.10"}
|
||||||
black = { version = "^21.9b0", allow-prereleases = true, python = "^3.7" }
|
blacken-docs = {version = "^1.13.0", python = "^3.10"}
|
||||||
blacken-docs = "^1.8.0"
|
ipython = {version = "^8.10.1", python = "^3.10"}
|
||||||
ipython = { version = "^7.18.1", python = "^3.7" }
|
mdformat = {version = "^0.7.16", python = "^3.10"}
|
||||||
mypy = "^0.800"
|
mdformat-gfm = {version = "^0.3.5", python = "^3.10"}
|
||||||
pre-commit = "^2.7.1"
|
mypy = {version = "^1.1.1", python = "^3.10"}
|
||||||
pre-commit-hooks = "^3.3.0"
|
pre-commit = {version = "^2.7.1", python = "^3.10"}
|
||||||
pylint = "^2.4.4"
|
pre-commit-hooks = {version = "^3.3.0", python = "^3.10"}
|
||||||
pytest = "^6.0.2"
|
pylint = {version = "^2.4.4", python = "^3.10"}
|
||||||
pytest-cov = "^2.10.1"
|
reorder-python-imports = {version = "^2.3.5", python = "^3.10"}
|
||||||
reorder-python-imports = "^2.3.5"
|
types-toml = {version = "^0.10.4", python = "^3.10"}
|
||||||
safety = "^1.9.0"
|
# Implicit python version check fails for this one
|
||||||
toml = "^0.10.1"
|
packaging = {version = "^23.0", python = "^3.10"}
|
||||||
tox = "^3.20.0"
|
|
||||||
tox-poetry-installer = { version = "^0.8.1", extras = ["poetry"] }
|
[tool.poetry.group.security.dependencies]
|
||||||
types-toml = "^0.10.4"
|
bandit = {version = "^1.6.2", python = "^3.10"}
|
||||||
mdformat = "^0.6.4"
|
safety = {version = "^2.2.0", python = "^3.10"}
|
||||||
mdformat-gfm = "^0.2"
|
poetry = {version = "^1.2.0", python = "^3.10"}
|
||||||
|
|
||||||
|
[tool.poetry.group.test.dependencies]
|
||||||
|
pytest = {version = "^6.0.2"}
|
||||||
|
pytest-cov = {version = "^2.10.1"}
|
||||||
|
toml = {version = "^0.10.1"}
|
||||||
|
typing-extensions = {version = "^4.5.0", python = "^3.8"}
|
||||||
|
|
||||||
|
[tool.poetry.group.ci.dependencies]
|
||||||
|
tox = {version = "^3.20.0"}
|
||||||
|
tox-poetry-installer = {version = "^0.10.1", extras = ["poetry"]}
|
||||||
|
# This doesn't get installed under py3.7 for some reason, but it's
|
||||||
|
# required for poetry. Will need to debug this more in the future
|
||||||
|
backports-cached-property = "^1.0.2"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core>=1.0.0"]
|
requires = ["poetry-core>=1.1.0"]
|
||||||
build-backend = "poetry.core.masonry.api"
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
43
tox.ini
43
tox.ini
@ -1,5 +1,5 @@
|
|||||||
[tox]
|
[tox]
|
||||||
envlist = py36, py37, py38, py39, py310, static, static-tests, security
|
envlist = py3{7-11}, static, static-tests, security
|
||||||
isolated_build = true
|
isolated_build = true
|
||||||
skip_missing_interpreters = true
|
skip_missing_interpreters = true
|
||||||
|
|
||||||
@ -9,10 +9,8 @@ require_locked_deps = true
|
|||||||
require_poetry = true
|
require_poetry = true
|
||||||
extras =
|
extras =
|
||||||
ansible
|
ansible
|
||||||
locked_deps =
|
poetry_dep_groups =
|
||||||
pytest
|
test
|
||||||
pytest-cov
|
|
||||||
toml
|
|
||||||
commands =
|
commands =
|
||||||
pytest {toxinidir}/tests/ \
|
pytest {toxinidir}/tests/ \
|
||||||
--cov vault2vault \
|
--cov vault2vault \
|
||||||
@ -21,20 +19,11 @@ commands =
|
|||||||
|
|
||||||
[testenv:static]
|
[testenv:static]
|
||||||
description = Static formatting and quality enforcement
|
description = Static formatting and quality enforcement
|
||||||
basepython = python3.8
|
basepython = python3.10
|
||||||
platform = linux
|
platform = linux
|
||||||
ignore_errors = true
|
ignore_errors = true
|
||||||
locked_deps =
|
poetry_dep_groups =
|
||||||
black
|
dev
|
||||||
blacken-docs
|
|
||||||
mdformat
|
|
||||||
mdformat-gfm
|
|
||||||
mypy
|
|
||||||
reorder-python-imports
|
|
||||||
pre-commit
|
|
||||||
pre-commit-hooks
|
|
||||||
pylint
|
|
||||||
types-toml
|
|
||||||
commands =
|
commands =
|
||||||
pre-commit run \
|
pre-commit run \
|
||||||
--all-files
|
--all-files
|
||||||
@ -46,7 +35,7 @@ commands =
|
|||||||
|
|
||||||
[testenv:static-tests]
|
[testenv:static-tests]
|
||||||
description = Static formatting and quality enforcement for the tests
|
description = Static formatting and quality enforcement for the tests
|
||||||
basepython = python3.8
|
basepython = python3.10
|
||||||
platform = linux
|
platform = linux
|
||||||
ignore_errors = true
|
ignore_errors = true
|
||||||
locked_deps =
|
locked_deps =
|
||||||
@ -63,14 +52,12 @@ commands =
|
|||||||
|
|
||||||
[testenv:security]
|
[testenv:security]
|
||||||
description = Security checks
|
description = Security checks
|
||||||
basepython = python3.8
|
basepython = python3.10
|
||||||
platform = linux
|
platform = linux
|
||||||
ignore_errors = true
|
ignore_errors = true
|
||||||
skip_install = true
|
skip_install = true
|
||||||
locked_deps =
|
poetry_dep_groups =
|
||||||
bandit
|
security
|
||||||
safety
|
|
||||||
poetry
|
|
||||||
commands =
|
commands =
|
||||||
bandit {toxinidir}/vault2vault.py \
|
bandit {toxinidir}/vault2vault.py \
|
||||||
--recursive \
|
--recursive \
|
||||||
@ -82,8 +69,14 @@ commands =
|
|||||||
poetry export \
|
poetry export \
|
||||||
--format requirements.txt \
|
--format requirements.txt \
|
||||||
--output {envtmpdir}/requirements.txt \
|
--output {envtmpdir}/requirements.txt \
|
||||||
--without-hashes \
|
--without-hashes
|
||||||
--dev
|
# For now these groups are disabled until this bug is resolved
|
||||||
|
# in poetry-plugin-export:
|
||||||
|
# https://github.com/python-poetry/poetry-plugin-export/issues/176
|
||||||
|
# --with dev \
|
||||||
|
# --with ci \
|
||||||
|
# --with security \
|
||||||
|
# --with test
|
||||||
safety check \
|
safety check \
|
||||||
--file {envtmpdir}/requirements.txt \
|
--file {envtmpdir}/requirements.txt \
|
||||||
--json
|
--json
|
||||||
|
479
vault2vault.py
479
vault2vault.py
@ -9,24 +9,26 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
from typing import List
|
from typing import List
|
||||||
from typing import Tuple
|
from typing import Optional
|
||||||
from typing import Union
|
|
||||||
|
|
||||||
import ruamel.yaml
|
import ruamel.yaml
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import ansible.constants
|
import ansible.constants
|
||||||
import ansible.parsing.vault
|
from ansible.parsing.vault import VaultSecret
|
||||||
|
from ansible.parsing.vault import VaultLib
|
||||||
|
from ansible.parsing.vault import AnsibleVaultError
|
||||||
except ImportError:
|
except ImportError:
|
||||||
print(
|
print(
|
||||||
"FATAL: No supported version of Ansible could be imported under the current python interpreter"
|
"FATAL: No supported version of Ansible could be imported under the current python interpreter",
|
||||||
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
__title__ = "vault2vault"
|
__title__ = "vault2vault"
|
||||||
__summary__ = "Recursively rekey ansible-vault encrypted files and in-line variables"
|
__summary__ = "Recursively rekey ansible-vault encrypted files and in-line variables"
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.1.3"
|
||||||
__url__ = "https://github.com/enpaul/vault2vault/"
|
__url__ = "https://github.com/enpaul/vault2vault/"
|
||||||
__license__ = "MIT"
|
__license__ = "MIT"
|
||||||
__authors__ = ["Ethan Paul <24588726+enpaul@users.noreply.github.com>"]
|
__authors__ = ["Ethan Paul <24588726+enpaul@users.noreply.github.com>"]
|
||||||
@ -44,8 +46,8 @@ ruamel.yaml.add_constructor(
|
|||||||
|
|
||||||
|
|
||||||
def rekey(
|
def rekey(
|
||||||
old: ansible.parsing.vault.VaultLib,
|
old: VaultLib,
|
||||||
new: ansible.parsing.vault.VaultLib,
|
new: VaultLib,
|
||||||
content: bytes,
|
content: bytes,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""Rekey vaulted content to use a new vault password
|
"""Rekey vaulted content to use a new vault password
|
||||||
@ -61,6 +63,208 @@ def rekey(
|
|||||||
return new.encrypt(old.decrypt(content))
|
return new.encrypt(old.decrypt(content))
|
||||||
|
|
||||||
|
|
||||||
|
# This whole function needs to be rebuilt from the ground up so I don't
|
||||||
|
# feel bad about disabling this warning
|
||||||
|
def _process_file( # pylint: disable=too-many-statements
|
||||||
|
path: Path,
|
||||||
|
old: VaultLib,
|
||||||
|
new: VaultLib,
|
||||||
|
interactive: bool,
|
||||||
|
backup: bool,
|
||||||
|
ignore: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Determine whether a filepath includes vaulted data and if so, rekey it
|
||||||
|
|
||||||
|
:param path: Path to the file to check
|
||||||
|
:param old: VaultLib object with the current (old) vault password encoded in it
|
||||||
|
:param new: VaultLib object with the target (new) vault password encoded in it
|
||||||
|
:param interactive: Whether to prompt interactively for confirmation before each
|
||||||
|
rekey operation
|
||||||
|
:param backup: Whether to copy the original file to a backup before making any
|
||||||
|
in-place changes
|
||||||
|
:param ignore: Whether to ignore any errors that come from failing to decrypt
|
||||||
|
any vaulted data
|
||||||
|
"""
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
logger.debug(f"Processing file {path}")
|
||||||
|
|
||||||
|
def _process_yaml_data( # pylint: disable=too-many-locals
|
||||||
|
content: bytes, data: Any, ignore: bool, name: str = ""
|
||||||
|
):
|
||||||
|
if isinstance(data, dict):
|
||||||
|
for key, value in data.items():
|
||||||
|
content = _process_yaml_data(
|
||||||
|
content, value, ignore, name=f"{name}.{key}"
|
||||||
|
)
|
||||||
|
elif isinstance(data, list):
|
||||||
|
for index, item in enumerate(data):
|
||||||
|
content = _process_yaml_data(
|
||||||
|
content, item, ignore, name=f"{name}.{index}"
|
||||||
|
)
|
||||||
|
elif isinstance(data, ruamel.yaml.comments.TaggedScalar) and old.is_encrypted(
|
||||||
|
data.value
|
||||||
|
):
|
||||||
|
logger.info(f"Identified vaulted content in {path} at {name}")
|
||||||
|
confirm = (
|
||||||
|
_confirm(f"Rekey vault encrypted variable {name} in file {path}?")
|
||||||
|
if interactive
|
||||||
|
else True
|
||||||
|
)
|
||||||
|
|
||||||
|
if not confirm:
|
||||||
|
logger.debug(
|
||||||
|
f"User skipped vault encrypted content in {path} at {name} via interactive mode"
|
||||||
|
)
|
||||||
|
return content
|
||||||
|
|
||||||
|
try:
|
||||||
|
new_data = rekey(old, new, data.value.encode())
|
||||||
|
except AnsibleVaultError as err:
|
||||||
|
msg = f"Failed to decrypt vault encrypted data in {path} at {name} with provided vault secret"
|
||||||
|
if ignore:
|
||||||
|
logger.warning(msg)
|
||||||
|
return content
|
||||||
|
raise RuntimeError(msg) from err
|
||||||
|
content_decoded = content.decode("utf-8")
|
||||||
|
|
||||||
|
# Ok so this next section is probably the worst possible way to do this, but I did
|
||||||
|
# it this way to solve a very specific problem that would absolutely prevent people
|
||||||
|
# from using this tool: round trip YAML format preservation. Namely, that it's impossible.
|
||||||
|
# Ruamel gets the closest to achieving this: it can do round trip format preservation
|
||||||
|
# when the starting state is in _some_ known state (this is better than competitors which
|
||||||
|
# require the starting state to be in a _specific_ known state). But given how many
|
||||||
|
# ways there are to write YAML- and by extension, how many opinions there are on the
|
||||||
|
# "correct" way to write YAML- it is not possible to configure ruamel to account for all of
|
||||||
|
# them, even if everyones YAML style was compatible with ruamel's roundtrip formatting (note:
|
||||||
|
# they aren't). So there's the problem: to be useful, this tool would need to reformat every
|
||||||
|
# YAML file it touched, which means nobody would use it.
|
||||||
|
#
|
||||||
|
# To avoid the YAML formatting problem, we need a way to replace the target content
|
||||||
|
# in the raw text of the file without dumping the parsed YAML. We want to preserve
|
||||||
|
# indendation, remove any extra newlines that would be left over, add any necessary
|
||||||
|
# newlines without clobbering the following lines, and ideally avoid reimplementing
|
||||||
|
# a YAML formatter. The answer to this problem- as the answer to so many stupid problems
|
||||||
|
# seems to be- is a regex. If this is too janky for you (I know it is for me) go support
|
||||||
|
# the estraven project I'm trying to get off the ground: https://github.com/enpaul/estraven
|
||||||
|
#
|
||||||
|
# Ok, thanks for sticking with me as I was poetic about this. The solution below...
|
||||||
|
# is awful, I can admit that. But it does work, so I'll leave it up to
|
||||||
|
# your judgement as to whether it's worthwhile or not. Here's how it works:
|
||||||
|
#
|
||||||
|
# 1. First we take the first line of the original (unmodified) vaulted content. This line
|
||||||
|
# of text has several important qualities: 1) it exists in the raw text of the file, 2)
|
||||||
|
# it is pseudo-guaranteed to be unique, and 3) it is guaranteed to exist (vaulted content
|
||||||
|
# will be at least one line long, but possibly no more)
|
||||||
|
search_data = data.value.split("\n")[1]
|
||||||
|
try:
|
||||||
|
# 2. Next we use a regex to grab the full line of text from the file that includes the above
|
||||||
|
# string. This is important because the full line of text will include the leading
|
||||||
|
# whitespace, which ruamel helpfully strips out from the parsed data.
|
||||||
|
# 3. Next we grab the number of leading spaces on the line using the capture group from the
|
||||||
|
# regex
|
||||||
|
padding = len(
|
||||||
|
re.search(rf"\n(\s*){search_data}\n", content_decoded).groups()[0]
|
||||||
|
)
|
||||||
|
except (TypeError, AttributeError):
|
||||||
|
# This is to handle an edgecase where the vaulted content is actually a yaml anchor. For
|
||||||
|
# example, if a single vaulted secret needs to be stored under multiple variable names.
|
||||||
|
# In that case, the vaulted content iself will only appear once in the file, but the data
|
||||||
|
# parsed by ruamel will include it twice. If we fail to get a match on the first line, then
|
||||||
|
# we check whether the data is a yaml anchor and, if it is, we skip it.
|
||||||
|
if data.anchor.value:
|
||||||
|
logger.debug(
|
||||||
|
f"Content replacement for encrypted content in {path} at {name} was not found, so replacement will be skipped because target is a YAML anchor"
|
||||||
|
)
|
||||||
|
return content
|
||||||
|
raise
|
||||||
|
|
||||||
|
# 4. Now with the leading whitespace padding, we add this same number of spaces to each line
|
||||||
|
# of *both* the old vaulted data and the new vaulted data. It's important to do both because
|
||||||
|
# we'll need to do a replacement in a moment so we need to know both what we're replacing
|
||||||
|
# and what we're replacing it with.
|
||||||
|
padded_old_data = "\n".join(
|
||||||
|
[f"{' ' * padding}{item}" for item in data.value.split("\n") if item]
|
||||||
|
)
|
||||||
|
padded_new_data = "\n".join(
|
||||||
|
[
|
||||||
|
f"{' ' * padding}{item}"
|
||||||
|
for item in new_data.decode("utf-8").split("\n")
|
||||||
|
if item
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. Finally, we actually replace the content. This needs to have a count=1 so that if the same
|
||||||
|
# encrypted block appears twice in the same file we only replace the first occurance of it,
|
||||||
|
# otherwise the later replacement attempts will fail. We also need to re-encode it back to
|
||||||
|
# bytes because all file operations with vault are done in bytes mode
|
||||||
|
content = content_decoded.replace(
|
||||||
|
padded_old_data, padded_new_data, 1
|
||||||
|
).encode()
|
||||||
|
return content
|
||||||
|
|
||||||
|
with path.open("rb") as infile:
|
||||||
|
raw = infile.read()
|
||||||
|
|
||||||
|
# The 'is_encrypted' check doesn't rely on the vault secret in the VaultLib matching the
|
||||||
|
# secret the data was encrypted with, it just checks that the data is encrypted with some
|
||||||
|
# vault secret. We could use either `old` or `new` for this check, it doesn't actually matter.
|
||||||
|
if old.is_encrypted(raw):
|
||||||
|
logger.info(f"Identified vault encrypted file: {path}")
|
||||||
|
|
||||||
|
confirm = (
|
||||||
|
_confirm(f"Rekey vault encrypted file {path}?") if interactive else True
|
||||||
|
)
|
||||||
|
|
||||||
|
if not confirm:
|
||||||
|
logger.debug(
|
||||||
|
f"User skipped vault encrypted file {path} via interactive mode"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if backup:
|
||||||
|
path.rename(f"{path}.bak")
|
||||||
|
|
||||||
|
try:
|
||||||
|
updated = rekey(old, new, raw)
|
||||||
|
except AnsibleVaultError:
|
||||||
|
msg = f"Failed to decrypt vault encrypted file {path} with provided vault secret"
|
||||||
|
if ignore:
|
||||||
|
logger.warning(msg)
|
||||||
|
return
|
||||||
|
raise RuntimeError(msg) from None
|
||||||
|
elif path.suffix.lower() in YAML_FILE_EXTENSIONS:
|
||||||
|
logger.debug(f"Identified YAML file: {path}")
|
||||||
|
|
||||||
|
confirm = (
|
||||||
|
_confirm(f"Search YAML file {path} for vault encrypted variables?")
|
||||||
|
if interactive
|
||||||
|
else True
|
||||||
|
)
|
||||||
|
|
||||||
|
data = yaml.load(raw)
|
||||||
|
|
||||||
|
if not confirm:
|
||||||
|
logger.debug(
|
||||||
|
f"User skipped processing YAML file {path} via interactive mode"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if backup:
|
||||||
|
shutil.copy(path, f"{path}.bak")
|
||||||
|
|
||||||
|
updated = _process_yaml_data(raw, data, ignore=ignore)
|
||||||
|
else:
|
||||||
|
logger.debug(f"Skipping non-vault file {path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.debug(f"Writing updated file contents to {path}")
|
||||||
|
|
||||||
|
with path.open("wb") as outfile:
|
||||||
|
outfile.write(updated)
|
||||||
|
|
||||||
|
|
||||||
def _get_args() -> argparse.Namespace:
|
def _get_args() -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog=__title__,
|
prog=__title__,
|
||||||
@ -131,174 +335,6 @@ def _confirm(prompt: str, default: bool = True) -> bool:
|
|||||||
print("Please input one of the specified options", file=sys.stderr)
|
print("Please input one of the specified options", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
# This whole function needs to be rebuilt from the ground up so I don't
|
|
||||||
# feel bad about disabling this warning
|
|
||||||
def _process_file( # pylint: disable=too-many-statements
|
|
||||||
path: Path,
|
|
||||||
old: ansible.parsing.vault.VaultLib,
|
|
||||||
new: ansible.parsing.vault.VaultLib,
|
|
||||||
interactive: bool,
|
|
||||||
backup: bool,
|
|
||||||
ignore: bool,
|
|
||||||
) -> None:
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
logger.debug(f"Processing file {path}")
|
|
||||||
|
|
||||||
def _process_yaml_data(content: bytes, data: Any, name: str = ""):
|
|
||||||
if isinstance(data, dict):
|
|
||||||
for key, value in data.items():
|
|
||||||
content = _process_yaml_data(content, value, f"{name}.{key}")
|
|
||||||
elif isinstance(data, list):
|
|
||||||
for index, item in enumerate(data):
|
|
||||||
content = _process_yaml_data(content, item, f"{name}.{index}")
|
|
||||||
elif isinstance(data, ruamel.yaml.comments.TaggedScalar) and old.is_encrypted(
|
|
||||||
data.value
|
|
||||||
):
|
|
||||||
logger.debug(f"Identified vaulted content in {path} at '{name}'")
|
|
||||||
confirm = (
|
|
||||||
_confirm(f"Rekey vault encrypted variable {name} in file {path}?")
|
|
||||||
if interactive
|
|
||||||
else True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not confirm:
|
|
||||||
logger.debug(
|
|
||||||
f"User skipped vault encrypted content in {path} at '{name}' via interactive mode"
|
|
||||||
)
|
|
||||||
return content
|
|
||||||
|
|
||||||
new_data = rekey(old, new, data.value.encode())
|
|
||||||
content_decoded = content.decode("utf-8")
|
|
||||||
|
|
||||||
# Ok so this next section is probably the worst possible way to do this, but I did
|
|
||||||
# it this way to solve a very specific problem that would absolutely prevent people
|
|
||||||
# from using this tool: round trip YAML format preservation. Namely, that it's impossible.
|
|
||||||
# Ruamel gets the closest to achieving this: it can do round trip format preservation
|
|
||||||
# when the starting state is in _some_ known state (this is better than competitors which
|
|
||||||
# require the starting state to be in a _specific_ known state). But given how many
|
|
||||||
# ways there are to write YAML- and by extension, how many opinions there are on the
|
|
||||||
# "correct" way to write YAML- it is not possible to configure ruamel to account for all of
|
|
||||||
# them, even if everyones YAML style was compatible with ruamel's roundtrip formatting (note:
|
|
||||||
# they aren't). So there's the problem: to be useful, this tool would need to reformat every
|
|
||||||
# YAML file it touched, which means nobody would use it.
|
|
||||||
#
|
|
||||||
# To avoid the YAML formatting problem, we need a way to replace the target content
|
|
||||||
# in the raw text of the file without dumping the parsed YAML. We want to preserve
|
|
||||||
# indendation, remove any extra newlines that would be left over, add any necessary
|
|
||||||
# newlines without clobbering the following lines, and ideally avoid reimplementing
|
|
||||||
# a YAML formatter. The answer to this problem- as the answer to so many stupid problems
|
|
||||||
# seems to be- is a regex. If this is too janky for you (I know it is for me) go support
|
|
||||||
# the estraven project I'm trying to get off the ground: https://github.com/enpaul/estraven
|
|
||||||
#
|
|
||||||
# Ok, thanks for sticking with me as I was poetic about this. The solution below...
|
|
||||||
# is awful, I can admit that. But it does work, so I'll leave it up to
|
|
||||||
# your judgement as to whether it's worthwhile or not. Here's how it works:
|
|
||||||
#
|
|
||||||
# 1. First we take the first line of the original (unmodified) vaulted content. This line
|
|
||||||
# of text has several important qualities: 1) it exists in the raw text of the file, 2)
|
|
||||||
# it is pseudo-guaranteed to be unique, and 3) it is guaranteed to exist (vaulted content
|
|
||||||
# will be at least one line long, but possibly no more)
|
|
||||||
search_data = data.value.split("\n")[1]
|
|
||||||
try:
|
|
||||||
# 2. Next we use a regex to grab the full line of text from the file that includes the above
|
|
||||||
# string. This is important because the full line of text will include the leading
|
|
||||||
# whitespace, which ruamel helpfully strips out from the parsed data.
|
|
||||||
# 3. Next we grab the number of leading spaces on the line using the capture group from the
|
|
||||||
# regex
|
|
||||||
padding = len(
|
|
||||||
re.search(rf"\n(\s*){search_data}\n", content_decoded).groups()[0]
|
|
||||||
)
|
|
||||||
except (TypeError, AttributeError):
|
|
||||||
# This is to handle an edgecase where
|
|
||||||
if data.anchor.value:
|
|
||||||
logger.debug(
|
|
||||||
f"Content replacement for encrypted content in {path} at {name} was not found, so replacement will be skipped because target is a YAML anchor"
|
|
||||||
)
|
|
||||||
return content
|
|
||||||
raise
|
|
||||||
|
|
||||||
# 4. Now with the leading whitespace padding, we add this same number of spaces to each line
|
|
||||||
# of *both* the old vaulted data and the new vaulted data. It's important to do both because
|
|
||||||
# we'll need to do a replacement in a moment so we need to know both what we're replacing
|
|
||||||
# and what we're replacing it with.
|
|
||||||
padded_old_data = "\n".join(
|
|
||||||
[f"{' ' * padding}{item}" for item in data.value.split("\n") if item]
|
|
||||||
)
|
|
||||||
padded_new_data = "\n".join(
|
|
||||||
[
|
|
||||||
f"{' ' * padding}{item}"
|
|
||||||
for item in new_data.decode("utf-8").split("\n")
|
|
||||||
if item
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
# 5. Finally, we actually replace the content. We also need to re-encode it back to bytes
|
|
||||||
# because all file operations with vault are done in bytes mode
|
|
||||||
content = content_decoded.replace(padded_old_data, padded_new_data).encode()
|
|
||||||
return content
|
|
||||||
|
|
||||||
with path.open("rb") as infile:
|
|
||||||
raw = infile.read()
|
|
||||||
|
|
||||||
# The 'is_encrypted' check doesn't rely on the vault secret in the VaultLib matching the
|
|
||||||
# secret the data was encrypted with, it just checks that the data is encrypted with some
|
|
||||||
# vault secret. We could use either `old` or `new` for this check, it doesn't actually matter.
|
|
||||||
if old.is_encrypted(raw):
|
|
||||||
logger.debug(f"Identified vault encrypted file: {path}")
|
|
||||||
|
|
||||||
confirm = (
|
|
||||||
_confirm(f"Rekey vault encrypted file {path}?") if interactive else True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not confirm:
|
|
||||||
logger.debug(
|
|
||||||
f"User skipped vault encrypted file {path} via interactive mode"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
if backup:
|
|
||||||
path.rename(f"{path}.bak")
|
|
||||||
|
|
||||||
try:
|
|
||||||
updated = rekey(old, new, raw)
|
|
||||||
except ansible.parsing.vault.AnsibleVaultError:
|
|
||||||
msg = f"Failed to decrypt vault encrypted file {path} with provided vault secret"
|
|
||||||
if ignore:
|
|
||||||
logger.warning(msg)
|
|
||||||
return
|
|
||||||
raise RuntimeError(msg) from None
|
|
||||||
elif path.suffix.lower() in YAML_FILE_EXTENSIONS:
|
|
||||||
logger.debug(f"Identified YAML file: {path}")
|
|
||||||
|
|
||||||
confirm = (
|
|
||||||
_confirm(f"Search YAML file {path} for vault encrypted variables?")
|
|
||||||
if interactive
|
|
||||||
else True
|
|
||||||
)
|
|
||||||
|
|
||||||
data = yaml.load(raw)
|
|
||||||
|
|
||||||
if not confirm:
|
|
||||||
logger.debug(
|
|
||||||
f"User skipped processing YAML file {path} via interactive mode"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
if backup:
|
|
||||||
shutil.copy(path, f"{path}.bak")
|
|
||||||
|
|
||||||
updated = _process_yaml_data(raw, data)
|
|
||||||
else:
|
|
||||||
logger.debug(f"Skipping non-vault file {path}")
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.debug(f"Writing updated file contents to {path}")
|
|
||||||
|
|
||||||
with path.open("wb") as outfile:
|
|
||||||
outfile.write(updated)
|
|
||||||
|
|
||||||
|
|
||||||
def _expand_paths(paths: Iterable[Path]) -> List[Path]:
|
def _expand_paths(paths: Iterable[Path]) -> List[Path]:
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -309,61 +345,53 @@ def _expand_paths(paths: Iterable[Path]) -> List[Path]:
|
|||||||
logger.debug(f"Including file {path}")
|
logger.debug(f"Including file {path}")
|
||||||
results.append(path)
|
results.append(path)
|
||||||
elif path.is_dir():
|
elif path.is_dir():
|
||||||
logger.debug(f"Descending into subdirectory {path}")
|
logger.debug(f"Identifying files under {path}")
|
||||||
results += _expand_paths(path.iterdir())
|
results += _expand_paths(path.iterdir())
|
||||||
else:
|
else:
|
||||||
logger.debug(f"Discarding path {path}")
|
logger.debug(f"Discarding path {path}")
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def _read_vault_pass_file(path: Union[Path, str]) -> str:
|
def _load_password(
|
||||||
|
fpath: Optional[str], desc: str = "", confirm: bool = True
|
||||||
|
) -> VaultSecret:
|
||||||
|
"""Load a password from a file or interactively
|
||||||
|
|
||||||
|
:param fpath: Optional path to the file containing the vault password. If not provided then
|
||||||
|
the password will be prompted for interactively.
|
||||||
|
:param desc: Description text to inject into the interactive password prompt. Useful when using
|
||||||
|
this function multiple times to identify different passwords to the user.
|
||||||
|
:param confirm: Whether to prompt twice for the input and check that the two inputs match
|
||||||
|
:returns: Populated vault secret object with the loaded password
|
||||||
|
"""
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
if fpath:
|
||||||
try:
|
try:
|
||||||
with Path(path).resolve().open(encoding="utf-8") as infile:
|
with Path(fpath).resolve().open("rb") as infile:
|
||||||
return infile.read()
|
return VaultSecret(infile.read())
|
||||||
except (FileNotFoundError, PermissionError):
|
except (FileNotFoundError, PermissionError) as err:
|
||||||
logger.error(
|
raise RuntimeError(
|
||||||
f"Specified vault password file '{path}' does not exist or is unreadable"
|
f"Specified vault password file '{fpath}' does not exist or is unreadable"
|
||||||
)
|
) from err
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
logger.debug("No vault password file provided, prompting for interactive input")
|
||||||
|
|
||||||
def _load_passwords(
|
password_1 = getpass.getpass(
|
||||||
old_file: str, new_file: str
|
prompt=f"Enter {desc} Ansible Vault password: ", stream=sys.stderr
|
||||||
) -> Tuple[ansible.parsing.vault.VaultSecret, ansible.parsing.vault.VaultSecret]:
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
if old_file:
|
|
||||||
old_vault_pass = _read_vault_pass_file(old_file)
|
|
||||||
logger.info(f"Loaded old vault password from {Path(old_file).resolve()}")
|
|
||||||
else:
|
|
||||||
logger.debug(
|
|
||||||
"No old vault password file provided, prompting for old vault password input"
|
|
||||||
)
|
|
||||||
old_vault_pass = getpass.getpass(
|
|
||||||
prompt="Old Ansible Vault password: ", stream=sys.stderr
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if new_file:
|
if confirm:
|
||||||
new_vault_pass = _read_vault_pass_file(new_file)
|
password_2 = getpass.getpass(
|
||||||
logger.info(f"Loaded new vault password from {Path(new_file).resolve()}")
|
prompt=f"Confirm (re-enter) {desc} Ansible Vault password: ",
|
||||||
else:
|
stream=sys.stderr,
|
||||||
logger.debug(
|
|
||||||
"No new vault password file provided, prompting for new vault password input"
|
|
||||||
)
|
)
|
||||||
new_vault_pass = getpass.getpass(
|
|
||||||
prompt="New Ansible Vault password: ", stream=sys.stderr
|
|
||||||
)
|
|
||||||
confirm = getpass.getpass(
|
|
||||||
prompt="Confirm new Ansible Vault password: ", stream=sys.stderr
|
|
||||||
)
|
|
||||||
if new_vault_pass != confirm:
|
|
||||||
logger.error("New vault passwords do not match")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
return ansible.parsing.vault.VaultSecret(
|
if password_1 != password_2:
|
||||||
old_vault_pass.encode("utf-8")
|
raise RuntimeError(f"Provided {desc} passwords do not match")
|
||||||
), ansible.parsing.vault.VaultSecret(new_vault_pass.encode("utf-8"))
|
|
||||||
|
return VaultSecret(password_1.encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@ -383,17 +411,26 @@ def main():
|
|||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
if not args.paths:
|
if not args.paths:
|
||||||
logger.warning("No path provided, nothing to do!")
|
logger.warning("No paths provided, nothing to do!")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
old_pass, new_pass = _load_passwords(args.old_pass_file, args.new_pass_file)
|
try:
|
||||||
in_vault = ansible.parsing.vault.VaultLib([(args.vault_id, old_pass)])
|
old_pass = _load_password(args.old_pass_file, desc="existing", confirm=False)
|
||||||
out_vault = ansible.parsing.vault.VaultLib([(args.vault_id, new_pass)])
|
new_pass = _load_password(args.new_pass_file, desc="new", confirm=True)
|
||||||
|
|
||||||
logger.debug(
|
in_vault = VaultLib([(args.vault_id, old_pass)])
|
||||||
|
out_vault = VaultLib([(args.vault_id, new_pass)])
|
||||||
|
except RuntimeError as err:
|
||||||
|
logger.error(str(err))
|
||||||
|
sys.exit(1)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
sys.exit(130)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
f"Identifying all files under {len(args.paths)} input paths: {', '.join(args.paths)}"
|
f"Identifying all files under {len(args.paths)} input paths: {', '.join(args.paths)}"
|
||||||
)
|
)
|
||||||
files = _expand_paths(args.paths)
|
files = _expand_paths(args.paths)
|
||||||
|
logger.info(f"Identified {len(files)} files for processing")
|
||||||
|
|
||||||
for filepath in files:
|
for filepath in files:
|
||||||
_process_file(
|
_process_file(
|
||||||
|
Loading…
x
Reference in New Issue
Block a user