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
@@ -0,0 +1,255 @@
1
+ """
2
+ Hook installer for Gitrupt.
3
+
4
+ Safely installs Git hooks without destroying existing ones.
5
+ Chains existing hooks rather than replacing them.
6
+
7
+ Supports: pre-commit, pre-push.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import os
14
+ import platform
15
+ import stat
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ GITRUPT_MARKER = "# Gitrupt pre-commit hook"
22
+ GITRUPT_VERSION_MARKER = "# Gitrupt-version:"
23
+
24
+ # Per-hook configuration: how to identify a Gitrupt-installed hook, and how
25
+ # to build the shell template.
26
+ _HOOK_MARKERS: dict[str, str] = {
27
+ "pre-commit": "# Gitrupt pre-commit hook",
28
+ "pre-push": "# Gitrupt pre-push hook",
29
+ }
30
+
31
+ _HOOK_MODULES: dict[str, str] = {
32
+ "pre-commit": "gitrupt.hooks.pre_commit",
33
+ "pre-push": "gitrupt.hooks.pre_push",
34
+ }
35
+
36
+
37
+ # ── Templates ───────────────────────────────────────────────────────────────
38
+
39
+ PRE_COMMIT_TEMPLATE = """\
40
+ #!/bin/sh
41
+ {marker}
42
+ {version_marker} {version}
43
+ # Installed by Gitrupt. Do not edit manually.
44
+ # Uninstall with: gitrupt uninstall
45
+
46
+ "{python_executable}" -m {module}
47
+ GITRUPT_EXIT=$?
48
+
49
+ {existing_section}
50
+
51
+ if [ $GITRUPT_EXIT -ne 0 ]; then
52
+ exit $GITRUPT_EXIT
53
+ fi
54
+
55
+ exit 0
56
+ """
57
+
58
+ PRE_PUSH_TEMPLATE = """\
59
+ #!/bin/sh
60
+ {marker}
61
+ {version_marker} {version}
62
+ # Installed by Gitrupt. Do not edit manually.
63
+ # Uninstall with: gitrupt uninstall
64
+ #
65
+ # $1 = remote name, $2 = remote URL
66
+ # stdin = one line per ref: <local_ref> <local_sha> <remote_ref> <remote_sha>
67
+
68
+ REMOTE_NAME="$1"
69
+ REMOTE_URL="$2"
70
+
71
+ # Capture stdin once so both Gitrupt and any chained hook can read it.
72
+ STDIN_DATA=$(cat)
73
+
74
+ printf '%s\\n' "$STDIN_DATA" | "{python_executable}" -m {module} "$REMOTE_NAME" "$REMOTE_URL"
75
+ GITRUPT_EXIT=$?
76
+
77
+ {existing_section}
78
+
79
+ if [ $GITRUPT_EXIT -ne 0 ]; then
80
+ exit $GITRUPT_EXIT
81
+ fi
82
+
83
+ exit 0
84
+ """
85
+
86
+ _EXISTING_PRE_COMMIT = """\
87
+ # Run existing hook (preserved by Gitrupt)
88
+ if [ -f "{backup}" ]; then
89
+ "{backup}" "$@"
90
+ EXISTING_EXIT=$?
91
+ if [ $EXISTING_EXIT -ne 0 ]; then
92
+ exit $EXISTING_EXIT
93
+ fi
94
+ fi
95
+ """
96
+
97
+ _EXISTING_PRE_PUSH = """\
98
+ # Run existing hook (preserved by Gitrupt), feeding it the same stdin.
99
+ if [ -f "{backup}" ]; then
100
+ printf '%s\\n' "$STDIN_DATA" | "{backup}" "$REMOTE_NAME" "$REMOTE_URL"
101
+ EXISTING_EXIT=$?
102
+ if [ $EXISTING_EXIT -ne 0 ]; then
103
+ exit $EXISTING_EXIT
104
+ fi
105
+ fi
106
+ """
107
+
108
+ _NO_EXISTING = "# No existing hook was present"
109
+
110
+
111
+ class HookInstallError(Exception):
112
+ """Raised when hook installation fails."""
113
+
114
+
115
+ class HookInstaller:
116
+ """Installs and manages Gitrupt Git hooks."""
117
+
118
+ def __init__(self, repo_root: str, hooks_dir: str) -> None:
119
+ self._repo_root = repo_root
120
+ self._hooks_dir = Path(hooks_dir)
121
+
122
+ # -------------------------------------------------------------------------
123
+ # Public API — pre-commit (backward compatible wrappers)
124
+ # -------------------------------------------------------------------------
125
+
126
+ def install_pre_commit(self) -> tuple[bool, bool, str]:
127
+ return self.install_hook("pre-commit")
128
+
129
+ def uninstall_pre_commit(self) -> tuple[bool, str]:
130
+ return self.uninstall_hook("pre-commit")
131
+
132
+ def is_installed(self) -> bool:
133
+ """Backward-compatible check for the pre-commit hook."""
134
+ return self.is_hook_installed("pre-commit")
135
+
136
+ def hook_path(self) -> str:
137
+ """Backward-compatible path for the pre-commit hook."""
138
+ return str(self._hooks_dir / "pre-commit")
139
+
140
+ # -------------------------------------------------------------------------
141
+ # Public API — generic
142
+ # -------------------------------------------------------------------------
143
+
144
+ def install_hook(self, hook_name: str) -> tuple[bool, bool, str]:
145
+ """
146
+ Install a Gitrupt hook.
147
+
148
+ Returns:
149
+ (success, had_existing_hook, hook_path)
150
+ """
151
+ if hook_name not in _HOOK_MARKERS:
152
+ raise HookInstallError(f"Unsupported hook: {hook_name}")
153
+
154
+ marker = _HOOK_MARKERS[hook_name]
155
+ hook_path = self._hooks_dir / hook_name
156
+ backup_path = self._hooks_dir / f"{hook_name}.gitrupt-backup"
157
+
158
+ had_existing = False
159
+ if hook_path.exists():
160
+ content = hook_path.read_text(encoding="utf-8", errors="replace")
161
+ if marker in content:
162
+ logger.info("Gitrupt %s hook already installed at %s", hook_name, hook_path)
163
+ return True, False, str(hook_path)
164
+
165
+ logger.info("Existing %s found, backing up to %s", hook_name, backup_path)
166
+ backup_path.write_bytes(hook_path.read_bytes())
167
+ _make_executable(backup_path)
168
+ had_existing = True
169
+
170
+ hook_content = self._build_hook_content(hook_name, str(backup_path) if had_existing else None)
171
+
172
+ try:
173
+ self._hooks_dir.mkdir(parents=True, exist_ok=True)
174
+ hook_path.write_text(hook_content, encoding="utf-8")
175
+ _make_executable(hook_path)
176
+ except OSError as e:
177
+ raise HookInstallError(f"Failed to write {hook_name} to {hook_path}: {e}") from e
178
+
179
+ logger.info("Installed %s hook at %s", hook_name, hook_path)
180
+ return True, had_existing, str(hook_path)
181
+
182
+ def uninstall_hook(self, hook_name: str) -> tuple[bool, str]:
183
+ if hook_name not in _HOOK_MARKERS:
184
+ raise HookInstallError(f"Unsupported hook: {hook_name}")
185
+
186
+ marker = _HOOK_MARKERS[hook_name]
187
+ hook_path = self._hooks_dir / hook_name
188
+ backup_path = self._hooks_dir / f"{hook_name}.gitrupt-backup"
189
+
190
+ if not hook_path.exists():
191
+ return False, str(hook_path)
192
+
193
+ content = hook_path.read_text(encoding="utf-8", errors="replace")
194
+ if marker not in content:
195
+ logger.warning("%s at %s does not appear to be a Gitrupt hook", hook_name, hook_path)
196
+ return False, str(hook_path)
197
+
198
+ if backup_path.exists():
199
+ hook_path.write_bytes(backup_path.read_bytes())
200
+ _make_executable(hook_path)
201
+ backup_path.unlink(missing_ok=True)
202
+ logger.info("Restored original %s hook from backup", hook_name)
203
+ else:
204
+ hook_path.unlink(missing_ok=True)
205
+ logger.info("Removed Gitrupt %s hook", hook_name)
206
+
207
+ return True, str(hook_path)
208
+
209
+ def is_hook_installed(self, hook_name: str) -> bool:
210
+ if hook_name not in _HOOK_MARKERS:
211
+ return False
212
+ hook_path = self._hooks_dir / hook_name
213
+ if not hook_path.exists():
214
+ return False
215
+ content = hook_path.read_text(encoding="utf-8", errors="replace")
216
+ return _HOOK_MARKERS[hook_name] in content
217
+
218
+ # -------------------------------------------------------------------------
219
+ # Internal
220
+ # -------------------------------------------------------------------------
221
+
222
+ def _build_hook_content(self, hook_name: str, existing_backup: str | None) -> str:
223
+ from gitrupt import __version__
224
+
225
+ if existing_backup:
226
+ if hook_name == "pre-push":
227
+ existing_section = _EXISTING_PRE_PUSH.format(backup=existing_backup)
228
+ else:
229
+ existing_section = _EXISTING_PRE_COMMIT.format(backup=existing_backup)
230
+ else:
231
+ existing_section = _NO_EXISTING
232
+
233
+ template = PRE_PUSH_TEMPLATE if hook_name == "pre-push" else PRE_COMMIT_TEMPLATE
234
+
235
+ return template.format(
236
+ marker=_HOOK_MARKERS[hook_name],
237
+ version_marker=GITRUPT_VERSION_MARKER,
238
+ version=__version__,
239
+ python_executable=sys.executable,
240
+ module=_HOOK_MODULES[hook_name],
241
+ existing_section=existing_section,
242
+ )
243
+
244
+
245
+ # ── Platform helper ─────────────────────────────────────────────────────────
246
+
247
+
248
+ def _make_executable(path: Path) -> None:
249
+ if platform.system() == "Windows":
250
+ return
251
+ try:
252
+ current = stat.S_IMODE(os.stat(path).st_mode)
253
+ os.chmod(path, current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
254
+ except OSError as e:
255
+ logger.warning("Failed to make %s executable: %s", path, e)
@@ -0,0 +1,103 @@
1
+ """
2
+ Pre-commit hook entry point.
3
+
4
+ This module is invoked by the Git pre-commit hook script:
5
+ python -m gitrupt.hooks.pre_commit
6
+
7
+ Exit codes:
8
+ 0 — scan passed, commit allowed
9
+ 1 — scan blocked, commit aborted
10
+ 2 — Gitrupt internal error (fails open for resilience)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import sys
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def main() -> int:
22
+ """
23
+ Run the pre-commit scan and return an exit code.
24
+
25
+ Fails open (returns 0) on internal errors to avoid breaking
26
+ developer workflows due to Gitrupt bugs.
27
+ """
28
+ try:
29
+ return _run_pre_commit()
30
+ except KeyboardInterrupt:
31
+ return 0 # Ctrl+C → allow commit
32
+ except Exception as e:
33
+ # Gitrupt internal error — fail open with a warning
34
+ import sys
35
+ print(
36
+ f"\n[!] Gitrupt encountered an internal error: {e}\n"
37
+ f" Commit proceeding (fail-open). Please report this bug.\n",
38
+ file=sys.stderr,
39
+ )
40
+ logger.exception("Pre-commit hook internal error")
41
+ return 0
42
+
43
+
44
+ def _run_pre_commit() -> int:
45
+ """Core pre-commit scan logic."""
46
+ from gitrupt.config import ConfigurationError, load_config
47
+ from gitrupt.git import GitAdapter, GitError
48
+ from gitrupt.reporting import (
49
+ print_header,
50
+ print_scanning,
51
+ print_decision,
52
+ print_scan_stats,
53
+ print_error,
54
+ )
55
+ from gitrupt.risk import RiskEngine
56
+ from gitrupt.scanner import run_scan
57
+
58
+ # Detect Git repository
59
+ repo_root = GitAdapter.find_repo_root()
60
+ if not repo_root:
61
+ # Not in a Git repo — pass through silently
62
+ return 0
63
+
64
+ # Load configuration
65
+ try:
66
+ config = load_config(repo_root)
67
+ except ConfigurationError as e:
68
+ print_header()
69
+ print_error(f"Configuration error: {e}")
70
+ print_error("Fix .gitrupt.yml and try again.")
71
+ return 1
72
+
73
+ # Get staged files
74
+ try:
75
+ target = GitAdapter.build_scan_target(repo_root)
76
+ except GitError as e:
77
+ print_header()
78
+ print_error(f"Git error: {e}")
79
+ return 0 # Fail open for Git errors
80
+
81
+ # No staged files → nothing to scan
82
+ if not target.staged_files:
83
+ return 0
84
+
85
+ print_header()
86
+ print_scanning(len(target.staged_files))
87
+
88
+ # Run all scanners
89
+ scan_result = run_scan(target, config)
90
+
91
+ # Apply policy
92
+ risk_engine = RiskEngine(config.policy)
93
+ decision = risk_engine.evaluate(scan_result)
94
+
95
+ # Report
96
+ print_decision(decision)
97
+ print_scan_stats(scan_result)
98
+
99
+ return decision.exit_code
100
+
101
+
102
+ if __name__ == "__main__":
103
+ sys.exit(main())
@@ -0,0 +1,178 @@
1
+ """
2
+ Pre-push hook entry point.
3
+
4
+ Invoked by the Git pre-push hook script:
5
+ python -m gitrupt.hooks.pre_push <remote_name> <remote_url>
6
+
7
+ Reads ref lines from stdin:
8
+ <local_ref> <local_sha> <remote_ref> <remote_sha>
9
+
10
+ Exit codes:
11
+ 0 — scan passed, push allowed
12
+ 1 — scan blocked, push aborted
13
+ 2 — reserved (unused; internal errors fail open with 0)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import sys
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ ZERO_SHA = "0" * 40
24
+
25
+
26
+ def _parse_ref_lines(stdin_text: str) -> list[tuple[str, str, str, str]]:
27
+ """
28
+ Parse pre-push stdin into (local_ref, local_sha, remote_ref, remote_sha).
29
+
30
+ Deletions (local_sha == all zeros) are dropped — nothing to scan.
31
+ """
32
+ refs: list[tuple[str, str, str, str]] = []
33
+ for raw in stdin_text.splitlines():
34
+ line = raw.strip()
35
+ if not line:
36
+ continue
37
+ parts = line.split()
38
+ if len(parts) != 4:
39
+ continue
40
+ local_ref, local_sha, remote_ref, remote_sha = parts
41
+ if local_sha == ZERO_SHA:
42
+ continue
43
+ refs.append((local_ref, local_sha, remote_ref, remote_sha))
44
+ return refs
45
+
46
+
47
+ def main() -> int:
48
+ """
49
+ Run the pre-push scan and return an exit code.
50
+
51
+ Fails open (returns 0) on internal errors so a Gitrupt bug never
52
+ blocks a developer from pushing working code.
53
+ """
54
+ try:
55
+ return _run_pre_push()
56
+ except KeyboardInterrupt:
57
+ return 0
58
+ except Exception as e:
59
+ print(
60
+ f"\n[!] Gitrupt encountered an internal error: {e}\n"
61
+ f" Push proceeding (fail-open). Please report this bug.\n",
62
+ file=sys.stderr,
63
+ )
64
+ logger.exception("Pre-push hook internal error")
65
+ return 0
66
+
67
+
68
+ def _run_pre_push() -> int:
69
+ from gitrupt.config import ConfigurationError, load_config
70
+ from gitrupt.git import GitAdapter, GitError
71
+ from gitrupt.models import ScanResult
72
+ from gitrupt.reporting import (
73
+ print_decision,
74
+ print_error,
75
+ print_header,
76
+ print_scan_stats,
77
+ )
78
+ from gitrupt.risk import RiskEngine
79
+ from gitrupt.scanner import build_scanners, run_scan
80
+
81
+ remote_name = sys.argv[1] if len(sys.argv) > 1 else ""
82
+ remote_url = sys.argv[2] if len(sys.argv) > 2 else "" # noqa: F841
83
+
84
+ try:
85
+ stdin_text = sys.stdin.read()
86
+ except Exception:
87
+ stdin_text = ""
88
+
89
+ refs = _parse_ref_lines(stdin_text)
90
+ if not refs:
91
+ return 0
92
+
93
+ repo_root = GitAdapter.find_repo_root()
94
+ if not repo_root:
95
+ return 0
96
+
97
+ try:
98
+ config = load_config(repo_root)
99
+ except ConfigurationError as e:
100
+ print_header()
101
+ print_error(f"Configuration error: {e}")
102
+ return 1
103
+
104
+ all_findings = []
105
+ scanners_run: set[str] = set()
106
+ files_scanned = 0
107
+ total_duration = 0.0
108
+
109
+ for local_ref, local_sha, remote_ref, remote_sha in refs:
110
+ try:
111
+ target = GitAdapter.build_push_scan_target(
112
+ repo_root, remote_sha, local_sha
113
+ )
114
+ except GitError as e:
115
+ print_header()
116
+ print_error(f"Git error preparing {local_ref}: {e}")
117
+ continue
118
+
119
+ if not target.staged_files:
120
+ continue
121
+
122
+ text_provider = _make_text_provider(local_sha)
123
+ byte_provider = _make_byte_provider(local_sha)
124
+
125
+ scanners = build_scanners(
126
+ config,
127
+ text_provider=text_provider,
128
+ byte_provider=byte_provider,
129
+ )
130
+
131
+ result = run_scan(target, config, scanners=scanners)
132
+ all_findings.extend(result.findings)
133
+ scanners_run.update(result.scanners_run)
134
+ files_scanned += result.files_scanned
135
+ total_duration += result.scan_duration_ms
136
+
137
+ merged = ScanResult(
138
+ findings=all_findings,
139
+ files_scanned=files_scanned,
140
+ scan_duration_ms=total_duration,
141
+ scanners_run=sorted(scanners_run),
142
+ )
143
+
144
+ print_header()
145
+
146
+ engine = RiskEngine(config.policy, config.suppressions)
147
+ decision = engine.evaluate(merged)
148
+ print_decision(decision)
149
+ print_scan_stats(merged)
150
+ return decision.exit_code
151
+
152
+
153
+ def _make_text_provider(ref: str):
154
+ """Return a provider that reads text content at <ref>:<path>."""
155
+ ref_prefix = f"{ref}:"
156
+
157
+ def provider(repo_root: str, path: str) -> str | None:
158
+ from gitrupt.git import GitAdapter
159
+
160
+ return GitAdapter.get_file_content_at(repo_root, ref_prefix, path)
161
+
162
+ return provider
163
+
164
+
165
+ def _make_byte_provider(ref: str):
166
+ """Return a provider that reads raw bytes at <ref>:<path>."""
167
+ ref_prefix = f"{ref}:"
168
+
169
+ def provider(repo_root: str, path: str) -> bytes | None:
170
+ from gitrupt.git import GitAdapter
171
+
172
+ return GitAdapter.get_file_bytes_at(repo_root, ref_prefix, path)
173
+
174
+ return provider
175
+
176
+
177
+ if __name__ == "__main__":
178
+ sys.exit(main())