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/__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()
|