envdrift 4.2.1__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.
Files changed (52) hide show
  1. envdrift/__init__.py +30 -0
  2. envdrift/_version.py +34 -0
  3. envdrift/api.py +192 -0
  4. envdrift/cli.py +42 -0
  5. envdrift/cli_commands/__init__.py +1 -0
  6. envdrift/cli_commands/diff.py +91 -0
  7. envdrift/cli_commands/encryption.py +630 -0
  8. envdrift/cli_commands/encryption_helpers.py +93 -0
  9. envdrift/cli_commands/hook.py +75 -0
  10. envdrift/cli_commands/init_cmd.py +117 -0
  11. envdrift/cli_commands/partial.py +222 -0
  12. envdrift/cli_commands/sync.py +1140 -0
  13. envdrift/cli_commands/validate.py +109 -0
  14. envdrift/cli_commands/vault.py +376 -0
  15. envdrift/cli_commands/version.py +15 -0
  16. envdrift/config.py +489 -0
  17. envdrift/constants.json +18 -0
  18. envdrift/core/__init__.py +30 -0
  19. envdrift/core/diff.py +233 -0
  20. envdrift/core/encryption.py +400 -0
  21. envdrift/core/parser.py +260 -0
  22. envdrift/core/partial_encryption.py +239 -0
  23. envdrift/core/schema.py +253 -0
  24. envdrift/core/validator.py +312 -0
  25. envdrift/encryption/__init__.py +117 -0
  26. envdrift/encryption/base.py +217 -0
  27. envdrift/encryption/dotenvx.py +236 -0
  28. envdrift/encryption/sops.py +458 -0
  29. envdrift/env_files.py +60 -0
  30. envdrift/integrations/__init__.py +21 -0
  31. envdrift/integrations/dotenvx.py +689 -0
  32. envdrift/integrations/precommit.py +266 -0
  33. envdrift/integrations/sops.py +85 -0
  34. envdrift/output/__init__.py +21 -0
  35. envdrift/output/rich.py +424 -0
  36. envdrift/py.typed +0 -0
  37. envdrift/sync/__init__.py +26 -0
  38. envdrift/sync/config.py +218 -0
  39. envdrift/sync/engine.py +383 -0
  40. envdrift/sync/operations.py +138 -0
  41. envdrift/sync/result.py +99 -0
  42. envdrift/vault/__init__.py +107 -0
  43. envdrift/vault/aws.py +282 -0
  44. envdrift/vault/azure.py +170 -0
  45. envdrift/vault/base.py +150 -0
  46. envdrift/vault/gcp.py +210 -0
  47. envdrift/vault/hashicorp.py +238 -0
  48. envdrift-4.2.1.dist-info/METADATA +160 -0
  49. envdrift-4.2.1.dist-info/RECORD +52 -0
  50. envdrift-4.2.1.dist-info/WHEEL +4 -0
  51. envdrift-4.2.1.dist-info/entry_points.txt +2 -0
  52. envdrift-4.2.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,630 @@
1
+ """Encryption and decryption commands for envdrift.
2
+
3
+ Supports multiple encryption backends:
4
+ - dotenvx (default): Uses dotenvx CLI for encryption
5
+ - sops: Uses Mozilla SOPS for encryption
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Annotated
12
+
13
+ import typer
14
+
15
+ from envdrift.core.encryption import EncryptionDetector
16
+ from envdrift.core.parser import EnvParser
17
+ from envdrift.core.schema import SchemaLoader, SchemaLoadError
18
+ from envdrift.encryption import EncryptionProvider, get_encryption_backend
19
+ from envdrift.encryption.base import EncryptionBackendError, EncryptionNotFoundError
20
+ from envdrift.output.rich import (
21
+ console,
22
+ print_encryption_report,
23
+ print_error,
24
+ print_success,
25
+ print_warning,
26
+ )
27
+ from envdrift.vault.base import SecretNotFoundError, VaultError
28
+
29
+
30
+ def _load_encryption_config():
31
+ import tomllib
32
+
33
+ from envdrift.config import ConfigNotFoundError, EnvdriftConfig, find_config, load_config
34
+
35
+ config_path = find_config()
36
+ if not config_path:
37
+ return EnvdriftConfig(), None
38
+
39
+ try:
40
+ return load_config(config_path), config_path
41
+ except tomllib.TOMLDecodeError as e:
42
+ print_warning(f"TOML syntax error in {config_path}: {e}")
43
+ except ConfigNotFoundError as e:
44
+ print_warning(str(e))
45
+
46
+ return EnvdriftConfig(), None
47
+
48
+
49
+ def _resolve_config_path(config_path: Path | None, value: Path | str | None) -> Path | None:
50
+ if not value:
51
+ return None
52
+
53
+ path = Path(value)
54
+ if config_path and not path.is_absolute():
55
+ return (config_path.parent / path).resolve()
56
+ return path
57
+
58
+
59
+ def encrypt_cmd(
60
+ env_file: Annotated[Path, typer.Argument(help="Path to .env file")] = Path(".env"),
61
+ check: Annotated[
62
+ bool, typer.Option("--check", help="Only check encryption status, don't encrypt")
63
+ ] = False,
64
+ backend: Annotated[
65
+ str | None,
66
+ typer.Option(
67
+ "--backend",
68
+ "-b",
69
+ help="Encryption backend to use: dotenvx or sops (defaults to config or dotenvx)",
70
+ ),
71
+ ] = None,
72
+ schema: Annotated[
73
+ str | None,
74
+ typer.Option("--schema", "-s", help="Schema for sensitive field detection"),
75
+ ] = None,
76
+ service_dir: Annotated[
77
+ Path | None,
78
+ typer.Option("--service-dir", "-d", help="Service directory for imports"),
79
+ ] = None,
80
+ # SOPS-specific options
81
+ age_recipients: Annotated[
82
+ str | None,
83
+ typer.Option("--age", help="Age public key(s) for SOPS encryption"),
84
+ ] = None,
85
+ kms_arn: Annotated[
86
+ str | None,
87
+ typer.Option("--kms", help="AWS KMS key ARN for SOPS encryption"),
88
+ ] = None,
89
+ gcp_kms: Annotated[
90
+ str | None,
91
+ typer.Option("--gcp-kms", help="GCP KMS resource ID for SOPS encryption"),
92
+ ] = None,
93
+ azure_kv: Annotated[
94
+ str | None,
95
+ typer.Option("--azure-kv", help="Azure Key Vault key URL for SOPS encryption"),
96
+ ] = None,
97
+ sops_config_file: Annotated[
98
+ Path | None,
99
+ typer.Option("--sops-config", help="Path to .sops.yaml config for SOPS"),
100
+ ] = None,
101
+ age_key_file: Annotated[
102
+ Path | None,
103
+ typer.Option("--age-key-file", help="Path to age private key file for SOPS"),
104
+ ] = None,
105
+ # Deprecated vault options
106
+ verify_vault: Annotated[
107
+ bool,
108
+ typer.Option(
109
+ "--verify-vault",
110
+ help="(Deprecated) Use `envdrift decrypt --verify-vault` instead",
111
+ hidden=True,
112
+ ),
113
+ ] = False,
114
+ vault_provider: Annotated[
115
+ str | None,
116
+ typer.Option(
117
+ "--provider", "-p", help="(Deprecated) Use with decrypt --verify-vault", hidden=True
118
+ ),
119
+ ] = None,
120
+ vault_url: Annotated[
121
+ str | None,
122
+ typer.Option(
123
+ "--vault-url", help="(Deprecated) Use with decrypt --verify-vault", hidden=True
124
+ ),
125
+ ] = None,
126
+ vault_region: Annotated[
127
+ str | None,
128
+ typer.Option("--region", help="(Deprecated) Use with decrypt --verify-vault", hidden=True),
129
+ ] = None,
130
+ vault_secret: Annotated[
131
+ str | None,
132
+ typer.Option("--secret", help="(Deprecated) Use with decrypt --verify-vault", hidden=True),
133
+ ] = None,
134
+ ) -> None:
135
+ """
136
+ Check encryption status of an .env file or encrypt it.
137
+
138
+ Supports multiple encryption backends:
139
+ - dotenvx (default or config): Uses dotenvx CLI for encryption
140
+ - sops: Uses Mozilla SOPS for encryption
141
+
142
+ If --backend is not provided, envdrift uses the backend from config
143
+ (envdrift.toml/pyproject.toml) or falls back to dotenvx.
144
+
145
+ When run with --check, prints an encryption report and exits with code 1
146
+ if the detector recommends blocking a commit.
147
+
148
+ When run without --check, attempts to perform encryption using the
149
+ specified backend; if the tool is not available, prints installation
150
+ instructions and exits.
151
+
152
+ Examples:
153
+ envdrift encrypt # Encrypt with dotenvx (default)
154
+ envdrift encrypt --backend sops # Encrypt with SOPS
155
+ envdrift encrypt --check # Check encryption status only
156
+ envdrift encrypt -b sops --age AGE_PUBLIC_KEY # SOPS with age key
157
+ envdrift encrypt --sops-config .sops.yaml # SOPS with explicit config
158
+ """
159
+ if not env_file.exists():
160
+ print_error(f"ENV file not found: {env_file}")
161
+ raise typer.Exit(code=1)
162
+
163
+ if verify_vault or vault_provider or vault_url or vault_region or vault_secret:
164
+ print_error("Vault verification moved to `envdrift decrypt --verify-vault ...`")
165
+ raise typer.Exit(code=1)
166
+
167
+ envdrift_config, config_path = _load_encryption_config()
168
+ encryption_config = getattr(envdrift_config, "encryption", None)
169
+
170
+ if backend is None:
171
+ backend = encryption_config.backend if encryption_config else "dotenvx"
172
+
173
+ # Validate backend
174
+ try:
175
+ backend_enum = EncryptionProvider(backend.lower())
176
+ except ValueError:
177
+ print_error(f"Unknown encryption backend: {backend}")
178
+ print_error("Supported backends: dotenvx, sops")
179
+ raise typer.Exit(code=1) from None
180
+
181
+ if encryption_config and backend_enum == EncryptionProvider.SOPS:
182
+ if age_recipients is None:
183
+ age_recipients = encryption_config.sops_age_recipients
184
+ if kms_arn is None:
185
+ kms_arn = encryption_config.sops_kms_arn
186
+ if gcp_kms is None:
187
+ gcp_kms = encryption_config.sops_gcp_kms
188
+ if azure_kv is None:
189
+ azure_kv = encryption_config.sops_azure_kv
190
+
191
+ if sops_config_file is None:
192
+ sops_config_file = _resolve_config_path(
193
+ config_path,
194
+ encryption_config.sops_config_file,
195
+ )
196
+ if age_key_file is None:
197
+ age_key_file = _resolve_config_path(
198
+ config_path,
199
+ encryption_config.sops_age_key_file,
200
+ )
201
+
202
+ # Load schema if provided
203
+ schema_meta = None
204
+ if schema:
205
+ loader = SchemaLoader()
206
+ try:
207
+ settings_cls = loader.load(schema, service_dir)
208
+ schema_meta = loader.extract_metadata(settings_cls)
209
+ except SchemaLoadError as e:
210
+ print_warning(f"Could not load schema: {e}")
211
+
212
+ # Parse env file
213
+ parser = EnvParser()
214
+ env = parser.parse(env_file)
215
+
216
+ # Analyze encryption
217
+ detector = EncryptionDetector()
218
+ report = detector.analyze(env, schema_meta)
219
+ detected_backend = detector.detect_backend_for_file(env_file)
220
+ if detected_backend:
221
+ report.detected_backend = detected_backend
222
+ elif report.detected_backend is None:
223
+ report.detected_backend = backend_enum.value
224
+
225
+ if check:
226
+ # Just report status
227
+ print_encryption_report(report)
228
+
229
+ if detector.should_block_commit(report):
230
+ raise typer.Exit(code=1)
231
+ else:
232
+ # Attempt encryption using the selected backend
233
+ try:
234
+ backend_config: dict[str, object] = {}
235
+ if encryption_config and backend_enum == EncryptionProvider.DOTENVX:
236
+ backend_config["auto_install"] = encryption_config.dotenvx_auto_install
237
+ if backend_enum == EncryptionProvider.SOPS:
238
+ if encryption_config:
239
+ backend_config["auto_install"] = encryption_config.sops_auto_install
240
+ if sops_config_file:
241
+ backend_config["config_file"] = sops_config_file
242
+ if age_key_file:
243
+ backend_config["age_key_file"] = age_key_file
244
+
245
+ encryption_backend = get_encryption_backend(backend_enum, **backend_config)
246
+
247
+ if not encryption_backend.is_installed():
248
+ print_error(f"{encryption_backend.name} is not installed")
249
+ console.print(encryption_backend.install_instructions())
250
+ raise typer.Exit(code=1)
251
+
252
+ # Build kwargs for SOPS-specific options
253
+ encrypt_kwargs = {}
254
+ if backend_enum == EncryptionProvider.SOPS:
255
+ if age_recipients:
256
+ encrypt_kwargs["age_recipients"] = age_recipients
257
+ if kms_arn:
258
+ encrypt_kwargs["kms_arn"] = kms_arn
259
+ if gcp_kms:
260
+ encrypt_kwargs["gcp_kms"] = gcp_kms
261
+ if azure_kv:
262
+ encrypt_kwargs["azure_kv"] = azure_kv
263
+
264
+ result = encryption_backend.encrypt(env_file, **encrypt_kwargs)
265
+ if result.success:
266
+ print_success(f"Encrypted {env_file} using {encryption_backend.name}")
267
+ else:
268
+ print_error(result.message)
269
+ raise typer.Exit(code=1)
270
+
271
+ except EncryptionNotFoundError as e:
272
+ print_error(str(e))
273
+ raise typer.Exit(code=1) from None
274
+ except EncryptionBackendError as e:
275
+ print_error(f"Encryption failed: {e}")
276
+ raise typer.Exit(code=1) from None
277
+
278
+
279
+ def _verify_decryption_with_vault(
280
+ env_file: Path,
281
+ provider: str,
282
+ vault_url: str | None,
283
+ region: str | None,
284
+ project_id: str | None,
285
+ secret_name: str,
286
+ ci: bool = False,
287
+ auto_install: bool = False,
288
+ ) -> bool:
289
+ """
290
+ Verify that a vault-stored private key can decrypt the given .env file.
291
+
292
+ Performs a non-destructive check by fetching the secret named `secret_name` from the specified vault provider, injecting the retrieved key into an isolated environment, and attempting to decrypt a temporary copy of `env_file` using the dotenvx integration. Prints user-facing status and remediation guidance; does not modify the original file.
293
+
294
+ Parameters:
295
+ env_file (Path): Path to the .env file to test decryption for.
296
+ provider (str): Vault provider identifier (e.g., "azure", "aws", "hashicorp", "gcp").
297
+ vault_url (str | None): Vault endpoint URL when required by the provider (e.g., Azure or HashiCorp); may be None for providers that do not require it.
298
+ region (str | None): Region identifier for providers that require it (e.g., AWS); may be None.
299
+ project_id (str | None): GCP project ID for Secret Manager.
300
+ secret_name (str): Name of the secret in the vault that contains the private key (or an environment-style value like "DOTENV_PRIVATE_KEY_ENV=key").
301
+
302
+ Returns:
303
+ bool: `True` if the vault key successfully decrypts a temporary copy of `env_file`, `False` otherwise.
304
+ """
305
+ import os
306
+ import tempfile
307
+
308
+ from envdrift.vault import get_vault_client
309
+
310
+ if not ci:
311
+ console.print()
312
+ console.print("[bold]Vault Key Verification[/bold]")
313
+ console.print(f"[dim]Provider: {provider} | Secret: {secret_name}[/dim]")
314
+
315
+ try:
316
+ # Create vault client
317
+ vault_kwargs: dict = {}
318
+ if provider == "azure":
319
+ vault_kwargs["vault_url"] = vault_url
320
+ elif provider == "aws":
321
+ vault_kwargs["region"] = region or "us-east-1"
322
+ elif provider == "hashicorp":
323
+ vault_kwargs["url"] = vault_url
324
+ elif provider == "gcp":
325
+ vault_kwargs["project_id"] = project_id
326
+
327
+ vault_client = get_vault_client(provider, **vault_kwargs)
328
+ vault_client.ensure_authenticated()
329
+
330
+ # Fetch private key from vault
331
+ if not ci:
332
+ console.print("[dim]Fetching private key from vault...[/dim]")
333
+ private_key = vault_client.get_secret(secret_name)
334
+
335
+ # SecretValue can be truthy even if value is empty; check both
336
+ if not private_key or (hasattr(private_key, "value") and not private_key.value):
337
+ print_error(f"Secret '{secret_name}' is empty in vault")
338
+ return False
339
+
340
+ # Extract the actual value from SecretValue object
341
+ # The vault client returns a SecretValue with .value attribute
342
+ if hasattr(private_key, "value"):
343
+ private_key_str = private_key.value
344
+ elif isinstance(private_key, str):
345
+ private_key_str = private_key
346
+ else:
347
+ private_key_str = str(private_key)
348
+
349
+ if not ci:
350
+ console.print("[dim]Private key retrieved successfully[/dim]")
351
+
352
+ # Try to decrypt using the vault key
353
+ if not ci:
354
+ console.print("[dim]Testing decryption with vault key...[/dim]")
355
+
356
+ from envdrift.integrations.dotenvx import DotenvxError, DotenvxWrapper
357
+
358
+ dotenvx = DotenvxWrapper(auto_install=auto_install)
359
+ if not dotenvx.is_installed():
360
+ print_error("dotenvx is not installed - cannot verify decryption")
361
+ return False
362
+
363
+ # The vault stores secrets in "DOTENV_PRIVATE_KEY_ENV=key" format
364
+ # Parse out the actual key value if it's in that format
365
+ actual_private_key = private_key_str
366
+ if "=" in private_key_str and private_key_str.startswith("DOTENV_PRIVATE_KEY"):
367
+ # Extract just the key value after the =
368
+ actual_private_key = private_key_str.split("=", 1)[1]
369
+ # Get the variable name from the vault value
370
+ key_var_name = private_key_str.split("=", 1)[0]
371
+ else:
372
+ # Key is just the raw value, construct variable name from env file
373
+ env_name = env_file.stem.replace(".env", "").replace(".", "_").upper()
374
+ if env_name.startswith("_"):
375
+ env_name = env_name[1:]
376
+ if not env_name:
377
+ env_name = "PRODUCTION" # Default
378
+ key_var_name = f"DOTENV_PRIVATE_KEY_{env_name}"
379
+
380
+ # Build a clean environment so dotenvx cannot fall back to stray keys
381
+ dotenvx_env = {
382
+ k: v for k, v in os.environ.items() if not k.startswith("DOTENV_PRIVATE_KEY")
383
+ }
384
+ dotenvx_env.pop("DOTENV_KEY", None)
385
+ dotenvx_env[key_var_name] = actual_private_key
386
+
387
+ # Work inside an isolated temp directory with only the vault key
388
+ with tempfile.TemporaryDirectory(prefix=".envdrift-verify-") as temp_dir:
389
+ temp_dir_path = Path(temp_dir)
390
+ tmp_path = temp_dir_path / env_file.name # Preserve filename for key naming
391
+
392
+ # Copy env file into isolated directory; inject vault key via environment
393
+ tmp_path.write_text(env_file.read_text())
394
+
395
+ try:
396
+ dotenvx.decrypt(
397
+ tmp_path,
398
+ env_keys_file=None,
399
+ env=dotenvx_env,
400
+ cwd=temp_dir_path,
401
+ )
402
+ print_success("✓ Vault key can decrypt this file - keys are in sync!")
403
+ return True
404
+ except DotenvxError as e:
405
+ print_error("✗ Vault key CANNOT decrypt this file!")
406
+ console.print(f"[red]Error: {e}[/red]")
407
+ console.print()
408
+ console.print(
409
+ "[yellow]This means the file was encrypted with a DIFFERENT key.[/yellow]"
410
+ )
411
+ console.print("[yellow]The team's shared vault key won't work![/yellow]")
412
+ console.print()
413
+ console.print("[bold]To fix:[/bold]")
414
+ console.print(f" 1. Restore the encrypted file: git restore {env_file}")
415
+
416
+ # Construct sync command with the same provider options
417
+ sync_cmd = f"envdrift sync --force -c pair.txt -p {provider}"
418
+ if vault_url:
419
+ sync_cmd += f" --vault-url {vault_url}"
420
+ if region:
421
+ sync_cmd += f" --region {region}"
422
+ if project_id:
423
+ sync_cmd += f" --project-id {project_id}"
424
+ console.print(f" 2. Restore vault key locally: {sync_cmd}")
425
+
426
+ console.print(f" 3. Re-encrypt with the vault key: envdrift encrypt {env_file}")
427
+ return False
428
+
429
+ except SecretNotFoundError:
430
+ print_error(f"Secret '{secret_name}' not found in vault")
431
+ return False
432
+ except VaultError as e:
433
+ print_error(f"Vault error: {e}")
434
+ return False
435
+ except ImportError as e:
436
+ print_error(f"Import error: {e}")
437
+ return False
438
+ except Exception as e:
439
+ import logging
440
+ import traceback
441
+
442
+ logging.debug("Unexpected vault verification error:\n%s", traceback.format_exc())
443
+ print_error(f"Unexpected error during vault verification: {e}")
444
+ return False
445
+
446
+
447
+ def decrypt_cmd(
448
+ env_file: Annotated[Path, typer.Argument(help="Path to encrypted .env file")] = Path(".env"),
449
+ backend: Annotated[
450
+ str | None,
451
+ typer.Option(
452
+ "--backend",
453
+ "-b",
454
+ help="Encryption backend: dotenvx, sops (auto-detects or uses config if not specified)",
455
+ ),
456
+ ] = None,
457
+ sops_config_file: Annotated[
458
+ Path | None,
459
+ typer.Option("--sops-config", help="Path to .sops.yaml config for SOPS"),
460
+ ] = None,
461
+ age_key_file: Annotated[
462
+ Path | None,
463
+ typer.Option("--age-key-file", help="Path to age private key file for SOPS"),
464
+ ] = None,
465
+ verify_vault: Annotated[
466
+ bool,
467
+ typer.Option(
468
+ "--verify-vault", help="Verify vault key can decrypt without modifying the file"
469
+ ),
470
+ ] = False,
471
+ ci: Annotated[
472
+ bool,
473
+ typer.Option("--ci", help="CI mode: non-interactive; exits non-zero on errors"),
474
+ ] = False,
475
+ vault_provider: Annotated[
476
+ str | None,
477
+ typer.Option("--provider", "-p", help="Vault provider: azure, aws, hashicorp, gcp"),
478
+ ] = None,
479
+ vault_url: Annotated[
480
+ str | None,
481
+ typer.Option("--vault-url", help="Vault URL (Azure/HashiCorp)"),
482
+ ] = None,
483
+ vault_region: Annotated[
484
+ str | None,
485
+ typer.Option("--region", help="AWS region"),
486
+ ] = None,
487
+ vault_project_id: Annotated[
488
+ str | None,
489
+ typer.Option("--project-id", help="GCP project ID (Secret Manager)"),
490
+ ] = None,
491
+ vault_secret: Annotated[
492
+ str | None,
493
+ typer.Option("--secret", help="Vault secret name for the private key"),
494
+ ] = None,
495
+ ) -> None:
496
+ """
497
+ Decrypt an encrypted .env file.
498
+
499
+ Supports multiple encryption backends:
500
+ - dotenvx: Uses dotenvx CLI for decryption
501
+ - sops: Uses Mozilla SOPS for decryption
502
+
503
+ If --backend is not specified, the backend will be auto-detected based on
504
+ the file content. When auto-detection fails, envdrift falls back to the
505
+ configured backend or dotenvx.
506
+
507
+ Examples:
508
+ envdrift decrypt # Auto-detect backend
509
+ envdrift decrypt --backend sops # Force SOPS decryption
510
+ envdrift decrypt --verify-vault ... # Verify vault key (dotenvx only)
511
+
512
+ Parameters:
513
+ env_file (Path): Path to the encrypted .env file to operate on.
514
+ backend (str | None): Encryption backend to use (dotenvx or sops).
515
+ sops_config_file (Path | None): Path to .sops.yaml when using SOPS.
516
+ age_key_file (Path | None): Path to age private key file for SOPS.
517
+ verify_vault (bool): If true, perform a vault-based verification instead of local decryption.
518
+ ci (bool): CI mode (non-interactive); affects exit behavior for errors.
519
+ vault_provider (str | None): Vault provider identifier; supported values include "azure", "aws", "hashicorp", and "gcp". Required when --verify-vault is used.
520
+ vault_url (str | None): Vault URL required for providers that need it (Azure and HashiCorp) when verifying with a vault key.
521
+ vault_region (str | None): AWS region when using the AWS provider for vault verification.
522
+ vault_project_id (str | None): GCP project ID when using the GCP provider for vault verification.
523
+ vault_secret (str | None): Name of the vault secret that holds the private key; required when --verify-vault is used.
524
+ """
525
+ if not env_file.exists():
526
+ print_error(f"ENV file not found: {env_file}")
527
+ raise typer.Exit(code=1)
528
+
529
+ envdrift_config, config_path = _load_encryption_config()
530
+ encryption_config = getattr(envdrift_config, "encryption", None)
531
+
532
+ # Auto-detect backend if not specified
533
+ if backend is None:
534
+ detector = EncryptionDetector()
535
+ detected = detector.detect_backend_for_file(env_file)
536
+ if detected:
537
+ backend = detected
538
+ console.print(f"[dim]Auto-detected encryption backend: {backend}[/dim]")
539
+ else:
540
+ backend = encryption_config.backend if encryption_config else "dotenvx"
541
+
542
+ # Validate backend
543
+ try:
544
+ backend_enum = EncryptionProvider(backend.lower())
545
+ except ValueError:
546
+ print_error(f"Unknown encryption backend: {backend}")
547
+ print_error("Supported backends: dotenvx, sops")
548
+ raise typer.Exit(code=1) from None
549
+
550
+ if encryption_config and backend_enum == EncryptionProvider.SOPS:
551
+ if sops_config_file is None:
552
+ sops_config_file = _resolve_config_path(
553
+ config_path,
554
+ encryption_config.sops_config_file,
555
+ )
556
+ if age_key_file is None:
557
+ age_key_file = _resolve_config_path(
558
+ config_path,
559
+ encryption_config.sops_age_key_file,
560
+ )
561
+
562
+ if verify_vault:
563
+ # Vault verification currently only works with dotenvx
564
+ if backend_enum != EncryptionProvider.DOTENVX:
565
+ print_error("Vault verification is only supported with dotenvx backend")
566
+ raise typer.Exit(code=1)
567
+
568
+ if not vault_provider:
569
+ print_error("--verify-vault requires --provider")
570
+ raise typer.Exit(code=1)
571
+ if not vault_secret:
572
+ print_error("--verify-vault requires --secret (vault secret name)")
573
+ raise typer.Exit(code=1)
574
+ if vault_provider in ("azure", "hashicorp") and not vault_url:
575
+ print_error(f"--verify-vault with {vault_provider} requires --vault-url")
576
+ raise typer.Exit(code=1)
577
+ if vault_provider == "gcp" and not vault_project_id:
578
+ print_error("--verify-vault with gcp requires --project-id")
579
+ raise typer.Exit(code=1)
580
+
581
+ vault_check_passed = _verify_decryption_with_vault(
582
+ env_file=env_file,
583
+ provider=vault_provider,
584
+ vault_url=vault_url,
585
+ region=vault_region,
586
+ project_id=vault_project_id,
587
+ secret_name=vault_secret,
588
+ ci=ci,
589
+ auto_install=encryption_config.dotenvx_auto_install if encryption_config else False,
590
+ )
591
+ if not vault_check_passed:
592
+ raise typer.Exit(code=1)
593
+
594
+ console.print("[dim]Vault verification completed. Original file was not decrypted.[/dim]")
595
+ console.print("[dim]Run without --verify-vault to decrypt the file locally.[/dim]")
596
+ return
597
+
598
+ # Decrypt using the selected backend
599
+ try:
600
+ backend_config: dict[str, object] = {}
601
+ if encryption_config and backend_enum == EncryptionProvider.DOTENVX:
602
+ backend_config["auto_install"] = encryption_config.dotenvx_auto_install
603
+ if backend_enum == EncryptionProvider.SOPS:
604
+ if encryption_config:
605
+ backend_config["auto_install"] = encryption_config.sops_auto_install
606
+ if sops_config_file:
607
+ backend_config["config_file"] = sops_config_file
608
+ if age_key_file:
609
+ backend_config["age_key_file"] = age_key_file
610
+
611
+ encryption_backend = get_encryption_backend(backend_enum, **backend_config)
612
+
613
+ if not encryption_backend.is_installed():
614
+ print_error(f"{encryption_backend.name} is not installed")
615
+ console.print(encryption_backend.install_instructions())
616
+ raise typer.Exit(code=1)
617
+
618
+ result = encryption_backend.decrypt(env_file)
619
+ if result.success:
620
+ print_success(f"Decrypted {env_file} using {encryption_backend.name}")
621
+ else:
622
+ print_error(result.message)
623
+ raise typer.Exit(code=1)
624
+
625
+ except EncryptionNotFoundError as e:
626
+ print_error(str(e))
627
+ raise typer.Exit(code=1) from None
628
+ except EncryptionBackendError as e:
629
+ print_error(f"Decryption failed: {e}")
630
+ raise typer.Exit(code=1) from None