ansible-vars 1.0.6__py3-none-any.whl → 1.0.8__py3-none-any.whl

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/cli.py CHANGED
@@ -39,7 +39,7 @@ from .vault import VaultFile, EncryptedVar, ProtoEncryptedVar
39
39
  from .vault_crypt import VaultKey, VaultKeyring
40
40
  from .util import DiffFileLogger, VaultDaemon
41
41
  from .constants import Unset, MatchLocation, SENTINEL_KEY
42
- from .errors import YAMLFormatError
42
+ from .errors import YAMLFormatError, UnsupportedGenericFileOperation
43
43
 
44
44
  ## CLI argument parsing
45
45
 
@@ -84,6 +84,10 @@ Specify vault keys to load for en-/decryption. Not required for vars files with
84
84
  A key is a combination of an identifier (can be a vault ID or anything else, ideally unique) and a passphrase.
85
85
  By default, available keys are auto-detected if your current directory contains an Ansible config and appended to the ones you supplied.
86
86
  If no explicit encryption key is specified, the first supplied/available key is used.
87
+
88
+ [!] When editing a hybrid vault file, changing even a single variable will change all variables' ciphers, as salts are generated randomly.
89
+ You can prevent this by passing a fixed salt via `-S <salt>`, which will result in any plaintext always resolving to the same cipher.
90
+ For Ansible's AES-256, a length of at least 32 characters is recommended.
87
91
  ''',
88
92
  'log_args': '''
89
93
  Log a diff of any vault changes performed with this program to an encrypted or plain logfile, creating it if necessary.
@@ -198,6 +202,7 @@ DEFAULT_EDITOR: str = os.environ.get('EDITOR', 'notepad.exe' if os.name == 'nt'
198
202
  DEFAULT_COLOR_MODE: str = os.environ.get('AV_COLOR_MODE', '256' if sys.stdout.isatty() else 'none')
199
203
  DEFAULT_TEMP_DIR: str = os.environ.get('AV_TEMP_DIR', gettempdir())
200
204
  DEFAULT_CREATE_PLAIN: bool = os.environ.get('AV_CREATE_PLAIN', 'no').lower() in [ 'yes', 'y', 'true', 't', '1' ]
205
+ DEFAULT_SALT: str | None = os.environ.get('AV_SALT', None)
201
206
 
202
207
  args: ArgumentParser = ArgumentParser(
203
208
  prog = 'ansible-vars',
@@ -243,6 +248,10 @@ key_args.add_argument(
243
248
  )
244
249
  key_args.add_argument('--no-detect-keys', '-D', action='store_false', dest='detect_keys', help='disable automatic key detection')
245
250
  key_args.add_argument('--encryption-key', '-K', type=str, metavar='<identifier>', help='which of the loaded keys to use for encryption')
251
+ key_args.add_argument(
252
+ '--fixed-salt', '-S', type=str, metavar='<salt>', default=DEFAULT_SALT,
253
+ help='a fixed salt to use for encryption (should be 32+ chars!)'
254
+ )
246
255
 
247
256
  log_args = args.add_argument_group('logging vault changes', description=HELP['log_args'])
248
257
  log_mutex = log_args.add_mutually_exclusive_group()
@@ -253,11 +262,17 @@ log_args.add_argument('--logging-key', '-Q', type=str, metavar='<identifier>', h
253
262
  # Commands
254
263
  commands = args.add_subparsers(dest='command', metavar='<command>', required=True)
255
264
 
256
- cmd_keyring = commands.add_parser('keyring', help='show available vault keys and their passphrases', description=HELP['cmd_keyring'])
265
+ cmd_keyring = commands.add_parser(
266
+ 'keyring', help='show available vault keys and their passphrases', description=HELP['cmd_keyring'],
267
+ formatter_class=RawDescriptionHelpFormatter
268
+ )
257
269
  cmd_keyring.add_argument('--json', '-j', action='store_true', dest='as_json', help='print the vault keys as JSON and nothing else')
258
270
  cmd_keyring.add_argument('--keys-only', '-o', action='store_false', dest='show_passphrases', help='show only the vault keys, not the passphrases')
259
271
 
260
- cmd_create = commands.add_parser('create', help='create a new vault', description=HELP['cmd_create'])
272
+ cmd_create = commands.add_parser(
273
+ 'create', help=f"create a new vault ({ 'hybrid/plain' if DEFAULT_CREATE_PLAIN else 'fully encrypted' } by default)",
274
+ description=HELP['cmd_create'], formatter_class=RawDescriptionHelpFormatter
275
+ )
261
276
  cmd_create.add_argument('vault_path', type=str, metavar='<vault path>', help='path to create a new vault at') \
262
277
  .completer = _prefixed_path_completer # type: ignore
263
278
  # Invert flag if the user wants plain mode by default
@@ -266,50 +281,74 @@ if DEFAULT_CREATE_PLAIN:
266
281
  else:
267
282
  cmd_create.add_argument('--plain', '-p', action='store_false', dest='encrypt_vault', help='create without full file encryption')
268
283
  cmd_create.add_argument('--make-parents', '-m', action='store_true', help='create all directories in the given path')
269
- cmd_mutex = cmd_create.add_mutually_exclusive_group()
270
- cmd_mutex.add_argument('--no-edit', '-n', action='store_false', dest='open_edit_mode', help='just create the file, don\'t open it for editing')
271
- cmd_mutex.add_argument(
284
+ create_mutex = cmd_create.add_mutually_exclusive_group()
285
+ create_mutex.add_argument('--no-edit', '-n', action='store_false', dest='open_edit_mode', help='just create the file, don\'t open it for editing')
286
+ create_mutex.add_argument(
272
287
  '--edit-command', '-e', type=str, default=DEFAULT_EDITOR, help=f"editor command to use (runs as <command> <some path>) (default: { DEFAULT_EDITOR })"
273
288
  )
274
289
 
275
- cmd_edit = commands.add_parser('edit', help='edit a vault', description=HELP['cmd_edit'])
290
+ cmd_edit = commands.add_parser(
291
+ 'edit', help='edit a vault', description=HELP['cmd_edit'], formatter_class=RawDescriptionHelpFormatter
292
+ )
276
293
  cmd_edit.add_argument('vault_path', type=str, metavar='<vault path>', help='path of vault to edit') \
277
294
  .completer = _prefixed_path_completer # type: ignore
278
295
  cmd_edit.add_argument(
279
296
  '--edit-command', '-e', type=str, default=DEFAULT_EDITOR, help=f"editor command to use (runs as <command> <some path>) (default: { DEFAULT_EDITOR })"
280
297
  )
281
298
 
282
- cmd_view = commands.add_parser('view', help='show the decrypted contents of a vault')
299
+ cmd_view = commands.add_parser(
300
+ 'view', help='show the decrypted contents of a vault', formatter_class=RawDescriptionHelpFormatter
301
+ )
283
302
  cmd_view.add_argument('vault_path', type=str, metavar='<vault path>', help='path of vault to dump') \
284
303
  .completer = _prefixed_path_completer # type: ignore
285
304
  cmd_view.add_argument('--json', '-j', action='store_true', dest='as_json', help='print the vault data as JSON and nothing else')
286
305
 
287
- cmd_info = commands.add_parser('info', help='show information about a vault\'s variables', description=HELP['cmd_info'])
306
+ cmd_info = commands.add_parser(
307
+ 'info', help='show information about a vault\'s variables', description=HELP['cmd_info'],
308
+ formatter_class=RawDescriptionHelpFormatter
309
+ )
288
310
  cmd_info.add_argument('vault_path', type=str, metavar='<vault path>', help='path of vault to analyze') \
289
311
  .completer = _prefixed_path_completer # type: ignore
290
312
  cmd_info.add_argument('--json', '-j', action='store_true', dest='as_json', help='print the information as JSON and nothing else')
291
313
 
292
- cmd_encrypt = commands.add_parser('encrypt', help='encrypt a file in-place or a string with the encryption key', description=HELP['cmd_encrypt'])
314
+ cmd_encrypt = commands.add_parser(
315
+ 'encrypt', help='encrypt a file in-place or a string with the encryption key', description=HELP['cmd_encrypt'],
316
+ formatter_class=RawDescriptionHelpFormatter
317
+ )
293
318
  cmd_encrypt.add_argument('target_type', type=str, choices=['file', 'string'], help='select if target is a file path or a string')
294
319
  cmd_encrypt.add_argument('target', type=str, metavar='<vault path | string>', help='path of vault or string value to encrypt') \
295
320
  .completer = _prefixed_path_completer # type: ignore
321
+ cmd_encrypt.add_argument('--quiet', '-q', action='store_true', help='only output the encrypted value (ignored in file mode)')
296
322
 
297
- cmd_decrypt = commands.add_parser('decrypt', help='decrypt a file in-place or a string', description=HELP['cmd_decrypt'])
323
+ cmd_decrypt = commands.add_parser(
324
+ 'decrypt', help='decrypt a file in-place or a string', description=HELP['cmd_decrypt'],
325
+ formatter_class=RawDescriptionHelpFormatter
326
+ )
298
327
  cmd_decrypt.add_argument('target_type', type=str, choices=['file', 'string'], help='select if target is a file path or a string')
299
328
  cmd_decrypt.add_argument('target', type=str, metavar='<vault path | string>', help='path of vault or string value to decrypt') \
300
329
  .completer = _prefixed_path_completer # type: ignore
330
+ cmd_decrypt.add_argument('--quiet', '-q', action='store_true', help='only output the decrypted value (ignored in file mode)')
301
331
 
302
- cmd_is_enc = commands.add_parser('is-encrypted', help='check if a file or string is vault-encrypted', description=HELP['cmd_is_enc'])
332
+ cmd_is_enc = commands.add_parser(
333
+ 'is-encrypted', help='check if a file or string is vault-encrypted', description=HELP['cmd_is_enc'],
334
+ formatter_class=RawDescriptionHelpFormatter
335
+ )
303
336
  cmd_is_enc.add_argument('target_type', type=str, choices=['file', 'string'], help='select if target is a file path or a string')
304
337
  cmd_is_enc.add_argument('target', type=str, metavar='<vault path | string>', help='path of vault or string value to check') \
305
338
  .completer = _prefixed_path_completer # type: ignore
306
339
  cmd_is_enc.add_argument('--quiet', '-q', action='store_true', help='no output, only set the rc to 0 if encrypted or 100 if unencrypted')
307
340
 
308
- cmd_convert = commands.add_parser('convert', help='switch vault between outer (file) and inner (vars) encryption', description=HELP['cmd_convert'])
341
+ cmd_convert = commands.add_parser(
342
+ 'convert', help='switch vault between outer (file) and inner (vars) encryption', description=HELP['cmd_convert'],
343
+ formatter_class=RawDescriptionHelpFormatter
344
+ )
309
345
  cmd_convert.add_argument('vault_path', type=str, metavar='<vault path>', help='path of vault to convert') \
310
346
  .completer = _prefixed_path_completer # type: ignore
311
347
 
312
- cmd_grep = commands.add_parser('grep', help='search a file or folder for a pattern', description=HELP['cmd_grep'])
348
+ cmd_grep = commands.add_parser(
349
+ 'grep', help='search a file or folder for a pattern', description=HELP['cmd_grep'],
350
+ formatter_class=RawDescriptionHelpFormatter
351
+ )
313
352
  cmd_grep.add_argument('query', type=str, metavar='<pattern>', help='regex query to match with targets')
314
353
  cmd_grep.add_argument('targets', type=str, nargs='+', metavar='[<target> ...]', help='file(s) or folder(s) to search recursively') \
315
354
  .completer = _prefixed_path_completer # type: ignore
@@ -324,21 +363,30 @@ grep_mutex_type.add_argument('--multiline', '-m', action='store_true', help='mak
324
363
  cmd_grep.add_argument('--json', '-j', action='store_true', dest='as_json', help='print the matches as JSON and nothing else')
325
364
  cmd_grep.add_argument('--quiet', '-q', action='store_true', help='no output, only set the rc to 0 if any matches found or 100 if none found')
326
365
 
327
- cmd_diff = commands.add_parser('diff', help='show line differences between two vaults', description=HELP['cmd_diff'])
366
+ cmd_diff = commands.add_parser(
367
+ 'diff', help='show line differences between two vaults', description=HELP['cmd_diff'],
368
+ formatter_class=RawDescriptionHelpFormatter
369
+ )
328
370
  cmd_diff.add_argument('old_vault', type=str, metavar='<old vault vault path>', help='path of "old"/base vault') \
329
371
  .completer = _prefixed_path_completer # type: ignore
330
372
  cmd_diff.add_argument('new_vault', type=str, metavar='<new vault vault path>', help='path of "new"/changed vault') \
331
373
  .completer = _prefixed_path_completer # type: ignore
332
374
  cmd_diff.add_argument('--context-lines', '-c', type=int, metavar='<amount>', default=3, help='show <amount> lines of context around changed lines (default: 3)')
333
375
 
334
- cmd_changes = commands.add_parser('changes', help='show var changes between vaults', description=HELP['cmd_changes'])
376
+ cmd_changes = commands.add_parser(
377
+ 'changes', help='show var changes between vaults', description=HELP['cmd_changes'],
378
+ formatter_class=RawDescriptionHelpFormatter
379
+ )
335
380
  cmd_changes.add_argument('old_vault', type=str, metavar='<old vault vault path>', help='path of "old"/base vault') \
336
381
  .completer = _prefixed_path_completer # type: ignore
337
382
  cmd_changes.add_argument('new_vault', type=str, metavar='<new vault vault path>', help='path of "new"/changed vault') \
338
383
  .completer = _prefixed_path_completer # type: ignore
339
384
  cmd_changes.add_argument('--json', '-j', action='store_true', dest='as_json', help='print added/changed/removed/decrypted vars as JSON and nothing else')
340
385
 
341
- cmd_daemon = commands.add_parser('file-daemon', help='sync decrypted vault copies into a folder', description=HELP['cmd_daemon'])
386
+ cmd_daemon = commands.add_parser(
387
+ 'file-daemon', help='sync decrypted vault copies into a folder', description=HELP['cmd_daemon'],
388
+ formatter_class=RawDescriptionHelpFormatter
389
+ )
342
390
  cmd_daemon.add_argument('target_root', type=str, metavar='<target root path>', help='root folder the decrypted files and folders should be synced into (non-existent or empty)') \
343
391
  .completer = _prefixed_path_completer # type: ignore
344
392
  # This arg can be repeated (results in [ [source, rel_target], ... ])
@@ -350,7 +398,10 @@ cmd_daemon.add_argument('--no-recurse', '-n', action='store_false', dest='recurs
350
398
  cmd_daemon.add_argument('--no-default-dirs', '-N', action='store_false', dest='include_default_dirs', help='don\'t include default sync sources')
351
399
  cmd_daemon.add_argument('--force', '-f', action='store_true', help='if the target root already exists and is not empty, delete its contents')
352
400
 
353
- cmd_get = commands.add_parser('get', help='get a key\'s (decrypted) value if it exists', description=HELP['cmd_get'])
401
+ cmd_get = commands.add_parser(
402
+ 'get', help='get a key\'s (decrypted) value if it exists', description=HELP['cmd_get'],
403
+ formatter_class=RawDescriptionHelpFormatter
404
+ )
354
405
  cmd_get.add_argument('vault_path', type=str, metavar='<vault path>', help='path of vault to get value from') \
355
406
  .completer = _prefixed_path_completer # type: ignore
356
407
  cmd_get.add_argument('key_segments', type=str, nargs='+', metavar='<key segment> [<key segment> ...]', help='segment(s) of the key to look up (`[<num>]` for numbers)')
@@ -359,14 +410,20 @@ get_mutex_format = cmd_get.add_mutually_exclusive_group()
359
410
  get_mutex_format.add_argument('--quiet', '-q', action='store_true', help='only output the raw YAML value or set the rc to 100 if the key doesn\'t exist')
360
411
  get_mutex_format.add_argument('--json', '-j', action='store_true', dest='as_json', help='print the value as JSON or set the rc to 100 if the key doesn\'t exist')
361
412
 
362
- cmd_set = commands.add_parser('set', help='update a key\'s value or add a new key (experimental!)', description=HELP['cmd_set'])
413
+ cmd_set = commands.add_parser(
414
+ 'set', help='update a key\'s value or add a new key (experimental!)', description=HELP['cmd_set'],
415
+ formatter_class=RawDescriptionHelpFormatter
416
+ )
363
417
  cmd_set.add_argument('vault_path', type=str, metavar='<vault path>', help='path of vault to set value in') \
364
418
  .completer = _prefixed_path_completer # type: ignore
365
419
  cmd_set.add_argument('value', type=str, metavar='<value>', help='value to set (will be loaded as YAML)')
366
420
  cmd_set.add_argument('key_segments', type=str, nargs='+', metavar='<key segment> [<key segment> ...]', help='segment(s) of the key to look up (`[<num>]` for numbers)')
367
421
  cmd_set.add_argument('--encrypt', '-e', action='store_true', dest='encrypt_value', help='encrypt the value if it is\'t encrypted yet')
368
422
 
369
- cmd_del = commands.add_parser('del', help='delete a key and its value if they exist (experimental!)', description=HELP['cmd_del'])
423
+ cmd_del = commands.add_parser(
424
+ 'del', help='delete a key and its value if they exist (experimental!)', description=HELP['cmd_del'],
425
+ formatter_class=RawDescriptionHelpFormatter
426
+ )
370
427
  cmd_del.add_argument('vault_path', type=str, metavar='<vault path>', help='path of vault to delete key from') \
371
428
  .completer = _prefixed_path_completer # type: ignore
372
429
  cmd_del.add_argument('key_segments', type=str, nargs='+', metavar='<key segment> [<key segment> ...]', help='segment(s) of the key to look up (`[<num>]` for numbers)')
@@ -517,7 +574,7 @@ sys.excepthook = _exc_hook
517
574
  # Load vault keys
518
575
 
519
576
  _explicit_keys: list[VaultKey] = [ VaultKey(passphrase, vault_id=id) for id, passphrase in config.keys ]
520
- keyring = VaultKeyring(_explicit_keys.copy(), detect_available_keys=config.detect_keys)
577
+ keyring = VaultKeyring(_explicit_keys.copy(), detect_available_keys=config.detect_keys, default_salt=config.fixed_salt)
521
578
 
522
579
  if config.encryption_key:
523
580
  keyring.default_encryption_key = keyring.key_by_id(config.encryption_key)
@@ -527,6 +584,8 @@ try:
527
584
  debug(f"Encryption key: { keyring.encryption_key.id }")
528
585
  except:
529
586
  debug('Encryption key: unavailable')
587
+ if config.fixed_salt:
588
+ debug(f"Using fixed encryption salt: { config.fixed_salt }")
530
589
 
531
590
  # Set up logging
532
591
 
@@ -582,7 +641,30 @@ if config.command in [ 'create', 'edit' ]:
582
641
  vault = VaultFile.create(vault_path, full_encryption=config.encrypt_vault, permissions=0o600, keyring=keyring)
583
642
  print(f"Created { 'encrypted' if vault.full_encryption else 'plain' } vault at { vault_path }", Color.GOOD)
584
643
  else:
585
- vault = VaultFile(vault_path, keyring=keyring)
644
+ try:
645
+ vault = VaultFile(vault_path, keyring=keyring)
646
+ except YAMLFormatError:
647
+ print('Invalid vault format, will be treated as a generic file', Color.MEH)
648
+ with NamedTemporaryFile(mode='w+', dir=config.temp_dir, prefix='vaultlike_') as edit_file:
649
+ with open(vault_path, 'r+') as file:
650
+ # Load and decrypt file
651
+ content: str = file.read()
652
+ if (is_enc := VaultKey.is_encrypted(content)):
653
+ content = keyring.decrypt(content)
654
+ # Let user edit the content in a temporary file
655
+ edit_file.write(content)
656
+ edit_file.flush()
657
+ sys_command(f"{ config.edit_command } { edit_file.name }", shell=True)
658
+ edit_file.seek(0)
659
+ # Encrypt the new content and write it back
660
+ new_content: str = edit_file.read()
661
+ if is_enc:
662
+ new_content = keyring.encrypt(new_content)
663
+ file.seek(0)
664
+ file.truncate()
665
+ file.write(new_content)
666
+ print(f"Saved changes!", Color.GOOD)
667
+ exit()
586
668
  # Open vault for edit mode
587
669
  if getattr(config, 'open_edit_mode', True):
588
670
  print(f"Editing vault at { vault_path }")
@@ -591,6 +673,7 @@ if config.command in [ 'create', 'edit' ]:
591
673
  # Write vault contents to temp file
592
674
  editable: str = vault.as_editable()
593
675
  edit_file.write(editable)
676
+ edit_file.flush()
594
677
  while True:
595
678
  # Open editor and wait for it to close
596
679
  edit_file.seek(0)
@@ -613,18 +696,22 @@ if config.command in [ 'create', 'edit' ]:
613
696
  break
614
697
  new_vault.save()
615
698
  print(f"Saved changes!", Color.GOOD)
616
- # Warn about decrypted variables
617
- if (changes := new_vault.changes(vault))[0]:
618
- print(f"\n[!] The following vars have been decrypted in this edit:", Color.MEH)
619
- print('\n'.join([ f"- { format_key_path(path) }" for path in changes[0] ]))
620
- # Inform about new plain leaf variables
699
+ decrypted_vars: list[tuple[Hashable, ...]] = []
621
700
  new_plain_leaves: list[tuple[Hashable, ...]] = []
622
701
  def _find_new_plain_vars(path: tuple[Hashable, ...], value: Any) -> Any:
623
702
  if path != ( SENTINEL_KEY, ) and type(value) is not EncryptedVar:
624
- if vault.get(path, default=Unset) != value:
625
- new_plain_leaves.append(path)
703
+ if (old_value := vault.get(path, default=Unset)) != value:
704
+ if type(old_value) is EncryptedVar:
705
+ decrypted_vars.append(path)
706
+ else:
707
+ new_plain_leaves.append(path)
626
708
  return value
627
709
  vault._transform_leaves(new_vault._data, _find_new_plain_vars, tuple())
710
+ # Warn about decrypted variables
711
+ if decrypted_vars:
712
+ print(f"\n[!] The following vars have been decrypted in this edit:", Color.MEH)
713
+ print('\n'.join([ f"- { format_key_path(path) }" for path in decrypted_vars ]))
714
+ # Warn about new plain leaf variables
628
715
  if not new_vault.full_encryption and new_plain_leaves:
629
716
  print(f"\n[!] The following plain vars have been added in this edit:", Color.MEH)
630
717
  print('\n'.join([ f"- { format_key_path(path) }" for path in new_plain_leaves ]))
@@ -639,11 +726,18 @@ if config.command in [ 'create', 'edit' ]:
639
726
 
640
727
  if config.command == 'view':
641
728
  vault_path: str = resolve_vault_path(config.vault_path)
642
- vault = VaultFile(vault_path, keyring=keyring)
643
- if config.as_json:
644
- print_json(vault.as_json())
645
- else:
646
- print_yaml(vault.as_plain())
729
+ try:
730
+ vault = VaultFile(vault_path, keyring=keyring)
731
+ if config.as_json:
732
+ print_json(vault.as_json())
733
+ else:
734
+ print_yaml(vault.as_plain())
735
+ except YAMLFormatError:
736
+ if config.as_json:
737
+ raise UnsupportedGenericFileOperation(operation='--json')
738
+ with open(vault_path) as file:
739
+ content: str = file.read()
740
+ print(keyring.decrypt(content) if VaultKey.is_encrypted(content) else content)
647
741
 
648
742
  # Info command
649
743
 
@@ -684,27 +778,53 @@ if config.command in [ 'encrypt', 'decrypt', 'is-encrypted' ]:
684
778
  # File target
685
779
  if config.target_type == 'file':
686
780
  vault_path: str = resolve_vault_path(config.target)
687
- vault = VaultFile(vault_path, keyring=keyring)
781
+ is_generic: bool = False
782
+ try:
783
+ vault = VaultFile(vault_path, keyring=keyring)
784
+ is_enc: bool = vault.full_encryption
785
+ except YAMLFormatError as e:
786
+ print('Invalid vault format, will be treated as a generic file', Color.MEH)
787
+ with open(vault_path) as file:
788
+ is_enc = VaultKey.is_encrypted(file.read())
789
+ is_generic = True
688
790
  if config.command in [ 'encrypt', 'decrypt' ]:
689
- if vault.full_encryption == (config.command == 'encrypt'):
690
- print(f"Vault is already { 'en' if vault.full_encryption else 'de' }crypted.")
791
+ if is_enc == (config.command == 'encrypt'):
792
+ print(f"Vault is already { 'en' if is_enc else 'de' }crypted.", Color.GOOD)
691
793
  else:
692
- vault.full_encryption = (config.command == 'encrypt')
693
- vault.save()
694
- print(f"Vault { 'en' if vault.full_encryption else 'de' }crypted.", Color.GOOD)
794
+ is_enc = (config.command == 'encrypt')
795
+ # Generic file
796
+ if is_generic:
797
+ with open(vault_path, 'r+') as file:
798
+ content: str = file.read()
799
+ file.seek(0)
800
+ file.truncate()
801
+ file.write(keyring.encrypt(content) if is_enc else keyring.decrypt(content))
802
+ # Vault file
803
+ else:
804
+ vault.full_encryption = is_enc # type: ignore
805
+ vault.save() # type: ignore
806
+ print(f"Vault { 'en' if is_enc else 'de' }crypted.", Color.GOOD)
695
807
  else:
696
808
  if config.quiet:
697
- exit(0 if vault.full_encryption else 100)
809
+ exit(0 if is_enc else 100)
698
810
  else:
699
- print(f"Vault is { 'encrypted' if vault.full_encryption else 'plain or hybrid' }.", Color.GOOD if vault.full_encryption else Color.MEH)
811
+ print(f"Vault is { 'fully encrypted' if is_enc else 'plain or hybrid' }.", Color.GOOD if is_enc else Color.MEH)
700
812
  # String target
701
813
  else:
702
814
  is_encrypted: bool = VaultKey.is_encrypted(config.target)
815
+ # The key may not be passed properly, in which case we auto-convert literal '\n' to newlines
816
+ # We can assume an encrypted value should not contain any literal backslashes
817
+ if is_encrypted:
818
+ config.target = config.target.replace('\\n', '\n')
703
819
  if config.command in [ 'encrypt', 'decrypt' ]:
704
820
  if is_encrypted == (config.command == 'encrypt'):
705
- print(f"Value is already { 'en' if is_encrypted else 'de' }crypted.")
821
+ if not config.quiet:
822
+ print(f"Value is already { 'en' if is_encrypted else 'de' }crypted.", Color.GOOD)
823
+ else:
824
+ print(config.target)
706
825
  else:
707
- print(f"{ 'En' if not is_encrypted else 'De' }crypted value:", Color.GOOD)
826
+ if not config.quiet:
827
+ print(f"{ 'En' if not is_encrypted else 'De' }crypted value:", Color.GOOD)
708
828
  print(keyring.encrypt(config.target) if (config.command == 'encrypt') else keyring.decrypt(config.target))
709
829
  else:
710
830
  if config.quiet:
@@ -732,7 +852,7 @@ if config.command == 'convert':
732
852
  vault.save()
733
853
  print(f"Vault converted to { 'outer' if vault.full_encryption else 'inner' } encryption.", Color.GOOD)
734
854
  if not vault.full_encryption:
735
- print('Please check the vault to make sure all secrets have been encrypted', Color.MEH)
855
+ print('Please check the vault to make sure all secrets have been encrypted!', Color.MEH)
736
856
  _convert()
737
857
 
738
858
  # Grep command
ansible_vars/errors.py CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  # YAML parsing
4
4
 
5
+ class UnsupportedGenericFileOperation(Exception):
6
+ '''The requested operation or flag is not available for generic (i.e. non-YAML-dictionary) files.'''
7
+
8
+ def __init__(self, *args: object, operation: str | None = None) -> None:
9
+ super().__init__(
10
+ f"The requested operation or flag is not available for generic files{ f': { operation }' * bool(operation) }", *args
11
+ )
12
+
5
13
  class YAMLFormatError(Exception):
6
14
  '''The supplied content is not a valid Ansible YAML file. Supports passing the triggering parent exception.'''
7
15
 
ansible_vars/util.py CHANGED
@@ -282,7 +282,7 @@ class VaultDaemon(FileSystemEventHandler):
282
282
  content: str = src.read()
283
283
  with open(target_path, 'w') as tgt:
284
284
  try:
285
- tgt.write(Vault(content, keyring=self.keyring).as_editable(with_header=False))
285
+ tgt.write(Vault(content, keyring=self.keyring).as_plain())
286
286
  self.debug(f"Created/Updated file { target_path } from vault contents")
287
287
  except:
288
288
  tgt.write(content)
@@ -338,7 +338,7 @@ class VaultDaemon(FileSystemEventHandler):
338
338
  content: str = src.read()
339
339
  with open(self.target_file, 'w') as tgt:
340
340
  try:
341
- tgt.write(Vault(content, keyring=self.keyring).as_editable(with_header=False))
341
+ tgt.write(Vault(content, keyring=self.keyring).as_plain())
342
342
  self.debug(f"Created/Updated file { self.target_file } from vault contents")
343
343
  except:
344
344
  tgt.write(content)
@@ -373,7 +373,7 @@ class VaultDaemon(FileSystemEventHandler):
373
373
  content: str = src.read()
374
374
  with open(target_path, 'w') as tgt:
375
375
  try:
376
- tgt.write(Vault(content, keyring=self.keyring).as_editable(with_header=False))
376
+ tgt.write(Vault(content, keyring=self.keyring).as_plain())
377
377
  self.debug(f"Created/Updated file { target_path } from vault contents")
378
378
  except:
379
379
  tgt.write(content)
ansible_vars/vault.py CHANGED
@@ -6,16 +6,14 @@ from io import StringIO
6
6
  from functools import reduce
7
7
  from typing import Type, Hashable, Callable, Any
8
8
  from types import MappingProxyType
9
- from collections import OrderedDict
10
9
  from difflib import unified_diff
11
10
 
12
11
  # External library imports
13
12
  from ruamel.yaml import YAML
14
13
  from ruamel.yaml.nodes import ScalarNode
15
- from ruamel.yaml.parser import CommentToken
16
14
  from ruamel.yaml.representer import Representer
17
15
  from ruamel.yaml.constructor import Constructor
18
- from ruamel.yaml.comments import CommentedMap, CommentedSeq
16
+ from ruamel.yaml.comments import CommentedMap
19
17
 
20
18
  # Internal module imports
21
19
  from .constants import ThrowError, octal, Indexable, ChangeList, MatchLocation, SENTINEL_KEY, EDIT_MODE_HEADER, ENCRYPTED_VAR_TAG
@@ -53,7 +51,7 @@ class EncryptedVar():
53
51
  cipher: Any = constructor.construct_scalar(node)
54
52
  if not isinstance(cipher, str):
55
53
  raise TypeError(f"Expected encrypted value to be a str, but got { type(cipher) }")
56
- return EncryptedVar(str(cipher), name=node.id)
54
+ return EncryptedVar(cipher, name=node.id)
57
55
 
58
56
  class ProtoEncryptedVar():
59
57
  '''A variable marked to be encrypted in a `Vault` editable.'''
@@ -77,14 +75,14 @@ class ProtoEncryptedVar():
77
75
 
78
76
  @classmethod
79
77
  def to_yaml(ProtoEncryptedVar: Type['ProtoEncryptedVar'], representer: Representer, var: 'ProtoEncryptedVar') -> Any:
80
- return representer.represent_scalar(ENCRYPTED_VAR_TAG, var.plaintext, style='\'')
78
+ return representer.represent_scalar(ENCRYPTED_VAR_TAG, var.plaintext, style=('|' if '\n' in var.plaintext else ''))
81
79
 
82
80
  @classmethod
83
81
  def from_yaml(ProtoEncryptedVar: Type['ProtoEncryptedVar'], constructor: Constructor, node: ScalarNode) -> 'ProtoEncryptedVar':
84
82
  plaintext: Any = constructor.construct_scalar(node)
85
83
  if not isinstance(plaintext, str):
86
84
  raise TypeError(f"Expected decrypted value to be a str, but got { type(plaintext) }")
87
- return ProtoEncryptedVar(str(plaintext).rstrip('\n'), name=node.id)
85
+ return ProtoEncryptedVar(plaintext.rstrip('\n'), name=node.id)
88
86
 
89
87
  class Vault():
90
88
  '''
@@ -205,7 +203,7 @@ class Vault():
205
203
  A read-only dictionary of the vault's variables, with any EncryptedVars already decrypted.
206
204
  Note that the state is frozen whenever you access this property, and not updated when the vault changes.
207
205
  '''
208
- copy: dict = self._data.copy()
206
+ copy: dict = Vault._copy_data(self._data)
209
207
  copy.pop(SENTINEL_KEY, None)
210
208
  def _traverse_and_decrypt(root: Any) -> Any:
211
209
  if isinstance(root, dict):
@@ -495,7 +493,7 @@ class Vault():
495
493
 
496
494
  def as_plain(self) -> str:
497
495
  '''Returns the vault in fully decrypted form as Jinja2 YAML code with the original metadata.'''
498
- copy: CommentedMap = self._data.copy()
496
+ copy: CommentedMap = Vault._copy_data(self._data)
499
497
  #copy.pop(SENTINEL_KEY, None) # <-- would break a file containing only metadata and the sentinel key
500
498
  def _decrypt_leaf(_: tuple[Hashable, ...], value: Any) -> Any:
501
499
  '''Transforms EncryptedVar leaves into decrypted strings.'''
@@ -510,7 +508,7 @@ class Vault():
510
508
  Returns the vault as Jinja2 YAML code with the original metadata.
511
509
  It is prepared for editing and later re-encryption with YAML tags and a static explanatory header.
512
510
  '''
513
- copy: CommentedMap = self._data.copy()
511
+ copy: CommentedMap = Vault._copy_data(self._data)
514
512
  def _convert_to_proto(path: tuple[Hashable, ...], value: Any) -> Any:
515
513
  '''Marks encrypted leaves for encryption after editing.'''
516
514
  if type(value) is not EncryptedVar:
@@ -526,7 +524,7 @@ class Vault():
526
524
 
527
525
  def as_encrypted(self) -> str:
528
526
  '''Returns the vault as Jinja2 YAML code with the original metadata, with encrypted variables and full encryption if enabled.'''
529
- copy: CommentedMap = self._data.copy()
527
+ copy: CommentedMap = Vault._copy_data(self._data)
530
528
  yaml_content: str = self._dump_to_str(copy)
531
529
  yaml_content = Vault._remove_sentinel(yaml_content)
532
530
  if self.full_encryption:
@@ -567,6 +565,23 @@ class Vault():
567
565
  self._parser.dump(data, builder)
568
566
  return builder.getvalue().strip('\n') + '\n'
569
567
 
568
+ @staticmethod
569
+ def _copy_data(data: Any) -> Any:
570
+ '''
571
+ Create a deep copy of any data, using the object's `copy()` method for dicts and lists.
572
+ Parser dicts and lists contain special data, and their copy function will preserve that.
573
+ The built-in `copy.deepcopy()` would require a `__deepcopy__` method for this to work.
574
+ Copying works for dicts and lists. Everything else is referenced normally.
575
+ Not thread-safe.
576
+ '''
577
+ if (is_dict := isinstance(data, dict)) or isinstance(data, list):
578
+ copy = data.copy()
579
+ keys = copy.keys() if is_dict else range(len(copy)) # type: ignore
580
+ for key in keys:
581
+ copy[key] = Vault._copy_data(copy[key])
582
+ return copy
583
+ return data
584
+
570
585
  # Comparing to older versions of this vault
571
586
 
572
587
  def diff(self, prev_vault: 'Vault', context_lines: int = 3, show_filenames: bool = True) -> str:
@@ -653,7 +668,7 @@ class Vault():
653
668
  def copy(self) -> 'Vault':
654
669
  '''Create a copy of this `Vault` instance.'''
655
670
  copy = Vault('', self.keyring)
656
- copy._data = self._data.copy()
671
+ copy._data = Vault._copy_data(self._data)
657
672
  copy.full_encryption = self.full_encryption
658
673
  copy.keyring = self.keyring
659
674
  copy._parser = self._parser
@@ -804,7 +819,7 @@ class VaultFile(Vault):
804
819
  def copy(self) -> 'VaultFile':
805
820
  '''Create a copy of this `VaultFile` instance.'''
806
821
  copy: VaultFile = self.from_editable(self, '')
807
- copy._data = self._data.copy()
822
+ copy._data = VaultFile._copy_data(self._data)
808
823
  copy.full_encryption = self.full_encryption
809
824
  copy.keyring = self.keyring
810
825
  copy._parser = self._parser
@@ -54,12 +54,12 @@ class VaultKey():
54
54
  '''
55
55
  return VaultLib.is_encrypted(VaultKey._strip_vault_tag(test_me))
56
56
 
57
- def encrypt(self, plain: str) -> str:
58
- '''Encrypts a string using this `VaultKey`'s secret.'''
57
+ def encrypt(self, plain: str, salt: str | None = None) -> str:
58
+ '''Encrypts a string using this `VaultKey`'s secret. You can specify an optional fixed salt (ideally 32+ chars).'''
59
59
  # Pass our secret directly to the encrypt call to skip expensive secret matching
60
60
  # Beware: the encrypt function takes a `secret`, but means just the VaultSecret and not a tuple of (vault_id, VaultSecret)
61
61
  # In other calls, `secret` or `secrets` may refer to the tuple(s)
62
- return self._vaultlib.encrypt(plain, secret=self.secret, vault_id=self.id).decode('utf-8').strip()
62
+ return self._vaultlib.encrypt(plain, secret=self.secret, vault_id=self.id, salt=salt).decode('utf-8').strip()
63
63
 
64
64
  def decrypt(self, vault_cipher: str) -> str:
65
65
  '''
@@ -70,7 +70,7 @@ class VaultKey():
70
70
  vault_cipher = VaultKey._strip_vault_tag(vault_cipher)
71
71
  try:
72
72
  decrypted: bytes = self._vaultlib.decrypt(vault_cipher)
73
- return decrypted.decode('utf-8').strip()
73
+ return decrypted.decode('utf-8')
74
74
  except AnsibleVaultError as e:
75
75
  if e.message.startswith('Decryption failed (no vault secrets were found that could decrypt)'):
76
76
  raise VaultKeyMatchError(f"Could not match cipher with { self }")
@@ -96,6 +96,7 @@ class VaultKeyring():
96
96
  self,
97
97
  keys: list[VaultKey] | None = None,
98
98
  default_encryption_key: VaultKey | None = None,
99
+ default_salt: str | None = None,
99
100
  detect_available_keys: bool = True
100
101
  ) -> None:
101
102
  '''
@@ -107,9 +108,13 @@ class VaultKeyring():
107
108
 
108
109
  When encrypting data, you can specify an explicit `VaultKey` to use. If none is specified, `default_encryption_key` is used.
109
110
  If no explicit or default keys are available, the first key of the `keys` parameter is used.
111
+
112
+ You can also set a fixed salt to use for encryption tasks, either passing it to the `encrypt` function directly
113
+ or as a `default_salt` here. A length of at least 32 chars is recommended for Ansible's AES-256.
110
114
  '''
111
115
  self.keys: list[VaultKey] = keys or []
112
116
  self.default_encryption_key: VaultKey | None = default_encryption_key
117
+ self.default_salt: str | None = default_salt
113
118
  if detect_available_keys:
114
119
  self.keys.extend(VaultKeyring.load_cli_secrets())
115
120
 
@@ -124,16 +129,15 @@ class VaultKeyring():
124
129
  raise NoVaultKeysError('No vault keys available for encryption')
125
130
  return self.default_encryption_key or self.keys[0]
126
131
 
127
- def encrypt(self, plain: str, key: VaultKey | None = None) -> str:
132
+ def encrypt(self, plain: str, key: VaultKey | None = None, salt: str | None = None) -> str:
128
133
  '''
129
134
  Encrypts the given vault data using the supplied `VaultKey`.
130
135
  If no key is supplied, the `VaultKeyring`'s `default_encryption_key` is used.
131
136
  If that key is also unset, the first key of the `VaultKeyring`'s `keys` is used.
137
+ You can specify an optional fixed salt for encrypting (ideally 32+ chars).
132
138
  '''
133
139
  # If no key is provided, use the default encryption key or first key in `keys`
134
- if not key:
135
- key = self.encryption_key
136
- return key.encrypt(plain)
140
+ return (key or self.encryption_key).encrypt(plain, salt=(salt or self.default_salt))
137
141
 
138
142
  def decrypt(self, vault_cipher: str, key: VaultKey | None = None) -> str:
139
143
  '''
@@ -143,7 +147,7 @@ class VaultKeyring():
143
147
 
144
148
  Expects a cipher with optional YAML tag preamble (`!vault | $ANSIBLE_VAULT;<options>\\n<cipher>`).
145
149
  '''
146
- if not key and not self.keys:
150
+ if not (key or self.keys):
147
151
  raise NoVaultKeysError('No vault keys available for decryption')
148
152
  if key:
149
153
  return key.decrypt(vault_cipher)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ansible-vars
3
- Version: 1.0.6
3
+ Version: 1.0.8
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
@@ -105,6 +105,10 @@ By default, the first loaded key is used for all encryption tasks. Note that aut
105
105
 
106
106
  You can disable automatic key detection by flagging `--no-encrypt-keys|-D`. Use `ansible-vars keyring` to view all available keys.
107
107
 
108
+ #### Encryption salts
109
+
110
+ Each time you edit a vault or otherwise encrypt a value, a randomly generated salt is used so even identical plain values will result in unique ciphers. However, this also means that each time a single encrypted variable is edited, all other ciphers in the vault will change as well. You can avoid this by passing a fixed salt via `--fixed-salt|-S <salt>`. Note that it should be at least 32 characters long and sufficiently random for Ansible's AES-256 encryption, and that you won't benefit from unique ciphers for identical plaintexts anymore.
111
+
108
112
  ### Diff logging
109
113
 
110
114
  **TL;DR:** You can use `-l <log directory>` to log changes to edited vaults to a vault-encrypted log file.
@@ -220,6 +224,10 @@ Set the tempfile/staging root as you would with `-T <path>`.
220
224
 
221
225
  Invert the default creation mode for files: If unset or `no`, files are created with full encryption unless specified otherwise via the `--plain|-p` flag. This behavior mirrors that of `ansible-vault`. When set to `yes`, the behavior and flag are inverted as files are created without encryption by default unless specified otherwise via the `--no-plain|-P` flag.
222
226
 
227
+ #### AV_SALT
228
+
229
+ Set a fixed salt as you would with `-S <salt>`.
230
+
223
231
  ### Python library
224
232
 
225
233
  When using `ansible-vars` as a library, import any of these modules from the `ansible_vars` module.
@@ -254,21 +262,22 @@ When editing a file or creating a daemon, decrypted vaults are written to disk t
254
262
 
255
263
  ## Known issues and limitations
256
264
 
257
- - YAML round-trip parser
265
+ - YAML round-trip parser:
258
266
  - Trailing comments and Jinja2 blocks may be misaligned and a trailing newline may be inserted/removed when switching between folded (`|`, `>`) and non-foldes values.
259
267
  - The `set` and `del` commands may remove trailing comments and Jinja2 blocks.
260
268
  - Explicit start/end markers (`---`, `...`) are not preserved.
261
269
  - Supports lists, dictionaries, and scalar values.
262
270
  - Does not support custom YAML tags (`!tag`).
263
- - Ansible
271
+ - Ansible:
264
272
  - Ansible only directly supports encrypted string values (although you can work around this with the `from_yaml` filter).
265
273
  - Ansible-encrypted strings must include a newline between the envelope and the cipher.
266
274
  - Ansible vault and variable file roots must be a dictionary.
267
- - `grep` command
275
+ - `grep` command:
268
276
  - Will ignore files which cannot be parsed as an Ansible YAML file.
269
- - `file-daemon` command
277
+ - `file-daemon` command:
270
278
  - Changes to file metadata (permissions, ...) are not mirrored.
271
- - `ansible-vars` cannot operate on files which are not (Jinja2) YAML dictionaries.
279
+ - `ansible-vars` does not support files which are not (Jinja2) YAML dictionaries, except for limited support in these commands:
280
+ - `edit`, `view` (without `--json` support), `encrypt`, `decrypt`, `is-encrypted`
272
281
 
273
282
  ## Extension plans
274
283
 
@@ -0,0 +1,12 @@
1
+ ansible_vars/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ ansible_vars/cli.py,sha256=TFPSuUmOTWjX6FF5aAd2PnJkyftSur9DfccYwzjQe6A,63959
3
+ ansible_vars/constants.py,sha256=Nd3sIuSoOvyfUfHfnsnJBDGMW7eNzbMm1NAvEQio9hE,1624
4
+ ansible_vars/errors.py,sha256=6dzyksPKWira9O2-Ir3MIOwr4XjN9MSBiRp5e6siY6Q,1256
5
+ ansible_vars/util.py,sha256=UwGPBT19pee7lBpWuBzLPAvcrHUBAn6i1MrJvzM9OQ4,21265
6
+ ansible_vars/vault.py,sha256=cMvFdtc3bw6yf-aChUEP34k2yafWS2UuubFO84De_rA,46383
7
+ ansible_vars/vault_crypt.py,sha256=nh2k686nTI3yERIp-qzx5iDE1kZKg10YG019QeZDnLM,10019
8
+ ansible_vars-1.0.8.dist-info/METADATA,sha256=KSO8y8E4DZeGzYjXKrnuj-aW8ni3TEAh1upcwGPdlL0,17967
9
+ ansible_vars-1.0.8.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
10
+ ansible_vars-1.0.8.dist-info/entry_points.txt,sha256=RrhkEH0MbfRzflguVrfYfthsFC5V2fkFnizUG3uHMtQ,55
11
+ ansible_vars-1.0.8.dist-info/licenses/LICENSE,sha256=ocyJHLG5wD12qB4uam2pqWTHIJmzloiyNyTex6Q2DKo,1062
12
+ ansible_vars-1.0.8.dist-info/RECORD,,
@@ -1,12 +0,0 @@
1
- ansible_vars/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- ansible_vars/cli.py,sha256=Mp-J_1Tz6vz2uPhTQ_OWdWeZp_Zfc5qDHk2SnIjOo2U,59045
3
- ansible_vars/constants.py,sha256=Nd3sIuSoOvyfUfHfnsnJBDGMW7eNzbMm1NAvEQio9hE,1624
4
- ansible_vars/errors.py,sha256=VnViEBMR3rIeioMj460DgdBA5S5FYiDObaDDhG2FMBs,857
5
- ansible_vars/util.py,sha256=BzS7n3UzaKqVZ3W78HVkJtdVCYofprqQDtU8wYH1d0Q,21325
6
- ansible_vars/vault.py,sha256=_dp1K5_UAFGgcg6iO4on4-L_BaJO2cHP6My3EU6enA4,45593
7
- ansible_vars/vault_crypt.py,sha256=JcFc6dTZ6EqhKXv_C5ofggTpBK8hWG3ZwrBrDNYcEIg,9501
8
- ansible_vars-1.0.6.dist-info/METADATA,sha256=G4q4OqsOTJ6R9cUyoh9odAn5ZmWR59i5knWHPalQROs,17198
9
- ansible_vars-1.0.6.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
10
- ansible_vars-1.0.6.dist-info/entry_points.txt,sha256=RrhkEH0MbfRzflguVrfYfthsFC5V2fkFnizUG3uHMtQ,55
11
- ansible_vars-1.0.6.dist-info/licenses/LICENSE,sha256=ocyJHLG5wD12qB4uam2pqWTHIJmzloiyNyTex6Q2DKo,1062
12
- ansible_vars-1.0.6.dist-info/RECORD,,