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.
- gitrupt/__init__.py +13 -0
- gitrupt/cli.py +546 -0
- gitrupt/config.py +269 -0
- gitrupt/git.py +590 -0
- gitrupt/hooks/__init__.py +7 -0
- gitrupt/hooks/install.py +255 -0
- gitrupt/hooks/pre_commit.py +103 -0
- gitrupt/hooks/pre_push.py +178 -0
- gitrupt/models.py +190 -0
- gitrupt/policy.py +36 -0
- gitrupt/reporting.py +316 -0
- gitrupt/risk.py +197 -0
- gitrupt/scanner.py +117 -0
- gitrupt/scanners/__init__.py +17 -0
- gitrupt/scanners/adapters.py +166 -0
- gitrupt/scanners/base.py +113 -0
- gitrupt/scanners/binaries.py +185 -0
- gitrupt/scanners/code_rules/__init__.py +36 -0
- gitrupt/scanners/code_rules/base.py +27 -0
- gitrupt/scanners/code_rules/go.py +65 -0
- gitrupt/scanners/code_rules/javascript.py +106 -0
- gitrupt/scanners/code_rules/php.py +71 -0
- gitrupt/scanners/code_rules/powershell.py +85 -0
- gitrupt/scanners/code_rules/python.py +153 -0
- gitrupt/scanners/code_rules/ruby.py +76 -0
- gitrupt/scanners/code_rules/rust.py +41 -0
- gitrupt/scanners/code_rules/shell.py +112 -0
- gitrupt/scanners/dependencies.py +244 -0
- gitrupt/scanners/ecosystems/__init__.py +30 -0
- gitrupt/scanners/ecosystems/base.py +60 -0
- gitrupt/scanners/ecosystems/node.py +128 -0
- gitrupt/scanners/ecosystems/python.py +157 -0
- gitrupt/scanners/entropy.py +123 -0
- gitrupt/scanners/forbidden_files.py +201 -0
- gitrupt/scanners/malware.py +219 -0
- gitrupt/scanners/osv_client.py +221 -0
- gitrupt/scanners/registry.py +66 -0
- gitrupt/scanners/secret_rules.py +368 -0
- gitrupt/scanners/secrets.py +558 -0
- gitrupt/scanners/suspicious_code.py +208 -0
- gitrupt/scanners/yara_loader.py +65 -0
- gitrupt/scanners/yara_rules_builtin.py +141 -0
- gitrupt-0.1.0.dist-info/METADATA +342 -0
- gitrupt-0.1.0.dist-info/RECORD +48 -0
- gitrupt-0.1.0.dist-info/WHEEL +5 -0
- gitrupt-0.1.0.dist-info/entry_points.txt +2 -0
- gitrupt-0.1.0.dist-info/licenses/LICENSE +23 -0
- gitrupt-0.1.0.dist-info/top_level.txt +1 -0
gitrupt/hooks/install.py
ADDED
|
@@ -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())
|