gitrupt 0.1.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 (48) hide show
  1. gitrupt/__init__.py +13 -0
  2. gitrupt/cli.py +546 -0
  3. gitrupt/config.py +269 -0
  4. gitrupt/git.py +590 -0
  5. gitrupt/hooks/__init__.py +7 -0
  6. gitrupt/hooks/install.py +255 -0
  7. gitrupt/hooks/pre_commit.py +103 -0
  8. gitrupt/hooks/pre_push.py +178 -0
  9. gitrupt/models.py +190 -0
  10. gitrupt/policy.py +36 -0
  11. gitrupt/reporting.py +316 -0
  12. gitrupt/risk.py +197 -0
  13. gitrupt/scanner.py +117 -0
  14. gitrupt/scanners/__init__.py +17 -0
  15. gitrupt/scanners/adapters.py +166 -0
  16. gitrupt/scanners/base.py +113 -0
  17. gitrupt/scanners/binaries.py +185 -0
  18. gitrupt/scanners/code_rules/__init__.py +36 -0
  19. gitrupt/scanners/code_rules/base.py +27 -0
  20. gitrupt/scanners/code_rules/go.py +65 -0
  21. gitrupt/scanners/code_rules/javascript.py +106 -0
  22. gitrupt/scanners/code_rules/php.py +71 -0
  23. gitrupt/scanners/code_rules/powershell.py +85 -0
  24. gitrupt/scanners/code_rules/python.py +153 -0
  25. gitrupt/scanners/code_rules/ruby.py +76 -0
  26. gitrupt/scanners/code_rules/rust.py +41 -0
  27. gitrupt/scanners/code_rules/shell.py +112 -0
  28. gitrupt/scanners/dependencies.py +244 -0
  29. gitrupt/scanners/ecosystems/__init__.py +30 -0
  30. gitrupt/scanners/ecosystems/base.py +60 -0
  31. gitrupt/scanners/ecosystems/node.py +128 -0
  32. gitrupt/scanners/ecosystems/python.py +157 -0
  33. gitrupt/scanners/entropy.py +123 -0
  34. gitrupt/scanners/forbidden_files.py +201 -0
  35. gitrupt/scanners/malware.py +219 -0
  36. gitrupt/scanners/osv_client.py +221 -0
  37. gitrupt/scanners/registry.py +66 -0
  38. gitrupt/scanners/secret_rules.py +368 -0
  39. gitrupt/scanners/secrets.py +558 -0
  40. gitrupt/scanners/suspicious_code.py +208 -0
  41. gitrupt/scanners/yara_loader.py +65 -0
  42. gitrupt/scanners/yara_rules_builtin.py +141 -0
  43. gitrupt-0.1.0.dist-info/METADATA +342 -0
  44. gitrupt-0.1.0.dist-info/RECORD +48 -0
  45. gitrupt-0.1.0.dist-info/WHEEL +5 -0
  46. gitrupt-0.1.0.dist-info/entry_points.txt +2 -0
  47. gitrupt-0.1.0.dist-info/licenses/LICENSE +23 -0
  48. gitrupt-0.1.0.dist-info/top_level.txt +1 -0
gitrupt/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """
2
+ Gitrupt — A local Git security firewall.
3
+
4
+ Prevents secrets, prohibited files, and threats from entering Git commits.
5
+ """
6
+
7
+ __version__ = "0.1.0"
8
+ __author__ = "Gitrupt Contributors"
9
+ __license__ = "MIT"
10
+
11
+ from gitrupt.models import Finding, PolicyDecision, ScanResult, Severity
12
+
13
+ __all__ = ["Finding", "PolicyDecision", "ScanResult", "Severity", "__version__"]
gitrupt/cli.py ADDED
@@ -0,0 +1,546 @@
1
+ """
2
+ Gitrupt CLI.
3
+
4
+ Commands:
5
+ gitrupt init — Install the pre-commit hook
6
+ gitrupt scan — Scan staged or working-tree files
7
+ gitrupt status — Show installation status
8
+ gitrupt config — Show current configuration
9
+ gitrupt uninstall — Remove the pre-commit hook
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import sys
16
+ from pathlib import Path
17
+ from typing import Optional
18
+
19
+ import typer
20
+ from typing_extensions import Annotated
21
+
22
+ from gitrupt import __version__
23
+
24
+ app = typer.Typer(
25
+ name="gitrupt",
26
+ help="Gitrupt -- A local Git security firewall.",
27
+ rich_markup_mode="rich",
28
+ no_args_is_help=True,
29
+ pretty_exceptions_show_locals=False,
30
+ )
31
+
32
+
33
+ def _setup_logging(verbose: bool) -> None:
34
+ level = logging.DEBUG if verbose else logging.WARNING
35
+ logging.basicConfig(
36
+ level=level,
37
+ format="%(levelname)s: %(name)s: %(message)s",
38
+ )
39
+
40
+
41
+ # ─────────────────────────────────────────────────────────────────────────────
42
+ # Global options callback
43
+ # ─────────────────────────────────────────────────────────────────────────────
44
+
45
+
46
+ def version_callback(value: bool) -> None:
47
+ if value:
48
+ typer.echo(f"Gitrupt {__version__}")
49
+ raise typer.Exit()
50
+
51
+
52
+ @app.callback()
53
+ def main_callback(
54
+ version: Annotated[
55
+ Optional[bool],
56
+ typer.Option(
57
+ "--version",
58
+ "-V",
59
+ callback=version_callback,
60
+ is_eager=True,
61
+ help="Show version and exit.",
62
+ ),
63
+ ] = None,
64
+ verbose: Annotated[
65
+ bool,
66
+ typer.Option("--verbose", "-v", help="Enable verbose/debug output."),
67
+ ] = False,
68
+ ) -> None:
69
+ """Gitrupt -- A local Git security firewall."""
70
+ _setup_logging(verbose)
71
+
72
+
73
+ # ─────────────────────────────────────────────────────────────────────────────
74
+ # Commands
75
+ # ─────────────────────────────────────────────────────────────────────────────
76
+
77
+
78
+ @app.command()
79
+ def init(
80
+ path: Annotated[
81
+ Optional[str],
82
+ typer.Argument(help="Repository path (default: current directory)."),
83
+ ] = None,
84
+ force: Annotated[
85
+ bool,
86
+ typer.Option("--force", "-f", help="Reinstall even if already installed."),
87
+ ] = False,
88
+ with_push: Annotated[
89
+ bool,
90
+ typer.Option("--with-push", help="Also install the pre-push hook."),
91
+ ] = False,
92
+ ) -> None:
93
+ """
94
+ Install Gitrupt protection in a Git repository.
95
+
96
+ Installs a pre-commit hook that scans staged changes before every commit.
97
+ Existing hooks are preserved and chained.
98
+
99
+ Use --with-push to also install the pre-push firewall.
100
+ """
101
+ from gitrupt.config import find_config_path, load_config, ConfigurationError
102
+ from gitrupt.git import GitAdapter
103
+ from gitrupt.hooks.install import HookInstaller, HookInstallError
104
+ from gitrupt.reporting import print_error, print_init_result, console
105
+
106
+ start_path = path or "."
107
+
108
+ if not GitAdapter.is_git_available():
109
+ print_error("Git is not installed or not on PATH.")
110
+ raise typer.Exit(code=1)
111
+
112
+ repo_root = GitAdapter.find_repo_root(start_path)
113
+ if not repo_root:
114
+ print_error(f"No Git repository found at or above: {Path(start_path).resolve()}")
115
+ raise typer.Exit(code=1)
116
+
117
+ hooks_dir = GitAdapter.find_hooks_dir(repo_root)
118
+ if not hooks_dir:
119
+ print_error("Could not locate the Git hooks directory.")
120
+ raise typer.Exit(code=1)
121
+
122
+ # Load config to decide which hooks to install (CLI flag overrides).
123
+ try:
124
+ config = load_config(repo_root)
125
+ except ConfigurationError:
126
+ config = None
127
+
128
+ install_push = with_push or (config is not None and config.hooks.pre_push)
129
+
130
+ installer = HookInstaller(repo_root=repo_root, hooks_dir=hooks_dir)
131
+
132
+ any_installed = False
133
+ had_existing_any = False
134
+ paths: list[str] = []
135
+
136
+ if not installer.is_hook_installed("pre-commit") or force:
137
+ try:
138
+ ok, had, hpath = installer.install_hook("pre-commit")
139
+ except HookInstallError as e:
140
+ print_error(str(e))
141
+ raise typer.Exit(code=1) from e
142
+ if ok:
143
+ any_installed = True
144
+ had_existing_any = had_existing_any or had
145
+ paths.append(hpath)
146
+
147
+ if install_push:
148
+ if not installer.is_hook_installed("pre-push") or force:
149
+ try:
150
+ ok, had, hpath = installer.install_hook("pre-push")
151
+ except HookInstallError as e:
152
+ print_error(str(e))
153
+ raise typer.Exit(code=1) from e
154
+ if ok:
155
+ any_installed = True
156
+ had_existing_any = had_existing_any or had
157
+ paths.append(hpath)
158
+
159
+ if not any_installed and not force:
160
+ console.print()
161
+ console.print("[bold blue]🛡️ Gitrupt[/bold blue]")
162
+ console.print()
163
+ console.print("[green]✓[/green] Gitrupt is already installed.")
164
+ console.print("[dim]Run with --force to reinstall.[/dim]")
165
+ return
166
+
167
+ config_path = find_config_path(repo_root)
168
+ print_init_result(
169
+ repo_root=repo_root,
170
+ hook_paths=paths,
171
+ was_existing_hook=had_existing_any,
172
+ config_exists=config_path is not None,
173
+ )
174
+
175
+
176
+ @app.command()
177
+ def scan(
178
+ staged: Annotated[
179
+ bool,
180
+ typer.Option("--staged", "-s", help="Scan only staged files (pre-commit behavior)."),
181
+ ] = False,
182
+ push: Annotated[
183
+ bool,
184
+ typer.Option("--push", help="Scan outgoing commits (pre-push behavior)."),
185
+ ] = False,
186
+ path: Annotated[
187
+ Optional[str],
188
+ typer.Argument(help="Repository path (default: current directory)."),
189
+ ] = None,
190
+ output_format: Annotated[
191
+ str,
192
+ typer.Option("--format", "-f", help="Output format: terminal, json, sarif."),
193
+ ] = "terminal",
194
+ ) -> None:
195
+ """
196
+ Scan repository files for security issues.
197
+
198
+ Default: staged files (matches the pre-commit hook).
199
+ --push: outgoing commits (matches the pre-push hook).
200
+ """
201
+ from gitrupt.config import ConfigurationError, load_config
202
+ from gitrupt.git import GitAdapter, GitError
203
+ from gitrupt.reporting import (
204
+ print_header, print_scanning, print_decision, print_scan_stats, print_error,
205
+ )
206
+ from gitrupt.risk import RiskEngine
207
+ from gitrupt.scanner import run_scan
208
+
209
+ start_path = path or "."
210
+
211
+ if not GitAdapter.is_git_available():
212
+ print_error("Git is not installed or not on PATH.")
213
+ raise typer.Exit(code=1)
214
+
215
+ repo_root = GitAdapter.find_repo_root(start_path)
216
+ if not repo_root:
217
+ print_error("Not inside a Git repository.")
218
+ raise typer.Exit(code=1)
219
+
220
+ try:
221
+ config = load_config(repo_root)
222
+ except ConfigurationError as e:
223
+ print_error(f"Configuration error: {e}")
224
+ raise typer.Exit(code=1) from e
225
+
226
+ try:
227
+ if push:
228
+ target = _build_push_target_interactive(repo_root)
229
+ else:
230
+ target = GitAdapter.build_scan_target(repo_root)
231
+ except GitError as e:
232
+ print_error(f"Git error: {e}")
233
+ raise typer.Exit(code=1) from e
234
+
235
+ if output_format in {"json", "sarif"}:
236
+ scan_result = run_scan(target, config)
237
+ if output_format == "json":
238
+ _output_json(scan_result)
239
+ else:
240
+ _output_sarif(scan_result)
241
+ return
242
+
243
+ print_header()
244
+ if not target.staged_files:
245
+ from gitrupt.reporting import console
246
+ console.print("[dim]No files to scan.[/dim]")
247
+ return
248
+
249
+ print_scanning(len(target.staged_files))
250
+ scan_result = run_scan(target, config)
251
+ engine = RiskEngine(config.policy, config.suppressions)
252
+ decision = engine.evaluate(scan_result)
253
+ print_decision(decision)
254
+ print_scan_stats(scan_result)
255
+ raise typer.Exit(code=decision.exit_code)
256
+
257
+
258
+ def _build_push_target_interactive(repo_root: str):
259
+ """Build a push scan target for the current branch vs its upstream."""
260
+ import subprocess
261
+ from gitrupt.git import GitAdapter, GitError
262
+
263
+ # Try to find the upstream
264
+ try:
265
+ result = subprocess.run(
266
+ ["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
267
+ check=False, capture_output=True, text=True, cwd=repo_root, timeout=10,
268
+ )
269
+ except Exception as e:
270
+ raise GitError(f"Could not resolve upstream: {e}") from e
271
+
272
+ if result.returncode != 0:
273
+ raise GitError(
274
+ "No upstream branch set. Push with -u first, or use --staged."
275
+ )
276
+
277
+ upstream = result.stdout.strip() # e.g. "origin/main"
278
+ remote_sha_res = subprocess.run(
279
+ ["git", "rev-parse", upstream],
280
+ check=False, capture_output=True, text=True, cwd=repo_root, timeout=10,
281
+ )
282
+ head_sha_res = subprocess.run(
283
+ ["git", "rev-parse", "HEAD"],
284
+ check=False, capture_output=True, text=True, cwd=repo_root, timeout=10,
285
+ )
286
+ if remote_sha_res.returncode != 0 or head_sha_res.returncode != 0:
287
+ raise GitError("Could not resolve upstream or HEAD.")
288
+
289
+ return GitAdapter.build_push_scan_target(
290
+ repo_root, remote_sha_res.stdout.strip(), head_sha_res.stdout.strip(),
291
+ )
292
+
293
+
294
+ @app.command()
295
+ def status(
296
+ path: Annotated[
297
+ Optional[str],
298
+ typer.Argument(help="Repository path (default: current directory)."),
299
+ ] = None,
300
+ ) -> None:
301
+ """
302
+ Show Gitrupt installation status.
303
+ """
304
+ from gitrupt.config import find_config_path
305
+ from gitrupt.git import GitAdapter
306
+ from gitrupt.hooks.install import HookInstaller
307
+ from gitrupt.reporting import print_status
308
+
309
+ start_path = path or "."
310
+
311
+ git_available = GitAdapter.is_git_available()
312
+ git_version = GitAdapter.get_git_version() if git_available else None
313
+ git_binary = GitAdapter.get_git_binary() if git_available else None
314
+ repo_root = GitAdapter.find_repo_root(start_path) if git_available else None
315
+
316
+ hook_installed = False
317
+ hook_path = None
318
+ hooks_dir = None
319
+ core_hooks_path = None
320
+
321
+ if repo_root:
322
+ hooks_dir = GitAdapter.find_hooks_dir(repo_root)
323
+ core_hooks_path = GitAdapter.get_core_hooks_path(repo_root)
324
+ if hooks_dir:
325
+ installer = HookInstaller(repo_root=repo_root, hooks_dir=hooks_dir)
326
+ hook_installed = installer.is_installed()
327
+ hook_path = installer.hook_path() if hook_installed else None
328
+
329
+ config_path = None
330
+ if repo_root:
331
+ cp = find_config_path(repo_root)
332
+ config_path = str(cp) if cp else None
333
+
334
+
335
+ # YARA availability (does not require an initialized engine)
336
+ from gitrupt.scanners.yara_loader import is_available as yara_is_available
337
+ yara_available = yara_is_available()
338
+
339
+ from gitrupt.scanners.ecosystems import supported_ecosystems
340
+ dependency_ecosystems = supported_ecosystems()
341
+
342
+
343
+
344
+ pre_push_installed = None
345
+ pre_push_path = None
346
+ if repo_root and hooks_dir:
347
+ installer = HookInstaller(repo_root=repo_root, hooks_dir=hooks_dir)
348
+ pre_push_installed = installer.is_hook_installed("pre-push")
349
+ pre_push_path = str(Path(hooks_dir) / "pre-push") if pre_push_installed else None
350
+
351
+
352
+
353
+ print_status(
354
+ repo_root=repo_root,
355
+ git_available=git_available,
356
+ hook_installed=hook_installed,
357
+ hook_path=hook_path,
358
+ config_path=config_path,
359
+ git_version=git_version,
360
+ git_binary=git_binary,
361
+ hooks_dir=hooks_dir,
362
+ core_hooks_path=core_hooks_path,
363
+ yara_available=yara_available,
364
+ dependency_ecosystems=dependency_ecosystems,
365
+ pre_push_installed=pre_push_installed,
366
+ pre_push_path=pre_push_path,
367
+ )
368
+
369
+
370
+ @app.command()
371
+ def uninstall(
372
+ path: Annotated[Optional[str], typer.Argument(help="Repository path.")] = None,
373
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation.")] = False,
374
+ with_push: Annotated[bool, typer.Option("--with-push", help="Also remove pre-push.")] = False,
375
+ ) -> None:
376
+ """Remove Gitrupt protection from a Git repository."""
377
+ from gitrupt.git import GitAdapter
378
+ from gitrupt.hooks.install import HookInstaller
379
+ from gitrupt.reporting import print_uninstall_result, print_error, console
380
+
381
+ start_path = path or "."
382
+ if not GitAdapter.is_git_available():
383
+ print_error("Git is not installed or not on PATH.")
384
+ raise typer.Exit(code=1)
385
+ repo_root = GitAdapter.find_repo_root(start_path)
386
+ if not repo_root:
387
+ print_error("Not inside a Git repository.")
388
+ raise typer.Exit(code=1)
389
+ hooks_dir = GitAdapter.find_hooks_dir(repo_root)
390
+ if not hooks_dir:
391
+ print_error("Could not locate the Git hooks directory.")
392
+ raise typer.Exit(code=1)
393
+
394
+ installer = HookInstaller(repo_root=repo_root, hooks_dir=hooks_dir)
395
+
396
+ if not yes:
397
+ confirm = typer.confirm("Remove Gitrupt protection?")
398
+ if not confirm:
399
+ typer.echo("Cancelled.")
400
+ return
401
+
402
+ results: list[tuple[str, bool]] = []
403
+ if installer.is_hook_installed("pre-commit"):
404
+ ok, p = installer.uninstall_hook("pre-commit")
405
+ results.append((p, ok))
406
+ if with_push and installer.is_hook_installed("pre-push"):
407
+ ok, p = installer.uninstall_hook("pre-push")
408
+ results.append((p, ok))
409
+
410
+ if not results:
411
+ console.print("[yellow]No Gitrupt hooks found to remove.[/yellow]")
412
+ return
413
+
414
+ for p, ok in results:
415
+ print_uninstall_result(p, ok)
416
+
417
+
418
+ @app.command(name="config")
419
+ def show_config(
420
+ path: Annotated[
421
+ Optional[str],
422
+ typer.Argument(help="Repository path (default: current directory)."),
423
+ ] = None,
424
+ ) -> None:
425
+ """
426
+ Show the current Gitrupt configuration.
427
+ """
428
+ from gitrupt.config import ConfigurationError, load_config
429
+ from gitrupt.git import GitAdapter
430
+ from gitrupt.reporting import print_config, print_error
431
+
432
+ start_path = path or "."
433
+
434
+ repo_root = GitAdapter.find_repo_root(start_path)
435
+
436
+ try:
437
+ config = load_config(repo_root or start_path)
438
+ except ConfigurationError as e:
439
+ print_error(f"Configuration error: {e}")
440
+ raise typer.Exit(code=1) from e
441
+
442
+ print_config(config.model_dump(mode="json"))
443
+
444
+
445
+ # ─────────────────────────────────────────────────────────────────────────────
446
+ # Helpers
447
+ # ─────────────────────────────────────────────────────────────────────────────
448
+
449
+
450
+ def _output_json(scan_result: object) -> None:
451
+ """Output scan results as JSON."""
452
+ import json
453
+ from gitrupt.models import ScanResult
454
+
455
+ if isinstance(scan_result, ScanResult):
456
+ data = {
457
+ "files_scanned": scan_result.files_scanned,
458
+ "scan_duration_ms": scan_result.scan_duration_ms,
459
+ "scanners_run": scan_result.scanners_run,
460
+ "findings": [
461
+ {
462
+ "id": f.id,
463
+ "scanner": f.scanner,
464
+ "rule_id": f.rule_id,
465
+ "severity": f.severity.value,
466
+ "confidence": f.confidence,
467
+ "file": f.file,
468
+ "line": f.line,
469
+ "message": f.message,
470
+ "description": f.description,
471
+ "evidence": f.evidence,
472
+ "recommendation": f.recommendation,
473
+ "can_override": f.can_override,
474
+ }
475
+ for f in scan_result.findings
476
+ ],
477
+ }
478
+ typer.echo(json.dumps(data, indent=2))
479
+
480
+
481
+ def _output_sarif(scan_result: object) -> None:
482
+ """Output scan results in SARIF JSON format."""
483
+ import json
484
+ from gitrupt.models import ScanResult
485
+
486
+ if not isinstance(scan_result, ScanResult):
487
+ return
488
+
489
+ sarif = {
490
+ "$schema": "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0.json",
491
+ "version": "2.1.0",
492
+ "runs": [
493
+ {
494
+ "tool": {
495
+ "driver": {
496
+ "name": "Gitrupt",
497
+ "informationUri": "https://github.com/gitrupt/gitrupt",
498
+ "rules": [
499
+ {
500
+ "id": finding.rule_id,
501
+ "name": finding.message,
502
+ "shortDescription": {"text": finding.description or finding.message},
503
+ "properties": {
504
+ "severity": finding.severity.value,
505
+ "scanner": finding.scanner,
506
+ },
507
+ }
508
+ for finding in scan_result.findings
509
+ ],
510
+ }
511
+ },
512
+ "results": [
513
+ {
514
+ "ruleId": finding.rule_id,
515
+ "level": "error" if finding.severity.value in {"critical", "high"} else "warning",
516
+ "message": {"text": finding.message},
517
+ "locations": [
518
+ {
519
+ "physicalLocation": {
520
+ "artifactLocation": {"uri": finding.file},
521
+ "region": {"startLine": finding.line or 1},
522
+ }
523
+ }
524
+ ],
525
+ "properties": {
526
+ "scanner": finding.scanner,
527
+ "confidence": finding.confidence,
528
+ "recommendation": finding.recommendation,
529
+ },
530
+ }
531
+ for finding in scan_result.findings
532
+ ],
533
+ }
534
+ ],
535
+ }
536
+
537
+ typer.echo(json.dumps(sarif, indent=2))
538
+
539
+
540
+ def app_main() -> None:
541
+ """Entry point for the console script."""
542
+ app()
543
+
544
+
545
+ if __name__ == "__main__":
546
+ app_main()