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/git.py ADDED
@@ -0,0 +1,590 @@
1
+ """
2
+ Git adapter for Gitrupt.
3
+
4
+ All Git operations go through this module via subprocess.
5
+ Never uses shell=True or constructs commands from untrusted input.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import subprocess
12
+ from pathlib import Path
13
+
14
+ from gitrupt.models import StagedFile, ScanTarget
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class GitError(Exception):
20
+ """Raised when a Git command fails or Git is unavailable."""
21
+
22
+
23
+ class GitAdapter:
24
+ """
25
+ Interface to the installed Git executable.
26
+
27
+ Uses subprocess with argument arrays — no shell interpolation.
28
+ """
29
+
30
+ def __init__(self, repo_root: str | None = None) -> None:
31
+ self._repo_root = repo_root
32
+
33
+ # -------------------------------------------------------------------------
34
+ # Repository detection
35
+ # -------------------------------------------------------------------------
36
+
37
+ @staticmethod
38
+ def is_git_available() -> bool:
39
+ """Check whether Git is installed and accessible."""
40
+ try:
41
+ result = subprocess.run(
42
+ ["git", "--version"],
43
+ check=False,
44
+ capture_output=True,
45
+ text=True,
46
+ timeout=5,
47
+ )
48
+ return result.returncode == 0
49
+ except (FileNotFoundError, subprocess.TimeoutExpired):
50
+ return False
51
+
52
+ @staticmethod
53
+ def find_repo_root(start: str | Path | None = None) -> str | None:
54
+ """
55
+ Find the root of the Git repository containing `start`.
56
+ Returns None if not inside a Git repository.
57
+ """
58
+ cwd = str(start) if start else None
59
+ try:
60
+ result = subprocess.run(
61
+ ["git", "rev-parse", "--show-toplevel"],
62
+ check=False,
63
+ capture_output=True,
64
+ text=True,
65
+ cwd=cwd,
66
+ timeout=10,
67
+ )
68
+ if result.returncode == 0:
69
+ return result.stdout.strip()
70
+ return None
71
+ except (FileNotFoundError, subprocess.TimeoutExpired):
72
+ return None
73
+
74
+ @staticmethod
75
+ def find_git_dir(repo_root: str) -> str | None:
76
+ """Find the .git directory for the repository."""
77
+ try:
78
+ result = subprocess.run(
79
+ ["git", "rev-parse", "--git-dir"],
80
+ check=False,
81
+ capture_output=True,
82
+ text=True,
83
+ cwd=repo_root,
84
+ timeout=10,
85
+ )
86
+ if result.returncode == 0:
87
+ git_dir = result.stdout.strip()
88
+ # May be relative; resolve relative to repo_root
89
+ path = Path(repo_root) / git_dir
90
+ return str(path.resolve())
91
+ return None
92
+ except (FileNotFoundError, subprocess.TimeoutExpired):
93
+ return None
94
+
95
+ @staticmethod
96
+ def find_hooks_dir(repo_root: str) -> str | None:
97
+ """Find the Git hooks directory."""
98
+ try:
99
+ result = subprocess.run(
100
+ ["git", "rev-parse", "--git-path", "hooks"],
101
+ check=False,
102
+ capture_output=True,
103
+ text=True,
104
+ cwd=repo_root,
105
+ timeout=10,
106
+ )
107
+ if result.returncode == 0:
108
+ hooks_path = result.stdout.strip()
109
+ path = Path(repo_root) / hooks_path
110
+ return str(path.resolve())
111
+ except (FileNotFoundError, subprocess.TimeoutExpired):
112
+ pass
113
+
114
+ # Fallback to standard location
115
+ git_dir = GitAdapter.find_git_dir(repo_root)
116
+ if git_dir:
117
+ return str(Path(git_dir) / "hooks")
118
+ return None
119
+
120
+ # -------------------------------------------------------------------------
121
+ # Staged file information
122
+ # -------------------------------------------------------------------------
123
+
124
+ @staticmethod
125
+ def get_staged_files(repo_root: str) -> list[StagedFile]:
126
+ """
127
+ Return a list of staged files with their status.
128
+
129
+ Uses: git diff --cached --name-status -z
130
+ The -z flag uses NUL separators to safely handle filenames with spaces/newlines.
131
+ """
132
+ try:
133
+ result = subprocess.run(
134
+ ["git", "diff", "--cached", "--name-status", "-z"],
135
+ check=False,
136
+ capture_output=True,
137
+ text=True,
138
+ cwd=repo_root,
139
+ timeout=30,
140
+ )
141
+ except (FileNotFoundError, subprocess.TimeoutExpired) as e:
142
+ raise GitError(f"Failed to list staged files: {e}") from e
143
+
144
+ if result.returncode != 0:
145
+ raise GitError(f"git diff --cached failed: {result.stderr.strip()}")
146
+
147
+ return _parse_name_status(result.stdout)
148
+
149
+
150
+
151
+ @staticmethod
152
+ def get_staged_file_bytes(repo_root: str, file_path: str) -> bytes | None:
153
+ """
154
+ Return the raw bytes of the staged (index) version of a file.
155
+
156
+ Uses: git show :path (binary-safe — no text decoding).
157
+ Returns None for missing files or on error.
158
+ """
159
+ try:
160
+ result = subprocess.run(
161
+ ["git", "show", f":{file_path}"],
162
+ check=False,
163
+ capture_output=True,
164
+ cwd=repo_root,
165
+ timeout=30,
166
+ )
167
+ except (FileNotFoundError, subprocess.TimeoutExpired) as e:
168
+ logger.warning("Failed to read staged bytes of %s: %s", file_path, e)
169
+ return None
170
+
171
+ if result.returncode != 0:
172
+ return None
173
+ return result.stdout
174
+
175
+
176
+
177
+
178
+
179
+
180
+
181
+
182
+
183
+
184
+
185
+ @staticmethod
186
+ def get_staged_diff(repo_root: str) -> str:
187
+ """
188
+ Return the unified diff of all staged changes.
189
+
190
+ Uses: git diff --cached
191
+ """
192
+ try:
193
+ result = subprocess.run(
194
+ ["git", "diff", "--cached", "--unified=3"],
195
+ check=False,
196
+ capture_output=True,
197
+ text=True,
198
+ cwd=repo_root,
199
+ timeout=60,
200
+ errors="replace", # Handle non-UTF-8 content gracefully
201
+ )
202
+ except (FileNotFoundError, subprocess.TimeoutExpired) as e:
203
+ raise GitError(f"Failed to get staged diff: {e}") from e
204
+
205
+ if result.returncode != 0:
206
+ logger.warning("git diff --cached returned %d: %s", result.returncode, result.stderr)
207
+ return ""
208
+
209
+ return result.stdout
210
+
211
+ @staticmethod
212
+ def get_staged_file_content(repo_root: str, file_path: str) -> str | None:
213
+ """
214
+ Get the staged (index) content of a specific file.
215
+
216
+ Uses: git show :path
217
+ """
218
+ try:
219
+ result = subprocess.run(
220
+ ["git", "show", f":{file_path}"],
221
+ check=False,
222
+ capture_output=True,
223
+ text=True,
224
+ cwd=repo_root,
225
+ timeout=15,
226
+ errors="replace",
227
+ )
228
+ except (FileNotFoundError, subprocess.TimeoutExpired) as e:
229
+ logger.warning("Failed to get content of %s: %s", file_path, e)
230
+ return None
231
+
232
+ if result.returncode != 0:
233
+ return None
234
+
235
+ return result.stdout
236
+
237
+ @staticmethod
238
+ def is_binary_file(repo_root: str, file_path: str) -> bool:
239
+ """
240
+ Determine whether a staged file is binary.
241
+
242
+ Uses: git diff --cached --numstat
243
+ """
244
+ try:
245
+ result = subprocess.run(
246
+ ["git", "diff", "--cached", "--numstat", "--", file_path],
247
+ check=False,
248
+ capture_output=True,
249
+ text=True,
250
+ cwd=repo_root,
251
+ timeout=10,
252
+ )
253
+ except (FileNotFoundError, subprocess.TimeoutExpired):
254
+ return False
255
+
256
+ if result.returncode != 0:
257
+ return False
258
+
259
+ # Binary files show "-\t-\t" in numstat output
260
+ output = result.stdout.strip()
261
+ if output and output.startswith("-\t-\t"):
262
+ return True
263
+ return False
264
+
265
+ @staticmethod
266
+ def get_file_size(repo_root: str, file_path: str) -> int:
267
+ """Get the staged size of a file in bytes."""
268
+ try:
269
+ result = subprocess.run(
270
+ ["git", "cat-file", "-s", f":{file_path}"],
271
+ check=False,
272
+ capture_output=True,
273
+ text=True,
274
+ cwd=repo_root,
275
+ timeout=10,
276
+ )
277
+ except (FileNotFoundError, subprocess.TimeoutExpired):
278
+ return 0
279
+
280
+ if result.returncode != 0:
281
+ return 0
282
+
283
+ try:
284
+ return int(result.stdout.strip())
285
+ except ValueError:
286
+ return 0
287
+
288
+ # -------------------------------------------------------------------------
289
+ # High-level helpers
290
+ # -------------------------------------------------------------------------
291
+
292
+ @staticmethod
293
+ def build_scan_target(repo_root: str) -> ScanTarget:
294
+ """
295
+ Build a ScanTarget from the current staged state.
296
+
297
+ This is the primary entry point for scanners.
298
+ """
299
+ staged_files = GitAdapter.get_staged_files(repo_root)
300
+
301
+ # Enrich file metadata
302
+ enriched: list[StagedFile] = []
303
+ for sf in staged_files:
304
+ if sf.status == "D":
305
+ # Deleted files — no content to scan
306
+ enriched.append(sf)
307
+ continue
308
+
309
+ is_binary = GitAdapter.is_binary_file(repo_root, sf.path)
310
+ size = GitAdapter.get_file_size(repo_root, sf.path)
311
+ enriched.append(
312
+ StagedFile(
313
+ path=sf.path,
314
+ status=sf.status,
315
+ old_path=sf.old_path,
316
+ is_binary=is_binary,
317
+ size_bytes=size,
318
+ )
319
+ )
320
+
321
+ staged_diff = GitAdapter.get_staged_diff(repo_root)
322
+
323
+ return ScanTarget(
324
+ staged_files=enriched,
325
+ staged_diff=staged_diff,
326
+ repo_root=repo_root,
327
+ )
328
+
329
+ @staticmethod
330
+ def get_git_version() -> str | None:
331
+ """Return the installed Git version string."""
332
+ try:
333
+ result = subprocess.run(
334
+ ["git", "--version"],
335
+ check=False,
336
+ capture_output=True,
337
+ text=True,
338
+ timeout=5,
339
+ )
340
+ if result.returncode == 0:
341
+ return result.stdout.strip()
342
+ except (FileNotFoundError, subprocess.TimeoutExpired):
343
+ pass
344
+ return None
345
+
346
+
347
+
348
+
349
+
350
+ # -------------------------------------------------------------------------
351
+ # Ref-aware content reads (used by pre-push)
352
+ # -------------------------------------------------------------------------
353
+
354
+ @staticmethod
355
+ def get_file_content_at(repo_root: str, ref_prefix: str, path: str) -> str | None:
356
+ """
357
+ Read a file's text content from a specific ref.
358
+
359
+ ref_prefix is a Git ref prefix ending in ':':
360
+ ':' -> the index
361
+ 'HEAD:' -> the HEAD commit
362
+ '<sha>:' -> that commit
363
+ """
364
+ try:
365
+ result = subprocess.run(
366
+ ["git", "show", f"{ref_prefix}{path}"],
367
+ check=False,
368
+ capture_output=True,
369
+ text=True,
370
+ cwd=repo_root,
371
+ timeout=30,
372
+ errors="replace",
373
+ )
374
+ except (FileNotFoundError, subprocess.TimeoutExpired) as e:
375
+ logger.warning("Failed to read %s%s: %s", ref_prefix, path, e)
376
+ return None
377
+
378
+ if result.returncode != 0:
379
+ return None
380
+ return result.stdout
381
+
382
+ @staticmethod
383
+ def get_file_bytes_at(repo_root: str, ref_prefix: str, path: str) -> bytes | None:
384
+ """Read a file's raw bytes from a specific ref (binary-safe)."""
385
+ try:
386
+ result = subprocess.run(
387
+ ["git", "show", f"{ref_prefix}{path}"],
388
+ check=False,
389
+ capture_output=True,
390
+ cwd=repo_root,
391
+ timeout=30,
392
+ )
393
+ except (FileNotFoundError, subprocess.TimeoutExpired) as e:
394
+ logger.warning("Failed to read bytes of %s%s: %s", ref_prefix, path, e)
395
+ return None
396
+
397
+ if result.returncode != 0:
398
+ return None
399
+ return result.stdout
400
+
401
+ # -------------------------------------------------------------------------
402
+ # Outgoing-range operations (used by pre-push)
403
+ # -------------------------------------------------------------------------
404
+
405
+ @staticmethod
406
+ def get_diff_files(repo_root: str, base: str, head: str) -> list[StagedFile]:
407
+ """List files changed between two refs (uses the same parser as staged)."""
408
+ try:
409
+ result = subprocess.run(
410
+ ["git", "diff", "--name-status", "-z", base, head],
411
+ check=False,
412
+ capture_output=True,
413
+ text=True,
414
+ cwd=repo_root,
415
+ timeout=60,
416
+ )
417
+ except (FileNotFoundError, subprocess.TimeoutExpired) as e:
418
+ raise GitError(f"Failed to list outgoing files: {e}") from e
419
+
420
+ if result.returncode != 0:
421
+ raise GitError(f"git diff {base}..{head} failed: {result.stderr.strip()}")
422
+
423
+ return _parse_name_status(result.stdout)
424
+
425
+ @staticmethod
426
+ def get_diff_text(repo_root: str, base: str, head: str) -> str:
427
+ """Return the unified diff between two refs."""
428
+ try:
429
+ result = subprocess.run(
430
+ ["git", "diff", "--unified=3", base, head],
431
+ check=False,
432
+ capture_output=True,
433
+ text=True,
434
+ cwd=repo_root,
435
+ timeout=120,
436
+ errors="replace",
437
+ )
438
+ except (FileNotFoundError, subprocess.TimeoutExpired) as e:
439
+ raise GitError(f"Failed to compute outgoing diff: {e}") from e
440
+
441
+ if result.returncode != 0:
442
+ logger.warning(
443
+ "git diff %s..%s returned %d: %s",
444
+ base, head, result.returncode, result.stderr,
445
+ )
446
+ return ""
447
+
448
+ return result.stdout
449
+
450
+ @staticmethod
451
+ def build_push_scan_target(
452
+ repo_root: str,
453
+ remote_sha: str,
454
+ local_sha: str,
455
+ ) -> ScanTarget:
456
+ """
457
+ Build a ScanTarget for a pre-push scan.
458
+
459
+ - Deletions are skipped.
460
+ - When remote_sha is all zeros (new branch), the base is Git's empty
461
+ tree so every file in the pushed tree is treated as added.
462
+ - File binary/size metadata is computed from the commit content.
463
+ """
464
+ EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
465
+ ZERO = "0" * 40
466
+
467
+ base = remote_sha if remote_sha and remote_sha != ZERO else EMPTY_TREE
468
+ head = local_sha
469
+
470
+ files = GitAdapter.get_diff_files(repo_root, base, head)
471
+
472
+ enriched: list[StagedFile] = []
473
+ for sf in files:
474
+ if sf.status == "D":
475
+ enriched.append(sf)
476
+ continue
477
+
478
+ data = GitAdapter.get_file_bytes_at(repo_root, f"{head}:", sf.path)
479
+ if data is None:
480
+ enriched.append(sf)
481
+ continue
482
+
483
+ is_binary = b"\x00" in data[:8192]
484
+ enriched.append(
485
+ StagedFile(
486
+ path=sf.path,
487
+ status=sf.status,
488
+ old_path=sf.old_path,
489
+ is_binary=is_binary,
490
+ size_bytes=len(data),
491
+ )
492
+ )
493
+
494
+ diff = GitAdapter.get_diff_text(repo_root, base, head)
495
+
496
+ return ScanTarget(
497
+ staged_files=enriched,
498
+ staged_diff=diff,
499
+ repo_root=repo_root,
500
+ )
501
+
502
+
503
+
504
+
505
+ @staticmethod
506
+ def get_git_binary() -> str | None:
507
+ """Return the absolute path to the git executable on PATH."""
508
+ import shutil
509
+ return shutil.which("git")
510
+
511
+ @staticmethod
512
+ def get_core_hooks_path(repo_root: str) -> str | None:
513
+ """
514
+ Return the value of core.hooksPath if set, else None.
515
+
516
+ When core.hooksPath is set, Git uses it instead of .git/hooks.
517
+ Gitrupt must inform the user so they know where the hook actually lives.
518
+ """
519
+ try:
520
+ result = subprocess.run(
521
+ ["git", "config", "--get", "core.hooksPath"],
522
+ check=False,
523
+ capture_output=True,
524
+ text=True,
525
+ cwd=repo_root,
526
+ timeout=5,
527
+ )
528
+ except (FileNotFoundError, subprocess.TimeoutExpired):
529
+ return None
530
+
531
+ if result.returncode != 0:
532
+ return None
533
+
534
+ value = result.stdout.strip()
535
+ return value or None
536
+
537
+
538
+ # ─────────────────────────────────────────────────────────────────────────────
539
+ # Parsing helpers
540
+ # ─────────────────────────────────────────────────────────────────────────────
541
+
542
+
543
+ def _parse_name_status(output: str) -> list[StagedFile]:
544
+ """
545
+ Parse the output of: git diff --cached --name-status -z
546
+
547
+ Format per entry:
548
+ STATUS\0PATH\0 (for A, M, D, T)
549
+ STATUS\0OLD_PATH\0NEW_PATH\0 (for R, C with similarity score in status like R90)
550
+ """
551
+ if not output:
552
+ return []
553
+
554
+ files: list[StagedFile] = []
555
+ parts = output.split("\0")
556
+ i = 0
557
+
558
+ while i < len(parts):
559
+ part = parts[i].strip()
560
+ if not part:
561
+ i += 1
562
+ continue
563
+
564
+ status_code = part[0].upper()
565
+
566
+ if status_code in ("R", "C"):
567
+ # Rename or copy: STATUS\0old_path\0new_path
568
+ if i + 2 >= len(parts):
569
+ break
570
+ old_path = parts[i + 1]
571
+ new_path = parts[i + 2]
572
+ i += 3
573
+ if old_path and new_path:
574
+ files.append(
575
+ StagedFile(
576
+ path=new_path,
577
+ status=status_code,
578
+ old_path=old_path,
579
+ )
580
+ )
581
+ else:
582
+ # Standard: STATUS\0path
583
+ if i + 1 >= len(parts):
584
+ break
585
+ path = parts[i + 1]
586
+ i += 2
587
+ if path:
588
+ files.append(StagedFile(path=path, status=status_code))
589
+
590
+ return files
@@ -0,0 +1,7 @@
1
+ """
2
+ Hooks package for Gitrupt.
3
+ """
4
+
5
+ from gitrupt.hooks.install import HookInstaller
6
+
7
+ __all__ = ["HookInstaller"]