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/models.py ADDED
@@ -0,0 +1,190 @@
1
+ """
2
+ Data models for Gitrupt.
3
+
4
+ All scanners return Finding objects.
5
+ The Risk Engine consumes Finding objects and produces a PolicyDecision.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import uuid
11
+ from enum import Enum
12
+ from typing import Any
13
+
14
+ from pydantic import BaseModel, Field
15
+
16
+
17
+ class Severity(str, Enum):
18
+ """Finding severity level."""
19
+
20
+ LOW = "low"
21
+ MEDIUM = "medium"
22
+ HIGH = "high"
23
+ CRITICAL = "critical"
24
+
25
+ @property
26
+ def score(self) -> int:
27
+ """Numeric score for this severity level."""
28
+ return {
29
+ Severity.LOW: 20,
30
+ Severity.MEDIUM: 50,
31
+ Severity.HIGH: 70,
32
+ Severity.CRITICAL: 90,
33
+ }[self]
34
+
35
+ def __lt__(self, other: str) -> bool:
36
+ if not isinstance(other, Severity):
37
+ return NotImplemented
38
+ order = [Severity.LOW, Severity.MEDIUM, Severity.HIGH, Severity.CRITICAL]
39
+ return order.index(self) < order.index(other)
40
+
41
+ def __le__(self, other: str) -> bool:
42
+ if not isinstance(other, Severity):
43
+ return NotImplemented
44
+ return self == other or self < other
45
+
46
+ def __gt__(self, other: str) -> bool:
47
+ if not isinstance(other, Severity):
48
+ return NotImplemented
49
+ order = [Severity.LOW, Severity.MEDIUM, Severity.HIGH, Severity.CRITICAL]
50
+ return order.index(self) > order.index(other)
51
+
52
+ def __ge__(self, other: str) -> bool:
53
+ if not isinstance(other, Severity):
54
+ return NotImplemented
55
+ return self == other or self > other
56
+
57
+
58
+ class PolicyAction(str, Enum):
59
+ """Action to take when a finding is at a given severity level."""
60
+
61
+ ALLOW = "allow"
62
+ WARN = "warn"
63
+ BLOCK = "block"
64
+
65
+
66
+ class Finding(BaseModel):
67
+ """
68
+ A security finding from any scanner.
69
+
70
+ Scanners return Finding objects; they do not make blocking decisions.
71
+ The Risk Engine + Policy Engine make the blocking decision.
72
+ """
73
+
74
+ id: str = Field(default_factory=lambda: str(uuid.uuid4())[:8].upper())
75
+ scanner: str
76
+ rule_id: str
77
+ severity: Severity
78
+ confidence: float = Field(ge=0.0, le=1.0)
79
+ file: str
80
+ line: int | None = None
81
+ message: str
82
+ description: str = ""
83
+ evidence: str = "" # Always redacted — never the raw secret
84
+ recommendation: str = ""
85
+ can_override: bool = True
86
+
87
+ model_config = {"frozen": True}
88
+
89
+ def __hash__(self) -> int:
90
+ return hash((self.scanner, self.rule_id, self.file, self.line))
91
+
92
+ def __eq__(self, other: Any) -> bool:
93
+ if not isinstance(other, Finding):
94
+ return NotImplemented
95
+ return (
96
+ self.scanner == other.scanner
97
+ and self.rule_id == other.rule_id
98
+ and self.file == other.file
99
+ and self.line == other.line
100
+ )
101
+
102
+
103
+ class StagedFile(BaseModel):
104
+ """A file that is staged for commit."""
105
+
106
+ path: str
107
+ status: str # A=added, M=modified, D=deleted, R=renamed, C=copied
108
+ old_path: str | None = None # Only for renames
109
+ is_binary: bool = False
110
+ size_bytes: int = 0
111
+
112
+ model_config = {"frozen": True}
113
+
114
+
115
+ class ScanTarget(BaseModel):
116
+ """The target of a scan: staged files and their content."""
117
+
118
+ staged_files: list[StagedFile] = Field(default_factory=list)
119
+ staged_diff: str = ""
120
+ repo_root: str = ""
121
+
122
+ model_config = {"frozen": False}
123
+
124
+ @property
125
+ def file_paths(self) -> list[str]:
126
+ return [f.path for f in self.staged_files]
127
+
128
+
129
+ class ScanResult(BaseModel):
130
+ """The aggregated result of running all scanners."""
131
+
132
+ findings: list[Finding] = Field(default_factory=list)
133
+ files_scanned: int = 0
134
+ scan_duration_ms: float = 0.0
135
+ scanners_run: list[str] = Field(default_factory=list)
136
+
137
+ model_config = {"frozen": False}
138
+
139
+ @property
140
+ def has_findings(self) -> bool:
141
+ return len(self.findings) > 0
142
+
143
+ @property
144
+ def critical_findings(self) -> list[Finding]:
145
+ return [f for f in self.findings if f.severity == Severity.CRITICAL]
146
+
147
+ @property
148
+ def high_findings(self) -> list[Finding]:
149
+ return [f for f in self.findings if f.severity == Severity.HIGH]
150
+
151
+ @property
152
+ def medium_findings(self) -> list[Finding]:
153
+ return [f for f in self.findings if f.severity == Severity.MEDIUM]
154
+
155
+ @property
156
+ def low_findings(self) -> list[Finding]:
157
+ return [f for f in self.findings if f.severity == Severity.LOW]
158
+
159
+ @property
160
+ def max_severity(self) -> Severity | None:
161
+ if not self.findings:
162
+ return None
163
+ return max(f.severity for f in self.findings)
164
+
165
+
166
+ class PolicyDecision(BaseModel):
167
+ """The final policy decision after applying rules to scan results."""
168
+
169
+ action: PolicyAction
170
+ scan_result: ScanResult
171
+ blocking_findings: list[Finding] = Field(default_factory=list)
172
+ warning_findings: list[Finding] = Field(default_factory=list)
173
+
174
+ model_config = {"frozen": True}
175
+
176
+ @property
177
+ def blocked(self) -> bool:
178
+ return self.action == PolicyAction.BLOCK
179
+
180
+ @property
181
+ def allowed(self) -> bool:
182
+ return self.action == PolicyAction.ALLOW
183
+
184
+ @property
185
+ def has_warnings(self) -> bool:
186
+ return len(self.warning_findings) > 0
187
+
188
+ @property
189
+ def exit_code(self) -> int:
190
+ return 1 if self.blocked else 0
gitrupt/policy.py ADDED
@@ -0,0 +1,36 @@
1
+ """
2
+ Policy engine for Gitrupt.
3
+
4
+ Converts configuration into policy decisions.
5
+ Thin wrapper that makes policy intent explicit.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from gitrupt.config import PolicyConfig
11
+ from gitrupt.models import Finding, PolicyAction, Severity
12
+
13
+
14
+ def get_action_for_finding(finding: Finding, policy: PolicyConfig) -> PolicyAction:
15
+ """Return the policy action for a given finding."""
16
+ return policy.action_for(finding.severity)
17
+
18
+
19
+ def is_blocking(finding: Finding, policy: PolicyConfig) -> bool:
20
+ """Return True if this finding should block a commit."""
21
+ return get_action_for_finding(finding, policy) == PolicyAction.BLOCK
22
+
23
+
24
+ def is_warning(finding: Finding, policy: PolicyConfig) -> bool:
25
+ """Return True if this finding should produce a warning (but not block)."""
26
+ return get_action_for_finding(finding, policy) == PolicyAction.WARN
27
+
28
+
29
+ def get_severity_label(severity: Severity) -> str:
30
+ """Return a human-readable label for a severity level."""
31
+ return {
32
+ Severity.LOW: "Low",
33
+ Severity.MEDIUM: "Medium",
34
+ Severity.HIGH: "High",
35
+ Severity.CRITICAL: "Critical",
36
+ }[severity]
gitrupt/reporting.py ADDED
@@ -0,0 +1,316 @@
1
+ """
2
+ Terminal reporter for Gitrupt.
3
+
4
+ Produces human-readable, colored output for the developer.
5
+
6
+ Security rule: secrets are NEVER printed in full.
7
+ All evidence fields are already redacted before reaching this module.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import sys
14
+
15
+ from rich.console import Console
16
+ from rich.panel import Panel
17
+ from rich.table import Table
18
+ from rich import box
19
+ from rich.text import Text
20
+
21
+ from gitrupt.models import Finding, PolicyDecision, PolicyAction, ScanResult, Severity
22
+
23
+ # Force UTF-8 output on Windows to avoid cp1252 encoding errors with special chars
24
+ if sys.platform == "win32":
25
+ os.environ.setdefault("PYTHONIOENCODING", "utf-8")
26
+
27
+ # Create console instances — one for stdout, one for stderr
28
+ # force_terminal=True ensures colored output; highlight=False avoids auto-detection issues
29
+ console = Console(force_terminal=True, highlight=False)
30
+ error_console = Console(stderr=True, force_terminal=True, highlight=False)
31
+
32
+ # Severity color map
33
+ SEVERITY_COLORS = {
34
+ Severity.CRITICAL: "bold red",
35
+ Severity.HIGH: "red",
36
+ Severity.MEDIUM: "yellow",
37
+ Severity.LOW: "cyan",
38
+ }
39
+
40
+ SEVERITY_ICONS = {
41
+ Severity.CRITICAL: "[CRIT]",
42
+ Severity.HIGH: "[HIGH]",
43
+ Severity.MEDIUM: "[MED] ",
44
+ Severity.LOW: "[LOW] ",
45
+ }
46
+
47
+
48
+ def print_header() -> None:
49
+ """Print the Gitrupt header."""
50
+ console.print()
51
+ console.print("[bold blue][Gitrupt][/bold blue]")
52
+ console.print()
53
+
54
+
55
+ def print_scanning(file_count: int) -> None:
56
+ """Print the 'scanning...' status."""
57
+ console.print(f"[dim]Scanning {file_count} staged file{'s' if file_count != 1 else ''}...[/dim]")
58
+ console.print()
59
+
60
+
61
+ def print_decision(decision: PolicyDecision) -> None:
62
+ """Print the full scan result and policy decision."""
63
+ scan = decision.scan_result
64
+
65
+ if decision.blocked:
66
+ _print_blocked(decision)
67
+ elif decision.has_warnings:
68
+ _print_warnings(decision)
69
+ else:
70
+ _print_pass(scan)
71
+
72
+
73
+ def print_scan_stats(scan: ScanResult) -> None:
74
+ """Print scan statistics."""
75
+ duration = scan.scan_duration_ms
76
+ console.print(
77
+ f"[dim]Scanned {scan.files_scanned} file{'s' if scan.files_scanned != 1 else ''} "
78
+ f"in {duration:.0f}ms[/dim]"
79
+ )
80
+
81
+
82
+ def _print_blocked(decision: PolicyDecision) -> None:
83
+ """Print a BLOCKED result."""
84
+ error_console.print(
85
+ Panel(
86
+ "[bold red]COMMIT BLOCKED[/bold red]",
87
+ border_style="red",
88
+ expand=False,
89
+ )
90
+ )
91
+ error_console.print()
92
+
93
+ all_findings = decision.blocking_findings + decision.warning_findings
94
+
95
+ for finding in all_findings:
96
+ _print_finding(finding, is_blocking=finding in decision.blocking_findings)
97
+
98
+ error_console.print()
99
+ error_console.print("[dim]Fix the findings above and try again.[/dim]")
100
+ error_console.print(
101
+ "[dim]To bypass (not recommended): [bold]git commit --no-verify[/bold][/dim]"
102
+ )
103
+ error_console.print()
104
+
105
+
106
+ def _print_warnings(decision: PolicyDecision) -> None:
107
+ """Print a WARN result (commit proceeds but warnings shown)."""
108
+ console.print(
109
+ Panel(
110
+ "[yellow]WARNINGS -- commit proceeding[/yellow]",
111
+ border_style="yellow",
112
+ expand=False,
113
+ )
114
+ )
115
+ console.print()
116
+
117
+ for finding in decision.warning_findings:
118
+ _print_finding(finding, is_blocking=False)
119
+
120
+ console.print()
121
+ _print_pass_line(decision.scan_result)
122
+
123
+
124
+ def _print_pass(scan: ScanResult) -> None:
125
+ """Print a PASS result."""
126
+ _print_pass_line(scan)
127
+
128
+
129
+ def _print_pass_line(scan: ScanResult) -> None:
130
+ """Print the pass summary line."""
131
+ console.print(
132
+ f"[bold green][+][/bold green] Files checked: [bold]{scan.files_scanned}[/bold]"
133
+ )
134
+ console.print("[bold green][+][/bold green] No issues found")
135
+ console.print()
136
+ console.print(
137
+ Panel(
138
+ "[bold green]PASS -- commit allowed[/bold green]",
139
+ border_style="green",
140
+ expand=False,
141
+ )
142
+ )
143
+ console.print()
144
+
145
+
146
+ def _print_finding(finding: Finding, is_blocking: bool) -> None:
147
+ """Print a single finding."""
148
+ color = SEVERITY_COLORS.get(finding.severity, "white")
149
+ icon = SEVERITY_ICONS.get(finding.severity, "[???] ")
150
+
151
+ # Location string
152
+ location = finding.file
153
+ if finding.line:
154
+ location += f":{finding.line}"
155
+
156
+ # Severity badge
157
+ severity_label = f"[{color}]{finding.severity.value.upper():8}[/{color}]"
158
+
159
+ error_console.print(f" {icon} {severity_label} {location}")
160
+ error_console.print(f" {'':12} [bold]{finding.message}[/bold]")
161
+
162
+ if finding.description:
163
+ error_console.print(f" {'':12} [dim]{finding.description}[/dim]")
164
+
165
+ if finding.evidence:
166
+ error_console.print(f" {'':12} Evidence: [dim]{finding.evidence}[/dim]")
167
+
168
+ if finding.recommendation:
169
+ # Format multi-line recommendations
170
+ lines = finding.recommendation.strip().split("\n")
171
+ for i, line in enumerate(lines):
172
+ prefix = " " if i > 0 else ""
173
+ error_console.print(f" {'':12} [dim]{prefix}{line}[/dim]")
174
+
175
+ error_console.print()
176
+
177
+
178
+
179
+
180
+
181
+
182
+
183
+ def print_status(
184
+ repo_root: str | None,
185
+ git_available: bool,
186
+ hook_installed: bool,
187
+ hook_path: str | None,
188
+ config_path: str | None,
189
+ git_version: str | None,
190
+ git_binary: str | None = None,
191
+ hooks_dir: str | None = None,
192
+ core_hooks_path: str | None = None,
193
+ yara_available: bool | None = None,
194
+ dependency_ecosystems: tuple[str, ...] | None = None,
195
+ pre_push_installed: bool | None = None,
196
+ pre_push_path: str | None = None,
197
+ ) -> None:
198
+ """Print the status of Gitrupt installation."""
199
+ print_header()
200
+
201
+ table = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))
202
+ table.add_column("Item", style="dim", no_wrap=True)
203
+ table.add_column("Status", overflow="fold")
204
+
205
+ def status_row(label: str, ok: bool, value: str = "") -> None:
206
+ icon = "[green][+][/green]" if ok else "[red][-][/red]"
207
+ display = f"{icon} {value}" if value else icon
208
+ table.add_row(label, display)
209
+
210
+ status_row("Git available", git_available, git_version or "not found")
211
+ status_row("Git binary", bool(git_binary), git_binary or "not on PATH")
212
+ status_row("Inside Git repo", repo_root is not None, repo_root or "")
213
+
214
+ if hooks_dir:
215
+ hooks_note = hooks_dir
216
+ if core_hooks_path:
217
+ hooks_note = f"{hooks_dir} [yellow](core.hooksPath override)[/yellow]"
218
+ status_row("Hooks directory", True, hooks_note)
219
+
220
+ status_row(
221
+ "core.hooksPath",
222
+ core_hooks_path is None,
223
+ core_hooks_path if core_hooks_path else "not set (using default)",
224
+ )
225
+ status_row("pre-commit hook", hook_installed, hook_path or "not installed")
226
+
227
+ if pre_push_installed is not None:
228
+ status_row(
229
+ "pre-push hook",
230
+ pre_push_installed,
231
+ pre_push_path if pre_push_installed else "not installed",
232
+ )
233
+
234
+ if yara_available is not None:
235
+ yara_note = "available (full rule engine)" if yara_available else "not installed (fallback mode)"
236
+ status_row("YARA engine", True, yara_note)
237
+
238
+ if dependency_ecosystems is not None:
239
+ if dependency_ecosystems:
240
+ status_row("Dependency ecosystems", True, ", ".join(dependency_ecosystems))
241
+ else:
242
+ status_row("Dependency ecosystems", False, "none registered")
243
+
244
+ status_row("Configuration", config_path is not None, config_path or "using defaults")
245
+
246
+ console.print(table)
247
+
248
+ if core_hooks_path:
249
+ console.print(
250
+ "[yellow]Note:[/yellow] [bold]core.hooksPath[/bold] is set. Git reads hooks from "
251
+ f"[dim]{core_hooks_path}[/dim], not the default [dim].git/hooks[/dim]."
252
+ )
253
+ console.print()
254
+
255
+ if hook_installed and repo_root:
256
+ console.print("[bold green]Protection is active.[/bold green]")
257
+ elif repo_root:
258
+ console.print(
259
+ "[yellow]Hook not installed. Run [bold]gitrupt init[/bold] to enable protection.[/yellow]"
260
+ )
261
+ else:
262
+ console.print("[red]Not inside a Git repository.[/red]")
263
+ console.print()
264
+
265
+
266
+ def print_init_result(
267
+ repo_root: str,
268
+ hook_paths: list[str],
269
+ was_existing_hook: bool,
270
+ config_exists: bool,
271
+ ) -> None:
272
+ """Print the result of gitrupt init."""
273
+ print_header()
274
+ console.print("Installing security firewall...")
275
+ console.print()
276
+ console.print("[green][+][/green] Repository detected")
277
+ console.print("[green][+][/green] Existing hooks checked")
278
+ if was_existing_hook:
279
+ console.print("[green][+][/green] Existing hook(s) preserved and chained")
280
+ for hp in hook_paths:
281
+ console.print(f"[green][+][/green] Hook installed: [dim]{hp}[/dim]")
282
+ if config_exists:
283
+ console.print("[green][+][/green] Gitrupt configuration found")
284
+ else:
285
+ console.print("[dim] No .gitrupt.yml found -- using defaults[/dim]")
286
+ console.print()
287
+ console.print("[bold green]Protection enabled.[/bold green]")
288
+ console.print()
289
+
290
+
291
+
292
+
293
+ def print_uninstall_result(hook_path: str, success: bool) -> None:
294
+ """Print the result of gitrupt uninstall."""
295
+ print_header()
296
+ if success:
297
+ console.print(f"[green][+][/green] Hook removed: [dim]{hook_path}[/dim]")
298
+ console.print()
299
+ console.print("[yellow]Protection disabled.[/yellow]")
300
+ else:
301
+ console.print("[yellow]No Gitrupt hook found to remove.[/yellow]")
302
+ console.print()
303
+
304
+
305
+ def print_error(message: str) -> None:
306
+ """Print an error message."""
307
+ error_console.print(f"[red]Error:[/red] {message}")
308
+
309
+
310
+ def print_config(config_dict: dict) -> None:
311
+ """Print the current configuration."""
312
+ print_header()
313
+ import yaml # type: ignore[import-untyped]
314
+ console.print("[bold]Current configuration:[/bold]")
315
+ console.print()
316
+ console.print(yaml.dump(config_dict, default_flow_style=False, sort_keys=False))