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
envdrift/core/diff.py ADDED
@@ -0,0 +1,233 @@
1
+ """Cross-environment diff engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+ from pathlib import Path
8
+
9
+ from envdrift.core.parser import EnvFile
10
+ from envdrift.core.schema import SchemaMetadata
11
+
12
+
13
+ class DiffType(Enum):
14
+ """Type of difference between environments."""
15
+
16
+ ADDED = "added" # In env2 but not env1
17
+ REMOVED = "removed" # In env1 but not env2
18
+ CHANGED = "changed" # Different values
19
+ UNCHANGED = "unchanged" # Same values
20
+
21
+
22
+ @dataclass
23
+ class VarDiff:
24
+ """Difference for a single variable."""
25
+
26
+ name: str
27
+ diff_type: DiffType
28
+ value1: str | None # Value in env1 (masked if sensitive)
29
+ value2: str | None # Value in env2 (masked if sensitive)
30
+ is_sensitive: bool
31
+ line_number1: int | None = None # Line in env1
32
+ line_number2: int | None = None # Line in env2
33
+
34
+
35
+ @dataclass
36
+ class DiffResult:
37
+ """Result of comparing two env files."""
38
+
39
+ env1_path: Path
40
+ env2_path: Path
41
+ differences: list[VarDiff] = field(default_factory=list)
42
+
43
+ @property
44
+ def added_count(self) -> int:
45
+ """
46
+ Number of variables that are present in env2 but not in env1.
47
+
48
+ Returns:
49
+ int: Count of variables classified as `ADDED`.
50
+ """
51
+ return sum(1 for d in self.differences if d.diff_type == DiffType.ADDED)
52
+
53
+ @property
54
+ def removed_count(self) -> int:
55
+ """
56
+ Number of variables that are present in the first environment but missing in the second.
57
+
58
+ Returns:
59
+ int: Count of diffs with type `DiffType.REMOVED`.
60
+ """
61
+ return sum(1 for d in self.differences if d.diff_type == DiffType.REMOVED)
62
+
63
+ @property
64
+ def changed_count(self) -> int:
65
+ """
66
+ Number of variables whose values differ between the two environments.
67
+
68
+ Returns:
69
+ int: Count of VarDiff entries whose `diff_type` is `DiffType.CHANGED`.
70
+ """
71
+ return sum(1 for d in self.differences if d.diff_type == DiffType.CHANGED)
72
+
73
+ @property
74
+ def unchanged_count(self) -> int:
75
+ """
76
+ Return the number of variables that are unchanged between the two environments.
77
+
78
+ Returns:
79
+ int: Count of VarDiff entries whose `diff_type` is `DiffType.UNCHANGED`.
80
+ """
81
+ return sum(1 for d in self.differences if d.diff_type == DiffType.UNCHANGED)
82
+
83
+ @property
84
+ def has_drift(self) -> bool:
85
+ """
86
+ Determine whether there is any drift between the two environments.
87
+
88
+ Returns:
89
+ True if at least one variable was added, removed, or changed, False otherwise.
90
+ """
91
+ return self.added_count + self.removed_count + self.changed_count > 0
92
+
93
+ def get_added(self) -> list[VarDiff]:
94
+ """
95
+ List VarDiff entries that are present only in the second environment.
96
+
97
+ Returns:
98
+ list[VarDiff]: VarDiff objects whose `diff_type` is `DiffType.ADDED`.
99
+ """
100
+ return [d for d in self.differences if d.diff_type == DiffType.ADDED]
101
+
102
+ def get_removed(self) -> list[VarDiff]:
103
+ """
104
+ Retrieve variables present in the first environment but absent in the second.
105
+
106
+ Returns:
107
+ list[VarDiff]: VarDiff objects whose `diff_type` is `DiffType.REMOVED`.
108
+ """
109
+ return [d for d in self.differences if d.diff_type == DiffType.REMOVED]
110
+
111
+ def get_changed(self) -> list[VarDiff]:
112
+ """
113
+ Return all variables whose values differ between the two environments.
114
+
115
+ Returns:
116
+ list[VarDiff]: List of VarDiff entries whose `diff_type` is `DiffType.CHANGED`.
117
+ """
118
+ return [d for d in self.differences if d.diff_type == DiffType.CHANGED]
119
+
120
+
121
+ class DiffEngine:
122
+ """Compare two .env files."""
123
+
124
+ MASK_VALUE = "********"
125
+
126
+ def diff(
127
+ self,
128
+ env1: EnvFile,
129
+ env2: EnvFile,
130
+ schema: SchemaMetadata | None = None,
131
+ mask_values: bool = True,
132
+ include_unchanged: bool = False,
133
+ ) -> DiffResult:
134
+ """
135
+ Compute differences between two environment files and return a structured DiffResult.
136
+
137
+ Parameters:
138
+ env1 (EnvFile): First environment file (left-hand side of comparison).
139
+ env2 (EnvFile): Second environment file (right-hand side of comparison).
140
+ schema (SchemaMetadata | None): Optional schema used to identify sensitive fields.
141
+ mask_values (bool): If True, sensitive variable values are replaced with a mask in the result.
142
+ include_unchanged (bool): If True, variables with identical values in both files are included.
143
+
144
+ Returns:
145
+ DiffResult: Aggregated comparison result containing a list of VarDiff entries and summary counts.
146
+ """
147
+ result = DiffResult(env1_path=env1.path, env2_path=env2.path)
148
+
149
+ env1_vars = set(env1.variables.keys())
150
+ env2_vars = set(env2.variables.keys())
151
+
152
+ all_vars = env1_vars | env2_vars
153
+ sensitive_fields = set(schema.sensitive_fields) if schema else set()
154
+
155
+ for var_name in sorted(all_vars):
156
+ in_env1 = var_name in env1_vars
157
+ in_env2 = var_name in env2_vars
158
+ is_sensitive = var_name in sensitive_fields
159
+
160
+ var1 = env1.variables.get(var_name)
161
+ var2 = env2.variables.get(var_name)
162
+
163
+ # Get values (potentially masked)
164
+ value1 = var1.value if var1 else None
165
+ value2 = var2.value if var2 else None
166
+
167
+ if mask_values and is_sensitive:
168
+ display_value1 = self.MASK_VALUE if value1 else None
169
+ display_value2 = self.MASK_VALUE if value2 else None
170
+ else:
171
+ display_value1 = value1
172
+ display_value2 = value2
173
+
174
+ # Determine diff type
175
+ if not in_env1 and in_env2:
176
+ diff_type = DiffType.ADDED
177
+ elif in_env1 and not in_env2:
178
+ diff_type = DiffType.REMOVED
179
+ elif value1 != value2:
180
+ diff_type = DiffType.CHANGED
181
+ else:
182
+ diff_type = DiffType.UNCHANGED
183
+ if not include_unchanged:
184
+ continue
185
+
186
+ var_diff = VarDiff(
187
+ name=var_name,
188
+ diff_type=diff_type,
189
+ value1=display_value1,
190
+ value2=display_value2,
191
+ is_sensitive=is_sensitive,
192
+ line_number1=var1.line_number if var1 else None,
193
+ line_number2=var2.line_number if var2 else None,
194
+ )
195
+
196
+ result.differences.append(var_diff)
197
+
198
+ return result
199
+
200
+ def to_dict(self, result: DiffResult) -> dict:
201
+ """
202
+ Convert a DiffResult into a JSON-serializable dictionary.
203
+
204
+ Args:
205
+ result: DiffResult instance to convert.
206
+
207
+ Returns:
208
+ dict: Mapping with keys:
209
+ - "env1": string path of the first env file
210
+ - "env2": string path of the second env file
211
+ - "summary": dict with counts ("added", "removed", "changed") and "has_drift" flag
212
+ - "differences": list of dicts for each variable containing "name", "type", "value_env1", "value_env2", and "sensitive"
213
+ """
214
+ return {
215
+ "env1": str(result.env1_path),
216
+ "env2": str(result.env2_path),
217
+ "summary": {
218
+ "added": result.added_count,
219
+ "removed": result.removed_count,
220
+ "changed": result.changed_count,
221
+ "has_drift": result.has_drift,
222
+ },
223
+ "differences": [
224
+ {
225
+ "name": d.name,
226
+ "type": d.diff_type.value,
227
+ "value_env1": d.value1,
228
+ "value_env2": d.value2,
229
+ "sensitive": d.is_sensitive,
230
+ }
231
+ for d in result.differences
232
+ ],
233
+ }
@@ -0,0 +1,400 @@
1
+ """Encryption detection for .env files.
2
+
3
+ Supports multiple encryption backends:
4
+ - dotenvx: Uses "encrypted:" prefix and dotenvx file headers
5
+ - SOPS: Uses "ENC[AES256_GCM,..." format
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING
14
+
15
+ from envdrift.core.parser import EncryptionStatus, EnvFile
16
+ from envdrift.core.schema import SchemaMetadata
17
+
18
+ if TYPE_CHECKING:
19
+ pass
20
+
21
+
22
+ @dataclass
23
+ class EncryptionReport:
24
+ """Report on encryption status of an env file."""
25
+
26
+ path: Path
27
+ is_fully_encrypted: bool = False
28
+ encrypted_vars: set[str] = field(default_factory=set)
29
+ plaintext_vars: set[str] = field(default_factory=set)
30
+ empty_vars: set[str] = field(default_factory=set)
31
+ plaintext_secrets: set[str] = field(
32
+ default_factory=set
33
+ ) # Plaintext vars that look like secrets
34
+ warnings: list[str] = field(default_factory=list)
35
+ detected_backend: str | None = None # Which encryption backend was detected
36
+
37
+ @property
38
+ def encryption_ratio(self) -> float:
39
+ """
40
+ Compute the fraction of non-empty variables that are encrypted.
41
+
42
+ Returns:
43
+ encryption_ratio (float): Fraction between 0.0 and 1.0 equal to encrypted_vars / (encrypted_vars + plaintext_vars). Returns 0.0 when there are no non-empty variables.
44
+ """
45
+ total = len(self.encrypted_vars) + len(self.plaintext_vars)
46
+ if total == 0:
47
+ return 0.0
48
+ return len(self.encrypted_vars) / total
49
+
50
+ @property
51
+ def total_vars(self) -> int:
52
+ """
53
+ Total number of variables considered by the report.
54
+
55
+ Returns:
56
+ int: Count of encrypted, plaintext, and empty variables.
57
+ """
58
+ return len(self.encrypted_vars) + len(self.plaintext_vars) + len(self.empty_vars)
59
+
60
+
61
+ class EncryptionDetector:
62
+ """Detect encryption status of .env files.
63
+
64
+ Supports multiple encryption backends:
65
+ - dotenvx: Values prefixed with "encrypted:", file has dotenvx headers
66
+ - SOPS: Values in format "ENC[AES256_GCM,data:...,iv:...,tag:...,type:str]"
67
+ """
68
+
69
+ # Patterns that indicate encrypted values (dotenvx format)
70
+ DOTENVX_ENCRYPTED_PREFIXES = [
71
+ "encrypted:",
72
+ ]
73
+
74
+ # Patterns that indicate encrypted values (SOPS format)
75
+ SOPS_ENCRYPTED_PATTERN = re.compile(r"^ENC\[AES256_GCM,")
76
+
77
+ # All encrypted prefixes for backward compatibility
78
+ ENCRYPTED_PREFIXES = DOTENVX_ENCRYPTED_PREFIXES + ["ENC["]
79
+
80
+ # Header patterns that indicate the file has been encrypted by dotenvx
81
+ DOTENVX_FILE_MARKERS = [
82
+ "#/---BEGIN DOTENV ENCRYPTED---/",
83
+ "DOTENV_PUBLIC_KEY",
84
+ ]
85
+
86
+ # Header/content patterns that indicate the file has been encrypted by SOPS
87
+ SOPS_FILE_MARKERS = [
88
+ "sops:", # YAML metadata
89
+ '"sops":', # JSON metadata
90
+ "ENC[AES256_GCM,", # Encrypted value marker
91
+ ]
92
+
93
+ # Combined file markers for backward compatibility
94
+ ENCRYPTED_FILE_MARKERS = DOTENVX_FILE_MARKERS + SOPS_FILE_MARKERS
95
+
96
+ # Patterns for suspicious plaintext secrets
97
+ SECRET_VALUE_PATTERNS = [
98
+ re.compile(r"^sk[-_]", re.IGNORECASE), # Stripe, OpenAI keys
99
+ re.compile(r"^pk[-_]", re.IGNORECASE), # Public keys
100
+ re.compile(r"^ghp_"), # GitHub personal tokens
101
+ re.compile(r"^gho_"), # GitHub OAuth tokens
102
+ re.compile(r"^xox[baprs]-"), # Slack tokens
103
+ re.compile(r"^AKIA[0-9A-Z]{16}$"), # AWS access keys
104
+ re.compile(r"^eyJ[A-Za-z0-9_-]+\.eyJ"), # JWT tokens
105
+ re.compile(r"^postgres(ql)?://[^:]+:[^@]+@"), # DB URLs with creds
106
+ re.compile(r"^mysql://[^:]+:[^@]+@"),
107
+ re.compile(r"^redis://[^:]+:[^@]+@"),
108
+ re.compile(r"^mongodb(\+srv)?://[^:]+:[^@]+@"),
109
+ ]
110
+
111
+ # Variable names that suggest sensitive content
112
+ SENSITIVE_NAME_PATTERNS = [
113
+ re.compile(r".*_KEY$", re.IGNORECASE),
114
+ re.compile(r".*_SECRET$", re.IGNORECASE),
115
+ re.compile(r".*_TOKEN$", re.IGNORECASE),
116
+ re.compile(r".*_PASSWORD$", re.IGNORECASE),
117
+ re.compile(r".*_PASS$", re.IGNORECASE),
118
+ re.compile(r".*_CREDENTIAL.*", re.IGNORECASE),
119
+ re.compile(r".*_API_KEY$", re.IGNORECASE),
120
+ re.compile(r"^JWT_.*", re.IGNORECASE),
121
+ re.compile(r"^AUTH_.*", re.IGNORECASE),
122
+ re.compile(r"^PRIVATE_.*", re.IGNORECASE),
123
+ re.compile(r".*_DSN$", re.IGNORECASE),
124
+ ]
125
+
126
+ def analyze(
127
+ self,
128
+ env_file: EnvFile,
129
+ schema: SchemaMetadata | None = None,
130
+ ) -> EncryptionReport:
131
+ """
132
+ Analyze an EnvFile to determine which variables are encrypted, plaintext, empty, and which plaintext values appear to be secrets.
133
+
134
+ Parameters:
135
+ env_file (EnvFile): Parsed env file to analyze.
136
+ schema (SchemaMetadata | None): Optional schema whose sensitive_fields will be treated as sensitive names.
137
+
138
+ Returns:
139
+ EncryptionReport: Report containing the file path, sets of encrypted/plaintext/empty variables, detected plaintext secrets, collected warnings, and the is_fully_encrypted flag.
140
+ """
141
+ report = EncryptionReport(path=env_file.path)
142
+
143
+ # Get sensitive fields from schema
144
+ schema_sensitive = set(schema.sensitive_fields) if schema else set()
145
+
146
+ for var_name, env_var in env_file.variables.items():
147
+ if env_var.encryption_status == EncryptionStatus.ENCRYPTED:
148
+ report.encrypted_vars.add(var_name)
149
+ elif env_var.encryption_status == EncryptionStatus.EMPTY:
150
+ report.empty_vars.add(var_name)
151
+ else:
152
+ report.plaintext_vars.add(var_name)
153
+
154
+ # Check if this plaintext value looks like a secret
155
+ is_suspicious = self.is_value_suspicious(env_var.value)
156
+ is_name_sensitive = self.is_name_sensitive(var_name)
157
+ is_schema_sensitive = var_name in schema_sensitive
158
+
159
+ if is_suspicious or is_name_sensitive or is_schema_sensitive:
160
+ report.plaintext_secrets.add(var_name)
161
+
162
+ if is_schema_sensitive:
163
+ report.warnings.append(
164
+ f"'{var_name}' is marked sensitive in schema but has plaintext value"
165
+ )
166
+ elif is_suspicious:
167
+ report.warnings.append(f"'{var_name}' has a value that looks like a secret")
168
+ elif is_name_sensitive:
169
+ report.warnings.append(f"'{var_name}' has a name suggesting sensitive data")
170
+
171
+ # Determine if fully encrypted
172
+ non_empty_vars = report.encrypted_vars | report.plaintext_vars
173
+ if non_empty_vars:
174
+ report.is_fully_encrypted = len(report.plaintext_vars) == 0
175
+
176
+ detected_backends = {
177
+ env_var.encryption_backend
178
+ for env_var in env_file.variables.values()
179
+ if env_var.encryption_backend
180
+ }
181
+ if len(detected_backends) == 1:
182
+ report.detected_backend = next(iter(detected_backends))
183
+
184
+ return report
185
+
186
+ def should_block_commit(self, report: EncryptionReport) -> bool:
187
+ """
188
+ Decides whether a commit should be blocked due to plaintext secrets found in the report.
189
+
190
+ Parameters:
191
+ report (EncryptionReport): Analysis report to evaluate.
192
+
193
+ Returns:
194
+ `true` if the report contains any plaintext secrets, `false` otherwise.
195
+ """
196
+ return len(report.plaintext_secrets) > 0
197
+
198
+ def has_encrypted_header(self, content: str) -> bool:
199
+ """
200
+ Determine whether the given file content contains encryption markers.
201
+
202
+ Parameters:
203
+ content (str): Raw file content to inspect for encryption markers.
204
+
205
+ Returns:
206
+ `true` if any encrypted-file marker is present in content, `false` otherwise.
207
+ """
208
+ for marker in self.ENCRYPTED_FILE_MARKERS:
209
+ if marker in content:
210
+ return True
211
+ return False
212
+
213
+ def has_dotenvx_header(self, content: str) -> bool:
214
+ """
215
+ Determine whether the given file content contains a dotenvx encryption header.
216
+
217
+ Parameters:
218
+ content (str): Raw file content to inspect for encryption markers.
219
+
220
+ Returns:
221
+ `true` if any dotenvx marker is present in content, `false` otherwise.
222
+ """
223
+ for marker in self.DOTENVX_FILE_MARKERS:
224
+ if marker in content:
225
+ return True
226
+ return False
227
+
228
+ def has_sops_header(self, content: str) -> bool:
229
+ """
230
+ Determine whether the given file content contains SOPS encryption markers.
231
+
232
+ Parameters:
233
+ content (str): Raw file content to inspect for encryption markers.
234
+
235
+ Returns:
236
+ `true` if any SOPS marker is present in content, `false` otherwise.
237
+ """
238
+ for marker in self.SOPS_FILE_MARKERS:
239
+ if marker in content:
240
+ return True
241
+ return False
242
+
243
+ def detect_backend(self, content: str) -> str | None:
244
+ """
245
+ Detect which encryption backend was used for the content.
246
+
247
+ Parameters:
248
+ content (str): Raw file content to inspect.
249
+
250
+ Returns:
251
+ "dotenvx", "sops", or None if no encryption detected.
252
+ """
253
+ if self.has_dotenvx_header(content):
254
+ return "dotenvx"
255
+ if self.has_sops_header(content):
256
+ return "sops"
257
+ return None
258
+
259
+ def detect_backend_for_file(self, path: Path) -> str | None:
260
+ """
261
+ Detect which encryption backend was used for a file.
262
+
263
+ Parameters:
264
+ path (Path): Filesystem path to the file to inspect.
265
+
266
+ Returns:
267
+ "dotenvx", "sops", or None if no encryption detected.
268
+ """
269
+ if not path.exists():
270
+ return None
271
+
272
+ content = path.read_text(encoding="utf-8")
273
+ return self.detect_backend(content)
274
+
275
+ def is_file_encrypted(self, path: Path) -> bool:
276
+ """
277
+ Determine whether a file contains encryption markers.
278
+
279
+ Parameters:
280
+ path (Path): Filesystem path to the file to inspect.
281
+
282
+ Returns:
283
+ `true` if the file contains encryption markers, `false` otherwise.
284
+ """
285
+ if not path.exists():
286
+ return False
287
+
288
+ content = path.read_text(encoding="utf-8")
289
+ return self.has_encrypted_header(content)
290
+
291
+ def is_value_encrypted(self, value: str) -> bool:
292
+ """
293
+ Determine whether a value is encrypted by any supported backend.
294
+
295
+ Parameters:
296
+ value (str): The value to check.
297
+
298
+ Returns:
299
+ True if the value appears encrypted, False otherwise.
300
+ """
301
+ if not value:
302
+ return False
303
+
304
+ # Check dotenvx format
305
+ for prefix in self.DOTENVX_ENCRYPTED_PREFIXES:
306
+ if value.startswith(prefix):
307
+ return True
308
+
309
+ # Check SOPS format
310
+ return bool(self.SOPS_ENCRYPTED_PATTERN.match(value))
311
+
312
+ def detect_value_backend(self, value: str) -> str | None:
313
+ """
314
+ Detect which encryption backend was used for a specific value.
315
+
316
+ Parameters:
317
+ value (str): The value to check.
318
+
319
+ Returns:
320
+ "dotenvx", "sops", or None if not encrypted.
321
+ """
322
+ if not value:
323
+ return None
324
+
325
+ # Check dotenvx format
326
+ for prefix in self.DOTENVX_ENCRYPTED_PREFIXES:
327
+ if value.startswith(prefix):
328
+ return "dotenvx"
329
+
330
+ # Check SOPS format
331
+ if self.SOPS_ENCRYPTED_PATTERN.match(value):
332
+ return "sops"
333
+
334
+ return None
335
+
336
+ def is_value_suspicious(self, value: str) -> bool:
337
+ """
338
+ Determine whether a plaintext value matches any configured secret patterns.
339
+
340
+ Returns:
341
+ `true` if the value appears to be a secret, `false` otherwise.
342
+ """
343
+ for pattern in self.SECRET_VALUE_PATTERNS:
344
+ if pattern.search(value):
345
+ return True
346
+ return False
347
+
348
+ def is_name_sensitive(self, name: str) -> bool:
349
+ """
350
+ Determine whether an environment variable name indicates sensitive data.
351
+
352
+ Parameters:
353
+ name (str): The environment variable name to test.
354
+
355
+ Returns:
356
+ True if the name matches any configured sensitive-name pattern, False otherwise.
357
+ """
358
+ for pattern in self.SENSITIVE_NAME_PATTERNS:
359
+ if pattern.match(name):
360
+ return True
361
+ return False
362
+
363
+ def get_recommendations(
364
+ self,
365
+ report: EncryptionReport,
366
+ backend: str | None = None,
367
+ ) -> list[str]:
368
+ """
369
+ Builds human-readable remediation recommendations derived from an EncryptionReport.
370
+
371
+ Parameters:
372
+ report (EncryptionReport): Analysis result for a single .env file used to derive recommendations.
373
+ backend (str | None): Encryption backend to recommend ("dotenvx", "sops", or None for auto-detect).
374
+
375
+ Returns:
376
+ list[str]: Ordered list of recommendation strings; empty if no actions are suggested.
377
+ """
378
+ recommendations = []
379
+
380
+ # Use detected backend if not specified
381
+ if backend is None:
382
+ backend = report.detected_backend or "dotenvx"
383
+
384
+ if report.plaintext_secrets:
385
+ recommendations.append(
386
+ f"Encrypt the following variables before committing: "
387
+ f"{', '.join(sorted(report.plaintext_secrets))}"
388
+ )
389
+
390
+ if backend == "sops":
391
+ recommendations.append(f"Run: envdrift encrypt --backend sops {report.path}")
392
+ else:
393
+ recommendations.append(f"Run: envdrift encrypt {report.path}")
394
+
395
+ if not report.is_fully_encrypted and report.encrypted_vars:
396
+ recommendations.append(
397
+ "File is partially encrypted. Consider encrypting all sensitive values."
398
+ )
399
+
400
+ return recommendations