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 ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.2.0"
envshield/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ from .cli import app
2
+
3
+ def main():
4
+ app()
5
+
6
+ if __name__ == "__main__":
7
+ main()
envshield/cli.py ADDED
@@ -0,0 +1,346 @@
1
+ # envshield/cli.py
2
+ import fnmatch
3
+ import os
4
+ import stat
5
+ from typing import List
6
+
7
+ import questionary
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+ from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn
12
+ from rich.table import Table
13
+
14
+ from .config import manager as config_manager
15
+ from .core import profile_manager, scanner, template_manager
16
+ from .core.exceptions import EnvShieldException
17
+ from .utils import git_utils
18
+
19
+ app = typer.Typer(
20
+ name="envshield",
21
+ help="🛡️ A CLI tool to manage, secure, and collaborate on environment variables.",
22
+ rich_markup_mode="markdown",
23
+ add_completion=False,
24
+ )
25
+
26
+ console = Console()
27
+
28
+ DEFAULT_EXCLUDE_PATTERNS = [
29
+ ".git/*",
30
+ "node_modules/*",
31
+ "vendor/*",
32
+ "*.min.js",
33
+ "*.min.css",
34
+ "package-lock.json",
35
+ "yarn.lock",
36
+ "poetry.lock",
37
+ "*.png",
38
+ "*.jpg",
39
+ "*.jpeg",
40
+ "*.gif",
41
+ "*.svg",
42
+ "*.woff",
43
+ "*.woff2",
44
+ "*.bin",
45
+ "**/*__pycache__/*",
46
+ ]
47
+
48
+
49
+ @app.command()
50
+ def init():
51
+ """Initializes EnvShield in the current project directory."""
52
+ console.print(
53
+ Panel(
54
+ "[bold cyan]Welcome to EnvShield Initialization![/bold cyan]\n\nThis wizard will help you set up your project.",
55
+ title="🛡️ EnvShield",
56
+ border_style="green",
57
+ )
58
+ )
59
+ if config_manager.config_file_exists():
60
+ overwrite = questionary.confirm(
61
+ "An `envshield.yml` file already exists. Do you want to overwrite it?",
62
+ default=False,
63
+ ).ask()
64
+ if not overwrite:
65
+ console.print("[yellow]Initialization cancelled.[/yellow]")
66
+ raise typer.Exit()
67
+ try:
68
+ project_name = questionary.text(
69
+ "What is the name of your project?", default=os.path.basename(os.getcwd())
70
+ ).ask()
71
+ env_file = questionary.text(
72
+ "What is your primary environment file for local development?",
73
+ default=".env",
74
+ ).ask()
75
+ has_template = questionary.confirm(
76
+ "Do you use a template file (e.g., .env.example)?", default=True
77
+ ).ask()
78
+ template_file = None
79
+ if has_template:
80
+ template_file = questionary.text(
81
+ "What is the name of your template file?", default=".env.example"
82
+ ).ask()
83
+ except (KeyboardInterrupt, TypeError):
84
+ console.print("\n[yellow]Initialization cancelled by user.[/yellow]")
85
+ raise typer.Exit()
86
+ console.print("\n[bold]Generating your `envshield.yml` file...[/bold]")
87
+ config_content = config_manager.generate_default_config_content(
88
+ project_name, env_file, template_file
89
+ )
90
+ config_manager.write_config_file(config_content)
91
+ console.print("\n[bold green]✨ Setup Complete! ✨[/bold green]")
92
+ console.print("Run `envshield list` to see your profiles.")
93
+
94
+
95
+ @app.command()
96
+ def onboard(
97
+ profile: str = typer.Argument(..., help="The profile to set up, e.g., 'local-dev'.")
98
+ ):
99
+ """A guided walkthrough to set up a new environment profile."""
100
+ try:
101
+ profile_manager.onboard_profile(profile)
102
+ except EnvShieldException as e:
103
+ console.print(f"[bold red]Error:[/bold red] {e}")
104
+ raise typer.Exit(code=1)
105
+ except Exception:
106
+ raise typer.Exit(code=1)
107
+
108
+
109
+ @app.command(name="list")
110
+ def list_profiles_command():
111
+ """Lists all available profiles from your envshield.yml file."""
112
+ try:
113
+ profile_manager.list_profiles()
114
+ except EnvShieldException as e:
115
+ console.print(f"[bold red]Error:[/bold red] {e}")
116
+ raise typer.Exit(code=1)
117
+
118
+
119
+ @app.command()
120
+ def use(
121
+ profile: str = typer.Argument(..., help="The name of the profile to activate.")
122
+ ):
123
+ """Switches the active environment to the specified profile."""
124
+ try:
125
+ profile_manager.use_profile(profile)
126
+ except EnvShieldException as e:
127
+ console.print(f"[bold red]Error:[/bold red] {e}")
128
+ raise typer.Exit(code=1)
129
+
130
+
131
+ template_app = typer.Typer(
132
+ name="template", help="Manage environment templates.", no_args_is_help=True
133
+ )
134
+ app.add_typer(template_app, name="template")
135
+
136
+
137
+ @template_app.command("check")
138
+ def template_check(
139
+ profile: str = typer.Option(
140
+ None, "--profile", "-p", help="The profile to check. Defaults to active."
141
+ )
142
+ ):
143
+ """Checks if your environment files are in sync with the template."""
144
+ from envshield import state
145
+
146
+ try:
147
+ if not profile:
148
+ profile = state.get_active_profile()
149
+ if not profile:
150
+ console.print(
151
+ "[red]Error:[/red] No profile specified and no profile is active."
152
+ )
153
+ raise typer.Exit(code=1)
154
+ console.print(f"Checking active profile: [cyan]{profile}[/cyan]")
155
+ template_manager.check_template(profile)
156
+ except EnvShieldException as e:
157
+ console.print(f"[bold red]Error:[/bold red] {e}")
158
+ raise typer.Exit(code=1)
159
+
160
+
161
+ @template_app.command("sync")
162
+ def template_sync(
163
+ profile: str = typer.Option(
164
+ None, "--profile", "-p", help="The profile to sync. Defaults to active."
165
+ )
166
+ ):
167
+ """Interactively add variables from your source files to your template."""
168
+ from envshield import state
169
+
170
+ try:
171
+ if not profile:
172
+ profile = state.get_active_profile()
173
+ if not profile:
174
+ console.print(
175
+ "[red]Error:[/red] No profile specified and no profile is active."
176
+ )
177
+ raise typer.Exit(code=1)
178
+ console.print(
179
+ f"Syncing template for active profile: [cyan]{profile}[/cyan]"
180
+ )
181
+ template_manager.sync_template(profile)
182
+ except EnvShieldException as e:
183
+ console.print(f"[bold red]Error:[/bold red] {e}")
184
+ raise typer.Exit(code=1)
185
+
186
+
187
+ @app.command()
188
+ def scan(
189
+ paths: List[str] = typer.Argument(
190
+ None,
191
+ help="Paths to files or directories to scan. Defaults to current directory.",
192
+ ),
193
+ staged: bool = typer.Option(
194
+ False, "--staged", help="Only scan files staged for the next Git commit."
195
+ ),
196
+ ):
197
+ """Scans files for hardcoded secrets."""
198
+ console.print("\n[bold cyan]🛡️ Running EnvShield Secret Scanner...[/bold cyan]")
199
+
200
+ exclude_patterns = DEFAULT_EXCLUDE_PATTERNS.copy()
201
+ try:
202
+ config = config_manager.load_config()
203
+ scan_config = config.get("secret_scanning", {})
204
+ exclude_patterns.extend(scan_config.get("exclude_files", []))
205
+ except EnvShieldException:
206
+ pass
207
+
208
+ files_to_scan = []
209
+ if staged:
210
+ console.print("Scanning [yellow]staged files[/yellow]...")
211
+ files_to_scan = git_utils.get_staged_files()
212
+ if not files_to_scan:
213
+ console.print("[green]No staged files to scan.[/green]")
214
+ raise typer.Exit()
215
+ elif paths:
216
+ for path in paths:
217
+ if os.path.isfile(path):
218
+ files_to_scan.append(os.path.abspath(path))
219
+ elif os.path.isdir(path):
220
+ for root, _, files in os.walk(path):
221
+ for file in files:
222
+ files_to_scan.append(os.path.join(root, file))
223
+ else:
224
+ console.print("Scanning [yellow]current directory[/yellow] recursively...")
225
+ for root, _, files in os.walk("."):
226
+ for file in files:
227
+ files_to_scan.append(os.path.join(root, file))
228
+
229
+ final_files_to_scan = []
230
+ for file_path in files_to_scan:
231
+ is_excluded = False
232
+ normalized_path = file_path.replace(os.getcwd() + os.sep, "")
233
+ for pattern in exclude_patterns:
234
+ if fnmatch.fnmatch(normalized_path, pattern):
235
+ is_excluded = True
236
+ break
237
+ if not is_excluded:
238
+ final_files_to_scan.append(file_path)
239
+
240
+ all_findings = []
241
+
242
+ with Progress(
243
+ SpinnerColumn(),
244
+ BarColumn(),
245
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
246
+ TextColumn("Scanning [cyan]{task.description}[/cyan]"),
247
+ console=console,
248
+ ) as progress:
249
+ scan_task = progress.add_task("files...", total=len(final_files_to_scan))
250
+ for file_path in final_files_to_scan:
251
+ # Update the progress bar with the current file name
252
+ progress.update(
253
+ scan_task, description=os.path.basename(file_path), advance=1
254
+ )
255
+
256
+ if os.path.exists(file_path) and os.path.getsize(file_path) > 1_000_000:
257
+ continue
258
+ findings = scanner.scan_file_for_secrets(file_path)
259
+ all_findings.extend(findings)
260
+
261
+ if not all_findings:
262
+ console.print(
263
+ "\n[bold green]✓ No secrets found. You're good to go![/bold green]"
264
+ )
265
+ raise typer.Exit()
266
+
267
+ console.print(
268
+ f"\n[bold red]🚨 DANGER: Found {len(all_findings)} potential secret(s)![/bold red]"
269
+ )
270
+
271
+ table = Table(title="Secret Scan Results", border_style="red")
272
+ table.add_column("File", style="cyan")
273
+ table.add_column("Line", style="yellow")
274
+ table.add_column("Secret Type", style="magenta")
275
+ table.add_column("Line Content", style="white")
276
+ for finding in all_findings:
277
+ table.add_row(
278
+ finding["file_path"],
279
+ str(finding["line_num"]),
280
+ finding["secret_type"],
281
+ finding["line_content"],
282
+ )
283
+ console.print(table)
284
+
285
+ if staged:
286
+ console.print(
287
+ "\n[bold red]Commit aborted. Please remove these secrets from your files before committing.[/bold red]"
288
+ )
289
+ else:
290
+ console.print(
291
+ "\n[bold red]Review the findings above and remove any active secrets from your project's files.[/bold red]"
292
+ )
293
+
294
+ raise typer.Exit(code=1)
295
+
296
+
297
+ @app.command("install-hook")
298
+ def install_hook():
299
+ """Installs the Git pre-commit hook to scan for secrets automatically."""
300
+ git_root = git_utils.get_git_root()
301
+ if not git_root:
302
+ console.print(
303
+ "[red]Error:[/red] Not inside a Git repository. Cannot install hook."
304
+ )
305
+ raise typer.Exit(code=1)
306
+ hooks_dir = os.path.join(git_root, ".git", "hooks")
307
+ pre_commit_path = os.path.join(hooks_dir, "pre-commit")
308
+ hook_script_content = (
309
+ "#!/bin/sh\n\n# Hook installed by EnvShield\nenvshield scan --staged\n"
310
+ )
311
+ try:
312
+ if os.path.exists(pre_commit_path):
313
+ overwrite = questionary.confirm(
314
+ "A pre-commit hook already exists. Do you want to overwrite it?",
315
+ default=False,
316
+ ).ask()
317
+ if not overwrite:
318
+ console.print("[yellow]Hook installation cancelled.[/yellow]")
319
+ raise typer.Exit()
320
+ with open(pre_commit_path, "w") as f:
321
+ f.write(hook_script_content)
322
+ os.chmod(
323
+ pre_commit_path,
324
+ stat.S_IMODE(os.stat(pre_commit_path).st_mode)
325
+ | stat.S_IXUSR
326
+ | stat.S_IXGRP
327
+ | stat.S_IXOTH,
328
+ )
329
+ console.print(
330
+ "[bold green]✓ Git pre-commit hook installed successfully![/bold green]"
331
+ )
332
+ console.print(
333
+ "EnvShield will now automatically scan for secrets before every commit."
334
+ )
335
+ except (IOError, OSError) as e:
336
+ console.print(
337
+ f"[red]Error:[/red] Failed to write or set permissions for the hook file: {e}"
338
+ )
339
+ raise typer.Exit(code=1)
340
+ except TypeError:
341
+ console.print("[yellow]Hook installation cancelled by user.[/yellow]")
342
+ raise typer.Exit()
343
+
344
+
345
+ if __name__ == "__main__":
346
+ app()
File without changes
@@ -0,0 +1,32 @@
1
+ # envshield/core/exceptions.py
2
+ class EnvShieldException(Exception):
3
+ """Base exception class for all EnvShield errors."""
4
+
5
+ pass
6
+
7
+
8
+ class ConfigNotFoundError(EnvShieldException):
9
+ """Raised when the envshield.yml configuration file cannot be found."""
10
+
11
+ def __init__(
12
+ self,
13
+ message="Configuration file 'envshield.yml' not found. Please run 'envshield init'.",
14
+ ):
15
+ self.message = message
16
+ super().__init__(self.message)
17
+
18
+
19
+ class ProfileNotFoundError(EnvShieldException):
20
+ """Raised when a specified profile is not found in the configuration."""
21
+
22
+ def __init__(self, profile_name: str):
23
+ self.message = f"Profile '{profile_name}' not found in 'envshield.yml'."
24
+ super().__init__(self.message)
25
+
26
+
27
+ class SourceFileNotFoundError(EnvShieldException):
28
+ """Raised when a profile's source file does not exist."""
29
+
30
+ def __init__(self, source_path: str):
31
+ self.message = f"Source file '{source_path}' does not exist."
32
+ super().__init__(self.message)
@@ -0,0 +1,55 @@
1
+ # envshield/core/file_updater.py
2
+ # Contains logic for safely updating variables within configuration files.
3
+ import re
4
+ from typing import List, Dict
5
+
6
+
7
+ def update_variables_in_file(file_path: str, updates: List[dict]):
8
+ """
9
+ Updates one or more variables in a given file in-place.
10
+
11
+ Args:
12
+ file_path: The path to the file to be updated.
13
+ updates: A list of dictionaries, where each dict has a 'key' and a 'value'
14
+ e.g., [{'key': 'SECRET_KEY', 'value': 'new_secret'}]
15
+ """
16
+ try:
17
+ with open(file_path, "r") as f:
18
+ lines = f.readlines()
19
+ except IOError:
20
+ return
21
+
22
+ update_map = {u["key"]: u["value"] for u in updates}
23
+ updated_keys_handled = set()
24
+
25
+ new_lines = []
26
+ for line in lines:
27
+ match_found = False
28
+ for key, value in update_map.items():
29
+ # Skip keys we've already updated to avoid duplicate processing
30
+ if key in updated_keys_handled:
31
+ continue
32
+
33
+ # This regex is more specific: it looks for the key at the start of the line,
34
+ # ignoring whitespace, followed by an equals sign.
35
+ pattern = re.compile(rf"^\s*{re.escape(key)}\s*=")
36
+ if pattern.match(line):
37
+ if file_path.endswith(".py"):
38
+ # For Python files, format as: KEY = "VALUE"
39
+ new_lines.append(f'{key} = "{value}"\n')
40
+ else:
41
+ # For .env files, format as: KEY=VALUE
42
+ new_lines.append(f"{key}={value}\n")
43
+
44
+ updated_keys_handled.add(key)
45
+ match_found = True
46
+ break
47
+
48
+ if not match_found:
49
+ new_lines.append(line)
50
+
51
+ try:
52
+ with open(file_path, "w") as f:
53
+ f.writelines(new_lines)
54
+ except IOError:
55
+ pass