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.
- envdrift/__init__.py +30 -0
- envdrift/_version.py +34 -0
- envdrift/api.py +192 -0
- envdrift/cli.py +42 -0
- envdrift/cli_commands/__init__.py +1 -0
- envdrift/cli_commands/diff.py +91 -0
- envdrift/cli_commands/encryption.py +630 -0
- envdrift/cli_commands/encryption_helpers.py +93 -0
- envdrift/cli_commands/hook.py +75 -0
- envdrift/cli_commands/init_cmd.py +117 -0
- envdrift/cli_commands/partial.py +222 -0
- envdrift/cli_commands/sync.py +1140 -0
- envdrift/cli_commands/validate.py +109 -0
- envdrift/cli_commands/vault.py +376 -0
- envdrift/cli_commands/version.py +15 -0
- envdrift/config.py +489 -0
- envdrift/constants.json +18 -0
- envdrift/core/__init__.py +30 -0
- envdrift/core/diff.py +233 -0
- envdrift/core/encryption.py +400 -0
- envdrift/core/parser.py +260 -0
- envdrift/core/partial_encryption.py +239 -0
- envdrift/core/schema.py +253 -0
- envdrift/core/validator.py +312 -0
- envdrift/encryption/__init__.py +117 -0
- envdrift/encryption/base.py +217 -0
- envdrift/encryption/dotenvx.py +236 -0
- envdrift/encryption/sops.py +458 -0
- envdrift/env_files.py +60 -0
- envdrift/integrations/__init__.py +21 -0
- envdrift/integrations/dotenvx.py +689 -0
- envdrift/integrations/precommit.py +266 -0
- envdrift/integrations/sops.py +85 -0
- envdrift/output/__init__.py +21 -0
- envdrift/output/rich.py +424 -0
- envdrift/py.typed +0 -0
- envdrift/sync/__init__.py +26 -0
- envdrift/sync/config.py +218 -0
- envdrift/sync/engine.py +383 -0
- envdrift/sync/operations.py +138 -0
- envdrift/sync/result.py +99 -0
- envdrift/vault/__init__.py +107 -0
- envdrift/vault/aws.py +282 -0
- envdrift/vault/azure.py +170 -0
- envdrift/vault/base.py +150 -0
- envdrift/vault/gcp.py +210 -0
- envdrift/vault/hashicorp.py +238 -0
- envdrift-4.2.1.dist-info/METADATA +160 -0
- envdrift-4.2.1.dist-info/RECORD +52 -0
- envdrift-4.2.1.dist-info/WHEEL +4 -0
- envdrift-4.2.1.dist-info/entry_points.txt +2 -0
- 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
|