ansible-vars 1.0.14__tar.gz → 1.0.16__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ansible_vars-1.0.14 → ansible_vars-1.0.16}/PKG-INFO +3 -3
- {ansible_vars-1.0.14 → ansible_vars-1.0.16}/README.md +2 -2
- {ansible_vars-1.0.14 → ansible_vars-1.0.16}/pyproject.toml +1 -1
- {ansible_vars-1.0.14 → ansible_vars-1.0.16}/src/ansible_vars/cli.py +6 -4
- ansible_vars-1.0.16/src/ansible_vars/py.typed +0 -0
- {ansible_vars-1.0.14 → ansible_vars-1.0.16}/src/ansible_vars/vault.py +6 -5
- {ansible_vars-1.0.14 → ansible_vars-1.0.16}/src/ansible_vars/vault_crypt.py +11 -9
- ansible_vars-1.0.16/test/test_cli.py +1 -0
- ansible_vars-1.0.16/test/test_lib.py +373 -0
- {ansible_vars-1.0.14 → ansible_vars-1.0.16}/.gitignore +0 -0
- {ansible_vars-1.0.14 → ansible_vars-1.0.16}/LICENSE +0 -0
- {ansible_vars-1.0.14 → ansible_vars-1.0.16}/src/ansible_vars/__init__.py +0 -0
- {ansible_vars-1.0.14 → ansible_vars-1.0.16}/src/ansible_vars/constants.py +0 -0
- {ansible_vars-1.0.14 → ansible_vars-1.0.16}/src/ansible_vars/errors.py +0 -0
- {ansible_vars-1.0.14 → ansible_vars-1.0.16}/src/ansible_vars/util.py +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: ansible-vars
|
3
|
-
Version: 1.0.
|
3
|
+
Version: 1.0.16
|
4
4
|
Summary: Manage vaults and variable files for Ansible
|
5
5
|
Project-URL: Homepage, https://github.com/xorwow/ansible-vars
|
6
6
|
Project-URL: Issues, https://github.com/xorwow/ansible-vars/issues
|
@@ -219,7 +219,7 @@ Starts a daemon which mirrors the decrypted contents of one or multiple vault or
|
|
219
219
|
|
220
220
|
#### get
|
221
221
|
|
222
|
-
Displays the (
|
222
|
+
Displays the (by default recursively decrypted) value of a specified key in a vault or variable file. Supports dictionary and list traversal, and JSON output.
|
223
223
|
|
224
224
|
#### set, del (experimental)
|
225
225
|
|
@@ -269,7 +269,7 @@ Contains the classes `Vault` and `VaultFile`. A `Vault` is initialized using the
|
|
269
269
|
|
270
270
|
The `VaultKey` class represents a single vault secret, comprised of an identifier and an `ansible.parsing.vault.VaultSecret`. Can be initialized using a plain passphrase instead of a `VaultSecret` as well.
|
271
271
|
|
272
|
-
The `VaultKeyring` combines a collection of `VaultKey`s. It supports auto-detection of any secrets available in the present working directory using the `ansible.cli` module, appending them to the `<keyring>.keys` collection. While all keys are tried in order for decryption operations, only one key can be used for encrypting data. This key is usually the first key in the `<keyring>.keys` collection, unless explicitly specified otherwise using `<keyring>.default_encryption_key` or passing a key to the `<keyring>.encrypt()` method.
|
272
|
+
The `VaultKeyring` combines a collection of `VaultKey`s. It supports auto-detection of any secrets available in the present working directory (or a custom source) using the `ansible.cli` module, appending them to the `<keyring>.keys` collection. While all keys are tried in order for decryption operations, only one key can be used for encrypting data. This key is usually the first key in the `<keyring>.keys` collection, unless explicitly specified otherwise using `<keyring>.default_encryption_key` or passing a key to the `<keyring>.encrypt()` method.
|
273
273
|
|
274
274
|
#### util module
|
275
275
|
|
@@ -197,7 +197,7 @@ Starts a daemon which mirrors the decrypted contents of one or multiple vault or
|
|
197
197
|
|
198
198
|
#### get
|
199
199
|
|
200
|
-
Displays the (
|
200
|
+
Displays the (by default recursively decrypted) value of a specified key in a vault or variable file. Supports dictionary and list traversal, and JSON output.
|
201
201
|
|
202
202
|
#### set, del (experimental)
|
203
203
|
|
@@ -247,7 +247,7 @@ Contains the classes `Vault` and `VaultFile`. A `Vault` is initialized using the
|
|
247
247
|
|
248
248
|
The `VaultKey` class represents a single vault secret, comprised of an identifier and an `ansible.parsing.vault.VaultSecret`. Can be initialized using a plain passphrase instead of a `VaultSecret` as well.
|
249
249
|
|
250
|
-
The `VaultKeyring` combines a collection of `VaultKey`s. It supports auto-detection of any secrets available in the present working directory using the `ansible.cli` module, appending them to the `<keyring>.keys` collection. While all keys are tried in order for decryption operations, only one key can be used for encrypting data. This key is usually the first key in the `<keyring>.keys` collection, unless explicitly specified otherwise using `<keyring>.default_encryption_key` or passing a key to the `<keyring>.encrypt()` method.
|
250
|
+
The `VaultKeyring` combines a collection of `VaultKey`s. It supports auto-detection of any secrets available in the present working directory (or a custom source) using the `ansible.cli` module, appending them to the `<keyring>.keys` collection. While all keys are tried in order for decryption operations, only one key can be used for encrypting data. This key is usually the first key in the `<keyring>.keys` collection, unless explicitly specified otherwise using `<keyring>.default_encryption_key` or passing a key to the `<keyring>.encrypt()` method.
|
251
251
|
|
252
252
|
#### util module
|
253
253
|
|
@@ -178,8 +178,7 @@ The sync works as long as the command is running, after which the target root di
|
|
178
178
|
''',
|
179
179
|
'cmd_get': '''
|
180
180
|
Looks up the value of a key in a vault and displays it if it exists.
|
181
|
-
|
182
|
-
For a list or dictionary, the full YAML code is printed, but child values are not automatically decrypted.
|
181
|
+
The value will be shown in (recursively) decrypted form.
|
183
182
|
|
184
183
|
JSON mode formatting:
|
185
184
|
- [ ... ] or { ... } for lists/dictionaries, "<value>" for strings, <value> for numbers
|
@@ -506,9 +505,12 @@ def print_yaml(code: str) -> None:
|
|
506
505
|
return std_print(code)
|
507
506
|
std_print(highlight(code, yaml_highlight_lexer, highlight_formatter).strip('\n'))
|
508
507
|
|
509
|
-
def print_diff(diff: str) -> None:
|
508
|
+
def print_diff(diff: str | None) -> None:
|
510
509
|
'''Print a diff with highlighting if a `color_mode` is available.'''
|
511
510
|
_color_map: dict = { '-': Color.TREE_REMOVED, '+': Color.TREE_ADDED, '@': Color.INFO, '*': Color.TREE_UNCHANGED }
|
511
|
+
if not diff:
|
512
|
+
print('Vaults are identical.', Color.TREE_UNCHANGED)
|
513
|
+
return
|
512
514
|
for line in diff.split('\n'):
|
513
515
|
color: Color = _color_map[line[0]] if (len(line) > 0 and line[0] in [ '-', '+', '@' ]) else _color_map['*']
|
514
516
|
print(line, color)
|
@@ -708,7 +710,7 @@ if config.command in [ 'create', 'edit' ]:
|
|
708
710
|
new_editable: str = edit_file.read()
|
709
711
|
if editable != new_editable:
|
710
712
|
try:
|
711
|
-
new_vault
|
713
|
+
new_vault = VaultFile.from_editable(vault, new_editable)
|
712
714
|
except YAMLFormatError as e:
|
713
715
|
print('Invalid YAML format:', Color.BAD)
|
714
716
|
print(e.parent if e.parent else e, Color.BAD)
|
File without changes
|
@@ -30,7 +30,7 @@ class EncryptedVar():
|
|
30
30
|
|
31
31
|
def __init__(self, cipher: str, name: str | None = None) -> None:
|
32
32
|
'''Initialize an encrypted variable with an optional variable name. The name is only used for internal representation.'''
|
33
|
-
# Encrypted has to hold a string like '$ANSIBLE_VAULT;1.2;AES256;
|
33
|
+
# Encrypted has to hold a string like '$ANSIBLE_VAULT;1.2;AES256;someid\n123456<...>' (the newline is important)
|
34
34
|
self.cipher: str = cipher
|
35
35
|
self.name: str | None = name
|
36
36
|
|
@@ -107,7 +107,7 @@ class Vault():
|
|
107
107
|
Parses a vault's (potentially encrypted) contents. Automatically detects if the content is wholly encrypted.
|
108
108
|
If no keyring is supplied, only plain vars and content are supported.
|
109
109
|
'''
|
110
|
-
# If no keyring is supplied, create an empty one which will raise an
|
110
|
+
# If no keyring is supplied, create an empty one which will raise an error if we try to en-/decrypt anything
|
111
111
|
self.keyring: VaultKeyring = keyring or VaultKeyring(keys=None, detect_available_keys=False)
|
112
112
|
# Full vault encryption, may also contain single encrypted variables either way
|
113
113
|
self.full_encryption: bool
|
@@ -208,7 +208,7 @@ class Vault():
|
|
208
208
|
def has(self, path: DictPath) -> bool:
|
209
209
|
'''Checks if the given key path is present in the vault's data.'''
|
210
210
|
try: self._traverse(path, decrypt=False)
|
211
|
-
except KeyError: return False
|
211
|
+
except ( KeyError, IndexError ): return False
|
212
212
|
return True
|
213
213
|
|
214
214
|
def get(
|
@@ -591,12 +591,13 @@ class Vault():
|
|
591
591
|
|
592
592
|
# Comparing to older versions of this vault
|
593
593
|
|
594
|
-
def diff(self, prev_vault: 'Vault', context_lines: int = 3, show_filenames: bool = True) -> str:
|
594
|
+
def diff(self, prev_vault: 'Vault', context_lines: int = 3, show_filenames: bool = True) -> str | None:
|
595
595
|
'''
|
596
596
|
Generates a diff for the edit mode Jinja2 YAML vault code (from `Vault.as_editable`) of a previous vault to this one's.
|
597
597
|
Set `context_lines` to specify how many lines of context are shown before and after the actual diff lines.
|
598
598
|
If `show_filenames` is set to True and the vaults are `VaultFile` objects,
|
599
599
|
the previous and current filenames will be shown in the diff header.
|
600
|
+
If there is no difference between the vaults, None is returned.
|
600
601
|
'''
|
601
602
|
# Generate filenames
|
602
603
|
prev_filename: str = 'Previous vault'
|
@@ -615,7 +616,7 @@ class Vault():
|
|
615
616
|
n = context_lines,
|
616
617
|
lineterm = ''
|
617
618
|
)
|
618
|
-
)
|
619
|
+
) or None # diff function returns empty list if there are no changes, even skipping the header
|
619
620
|
|
620
621
|
def changes(self, prev_vault: 'Vault') -> tuple[ChangeList, ChangeList, ChangeList, ChangeList]:
|
621
622
|
'''
|
@@ -3,6 +3,7 @@
|
|
3
3
|
# Standard library imports
|
4
4
|
import os, re
|
5
5
|
from typing import Type, cast
|
6
|
+
from contextlib import chdir
|
6
7
|
|
7
8
|
# External library imports
|
8
9
|
import ansible.constants as Ansible
|
@@ -202,16 +203,17 @@ class VaultKeyring():
|
|
202
203
|
# Load secrets for discovered vault IDs
|
203
204
|
if not vault_ids:
|
204
205
|
return []
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
#
|
209
|
-
|
210
|
-
|
211
|
-
|
206
|
+
# XXX Hacky, but the right-hand path from loading a vault ID like 'vaultid@get-password.sh' will be resolved from CWD
|
207
|
+
# Could possibly be avoided by splitting the detected vault IDs and transforming any right-hand paths
|
208
|
+
with chdir(pardir):
|
209
|
+
# Seems version-dependent if `initialize_context` is recognized and required
|
210
|
+
try:
|
211
|
+
secrets: list[tuple[str | None, VaultSecret]] = \
|
212
|
+
CLI.setup_vault_secrets(DataLoader(), vault_ids, auto_prompt=False, initialize_context=False) # type: ignore
|
213
|
+
except TypeError:
|
214
|
+
secrets: list[tuple[str | None, VaultSecret]] = \
|
215
|
+
CLI.setup_vault_secrets(DataLoader(), vault_ids, auto_prompt=False)
|
212
216
|
return list(map(VaultKey.from_ansible_secret, secrets))
|
213
|
-
finally:
|
214
|
-
os.chdir(prev_dir)
|
215
217
|
|
216
218
|
def __repr__(self) -> str:
|
217
219
|
return f"VaultKeyring({ ', '.join(map(lambda key: key.id, self.keys)) or 'no keys' })"
|
@@ -0,0 +1 @@
|
|
1
|
+
# TODO May be added in the future
|
@@ -0,0 +1,373 @@
|
|
1
|
+
# Run from project dir via `PYTHONPATH=src pytest`
|
2
|
+
|
3
|
+
from io import StringIO
|
4
|
+
from os import stat, unlink as rm_file
|
5
|
+
from json import loads as load_json
|
6
|
+
from typing import TypeAlias, Any
|
7
|
+
from pathlib import Path
|
8
|
+
|
9
|
+
from ruamel.yaml import YAML
|
10
|
+
|
11
|
+
from ansible_vars.vault import VaultFile, Vault, EncryptedVar
|
12
|
+
from ansible_vars.vault_crypt import VaultKeyring, VaultKey
|
13
|
+
|
14
|
+
JSONObject: TypeAlias = Any
|
15
|
+
|
16
|
+
class TestVaultCrypt:
|
17
|
+
|
18
|
+
PLAINTEXT: str = 'plaintext' # also used as passphrase
|
19
|
+
FIXED_SALT: str = 'a' * 32
|
20
|
+
CIPHERTEXT: str = '$ANSIBLE_VAULT;1.2;AES256;testid\n36313631363136313631363136313631363136313631363136313631363136313631363136313631\n3631363136313631363136313631363136313631363136310a313237663761353633656137653065\n32656664313566373062383839343461353163363831313666343763313738616336393261303361\n3239646238323033300a303163663861326632626335373030666436303266653739306133396239\n3439'
|
21
|
+
VAULT_ID: str = 'testid'
|
22
|
+
|
23
|
+
def test_create_key_from_str(self) -> None:
|
24
|
+
key: VaultKey = VaultKey(self.PLAINTEXT, vault_id=self.VAULT_ID)
|
25
|
+
assert key.passphrase == self.PLAINTEXT, 'decoded passphrase doesn\'t match loaded passphrase'
|
26
|
+
|
27
|
+
def test_create_simple_keyring(self) -> None:
|
28
|
+
key: VaultKey = VaultKey(self.PLAINTEXT, vault_id=self.VAULT_ID)
|
29
|
+
keyring: VaultKeyring = VaultKeyring(keys=[ key ], detect_available_keys=False)
|
30
|
+
assert keyring.key_by_id(self.VAULT_ID) == key, 'keyring doesn\'t contain added key'
|
31
|
+
assert keyring.encryption_key == key, 'keyring assigned wrong encryption key'
|
32
|
+
|
33
|
+
def test_detection_by_config_path_using_passfile(self, tmp_path: Path) -> None:
|
34
|
+
# Create a file containing the passphrase
|
35
|
+
pass_file_relpath: str = 'vaultpass'
|
36
|
+
pass_file_path: Path = tmp_path.joinpath(pass_file_relpath)
|
37
|
+
with open(pass_file_path, 'w') as f:
|
38
|
+
f.write(self.PLAINTEXT)
|
39
|
+
# Create an Ansible config pointing to the passphrase file (by its relative path, to test directory resolution)
|
40
|
+
config_path: Path = tmp_path.joinpath('ansible.cfg')
|
41
|
+
with open(config_path, 'w') as f:
|
42
|
+
f.write(f"[defaults]\nvault_identity_list={ self.VAULT_ID }@{ pass_file_relpath }")
|
43
|
+
# Try to load the secret via the config
|
44
|
+
keyring: VaultKeyring = VaultKeyring(detect_available_keys=True, detection_source=str(config_path))
|
45
|
+
assert len(keyring.keys) == 1, 'keyring did not detect any keys or too many'
|
46
|
+
assert keyring.keys[0].id == self.VAULT_ID, 'keyring extracted wrong vault ID'
|
47
|
+
assert keyring.keys[0].passphrase == self.PLAINTEXT, 'keyring loaded wrong passphrase'
|
48
|
+
|
49
|
+
def test_detection_by_vault_id_using_passfile(self, tmp_path: Path) -> None:
|
50
|
+
# Create a file containing the passphrase
|
51
|
+
pass_file_path: Path = tmp_path.joinpath('vaultpass')
|
52
|
+
with open(pass_file_path, 'w') as f:
|
53
|
+
f.write(self.PLAINTEXT)
|
54
|
+
# Try to load the secret via the config by directly supplying vault IDs
|
55
|
+
vault_ids: list[str] = [ f"{ self.VAULT_ID }@{ pass_file_path }" ] # can't resolve relative paths without base dir info
|
56
|
+
keyring: VaultKeyring = VaultKeyring(detect_available_keys=True, detection_source=vault_ids)
|
57
|
+
assert len(keyring.keys) == 1, 'keyring did not detect any keys or too many'
|
58
|
+
assert keyring.keys[0].id == self.VAULT_ID, 'keyring extracted wrong vault ID'
|
59
|
+
assert keyring.keys[0].passphrase == self.PLAINTEXT, 'keyring loaded wrong passphrase'
|
60
|
+
|
61
|
+
def test_encryption_check(self) -> None:
|
62
|
+
assert VaultKey.is_encrypted(self.CIPHERTEXT), 'expected ciphertext to be detected as encrypted'
|
63
|
+
assert not VaultKey.is_encrypted(self.PLAINTEXT), 'expected plaintext to be detected as plain'
|
64
|
+
|
65
|
+
def test_encrypt(self) -> None:
|
66
|
+
key: VaultKey = VaultKey(self.PLAINTEXT, vault_id=self.VAULT_ID)
|
67
|
+
cipher: str = key.encrypt(self.PLAINTEXT, salt=self.FIXED_SALT)
|
68
|
+
assert cipher == self.CIPHERTEXT, 'encrypted plaintext does not match expected cipher'
|
69
|
+
|
70
|
+
def test_decrypt(self) -> None:
|
71
|
+
key: VaultKey = VaultKey(self.PLAINTEXT, vault_id=self.VAULT_ID)
|
72
|
+
plaintext: str = key.decrypt(self.CIPHERTEXT)
|
73
|
+
assert plaintext == self.PLAINTEXT, 'decrypted ciphertext does not match expected plaintext'
|
74
|
+
|
75
|
+
def test_find_decryption_key(self) -> None:
|
76
|
+
keys: list[VaultKey] = [
|
77
|
+
VaultKey('INCORRECT_KEY_0', vault_id='wrong0'),
|
78
|
+
VaultKey('INCORRECT_KEY_1', vault_id='wrong1'),
|
79
|
+
VaultKey(self.PLAINTEXT, vault_id=self.VAULT_ID)
|
80
|
+
]
|
81
|
+
keyring: VaultKeyring = VaultKeyring(keys=keys, detect_available_keys=False)
|
82
|
+
assert keyring.decrypt(self.CIPHERTEXT) == self.PLAINTEXT, 'keyring did not find correct decryption key'
|
83
|
+
|
84
|
+
class TestVault:
|
85
|
+
|
86
|
+
KEYRING: VaultKeyring = VaultKeyring(
|
87
|
+
[ VaultKey('passphrase', vault_id='testid') ],
|
88
|
+
default_salt=('a' * 32), detect_available_keys=False
|
89
|
+
)
|
90
|
+
|
91
|
+
def test_file_load_save(self, tmp_path: Path) -> None:
|
92
|
+
sentinel: str = 'XXX_TEST_STRING_XXX'
|
93
|
+
vault_path: Path = tmp_path.joinpath('vault.yml')
|
94
|
+
with open(vault_path, 'w+') as f:
|
95
|
+
# Write test value to file
|
96
|
+
f.write(f"a: '{ sentinel }'")
|
97
|
+
f.flush()
|
98
|
+
f.seek(0)
|
99
|
+
assert sentinel in f.read(), 'expected value in file (manual write)'
|
100
|
+
# Load file to vault
|
101
|
+
vault: VaultFile = VaultFile(str(vault_path), self.KEYRING)
|
102
|
+
with open(vault_path, 'w+') as f:
|
103
|
+
# Empty file
|
104
|
+
f.write('')
|
105
|
+
f.flush()
|
106
|
+
f.seek(0)
|
107
|
+
assert sentinel not in f.read(), 'expected file to be empty'
|
108
|
+
# Save vault to file
|
109
|
+
vault.save()
|
110
|
+
with open(vault_path) as f:
|
111
|
+
assert sentinel in f.read(), 'expected value in file (vault write)'
|
112
|
+
|
113
|
+
def test_file_create(self, tmp_path: Path) -> None:
|
114
|
+
plain_content: str = '#test\n'
|
115
|
+
encrypted_content: str = self.KEYRING.encrypt(plain_content)
|
116
|
+
vault_path: Path = tmp_path.joinpath('vault.yml')
|
117
|
+
# Test vault with full encryption from plain content
|
118
|
+
vault: VaultFile = VaultFile.create(str(vault_path), content=plain_content, full_encryption=True, keyring=self.KEYRING)
|
119
|
+
exported: str = vault.as_encrypted().replace(' ', '')
|
120
|
+
assert vault.full_encryption, 'expected full encryption to be active'
|
121
|
+
assert plain_content not in exported, 'found plain content in a fully encrypted export'
|
122
|
+
assert encrypted_content in exported, 'could not find encrypted content in export'
|
123
|
+
# Test vault with no encryption from encrypted content
|
124
|
+
rm_file(vault_path)
|
125
|
+
vault = VaultFile.create(str(vault_path), content=encrypted_content, full_encryption=False, keyring=self.KEYRING)
|
126
|
+
exported = vault.as_encrypted().replace(' ', '')
|
127
|
+
assert not vault.full_encryption, 'expected full encryption to be disabled'
|
128
|
+
assert plain_content in exported, 'could not find plain content in export'
|
129
|
+
assert encrypted_content not in exported, 'found encrypted content in a plain export'
|
130
|
+
# Test vault with custom permissions
|
131
|
+
rm_file(vault_path)
|
132
|
+
vault = VaultFile.create(str(vault_path), full_encryption=False, permissions=0o640)
|
133
|
+
assert str(oct(stat(vault_path).st_mode))[-3:] == '640', 'expected 640 file permissions'
|
134
|
+
|
135
|
+
def test_file_copy(self, tmp_path: Path) -> None: # also tests Vault.copy, which is a subset of the VaultFile.copy
|
136
|
+
vault_path: Path = tmp_path.joinpath('vault.yml')
|
137
|
+
vault_orig: VaultFile = VaultFile.create(str(vault_path), full_encryption=False, keyring=self.KEYRING)
|
138
|
+
vault_orig.set('a', [ 1, 2, 3 ])
|
139
|
+
vault_copy: VaultFile = vault_orig.copy()
|
140
|
+
assert vault_copy.vault_path == vault_orig.vault_path, 'path should be identical'
|
141
|
+
assert vault_copy.keyring is vault_orig.keyring, 'keyrings should be the same reference'
|
142
|
+
assert vault_copy.full_encryption == vault_orig.full_encryption, 'encryption setting should be identical'
|
143
|
+
assert vault_copy._parser is vault_orig._parser, 'parsers should be the same reference'
|
144
|
+
assert vault_copy._data is not vault_orig._data, 'data should not be the same reference'
|
145
|
+
assert vault_copy._data['a'] is not vault_orig._data['a'], 'lists should not be the same reference'
|
146
|
+
assert vault_copy._data['a'] == vault_orig._data['a'], 'lists should have the same content'
|
147
|
+
|
148
|
+
def test_load_comments_only(self) -> None:
|
149
|
+
comment: str = '# this is a comment'
|
150
|
+
vault: Vault = Vault(comment, keyring=self.KEYRING)
|
151
|
+
assert not vault.full_encryption, 'expected vault to be plain'
|
152
|
+
assert not vault.decrypted_vars, 'expected vault to have no data'
|
153
|
+
assert comment in vault.as_plain(), 'comment did not survive loading'
|
154
|
+
|
155
|
+
def test_load_fully_encrypted(self) -> None:
|
156
|
+
vault: Vault = Vault(self.KEYRING.encrypt('test_var: test'), keyring=self.KEYRING)
|
157
|
+
decrypted_vars: dict[str, Any] = vault.decrypted_vars
|
158
|
+
assert vault.full_encryption, 'vault did not detect full encryption'
|
159
|
+
assert 'test_var' in decrypted_vars, 'vault did not load test var'
|
160
|
+
assert decrypted_vars['test_var'] == 'test', 'vault did not load correct value for test var'
|
161
|
+
|
162
|
+
def test_decrypted_vars(self) -> None:
|
163
|
+
example_vars: dict = {
|
164
|
+
'str_test': self.KEYRING.encrypt('test'),
|
165
|
+
'deep_test': [ self.KEYRING.encrypt('a'), self.KEYRING.encrypt('b') ]
|
166
|
+
}
|
167
|
+
vault: Vault = self._create_simple_vault(example_vars)
|
168
|
+
decrypted_vars: dict[str, Any] = vault.decrypted_vars
|
169
|
+
assert len(decrypted_vars) == len(example_vars), 'vault did not load correct amount of vars'
|
170
|
+
assert decrypted_vars['str_test'] == 'test', 'string was not decrypted correctly'
|
171
|
+
assert decrypted_vars['deep_test'] == [ 'a', 'b' ], 'child strings were not decrypted correctly'
|
172
|
+
|
173
|
+
def test_has_path(self) -> None:
|
174
|
+
vault: Vault = self._create_simple_vault({ 'a': [ 'x' ] })
|
175
|
+
assert vault.has(( 'a', 0 )), 'expected path to exist'
|
176
|
+
assert not vault.has(( 'a', 1 )), 'expected path not to exist'
|
177
|
+
|
178
|
+
def test_get_raw(self) -> None:
|
179
|
+
ciphertext: str = self.KEYRING.encrypt('x')
|
180
|
+
vault: Vault = self._create_simple_vault({ 'a': [ ciphertext ] })
|
181
|
+
got: Any = vault.get(( 'a', 0 ), default=None, decrypt=False)
|
182
|
+
assert isinstance(got, EncryptedVar) and got.cipher == ciphertext, 'expected encrypted variable'
|
183
|
+
|
184
|
+
def test_get_decrypted_simple(self) -> None:
|
185
|
+
plaintext: str = 'x'
|
186
|
+
vault: Vault = self._create_simple_vault({ 'a': [ self.KEYRING.encrypt(plaintext) ] })
|
187
|
+
got: Any = vault.get(( 'a', 0 ), default=None, decrypt=True)
|
188
|
+
assert got, 'expected path to exist'
|
189
|
+
assert got == plaintext, 'expected decrypted variable'
|
190
|
+
|
191
|
+
def test_get_decrypted_deep(self) -> None:
|
192
|
+
plaintext: str = 'x'
|
193
|
+
vault: Vault = self._create_simple_vault({ 'a': { 'b': [ self.KEYRING.encrypt(plaintext) ] } })
|
194
|
+
got: Any = vault.get(( 'a', 'b' ), default=None, decrypt=True)
|
195
|
+
assert got, 'expected path to exist'
|
196
|
+
assert isinstance(got, list) and len(got) == 1, 'expected to find list'
|
197
|
+
assert got[0] == plaintext, 'expected decrypted variable in list'
|
198
|
+
|
199
|
+
def test_get_with_index(self) -> None:
|
200
|
+
value: str = 'x'
|
201
|
+
vault: Vault = self._create_simple_vault({ 'a': value })
|
202
|
+
got: tuple[int, Any] = vault.get('a', with_index=True)
|
203
|
+
assert got[0] == 0, 'expected correct index'
|
204
|
+
assert got[1] == value, 'expected correct value'
|
205
|
+
got = vault.get('b', default=None, with_index=True)
|
206
|
+
assert got[0] == -1, 'expected default index'
|
207
|
+
assert got[1] is None, 'expected default value'
|
208
|
+
|
209
|
+
def test_set_value_without_keyring(self) -> None:
|
210
|
+
vault: Vault = Vault.create('', full_encryption=False, keyring=None)
|
211
|
+
value: str = 'x'
|
212
|
+
vault.set('a', value)
|
213
|
+
assert vault._data['a'] == value, 'expected vault data to contain plain value'
|
214
|
+
|
215
|
+
def test_set_list_entry(self) -> None:
|
216
|
+
vault: Vault = self._create_simple_vault()
|
217
|
+
vault.set('a', [ 1 ])
|
218
|
+
got: Any = vault.get('a')
|
219
|
+
assert isinstance(got, list) and len(got) == 1, 'expected vault data to contain list'
|
220
|
+
# Add item to list
|
221
|
+
vault.set(( 'a', 1 ), 2)
|
222
|
+
got = vault.get('a')
|
223
|
+
assert len(got) == 2 and got[1] == 2, 'expected new value in list'
|
224
|
+
|
225
|
+
def test_set_create_parents(self) -> None:
|
226
|
+
vault: Vault = self._create_simple_vault()
|
227
|
+
value: str = 'x'
|
228
|
+
could_set: bool = vault.set(( 'a', 'b' ), value, create_parents=False)
|
229
|
+
assert not could_set, 'should not be able to set value with missing parents'
|
230
|
+
assert not vault.decrypted_vars, 'vault should be empty'
|
231
|
+
could_set = vault.set(( 'a', 'b' ), value, create_parents=True)
|
232
|
+
assert could_set, 'should be able to set nested value'
|
233
|
+
assert 'a' in vault._data and 'b' in vault._data['a'], 'expected new path to exist'
|
234
|
+
assert vault._data['a']['b'] == value, 'expected nested value to be set'
|
235
|
+
|
236
|
+
def test_set_overwrite(self) -> None:
|
237
|
+
old_value: str = 'x'
|
238
|
+
new_value: str = 'y'
|
239
|
+
vault: Vault = self._create_simple_vault({ 'a': old_value })
|
240
|
+
could_set: bool = vault.set('a', new_value, overwrite=False)
|
241
|
+
assert not could_set and vault.get('a') == old_value, 'should not be able to overwrite value'
|
242
|
+
could_set = vault.set('a', new_value, overwrite=True)
|
243
|
+
assert could_set and vault.get('a') == new_value, 'expected overwritten value'
|
244
|
+
|
245
|
+
def test_set_encrypted_simple(self) -> None:
|
246
|
+
plaintext: str = 'x'
|
247
|
+
ciphertext: str = self.KEYRING.encrypt(plaintext)
|
248
|
+
vault: Vault = self._create_simple_vault()
|
249
|
+
vault.set('a', plaintext, encrypt=True)
|
250
|
+
got: Any = vault.get('a')
|
251
|
+
assert isinstance(got, EncryptedVar) and got.cipher == ciphertext, 'expected value to be encrypted'
|
252
|
+
|
253
|
+
def test_set_encrypted_deep(self) -> None:
|
254
|
+
plaintext: str = 'x'
|
255
|
+
ciphertext: str = self.KEYRING.encrypt(plaintext)
|
256
|
+
vault: Vault = self._create_simple_vault()
|
257
|
+
vault.set('a', { 'b': [ plaintext ] }, encrypt=True)
|
258
|
+
got: Any = vault.get(( 'a', 'b' ))
|
259
|
+
assert isinstance(got, list) and len(got) == 1, 'expected list to be added to data'
|
260
|
+
assert isinstance(got[0], EncryptedVar) and got[0].cipher == ciphertext, 'expected value to be recursively encrypted'
|
261
|
+
|
262
|
+
def test_pop(self) -> None:
|
263
|
+
value: str = 'x'
|
264
|
+
vault: Vault = self._create_simple_vault({ 'a': value })
|
265
|
+
assert 'a' in vault._data and vault._data['a'] == value, 'expected value to be in data'
|
266
|
+
assert vault.pop('a', default=None) == value, 'pop should return value'
|
267
|
+
assert 'a' not in vault._data, 'pop should have removed path'
|
268
|
+
assert vault.pop('a', default=None) is None, 'pop should not work twice'
|
269
|
+
|
270
|
+
def test_export_plain(self) -> None:
|
271
|
+
plaintext: str = 'XXX_TEST_STRING_XXX'
|
272
|
+
ciphertext: str = self.KEYRING.encrypt(plaintext)
|
273
|
+
vault: Vault = self._create_simple_vault({ 'a': ciphertext })
|
274
|
+
exported: str = vault.as_plain().replace(' ', '')
|
275
|
+
assert ciphertext not in exported, 'export should be decrypted'
|
276
|
+
assert plaintext in exported, 'export should contain plain value'
|
277
|
+
|
278
|
+
def test_export_encrypted(self) -> None:
|
279
|
+
plaintext: str = 'XXX_TEST_STRING_XXX'
|
280
|
+
ciphertext: str = self.KEYRING.encrypt(plaintext)
|
281
|
+
vault: Vault = self._create_simple_vault({ 'a': ciphertext })
|
282
|
+
exported: str = vault.as_encrypted().replace(' ', '')
|
283
|
+
assert plaintext not in exported, 'export should be encrypted'
|
284
|
+
assert ciphertext in exported, 'export should contain encrypted value'
|
285
|
+
|
286
|
+
def test_export_full_encryption(self) -> None:
|
287
|
+
plaintext: str = 'XXX_TEST_STRING_XXX'
|
288
|
+
ciphertext: str = self.KEYRING.encrypt(plaintext)
|
289
|
+
vault: Vault = self._create_simple_vault({ 'a': plaintext })
|
290
|
+
vault.full_encryption = True
|
291
|
+
exported: str = vault.as_encrypted().replace(' ', '')
|
292
|
+
assert plaintext not in exported and ciphertext not in exported, 'export should not contain value directly'
|
293
|
+
assert VaultKey.is_encrypted(exported), 'export should be fully encrypted'
|
294
|
+
|
295
|
+
def test_export_json(self) -> None:
|
296
|
+
plaintext: str = 'XXX_TEST_STRING_XXX'
|
297
|
+
ciphertext: str = self.KEYRING.encrypt(plaintext)
|
298
|
+
vault: Vault = self._create_simple_vault({ 'a': ciphertext })
|
299
|
+
exported: str = vault.as_json()
|
300
|
+
data: JSONObject = load_json(exported)
|
301
|
+
assert isinstance(data, dict), 'export should always be a dict'
|
302
|
+
assert 'a' in data and data['a'] == plaintext, 'export should contain plain value'
|
303
|
+
|
304
|
+
def test_export_import_editable(self) -> None:
|
305
|
+
key: str = 'MY_VAR'
|
306
|
+
value: str = 'MY_VALUE'
|
307
|
+
vault: Vault = self._create_simple_vault({ key: value })
|
308
|
+
editable: str = vault.as_editable(with_header=False)
|
309
|
+
assert key in editable and value in editable, 'expected example var in editable'
|
310
|
+
editable += '\nx: 1\ny: !enc 2'
|
311
|
+
vault = Vault.from_editable(vault, editable)
|
312
|
+
assert key in vault._data and vault._data[key] == value, 'expected original value to survive'
|
313
|
+
assert 'x' in vault._data and vault._data['x'] == 1, 'expected plain value to be loaded'
|
314
|
+
got: Any = vault.get('y', default=None, decrypt=False)
|
315
|
+
assert isinstance(got, EncryptedVar) and got.cipher == self.KEYRING.encrypt('2'), 'expected !enc value to be encrypted'
|
316
|
+
|
317
|
+
def test_diff(self) -> None:
|
318
|
+
old_vault: Vault = self._create_simple_vault({ 'a': 'x', 'b': 'y' })
|
319
|
+
new_vault: Vault = self._create_simple_vault({ 'b': 'y', 'c': 'z' })
|
320
|
+
diff: str | None = new_vault.diff(old_vault)
|
321
|
+
assert diff, 'expected a diff for two differing vaults'
|
322
|
+
diff_lines: list[str] = list(filter(
|
323
|
+
lambda line: not (line.startswith('---') or line.startswith('+++')),
|
324
|
+
diff.split('\n')
|
325
|
+
))
|
326
|
+
assert sum( int(line.startswith('-')) for line in diff_lines ) == 1, 'expected one removal'
|
327
|
+
assert any( line.startswith('-') and 'a' in line for line in diff_lines ), 'expected key "a" to be removed'
|
328
|
+
assert sum( int(line.startswith('+')) for line in diff_lines ) == 1, 'expected one addition'
|
329
|
+
assert any( line.startswith('+') and 'c' in line for line in diff_lines ), 'expected key "c" to be added'
|
330
|
+
assert old_vault.diff(old_vault) is None, 'expected empty diff for identical vaults'
|
331
|
+
|
332
|
+
def test_changes(self) -> None:
|
333
|
+
old_vault: Vault = self._create_simple_vault({ 'a': 'x', 'b': 'y', 'cipher': self.KEYRING.encrypt('secret') })
|
334
|
+
new_vault: Vault = self._create_simple_vault({ 'b': 'Y', 'c': 'z', 'cipher': 'secret' })
|
335
|
+
decrypted, removed, changed, added = new_vault.changes(old_vault)
|
336
|
+
assert decrypted == [ ( 'cipher', ) ], 'key "cipher" has been decrypted'
|
337
|
+
assert removed == [ ( 'a', ) ], 'key "a" has been removed'
|
338
|
+
assert sorted(changed) == [ ( 'b', ), ( 'cipher', ) ], 'keys "b" and "cipher" have been modified'
|
339
|
+
assert added == [ ( 'c', ) ], 'key "c" has been added'
|
340
|
+
|
341
|
+
def test_search_key(self) -> None:
|
342
|
+
vault: Vault = self._create_simple_vault({ 'aaa': 'x', 'bbb': 'y' })
|
343
|
+
found: Any = vault.search_keys('a+', is_regex=True)
|
344
|
+
assert isinstance(found, list) and len(found) == 1 and found == [ ( 'aaa', ) ], 'expected to find key "aaa" with regex'
|
345
|
+
found = vault.search_keys('bbb', is_regex=False)
|
346
|
+
assert isinstance(found, list) and len(found) == 1 and found == [ ( 'bbb', ) ], 'expected to find key "bbb"'
|
347
|
+
found = vault.search_keys('ccc', as_bool=True)
|
348
|
+
assert found is False, 'expected not to find any key "ccc"'
|
349
|
+
|
350
|
+
def test_search_value(self) -> None:
|
351
|
+
vault: Vault = self._create_simple_vault({ 'a': 'xxx', 'b': 'yyy' })
|
352
|
+
found: Any = vault.search_leaf_values('x+', is_regex=True)
|
353
|
+
assert isinstance(found, list) and len(found) == 1 and found == [ ( 'a', ) ], 'expected to find key "a" with regex'
|
354
|
+
found = vault.search_leaf_values('yyy', is_regex=False)
|
355
|
+
assert isinstance(found, list) and len(found) == 1 and found == [ ( 'b', ) ], 'expected to find key "b"'
|
356
|
+
found = vault.search_leaf_values('zzz', as_bool=True)
|
357
|
+
assert found is False, 'expected not to find any value "zzz"'
|
358
|
+
|
359
|
+
def test_search_text(self) -> None:
|
360
|
+
vault: Vault = self._create_simple_vault({ 'TEST_KEY': 'TEST_VALUE' })
|
361
|
+
found: Any = vault.search_vaulttext('TEST_K[A-Z]+', is_regex=True, from_plain=True)
|
362
|
+
assert isinstance(found, list) and len(found) == 1 and found == [ ( ( 1, 1 ), ( 1, 9 ) ) ], 'expected match on line 1'
|
363
|
+
found = vault.search_vaulttext('XXX', as_bool=True)
|
364
|
+
assert found is False, 'expected not to find string "XXX"'
|
365
|
+
|
366
|
+
def _create_simple_vault(self, vars: dict[str, JSONObject] | None = None) -> Vault:
|
367
|
+
'''Creates a vault from the JSON representation of the given vars (of empty if no vars are supplied).'''
|
368
|
+
with StringIO() as s:
|
369
|
+
if vars:
|
370
|
+
YAML().dump(vars, s)
|
371
|
+
content: str = s.getvalue()
|
372
|
+
content = content.replace('"$ANSIBLE_VAULT', '!vault "$ANSIBLE_VAULT') # hack for adding vault tags to encrypted values
|
373
|
+
return Vault(content, keyring=self.KEYRING)
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|