vibeguard-cli 1.0.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.
Files changed (81) hide show
  1. vibeguard/__init__.py +3 -0
  2. vibeguard/cli/__init__.py +1 -0
  3. vibeguard/cli/apply.py +413 -0
  4. vibeguard/cli/auth_cmd.py +318 -0
  5. vibeguard/cli/baseline_cmd.py +286 -0
  6. vibeguard/cli/config_cmd.py +252 -0
  7. vibeguard/cli/display.py +356 -0
  8. vibeguard/cli/doctor.py +228 -0
  9. vibeguard/cli/fix.py +977 -0
  10. vibeguard/cli/import_cmd.py +180 -0
  11. vibeguard/cli/init_cmd.py +113 -0
  12. vibeguard/cli/keys.py +193 -0
  13. vibeguard/cli/live_cmd.py +564 -0
  14. vibeguard/cli/main.py +667 -0
  15. vibeguard/cli/patch.py +805 -0
  16. vibeguard/cli/report.py +106 -0
  17. vibeguard/cli/scan.py +1227 -0
  18. vibeguard/core/__init__.py +1 -0
  19. vibeguard/core/auth.py +402 -0
  20. vibeguard/core/baseline.py +212 -0
  21. vibeguard/core/bootstrap.py +303 -0
  22. vibeguard/core/cache.py +77 -0
  23. vibeguard/core/config.py +99 -0
  24. vibeguard/core/dedup.py +168 -0
  25. vibeguard/core/downloader.py +222 -0
  26. vibeguard/core/example_detector.py +159 -0
  27. vibeguard/core/exit_codes.py +19 -0
  28. vibeguard/core/ignore.py +243 -0
  29. vibeguard/core/keyring.py +188 -0
  30. vibeguard/core/license.py +166 -0
  31. vibeguard/core/llm.py +206 -0
  32. vibeguard/core/path_classifier.py +152 -0
  33. vibeguard/core/repo_detector.py +143 -0
  34. vibeguard/core/sarif_import.py +342 -0
  35. vibeguard/core/triage.py +205 -0
  36. vibeguard/core/url_validator.py +259 -0
  37. vibeguard/core/validate.py +174 -0
  38. vibeguard/models/__init__.py +24 -0
  39. vibeguard/models/auth.py +92 -0
  40. vibeguard/models/baseline.py +105 -0
  41. vibeguard/models/finding.py +78 -0
  42. vibeguard/models/patch.py +164 -0
  43. vibeguard/models/scan_result.py +190 -0
  44. vibeguard/models/triage.py +53 -0
  45. vibeguard/reporters/__init__.py +7 -0
  46. vibeguard/reporters/badge.py +103 -0
  47. vibeguard/reporters/html.py +920 -0
  48. vibeguard/reporters/sarif.py +175 -0
  49. vibeguard/scanners/__init__.py +130 -0
  50. vibeguard/scanners/manifests/bandit.toml +39 -0
  51. vibeguard/scanners/manifests/cargo_audit.toml +31 -0
  52. vibeguard/scanners/manifests/checkov.toml +37 -0
  53. vibeguard/scanners/manifests/dockle.toml +46 -0
  54. vibeguard/scanners/manifests/gitleaks.toml +48 -0
  55. vibeguard/scanners/manifests/npm_audit.toml +31 -0
  56. vibeguard/scanners/manifests/nuclei.toml +58 -0
  57. vibeguard/scanners/manifests/pip_audit.toml +36 -0
  58. vibeguard/scanners/manifests/semgrep.toml +43 -0
  59. vibeguard/scanners/manifests/trivy.toml +50 -0
  60. vibeguard/scanners/manifests/trufflehog.toml +48 -0
  61. vibeguard/scanners/parsers/__init__.py +1 -0
  62. vibeguard/scanners/parsers/bandit.py +94 -0
  63. vibeguard/scanners/parsers/cargo_audit.py +185 -0
  64. vibeguard/scanners/parsers/checkov.py +179 -0
  65. vibeguard/scanners/parsers/dockle.py +185 -0
  66. vibeguard/scanners/parsers/gitleaks.py +95 -0
  67. vibeguard/scanners/parsers/npm_audit.py +219 -0
  68. vibeguard/scanners/parsers/nuclei.py +247 -0
  69. vibeguard/scanners/parsers/pip_audit.py +166 -0
  70. vibeguard/scanners/parsers/semgrep.py +110 -0
  71. vibeguard/scanners/parsers/trivy.py +86 -0
  72. vibeguard/scanners/parsers/trufflehog.py +150 -0
  73. vibeguard/scanners/runners/__init__.py +7 -0
  74. vibeguard/scanners/runners/base.py +41 -0
  75. vibeguard/scanners/runners/docker.py +86 -0
  76. vibeguard/scanners/runners/local.py +144 -0
  77. vibeguard_cli-1.0.0.dist-info/METADATA +223 -0
  78. vibeguard_cli-1.0.0.dist-info/RECORD +81 -0
  79. vibeguard_cli-1.0.0.dist-info/WHEEL +4 -0
  80. vibeguard_cli-1.0.0.dist-info/entry_points.txt +2 -0
  81. vibeguard_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
vibeguard/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """VibeGuard CLI - Unified security scanner orchestrator."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1 @@
1
+ """CLI commands for VibeGuard."""
vibeguard/cli/apply.py ADDED
@@ -0,0 +1,413 @@
1
+ """Apply command - safely apply patches with git checks (PRO tier)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess # nosec B404 # noqa: S404 # needed for git operations
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import typer
11
+ from rich.panel import Panel
12
+ from rich.syntax import Syntax
13
+
14
+ from vibeguard.cli.display import get_console
15
+ from vibeguard.core.exit_codes import ExitCode
16
+ from vibeguard.core.license import ProFeatureError, require_pro_license
17
+ from vibeguard.models.patch import validate_unified_diff
18
+
19
+ console = get_console()
20
+
21
+ # Default patches directory
22
+ PATCHES_DIR = ".vibeguard/patches"
23
+
24
+
25
+ def apply(
26
+ patch_file: Path | None = typer.Argument(
27
+ None,
28
+ help="Path to the .patch file to apply",
29
+ exists=True,
30
+ file_okay=True,
31
+ dir_okay=False,
32
+ resolve_path=True,
33
+ ),
34
+ path: Path = typer.Option(
35
+ Path("."),
36
+ "--path",
37
+ "-p",
38
+ help="Path to the git repository",
39
+ exists=True,
40
+ file_okay=False,
41
+ dir_okay=True,
42
+ resolve_path=True,
43
+ ),
44
+ dry_run: bool = typer.Option(
45
+ False,
46
+ "--dry-run",
47
+ help="Check if patch applies cleanly without actually applying",
48
+ ),
49
+ force: bool = typer.Option(
50
+ False,
51
+ "--force",
52
+ "-f",
53
+ help="Apply even if working directory has uncommitted changes",
54
+ ),
55
+ reverse: bool = typer.Option(
56
+ False,
57
+ "--reverse",
58
+ "-R",
59
+ help="Reverse/unapply the patch",
60
+ ),
61
+ ) -> None:
62
+ """Apply a generated patch to fix a security finding.
63
+
64
+ This is a PRO feature that requires an active Pro license.
65
+ Run 'vibeguard auth login <license-key>' to activate.
66
+
67
+ Safety checks performed:
68
+ - Verifies patch file is valid unified diff
69
+ - Checks repository is a git repo
70
+ - Warns if working directory has uncommitted changes
71
+ - Uses 'git apply --check' to verify patch applies cleanly
72
+ - Creates backup information before applying
73
+
74
+ Example:
75
+ vibeguard apply .vibeguard/patches/abc123.patch
76
+ vibeguard apply my-fix.patch --dry-run
77
+ vibeguard apply .vibeguard/patches/abc123.patch --reverse
78
+ """
79
+ target = path.resolve()
80
+
81
+ # Handle missing patch_file with helpful message
82
+ if patch_file is None:
83
+ _show_missing_patch_help(target)
84
+ raise typer.Exit(ExitCode.CONFIG_ERROR)
85
+
86
+ # Check Pro license
87
+ try:
88
+ require_pro_license("Patch application")
89
+ except ProFeatureError as e:
90
+ console.print(f"[red]Error:[/red] {e}")
91
+ raise typer.Exit(ExitCode.CONFIG_ERROR)
92
+
93
+ # Verify patch file exists and is readable
94
+ patch_content = _read_patch_file(patch_file)
95
+ if patch_content is None:
96
+ raise typer.Exit(ExitCode.CONFIG_ERROR)
97
+
98
+ # Validate patch format
99
+ is_valid, error = validate_unified_diff(patch_content)
100
+ if not is_valid:
101
+ console.print(f"[red]Error:[/red] Invalid patch file: {error}")
102
+ console.print(f"\n[dim]File: {patch_file}[/dim]")
103
+ raise typer.Exit(ExitCode.CONFIG_ERROR)
104
+
105
+ # Check if target is a git repository
106
+ if not _is_git_repo(target):
107
+ console.print(f"[red]Error:[/red] Not a git repository: {target}")
108
+ console.print("\nVibeGuard apply requires a git repository for safety checks.")
109
+ raise typer.Exit(ExitCode.CONFIG_ERROR)
110
+
111
+ # Load patch metadata if available
112
+ metadata = _load_patch_metadata(patch_file)
113
+
114
+ # Show patch info
115
+ _show_patch_info(patch_file, patch_content, metadata, reverse)
116
+
117
+ # Check working directory status
118
+ if not force:
119
+ has_changes, status_output = _check_working_directory(target)
120
+ if has_changes:
121
+ console.print("[yellow]Warning:[/yellow] Working directory has uncommitted changes.\n")
122
+ console.print("[dim]Uncommitted files:[/dim]")
123
+ console.print(status_output[:500])
124
+ console.print()
125
+ console.print(
126
+ "Use [cyan]--force[/cyan] to apply anyway, or commit/stash changes first."
127
+ )
128
+ raise typer.Exit(ExitCode.CONFIG_ERROR)
129
+
130
+ # Verify patch applies cleanly with git apply --check
131
+ console.print("[dim]Checking if patch applies cleanly...[/dim]")
132
+ can_apply, check_error = _git_apply_check(target, patch_file, reverse)
133
+
134
+ if not can_apply:
135
+ console.print("[red]Error:[/red] Patch cannot be applied cleanly.\n")
136
+ console.print("[bold]Git apply check output:[/bold]")
137
+ console.print(check_error)
138
+ console.print()
139
+ console.print("[dim]Tips:[/dim]")
140
+ console.print(" - The file may have been modified since the patch was generated")
141
+ console.print(
142
+ " - Try running [cyan]vibeguard scan[/cyan] and [cyan]vibeguard patch[/cyan] again"
143
+ )
144
+ console.print(" - Check if the patch was already applied")
145
+ raise typer.Exit(ExitCode.SCAN_ERROR)
146
+
147
+ console.print("[green]Patch applies cleanly![/green]\n")
148
+
149
+ # Dry run - stop here
150
+ if dry_run:
151
+ console.print("[dim]Dry run - patch not applied[/dim]")
152
+ console.print(f"\nTo apply: [cyan]vibeguard apply {patch_file}[/cyan]")
153
+ raise typer.Exit(ExitCode.SUCCESS)
154
+
155
+ # Get current HEAD for reference
156
+ head_before = _get_git_head(target)
157
+
158
+ # Apply the patch
159
+ action = "Reversing" if reverse else "Applying"
160
+ console.print(f"[dim]{action} patch...[/dim]")
161
+
162
+ success, apply_output = _git_apply(target, patch_file, reverse)
163
+
164
+ if not success:
165
+ console.print("[red]Error:[/red] Failed to apply patch.\n")
166
+ console.print(apply_output)
167
+ raise typer.Exit(ExitCode.SCAN_ERROR)
168
+
169
+ # Success!
170
+ action_past = "reversed" if reverse else "applied"
171
+ console.print(f"[green]Patch {action_past} successfully![/green]\n")
172
+
173
+ # Show what changed
174
+ console.print("[dim]Modified files:[/dim]")
175
+ modified = _get_modified_files_from_patch(patch_content)
176
+ for f in modified:
177
+ console.print(f" {f}")
178
+
179
+ console.print()
180
+ console.print("[dim]Next steps:[/dim]")
181
+ console.print(" 1. Review the changes: [cyan]git diff[/cyan]")
182
+ console.print(" 2. Run tests to verify the fix")
183
+ console.print(" 3. Commit when satisfied: [cyan]git commit -am 'Fix security finding'[/cyan]")
184
+
185
+ if head_before:
186
+ console.print(f"\n[dim]To undo: git checkout {head_before[:8]} -- <files>[/dim]")
187
+
188
+ raise typer.Exit(ExitCode.SUCCESS)
189
+
190
+
191
+ def _show_missing_patch_help(target: Path) -> None:
192
+ """Show helpful error when patch file is missing."""
193
+ console.print("[red]Error:[/red] Missing patch file.\n")
194
+ console.print("[bold]Usage:[/bold] vibeguard apply <patch-file>\n")
195
+
196
+ # Check for available patches
197
+ patches_dir = target / PATCHES_DIR
198
+ if patches_dir.exists():
199
+ patches = list(patches_dir.glob("*.patch"))
200
+ if patches:
201
+ console.print("[bold]Available patches:[/bold]")
202
+ for p in patches[:10]:
203
+ # Try to load metadata for context
204
+ meta = _load_patch_metadata(p)
205
+ if meta:
206
+ console.print(
207
+ f" [green]{p.name}[/green] {meta.get('file_path', '')[:40]}"
208
+ )
209
+ else:
210
+ console.print(f" [green]{p.name}[/green]")
211
+ if len(patches) > 10:
212
+ console.print(f" [dim]... and {len(patches) - 10} more[/dim]")
213
+ console.print(f"\n[bold]Example:[/bold] vibeguard apply {patches[0]}")
214
+ return
215
+
216
+ console.print("Generate a patch first using:")
217
+ console.print(" 1. [cyan]vibeguard scan[/cyan] (find security issues)")
218
+ console.print(" 2. [cyan]vibeguard patch <finding-id>[/cyan] (generate fix)")
219
+ console.print(" 3. [cyan]vibeguard apply <patch-file>[/cyan] (apply fix)")
220
+
221
+
222
+ def _read_patch_file(patch_file: Path) -> str | None:
223
+ """Read patch file content."""
224
+ try:
225
+ return patch_file.read_text(encoding="utf-8")
226
+ except OSError as e:
227
+ console.print(f"[red]Error:[/red] Cannot read patch file: {e}")
228
+ return None
229
+
230
+
231
+ def _is_git_repo(path: Path) -> bool:
232
+ """Check if path is inside a git repository."""
233
+ git_dir = path / ".git"
234
+ if git_dir.exists():
235
+ return True
236
+
237
+ # Check parent directories
238
+ try:
239
+ result = subprocess.run( # nosec B603 B607
240
+ ["git", "rev-parse", "--git-dir"],
241
+ cwd=path,
242
+ capture_output=True,
243
+ text=True,
244
+ timeout=10,
245
+ )
246
+ return result.returncode == 0
247
+ except (subprocess.SubprocessError, OSError):
248
+ return False
249
+
250
+
251
+ def _check_working_directory(path: Path) -> tuple[bool, str]:
252
+ """Check if working directory has uncommitted changes.
253
+
254
+ Returns:
255
+ Tuple of (has_changes, status_output)
256
+ """
257
+ try:
258
+ result = subprocess.run( # nosec B603 B607
259
+ ["git", "status", "--porcelain"],
260
+ cwd=path,
261
+ capture_output=True,
262
+ text=True,
263
+ timeout=30,
264
+ )
265
+ output = result.stdout.strip()
266
+ return bool(output), output
267
+ except (subprocess.SubprocessError, OSError):
268
+ return False, ""
269
+
270
+
271
+ def _git_apply_check(
272
+ path: Path, patch_file: Path, reverse: bool = False
273
+ ) -> tuple[bool, str]:
274
+ """Run git apply --check to verify patch.
275
+
276
+ Returns:
277
+ Tuple of (can_apply, error_output)
278
+ """
279
+ cmd = ["git", "apply", "--check"]
280
+ if reverse:
281
+ cmd.append("--reverse")
282
+ cmd.append(str(patch_file))
283
+
284
+ try:
285
+ result = subprocess.run( # nosec B603
286
+ cmd,
287
+ cwd=path,
288
+ capture_output=True,
289
+ text=True,
290
+ timeout=60,
291
+ )
292
+ if result.returncode == 0:
293
+ return True, ""
294
+ return False, result.stderr or result.stdout
295
+ except subprocess.TimeoutExpired:
296
+ return False, "Git apply check timed out"
297
+ except (subprocess.SubprocessError, OSError) as e:
298
+ return False, str(e)
299
+
300
+
301
+ def _git_apply(
302
+ path: Path, patch_file: Path, reverse: bool = False
303
+ ) -> tuple[bool, str]:
304
+ """Apply patch using git apply.
305
+
306
+ Returns:
307
+ Tuple of (success, output)
308
+ """
309
+ cmd = ["git", "apply"]
310
+ if reverse:
311
+ cmd.append("--reverse")
312
+ cmd.append(str(patch_file))
313
+
314
+ try:
315
+ result = subprocess.run( # nosec B603
316
+ cmd,
317
+ cwd=path,
318
+ capture_output=True,
319
+ text=True,
320
+ timeout=60,
321
+ )
322
+ if result.returncode == 0:
323
+ return True, result.stdout
324
+ return False, result.stderr or result.stdout
325
+ except subprocess.TimeoutExpired:
326
+ return False, "Git apply timed out"
327
+ except (subprocess.SubprocessError, OSError) as e:
328
+ return False, str(e)
329
+
330
+
331
+ def _get_git_head(path: Path) -> str | None:
332
+ """Get current HEAD commit hash."""
333
+ try:
334
+ result = subprocess.run( # nosec B603 B607
335
+ ["git", "rev-parse", "HEAD"],
336
+ cwd=path,
337
+ capture_output=True,
338
+ text=True,
339
+ timeout=10,
340
+ )
341
+ if result.returncode == 0:
342
+ return result.stdout.strip()
343
+ except (subprocess.SubprocessError, OSError):
344
+ pass
345
+ return None
346
+
347
+
348
+ def _load_patch_metadata(patch_file: Path) -> dict[str, Any] | None:
349
+ """Load patch metadata JSON if available."""
350
+ meta_path = patch_file.with_suffix(".json")
351
+ if not meta_path.exists():
352
+ return None
353
+
354
+ try:
355
+ content = meta_path.read_text(encoding="utf-8")
356
+ data: dict[str, Any] = json.loads(content)
357
+ return data
358
+ except (OSError, json.JSONDecodeError):
359
+ return None
360
+
361
+
362
+ def _show_patch_info(
363
+ patch_file: Path,
364
+ patch_content: str,
365
+ metadata: dict[str, Any] | None,
366
+ reverse: bool,
367
+ ) -> None:
368
+ """Display patch information."""
369
+ action = "Reversing" if reverse else "Applying"
370
+ title = f"{action} Patch"
371
+
372
+ info_lines = [f"[bold]File:[/bold] {patch_file.name}"]
373
+
374
+ if metadata:
375
+ if "file_path" in metadata:
376
+ info_lines.append(f"[bold]Target:[/bold] {metadata['file_path']}")
377
+ if "finding_id" in metadata:
378
+ info_lines.append(f"[bold]Finding:[/bold] {metadata['finding_id'][:12]}")
379
+ if "provider" in metadata and "model" in metadata:
380
+ info_lines.append(
381
+ f"[bold]Generated by:[/bold] {metadata['provider']}/{metadata['model']}"
382
+ )
383
+ if metadata.get("manual_review_required"):
384
+ info_lines.append("[yellow]Note: Manual review markers present[/yellow]")
385
+
386
+ console.print(Panel("\n".join(info_lines), title=title))
387
+ console.print()
388
+
389
+ # Show diff preview (first 30 lines)
390
+ lines = patch_content.split("\n")
391
+ preview = "\n".join(lines[:30])
392
+ if len(lines) > 30:
393
+ preview += f"\n... ({len(lines) - 30} more lines)"
394
+
395
+ syntax = Syntax(preview, "diff", theme="monokai", line_numbers=False)
396
+ console.print(Panel(syntax, title="Patch Preview"))
397
+ console.print()
398
+
399
+
400
+ def _get_modified_files_from_patch(patch_content: str) -> list[str]:
401
+ """Extract list of files modified by the patch."""
402
+ files = []
403
+ for line in patch_content.split("\n"):
404
+ if line.startswith("+++ "):
405
+ # Extract file path, removing a/ or b/ prefix
406
+ file_path = line[4:].strip()
407
+ if file_path.startswith("b/"):
408
+ file_path = file_path[2:]
409
+ elif file_path.startswith("a/"):
410
+ file_path = file_path[2:]
411
+ if file_path and file_path != "/dev/null":
412
+ files.append(file_path)
413
+ return files