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
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
|