envshield 1.2.0__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.
- envshield/__init__.py +1 -0
- envshield/__main__.py +7 -0
- envshield/cli.py +346 -0
- envshield/core/__init__.py +0 -0
- envshield/core/exceptions.py +32 -0
- envshield/core/file_updater.py +55 -0
- envshield/core/profile_manager.py +299 -0
- envshield/core/scanner.py +123 -0
- envshield/core/template_manager.py +184 -0
- envshield/parsers/__init__.py +0 -0
- envshield/parsers/_base.py +28 -0
- envshield/parsers/_dotenv.py +35 -0
- envshield/parsers/_python.py +43 -0
- envshield/parsers/factory.py +32 -0
- envshield/state.py +51 -0
- envshield/utils/__init__.py +0 -0
- envshield/utils/git_utils.py +56 -0
- envshield-1.2.0.dist-info/METADATA +220 -0
- envshield-1.2.0.dist-info/RECORD +22 -0
- envshield-1.2.0.dist-info/WHEEL +5 -0
- envshield-1.2.0.dist-info/entry_points.txt +2 -0
- envshield-1.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
# envshield/core/profile_manager.py
|
|
2
|
+
# Contains the core business logic for managing environment profiles.
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from typing import List, Union
|
|
8
|
+
|
|
9
|
+
import questionary
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.prompt import Prompt
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
|
|
16
|
+
from envshield import state
|
|
17
|
+
from envshield.config import manager as config_manager
|
|
18
|
+
from envshield.core import file_updater
|
|
19
|
+
from envshield.core.exceptions import ProfileNotFoundError, SourceFileNotFoundError
|
|
20
|
+
from envshield.parsers.factory import get_parser
|
|
21
|
+
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def list_profiles():
|
|
26
|
+
"""
|
|
27
|
+
Loads profiles from the config and displays them in a formatted table.
|
|
28
|
+
"""
|
|
29
|
+
config = config_manager.load_config()
|
|
30
|
+
profiles = config.get("profiles", {})
|
|
31
|
+
active_profile = state.get_active_profile()
|
|
32
|
+
if not profiles:
|
|
33
|
+
console.print("[yellow]No profiles found in 'envshield.yml'.[/yellow]")
|
|
34
|
+
return
|
|
35
|
+
table = Table(
|
|
36
|
+
title="Available Profiles",
|
|
37
|
+
border_style="blue",
|
|
38
|
+
show_header=True,
|
|
39
|
+
header_style="bold blue",
|
|
40
|
+
)
|
|
41
|
+
table.add_column("Active", justify="center", style="green")
|
|
42
|
+
table.add_column("Profile Name", style="cyan", no_wrap=True)
|
|
43
|
+
table.add_column("Description", style="white")
|
|
44
|
+
for name, details in profiles.items():
|
|
45
|
+
is_active = "✓" if name == active_profile else ""
|
|
46
|
+
table.add_row(is_active, name, details.get("description", ""))
|
|
47
|
+
console.print(table)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def use_profile(profile_name: str):
|
|
51
|
+
"""
|
|
52
|
+
Switches the active environment by processing the 'links' for the profile.
|
|
53
|
+
For each link, it creates a symlink from the source to the target.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
profile_name: The name of the profile to activate.
|
|
57
|
+
"""
|
|
58
|
+
config = config_manager.load_config()
|
|
59
|
+
profiles = config.get("profiles", {})
|
|
60
|
+
if profile_name not in profiles:
|
|
61
|
+
raise ProfileNotFoundError(profile_name)
|
|
62
|
+
profile_details = profiles[profile_name]
|
|
63
|
+
links = profile_details.get("links", [])
|
|
64
|
+
if not links:
|
|
65
|
+
console.print(
|
|
66
|
+
f"[yellow]Warning:[/yellow] Profile '{profile_name}' has no 'links' defined. No action taken."
|
|
67
|
+
)
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
console.print(f"Switching to profile [bold cyan]{profile_name}[/bold cyan]...")
|
|
71
|
+
|
|
72
|
+
# --- Symlink Logic ---
|
|
73
|
+
for link in links:
|
|
74
|
+
source_file = link.get("source")
|
|
75
|
+
target_file = link.get("target")
|
|
76
|
+
|
|
77
|
+
if not source_file or not target_file:
|
|
78
|
+
console.print(
|
|
79
|
+
f"[yellow]Warning:[/yellow] Skipping invalid link in profile '{profile_name}': {link}"
|
|
80
|
+
)
|
|
81
|
+
continue
|
|
82
|
+
if not os.path.exists(source_file):
|
|
83
|
+
raise SourceFileNotFoundError(source_file)
|
|
84
|
+
|
|
85
|
+
# If the target file (.env) already exists, we must remove it first.
|
|
86
|
+
# It could be a real file or a symlink from a previous 'use' command.
|
|
87
|
+
# os.lexists() is used to check for symlinks themselves without following them.
|
|
88
|
+
if os.path.lexists(target_file):
|
|
89
|
+
os.remove(target_file)
|
|
90
|
+
|
|
91
|
+
# Create the symbolic link.
|
|
92
|
+
try:
|
|
93
|
+
os.symlink(source_file, target_file)
|
|
94
|
+
console.print(
|
|
95
|
+
f" [green]✓[/green] Linked [magenta]{source_file}[/magenta] -> [bold magenta]{target_file}[/bold magenta]"
|
|
96
|
+
)
|
|
97
|
+
except OSError as e:
|
|
98
|
+
console.print(
|
|
99
|
+
f"[bold red]Error:[/bold red] Failed to create symbolic link for '{target_file}': {e}"
|
|
100
|
+
)
|
|
101
|
+
# We stop on the first error to avoid a partially configured state.
|
|
102
|
+
raise
|
|
103
|
+
|
|
104
|
+
# Only set the profile as active if all links were successful.
|
|
105
|
+
state.set_active_profile(profile_name)
|
|
106
|
+
console.print("\n[bold green]Environment is now active![/bold green]")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _run_script(script_command: Union[str, List[str]]):
|
|
110
|
+
"""
|
|
111
|
+
Runs a single shell command or a list of shell commands.
|
|
112
|
+
Raises an exception if any script fails.
|
|
113
|
+
"""
|
|
114
|
+
if not script_command:
|
|
115
|
+
return
|
|
116
|
+
commands = script_command if isinstance(script_command, list) else [script_command]
|
|
117
|
+
for command in commands:
|
|
118
|
+
console.print(
|
|
119
|
+
f"\n[bold]Executing script:[/bold] [bright_black]{command}[/bright_black]"
|
|
120
|
+
)
|
|
121
|
+
try:
|
|
122
|
+
process = subprocess.Popen(
|
|
123
|
+
command,
|
|
124
|
+
shell=True,
|
|
125
|
+
stdout=subprocess.PIPE,
|
|
126
|
+
stderr=subprocess.STDOUT,
|
|
127
|
+
text=True,
|
|
128
|
+
)
|
|
129
|
+
for line in process.stdout:
|
|
130
|
+
console.print(f"[dim] {line.strip()}[/dim]")
|
|
131
|
+
process.wait()
|
|
132
|
+
if process.returncode != 0:
|
|
133
|
+
raise subprocess.CalledProcessError(process.returncode, command)
|
|
134
|
+
console.print(f"[green]✓ Script finished successfully.[/green]")
|
|
135
|
+
except Exception as e:
|
|
136
|
+
console.print(f"[bold red]Error running script: {e}[/bold red]")
|
|
137
|
+
raise
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def onboard_profile(profile_name: str):
|
|
141
|
+
"""
|
|
142
|
+
Guides a new user through setting up a profile for the first time with
|
|
143
|
+
smarter, more versatile logic and pre/post scripts.
|
|
144
|
+
"""
|
|
145
|
+
console.print(
|
|
146
|
+
Panel(
|
|
147
|
+
f"[bold cyan]Welcome to EnvShield Onboarding for the '[yellow]{profile_name}[/yellow]' profile![/bold cyan]\n\nThis wizard will get you set up and ready to code.",
|
|
148
|
+
title="✨ Onboarding ✨",
|
|
149
|
+
border_style="green",
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
config = config_manager.load_config()
|
|
154
|
+
profiles = config.get("profiles", {})
|
|
155
|
+
|
|
156
|
+
if profile_name not in profiles:
|
|
157
|
+
raise ProfileNotFoundError(profile_name)
|
|
158
|
+
|
|
159
|
+
profile_details = profiles[profile_name]
|
|
160
|
+
links = profile_details.get("links", [])
|
|
161
|
+
onboarding_prompts = profile_details.get("onboarding_prompts", [])
|
|
162
|
+
pre_script = profile_details.get("pre_onboard_script")
|
|
163
|
+
post_script = profile_details.get("post_onboard_script")
|
|
164
|
+
|
|
165
|
+
if not links:
|
|
166
|
+
console.print(
|
|
167
|
+
f"[yellow]Warning:[/yellow] Profile '{profile_name}' has no 'links' defined. Nothing to onboard."
|
|
168
|
+
)
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
# --- Step 0: Run Pre-Onboarding Script(s) ---
|
|
172
|
+
if pre_script:
|
|
173
|
+
console.print(
|
|
174
|
+
"\n[bold]Step 0: Running pre-onboarding validation script(s)...[/bold]"
|
|
175
|
+
)
|
|
176
|
+
_run_script(pre_script)
|
|
177
|
+
|
|
178
|
+
# --- Step 1: Create Missing Source Files ---
|
|
179
|
+
console.print(
|
|
180
|
+
"\n[bold]Step 1: Checking for necessary configuration files...[/bold]"
|
|
181
|
+
)
|
|
182
|
+
created_files = False
|
|
183
|
+
for link in links:
|
|
184
|
+
source_file = link.get("source")
|
|
185
|
+
template_file = link.get("template")
|
|
186
|
+
if source_file and not os.path.exists(source_file):
|
|
187
|
+
console.print(
|
|
188
|
+
f" [yellow]![/yellow] Source file [magenta]{source_file}[/magenta] not found. Attempting to create it..."
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
if template_file and os.path.exists(template_file):
|
|
192
|
+
try:
|
|
193
|
+
shutil.copy(template_file, source_file)
|
|
194
|
+
console.print(
|
|
195
|
+
f" [green]✓[/green] Created from template '{template_file}'."
|
|
196
|
+
)
|
|
197
|
+
created_files = True
|
|
198
|
+
except IOError as e:
|
|
199
|
+
console.print(
|
|
200
|
+
f" [red]Error:[/red] Could not create '{source_file}': {e}"
|
|
201
|
+
)
|
|
202
|
+
raise typer.Exit(code=1)
|
|
203
|
+
else:
|
|
204
|
+
open(source_file, "a").close()
|
|
205
|
+
console.print(
|
|
206
|
+
f" [green]✓[/green] Created a blank file (no template specified for this link)."
|
|
207
|
+
)
|
|
208
|
+
created_files = True
|
|
209
|
+
|
|
210
|
+
if created_files:
|
|
211
|
+
console.print("[green]✓ All necessary source files have been created.[/green]")
|
|
212
|
+
else:
|
|
213
|
+
console.print("[green]✓ All source files already exist.[/green]")
|
|
214
|
+
|
|
215
|
+
# --- Step 2 Populate and Save Secrets ---
|
|
216
|
+
console.print("\n[bold]Step 2: Let's configure your secrets.[/bold]")
|
|
217
|
+
placeholder_keywords = [
|
|
218
|
+
"changeme",
|
|
219
|
+
"ask",
|
|
220
|
+
"todo",
|
|
221
|
+
"example",
|
|
222
|
+
"dummy",
|
|
223
|
+
"placeholder",
|
|
224
|
+
"get from",
|
|
225
|
+
"your-",
|
|
226
|
+
]
|
|
227
|
+
updates_by_file = {}
|
|
228
|
+
for link in links:
|
|
229
|
+
source_file = link.get("source")
|
|
230
|
+
if not source_file or not os.path.exists(source_file):
|
|
231
|
+
continue
|
|
232
|
+
try:
|
|
233
|
+
with open(source_file, "r") as f:
|
|
234
|
+
lines = f.readlines()
|
|
235
|
+
for line in lines:
|
|
236
|
+
line = line.strip()
|
|
237
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
238
|
+
continue
|
|
239
|
+
key, value = line.split("=", 1)
|
|
240
|
+
key = key.strip()
|
|
241
|
+
raw_value = value.strip().strip("\"'")
|
|
242
|
+
is_explicit_prompt = key in onboarding_prompts
|
|
243
|
+
is_placeholder = (
|
|
244
|
+
not raw_value
|
|
245
|
+
or raw_value.lower() == "x"
|
|
246
|
+
or any(
|
|
247
|
+
keyword in raw_value.lower() for keyword in placeholder_keywords
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
if is_explicit_prompt or is_placeholder:
|
|
251
|
+
if any(
|
|
252
|
+
update["key"] == key
|
|
253
|
+
for updates in updates_by_file.values()
|
|
254
|
+
for update in updates
|
|
255
|
+
):
|
|
256
|
+
continue
|
|
257
|
+
console.print(
|
|
258
|
+
f"\nFound variable to configure: [bold cyan]{key}[/bold cyan] in [magenta]{source_file}[/magenta]."
|
|
259
|
+
)
|
|
260
|
+
if raw_value:
|
|
261
|
+
console.print(
|
|
262
|
+
f" [dim]Current value/instruction: {raw_value}[/dim]"
|
|
263
|
+
)
|
|
264
|
+
new_value = Prompt.ask(
|
|
265
|
+
f" Please enter the value for [bold cyan]{key}[/bold cyan]",
|
|
266
|
+
password=True,
|
|
267
|
+
)
|
|
268
|
+
if source_file not in updates_by_file:
|
|
269
|
+
updates_by_file[source_file] = []
|
|
270
|
+
updates_by_file[source_file].append(
|
|
271
|
+
{"key": key, "value": new_value}
|
|
272
|
+
)
|
|
273
|
+
except IOError:
|
|
274
|
+
continue
|
|
275
|
+
if not updates_by_file:
|
|
276
|
+
console.print(
|
|
277
|
+
"[green]✓ No placeholders found to update. Your secrets appear to be configured.[/green]"
|
|
278
|
+
)
|
|
279
|
+
else:
|
|
280
|
+
for file_path, updates in updates_by_file.items():
|
|
281
|
+
file_updater.update_variables_in_file(file_path, updates)
|
|
282
|
+
console.print(
|
|
283
|
+
"[green]✓ All secrets have been securely saved to your local files.[/green]"
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
# --- Step 3: Final Activation ---
|
|
287
|
+
console.print("\n[bold]Step 3: Activating the environment...[/bold]")
|
|
288
|
+
use_profile(profile_name)
|
|
289
|
+
|
|
290
|
+
# --- Step 4: Run Post-Onboarding Script(s) ---
|
|
291
|
+
if post_script:
|
|
292
|
+
console.print(
|
|
293
|
+
"\n[bold]Step 4: Running post-onboarding setup script(s)...[/bold]"
|
|
294
|
+
)
|
|
295
|
+
_run_script(post_script)
|
|
296
|
+
|
|
297
|
+
console.print(
|
|
298
|
+
"\n[bold green]🎉 Onboarding Complete! Your environment is ready. 🎉[/bold green]"
|
|
299
|
+
)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# envshield/core/scanner.py
|
|
2
|
+
# The core secret scanning engine for EnvShield.
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from typing import Dict, List
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
# A comprehensive, curated list of high-confidence regex patterns for common secrets,
|
|
12
|
+
# inspired by community projects like https://github.com/mazen160/secrets-patterns-db
|
|
13
|
+
SECRET_PATTERNS: List[Dict[str, str]] = [
|
|
14
|
+
# --- Generic High-Confidence Patterns ---
|
|
15
|
+
{
|
|
16
|
+
"name": "Generic API Key",
|
|
17
|
+
"pattern": r"(?i)(key|api(?!version)|token|secret|password|auth|credential)[a-z0-9_ .\-,]{0,25}\s*[:=]\s*['\"]([0-9a-zA-Z\-_=]{16,64})['\"]",
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"name": "Private Key",
|
|
21
|
+
"pattern": r"-----BEGIN (?:EC|PGP|DSA|RSA|OPENSSH|ENCRYPTED)? ?PRIVATE KEY(?: BLOCK)?-----",
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"name": "JSON Web Token (JWT)",
|
|
25
|
+
"pattern": r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"name": "Database Connection String",
|
|
29
|
+
"pattern": r"(?i)(postgres|mysql|mongodb(?:\+srv)?|redis)://[^:]+:[^@]+@",
|
|
30
|
+
},
|
|
31
|
+
# --- Cloud & IaaS Providers ---
|
|
32
|
+
{
|
|
33
|
+
"name": "AWS Access Key ID",
|
|
34
|
+
"pattern": r"\b(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}\b",
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"name": "AWS Secret Access Key",
|
|
38
|
+
"pattern": r"(?i)aws(.{0,20})?['\"][0-9a-zA-Z\/+=]{40}['\"]",
|
|
39
|
+
},
|
|
40
|
+
{"name": "Google Cloud API Key", "pattern": r"\bAIza[0-9A-Za-z\-_]{35}\b"},
|
|
41
|
+
{"name": "Google OAuth Access Token", "pattern": r"\bya29\.[0-9A-Za-z\-_]+\b"},
|
|
42
|
+
# --- Version Control & DevOps ---
|
|
43
|
+
{
|
|
44
|
+
"name": "GitHub Personal Access Token (Classic)",
|
|
45
|
+
"pattern": r"\bghp_[0-9a-zA-Z]{36}\b",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"name": "GitHub Personal Access Token (Fine-grained)",
|
|
49
|
+
"pattern": r"\bgithub_pat_[0-9a-zA-Z]{22}_[0-9a-zA-Z]{59}\b",
|
|
50
|
+
},
|
|
51
|
+
{"name": "GitHub OAuth Access Token", "pattern": r"\bgho_[0-9a-zA-Z]{36}\b"},
|
|
52
|
+
{"name": "GitHub App Token", "pattern": r"\b(ghu|ghs)_[0-9a-zA-Z]{36}\b"},
|
|
53
|
+
{
|
|
54
|
+
"name": "Terraform Cloud/Enterprise Token",
|
|
55
|
+
"pattern": r"\b[a-zA-Z0-9]+\.atlasv1\.[a-zA-Z0-9\-_=]{60,70}\b",
|
|
56
|
+
},
|
|
57
|
+
# --- Communication & Messaging ---
|
|
58
|
+
{"name": "Slack Token", "pattern": r"\bxox[baprs]-[0-9a-zA-Z]{10,48}\b"},
|
|
59
|
+
{"name": "Telegram Bot Token", "pattern": r"\b[0-9]{8,10}:[a-zA-Z0-9_-]{35}\b"},
|
|
60
|
+
{"name": "Twilio API Key", "pattern": r"\bSK[0-9a-fA-F]{32}\b"},
|
|
61
|
+
{
|
|
62
|
+
"name": "SendGrid API Key",
|
|
63
|
+
"pattern": r"\bSG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}\b",
|
|
64
|
+
},
|
|
65
|
+
{"name": "Mailchimp API Key", "pattern": r"\b[0-9a-f]{32}-us[0-9]{1,2}\b"},
|
|
66
|
+
{"name": "Mailgun API Key", "pattern": r"\bkey-[0-9a-zA-Z]{32}\b"},
|
|
67
|
+
# --- SaaS, PaaS & APIs ---
|
|
68
|
+
{
|
|
69
|
+
"name": "Stripe API Key",
|
|
70
|
+
"pattern": r"\b(sk|pk)_(test|live)_[0-9a-zA-Z]{24,99}\b",
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"name": "Heroku API Key",
|
|
74
|
+
"pattern": r"(?i)heroku[a-z0-9_\- ]*['\"][0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}['\"]",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"name": "Discord Bot Token",
|
|
78
|
+
"pattern": r"\b[MN][A-Za-z\d]{23,25}\.[\w-]{6}\.[\w-]{27,}\b",
|
|
79
|
+
},
|
|
80
|
+
# --- Package Managers ---
|
|
81
|
+
{"name": "npm Token", "pattern": r"\bnpm_[a-zA-Z0-9]{36}\b"},
|
|
82
|
+
{
|
|
83
|
+
"name": "PyPI Upload Token",
|
|
84
|
+
"pattern": r"\bpypi-AgEIcHlwaS5vcmc[A-Za-z0-9-_]{50,1000}\b",
|
|
85
|
+
},
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def scan_file_for_secrets(file_path: str) -> List[Dict]:
|
|
90
|
+
"""
|
|
91
|
+
Scans a single file for any of the defined secret patterns.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
file_path: The path to the file to scan.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
A list of dictionaries, where each dictionary represents a found secret.
|
|
98
|
+
Returns an empty list if no secrets are found.
|
|
99
|
+
"""
|
|
100
|
+
findings = []
|
|
101
|
+
try:
|
|
102
|
+
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
103
|
+
# We read line by line to get the line number for reporting.
|
|
104
|
+
for line_num, line in enumerate(f, 1):
|
|
105
|
+
for secret in SECRET_PATTERNS:
|
|
106
|
+
# re.search finds the first occurrence in the line
|
|
107
|
+
if re.search(secret["pattern"], line):
|
|
108
|
+
findings.append(
|
|
109
|
+
{
|
|
110
|
+
"file_path": file_path,
|
|
111
|
+
"line_num": line_num,
|
|
112
|
+
"secret_type": secret["name"],
|
|
113
|
+
"line_content": line.strip(),
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
# We break after the first find per line to avoid duplicate reports
|
|
117
|
+
# for the same line if multiple patterns match.
|
|
118
|
+
break
|
|
119
|
+
except IOError:
|
|
120
|
+
# Fails silently if a file can't be opened (e.g., broken symlink)
|
|
121
|
+
return []
|
|
122
|
+
|
|
123
|
+
return findings
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# envshield/core/template_manager.py
|
|
2
|
+
# Core logic for template checking and syncing, with per-link support.
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import questionary
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
import datetime
|
|
9
|
+
|
|
10
|
+
from envshield.config import manager as config_manager
|
|
11
|
+
from envshield.core.exceptions import ProfileNotFoundError
|
|
12
|
+
from envshield.parsers.factory import get_parser
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _get_link_diff(link: dict):
|
|
18
|
+
"""Helper to get the variable difference for a single link."""
|
|
19
|
+
source_file = link.get("source")
|
|
20
|
+
template_file = link.get("template")
|
|
21
|
+
|
|
22
|
+
if not source_file or not template_file:
|
|
23
|
+
return None, None
|
|
24
|
+
|
|
25
|
+
source_parser = get_parser(source_file)
|
|
26
|
+
template_parser = get_parser(template_file)
|
|
27
|
+
|
|
28
|
+
if not source_parser or not template_parser:
|
|
29
|
+
return None, None
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
source_vars = source_parser.get_vars(source_file)
|
|
33
|
+
template_vars = template_parser.get_vars(template_file)
|
|
34
|
+
except FileNotFoundError:
|
|
35
|
+
return None, None # Handled by the calling function
|
|
36
|
+
|
|
37
|
+
extra_in_source = source_vars - template_vars
|
|
38
|
+
return extra_in_source, template_file
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def check_template(profile_name: str):
|
|
42
|
+
"""
|
|
43
|
+
Compares each link in a profile with its own specific template.
|
|
44
|
+
"""
|
|
45
|
+
config = config_manager.load_config()
|
|
46
|
+
profiles = config.get("profiles", {})
|
|
47
|
+
|
|
48
|
+
if profile_name not in profiles:
|
|
49
|
+
raise ProfileNotFoundError(profile_name)
|
|
50
|
+
|
|
51
|
+
profile_details = profiles[profile_name]
|
|
52
|
+
links = profile_details.get("links", [])
|
|
53
|
+
|
|
54
|
+
console.print(
|
|
55
|
+
f"\n[bold]Template Check for Profile: [cyan]{profile_name}[/cyan][/bold]"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
found_issues = False
|
|
59
|
+
for link in links:
|
|
60
|
+
source_file = link.get("source")
|
|
61
|
+
template_file = link.get("template")
|
|
62
|
+
|
|
63
|
+
if not template_file:
|
|
64
|
+
continue # Skip links without a template
|
|
65
|
+
|
|
66
|
+
console.print(
|
|
67
|
+
f"\n[bold]Checking Link:[/bold] [magenta]{source_file}[/magenta] vs [magenta]{template_file}[/magenta]"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
extra_vars, _ = _get_link_diff(link)
|
|
72
|
+
# We also need to get missing vars for the report
|
|
73
|
+
template_vars = get_parser(template_file).get_vars(template_file)
|
|
74
|
+
source_vars = get_parser(source_file).get_vars(source_file)
|
|
75
|
+
missing_vars = template_vars - source_vars
|
|
76
|
+
|
|
77
|
+
if not extra_vars and not missing_vars:
|
|
78
|
+
console.print("[green] ✓ In sync.[/green]")
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
found_issues = True
|
|
82
|
+
table = Table(show_header=True, header_style="bold blue")
|
|
83
|
+
table.add_column("Status", style="cyan")
|
|
84
|
+
table.add_column("Variable Name", style="white")
|
|
85
|
+
|
|
86
|
+
for var in sorted(list(missing_vars)):
|
|
87
|
+
table.add_row("[red]Missing in Source[/red]", var)
|
|
88
|
+
for var in sorted(list(extra_vars)):
|
|
89
|
+
table.add_row("[yellow]Extra in Source[/yellow]", var)
|
|
90
|
+
|
|
91
|
+
console.print(table)
|
|
92
|
+
|
|
93
|
+
except FileNotFoundError:
|
|
94
|
+
console.print(
|
|
95
|
+
f"[red] Error: One of the files was not found. Please run 'envshield onboard'.[/red]"
|
|
96
|
+
)
|
|
97
|
+
found_issues = True
|
|
98
|
+
|
|
99
|
+
if found_issues:
|
|
100
|
+
console.print(
|
|
101
|
+
"\n[bold]Suggestion:[/bold] Run `envshield template sync` to interactively update your templates."
|
|
102
|
+
)
|
|
103
|
+
else:
|
|
104
|
+
console.print(
|
|
105
|
+
"\n[bold green]✓ All configured templates are perfectly in sync![/bold green]"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _append_to_template(template_file: str, variables_to_add: list):
|
|
110
|
+
"""Appends new variables to the specified template file."""
|
|
111
|
+
if not variables_to_add:
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
console.print(f"\nUpdating template file: [magenta]{template_file}[/magenta]")
|
|
115
|
+
|
|
116
|
+
is_python_file = template_file.endswith(".py")
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
with open(template_file, "a") as f:
|
|
120
|
+
f.write(
|
|
121
|
+
"\n\n# === Variables added by EnvShield on {} ===\n".format(
|
|
122
|
+
datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
for var in sorted(variables_to_add):
|
|
126
|
+
if is_python_file:
|
|
127
|
+
line = f'{var} = "" # TODO: Add default value\n'
|
|
128
|
+
else: # .env style
|
|
129
|
+
line = f"{var}=\n"
|
|
130
|
+
f.write(line)
|
|
131
|
+
console.print("[green]✓ Template file updated successfully.[/green]")
|
|
132
|
+
except IOError as e:
|
|
133
|
+
console.print(f"[red]Error:[/red] Could not write to template file: {e}")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def sync_template(profile_name: str):
|
|
137
|
+
"""
|
|
138
|
+
Interactively syncs templates for each link in a profile.
|
|
139
|
+
"""
|
|
140
|
+
config = config_manager.load_config()
|
|
141
|
+
profiles = config.get("profiles", {})
|
|
142
|
+
if profile_name not in profiles:
|
|
143
|
+
raise ProfileNotFoundError(profile_name)
|
|
144
|
+
|
|
145
|
+
links = profiles[profile_name].get("links", [])
|
|
146
|
+
|
|
147
|
+
console.print(
|
|
148
|
+
f"\n[bold]Interactive Template Sync for Profile: [cyan]{profile_name}[/cyan][/bold]"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
synced_something = False
|
|
152
|
+
for link in links:
|
|
153
|
+
try:
|
|
154
|
+
extra_vars, template_file = _get_link_diff(link)
|
|
155
|
+
except FileNotFoundError:
|
|
156
|
+
continue # Skip if files don't exist
|
|
157
|
+
|
|
158
|
+
if not extra_vars or not template_file:
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
synced_something = True
|
|
162
|
+
console.print(
|
|
163
|
+
f"\nFound extra variables for template [magenta]{template_file}[/magenta]:"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
variables_to_add = questionary.checkbox(
|
|
168
|
+
f"Which variables to add to {template_file}?",
|
|
169
|
+
choices=[
|
|
170
|
+
questionary.Choice(title=var, checked=True)
|
|
171
|
+
for var in sorted(list(extra_vars))
|
|
172
|
+
],
|
|
173
|
+
).ask()
|
|
174
|
+
except (KeyboardInterrupt, TypeError):
|
|
175
|
+
console.print("\n[yellow]Sync cancelled by user.[/yellow]")
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
if variables_to_add:
|
|
179
|
+
_append_to_template(template_file, variables_to_add)
|
|
180
|
+
|
|
181
|
+
if not synced_something:
|
|
182
|
+
console.print(
|
|
183
|
+
"\n[bold green]✓ All templates are already up-to-date![/bold green]"
|
|
184
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# envshield/parsers/_base.py
|
|
2
|
+
# Defines the abstract base class for all file parsers.
|
|
3
|
+
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from typing import Set
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseParser(ABC):
|
|
9
|
+
"""
|
|
10
|
+
Abstract Base Class for file parsers.
|
|
11
|
+
Ensures that every parser implements a 'get_vars' method.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def get_vars(self, file_path: str) -> Set[str]:
|
|
16
|
+
"""
|
|
17
|
+
Parses a file and extracts the set of defined variable names.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
file_path: The path to the file to be parsed.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
A set of strings, where each string is a variable name.
|
|
24
|
+
|
|
25
|
+
Raises:
|
|
26
|
+
FileNotFoundError: If the file_path does not exist.
|
|
27
|
+
"""
|
|
28
|
+
pass
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# envshield/parsers/_dotenv.py
|
|
2
|
+
# A simple parser for key-value .env files.
|
|
3
|
+
import os
|
|
4
|
+
from typing import Set
|
|
5
|
+
from ._base import BaseParser
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DotenvParser(BaseParser):
|
|
9
|
+
"""
|
|
10
|
+
Parses traditional .env files.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def get_vars(self, file_path: str) -> Set[str]:
|
|
14
|
+
"""
|
|
15
|
+
Extracts variable names from a .env file.
|
|
16
|
+
- Ignores lines starting with '#' (comments).
|
|
17
|
+
- Ignores empty lines.
|
|
18
|
+
- Splits lines by the first '=' to get the key.
|
|
19
|
+
"""
|
|
20
|
+
if not os.path.exists(file_path):
|
|
21
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
22
|
+
|
|
23
|
+
variables = set()
|
|
24
|
+
with open(file_path, "r") as f:
|
|
25
|
+
for line in f:
|
|
26
|
+
line = line.strip()
|
|
27
|
+
if not line or line.startswith("#"):
|
|
28
|
+
continue
|
|
29
|
+
|
|
30
|
+
if "=" in line:
|
|
31
|
+
key = line.split("=", 1)[0].strip()
|
|
32
|
+
if key: # Ensure key is not empty
|
|
33
|
+
variables.add(key)
|
|
34
|
+
|
|
35
|
+
return variables
|