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,219 @@
1
+ """
2
+ Native malware scanner.
3
+
4
+ Runs three passes over each staged file:
5
+ 1. YARA engine (if yara-python is installed)
6
+ 2. Always-on ASCII signature set (EICAR + well-known strings)
7
+ 3. Binary heuristics (PE/ELF/Mach-O in wrong extensions, packers, entropy)
8
+
9
+ Never executes any file. Reads staged bytes only.
10
+
11
+ The scanner does not block. The Risk Engine owns the decision.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from typing import Callable
18
+
19
+ from gitrupt.config import MalwareConfig
20
+ from gitrupt.models import Finding, ScanTarget, Severity
21
+ from gitrupt.scanners.base import Scanner
22
+ from gitrupt.scanners.binaries import scan_binary_heuristics
23
+ from gitrupt.scanners.yara_loader import is_available as yara_is_available
24
+ from gitrupt.scanners.yara_loader import load_rules
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ ContentProvider = Callable[[str, str], bytes | None]
29
+
30
+
31
+ # ── Always-on ASCII signatures ──────────────────────────────────────────────
32
+ # These run even without yara-python. Byte-for-byte matches only.
33
+
34
+ _EICAR_PARTS = [
35
+ "X5O!P%@AP[4\\",
36
+ "PZX54(P^)7CC)7}",
37
+ "$EICAR-STANDARD-",
38
+ "ANTIVIRUS-TEST-",
39
+ "FILE!$H+H*",
40
+ ]
41
+ _EICAR_BYTES = ("".join(_EICAR_PARTS)).encode("ascii")
42
+
43
+
44
+ _ASCII_SIGNATURES: tuple[tuple[bytes, str, str, Severity, float], ...] = (
45
+ (
46
+ _EICAR_BYTES,
47
+ "eicar-test-file",
48
+ "EICAR antivirus test file detected",
49
+ Severity.CRITICAL,
50
+ 1.0,
51
+ ),
52
+ )
53
+
54
+
55
+ def _default_content_provider(repo_root: str, path: str) -> bytes | None:
56
+ """Read the staged bytes of a file via Git. Lazy import to avoid cycles."""
57
+ from gitrupt.git import GitAdapter
58
+
59
+ return GitAdapter.get_staged_file_bytes(repo_root, path)
60
+
61
+
62
+ def _is_binary_like(data: bytes) -> bool:
63
+ """
64
+ True if the bytes look like a real binary file.
65
+
66
+ Uses the same NUL-byte heuristic Git itself uses (a NUL in the first
67
+ 8 KB), plus executable magic bytes for edge cases (some packed
68
+ executables lack early NULs).
69
+ """
70
+ if not data:
71
+ return False
72
+ if b"\x00" in data[:8192]:
73
+ return True
74
+
75
+ from gitrupt.scanners.binaries import detect_executable_format
76
+ return detect_executable_format(data) is not None
77
+
78
+
79
+
80
+
81
+ class NativeMalwareScanner(Scanner):
82
+ """
83
+ Detects known malware signatures and suspicious binary files.
84
+
85
+ Configuration (MalwareConfig):
86
+ use_yara: try to load the YARA engine (default True)
87
+ max_file_size_mb: skip files larger than this (default 50 MB)
88
+ custom_yara_dirs: extra directories of *.yar files
89
+ """
90
+
91
+ def __init__(
92
+ self,
93
+ config: MalwareConfig | None = None,
94
+ content_provider: ContentProvider | None = None,
95
+ ) -> None:
96
+ self._config = config or MalwareConfig()
97
+ self._content_provider = content_provider or _default_content_provider
98
+ self._max_bytes = int(self._config.max_file_size_mb * 1024 * 1024)
99
+
100
+ self._yara_rules = None
101
+ if self._config.use_yara and yara_is_available():
102
+ self._yara_rules = load_rules(self._config.custom_yara_dirs)
103
+ if self._yara_rules is not None:
104
+ logger.debug("YARA engine loaded with built-in rules")
105
+
106
+ # ── Scanner interface ────────────────────────────────────────────────────
107
+
108
+ @property
109
+ def name(self) -> str:
110
+ return "malware"
111
+
112
+ @property
113
+ def description(self) -> str:
114
+ return "Detects known malware signatures and suspicious binaries."
115
+
116
+ def scan(self, target: ScanTarget) -> list[Finding]:
117
+ findings: list[Finding] = []
118
+
119
+ for staged in target.staged_files:
120
+ if staged.status == "D":
121
+ continue
122
+
123
+ data = self._content_provider(target.repo_root, staged.path)
124
+ if not data:
125
+ continue
126
+
127
+ if len(data) > self._max_bytes:
128
+ logger.info(
129
+ "Skipping %s (%d bytes > %d limit)",
130
+ staged.path,
131
+ len(data),
132
+ self._max_bytes,
133
+ )
134
+ continue
135
+
136
+ # Pass 1: always — ASCII signatures (EICAR etc.) are safe on
137
+ # any file because the strings are specific enough.
138
+ findings.extend(self._ascii_signature_pass(staged.path, data))
139
+
140
+ # Passes 2 and 3: only for real binaries.
141
+ # A .py file containing the literal b"UPX0" is documentation,
142
+ # not malware. Git's own is_binary flag is the primary signal;
143
+ # magic-byte detection is the fallback.
144
+ if staged.is_binary or _is_binary_like(data):
145
+ if self._yara_rules is not None:
146
+ findings.extend(self._yara_pass(staged.path, data))
147
+ findings.extend(scan_binary_heuristics(staged.path, data))
148
+
149
+ return findings
150
+
151
+ # ── Passes ───────────────────────────────────────────────────────────────
152
+
153
+ def _ascii_signature_pass(self, path: str, data: bytes) -> list[Finding]:
154
+ findings: list[Finding] = []
155
+ for sig, rule_id, message, severity, confidence in _ASCII_SIGNATURES:
156
+ if sig in data:
157
+ findings.append(
158
+ Finding(
159
+ scanner="malware",
160
+ rule_id=rule_id,
161
+ severity=severity,
162
+ confidence=confidence,
163
+ file=path,
164
+ line=None,
165
+ message=message,
166
+ description="File matches a known antivirus test / malware signature.",
167
+ evidence="<signature redacted>",
168
+ recommendation="Remove this file from the commit.",
169
+ can_override=False,
170
+ )
171
+ )
172
+ return findings
173
+
174
+ def _yara_pass(self, path: str, data: bytes) -> list[Finding]:
175
+ findings: list[Finding] = []
176
+ try:
177
+ matches = self._yara_rules.match(data=data)
178
+ except Exception as e:
179
+ logger.warning("YARA match failed on %s: %s", path, e)
180
+ return findings
181
+
182
+ for match in matches:
183
+ rule_name = match.rule
184
+ severity, confidence, description = self._yara_metadata(match)
185
+ findings.append(
186
+ Finding(
187
+ scanner="malware",
188
+ rule_id=f"yara-{rule_name}",
189
+ severity=severity,
190
+ confidence=confidence,
191
+ file=path,
192
+ line=None,
193
+ message=f"YARA rule matched: {rule_name}",
194
+ description=description,
195
+ evidence=f"rule={rule_name}",
196
+ recommendation="Inspect the file; do not commit untrusted content.",
197
+ can_override=False,
198
+ )
199
+ )
200
+ return findings
201
+
202
+ @staticmethod
203
+ def _yara_metadata(match) -> tuple[Severity, float, str]:
204
+ """Read severity/description from YARA rule meta with sane defaults."""
205
+ meta = getattr(match, "meta", {}) or {}
206
+ raw_severity = str(meta.get("severity", "high")).lower()
207
+ severity = {
208
+ "low": Severity.LOW,
209
+ "medium": Severity.MEDIUM,
210
+ "high": Severity.HIGH,
211
+ "critical": Severity.CRITICAL,
212
+ }.get(raw_severity, Severity.HIGH)
213
+
214
+ confidence = 0.85
215
+ if raw_severity == "critical":
216
+ confidence = 0.95
217
+
218
+ description = str(meta.get("description", "YARA rule matched."))
219
+ return severity, confidence, description
@@ -0,0 +1,221 @@
1
+ """
2
+ OSV vulnerability client.
3
+
4
+ Talks to https://api.osv.dev/v1/querybatch using stdlib urllib only.
5
+ Caches responses on disk keyed by SHA-256 of (ecosystem, name, version).
6
+
7
+ Never raises. On any error, returns an empty result and logs a warning —
8
+ a network failure must never block a commit.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import json
15
+ import logging
16
+ import time
17
+ import urllib.error
18
+ import urllib.request
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import Any, Callable
22
+
23
+ from gitrupt.scanners.ecosystems.base import Dependency
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ OSV_URL = "https://api.osv.dev/v1/querybatch"
28
+
29
+ # Transport contract: (url, body_bytes, timeout_seconds) -> (status, response_bytes)
30
+ Transport = Callable[[str, bytes, float], tuple[int, bytes]]
31
+
32
+
33
+ def _default_transport(url: str, body: bytes, timeout: float) -> tuple[int, bytes]:
34
+ req = urllib.request.Request(
35
+ url,
36
+ data=body,
37
+ headers={
38
+ "Content-Type": "application/json",
39
+ "User-Agent": "Gitrupt/1.0",
40
+ },
41
+ method="POST",
42
+ )
43
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
44
+ return resp.status, resp.read()
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class _CacheKey:
49
+ ecosystem: str
50
+ name: str
51
+ version: str
52
+
53
+ @property
54
+ def digest(self) -> str:
55
+ raw = f"{self.ecosystem}|{self.name}|{self.version}".encode("utf-8")
56
+ return hashlib.sha256(raw).hexdigest()
57
+
58
+
59
+ class OSVClient:
60
+ """
61
+ Batch-query OSV for vulnerabilities affecting a set of dependencies.
62
+
63
+ Usage:
64
+ client = OSVClient(timeout=10.0, cache_ttl_hours=24)
65
+ vulns = client.query_batch(deps)
66
+ # vulns: dict[(ecosystem, name, version)] -> list[dict]
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ timeout: float = 10.0,
72
+ cache_ttl_hours: int = 24,
73
+ cache_dir: Path | None = None,
74
+ transport: Transport | None = None,
75
+ ) -> None:
76
+ self._timeout = timeout
77
+ self._cache_ttl_seconds = max(0, cache_ttl_hours) * 3600
78
+ self._cache_dir = cache_dir or (Path.home() / ".gitrupt" / "cache")
79
+ self._transport = transport or _default_transport
80
+ self._cache: dict[str, dict[str, Any]] = self._load_cache()
81
+
82
+ # ── Public ──────────────────────────────────────────────────────────────
83
+
84
+ def query_batch(
85
+ self, deps: list[Dependency]
86
+ ) -> dict[tuple[str, str, str], list[dict]]:
87
+ """
88
+ Return a mapping of (ecosystem, name, version) -> list of vuln dicts.
89
+
90
+ Cache hits are used without network. Cache misses are queried in one
91
+ HTTP call. If the call fails, misses simply return no vulns.
92
+ """
93
+ result: dict[tuple[str, str, str], list[dict]] = {}
94
+ misses: list[Dependency] = []
95
+
96
+ for dep in deps:
97
+ key = (dep.osv_ecosystem, dep.name, dep.version)
98
+ if key in result:
99
+ continue
100
+ cached = self._cache_lookup(dep)
101
+ if cached is not None:
102
+ result[key] = cached
103
+ else:
104
+ misses.append(dep)
105
+
106
+ if misses:
107
+ fetched = self._query_remote(misses)
108
+ for dep in misses:
109
+ key = (dep.osv_ecosystem, dep.name, dep.version)
110
+ result[key] = fetched.get(key, [])
111
+
112
+ self._save_cache()
113
+
114
+ return result
115
+
116
+ # ── Cache ───────────────────────────────────────────────────────────────
117
+
118
+ def _cache_path(self) -> Path:
119
+ return self._cache_dir / "osv-cache.json"
120
+
121
+ def _load_cache(self) -> dict[str, dict[str, Any]]:
122
+ path = self._cache_path()
123
+ if not path.is_file():
124
+ return {}
125
+ try:
126
+ data = json.loads(path.read_text(encoding="utf-8"))
127
+ except (OSError, json.JSONDecodeError):
128
+ return {}
129
+
130
+ if not isinstance(data, dict) or data.get("version") != 1:
131
+ return {}
132
+
133
+ now = time.time()
134
+ ttl = self._cache_ttl_seconds
135
+ fresh: dict[str, dict[str, Any]] = {}
136
+ for digest, entry in (data.get("entries") or {}).items():
137
+ if not isinstance(entry, dict):
138
+ continue
139
+ ts = entry.get("ts")
140
+ if not isinstance(ts, (int, float)):
141
+ continue
142
+ if ttl and (now - ts) > ttl:
143
+ continue
144
+ fresh[digest] = entry
145
+ return fresh
146
+
147
+ def _save_cache(self) -> None:
148
+ try:
149
+ self._cache_dir.mkdir(parents=True, exist_ok=True)
150
+ payload = {
151
+ "version": 1,
152
+ "entries": self._cache,
153
+ }
154
+ tmp = self._cache_path().with_suffix(".tmp")
155
+ tmp.write_text(json.dumps(payload), encoding="utf-8")
156
+ tmp.replace(self._cache_path())
157
+ except OSError as e:
158
+ logger.debug("Could not persist OSV cache: %s", e)
159
+
160
+ def _cache_lookup(self, dep: Dependency) -> list[dict] | None:
161
+ digest = _CacheKey(dep.osv_ecosystem, dep.name, dep.version).digest
162
+ entry = self._cache.get(digest)
163
+ if entry is None:
164
+ return None
165
+ vulns = entry.get("vulns")
166
+ if not isinstance(vulns, list):
167
+ return None
168
+ return vulns
169
+
170
+ def _cache_store(self, dep: Dependency, vulns: list[dict]) -> None:
171
+ digest = _CacheKey(dep.osv_ecosystem, dep.name, dep.version).digest
172
+ self._cache[digest] = {"ts": time.time(), "vulns": vulns}
173
+
174
+ # ── Remote ──────────────────────────────────────────────────────────────
175
+
176
+ def _query_remote(
177
+ self, deps: list[Dependency]
178
+ ) -> dict[tuple[str, str, str], list[dict]]:
179
+ if not deps:
180
+ return {}
181
+
182
+ payload = {
183
+ "queries": [
184
+ {
185
+ "package": {"name": d.name, "ecosystem": d.osv_ecosystem},
186
+ "version": d.version,
187
+ }
188
+ for d in deps
189
+ ]
190
+ }
191
+ body = json.dumps(payload).encode("utf-8")
192
+
193
+ try:
194
+ status, response = self._transport(OSV_URL, body, self._timeout)
195
+ except (urllib.error.URLError, TimeoutError, OSError) as e:
196
+ logger.warning("OSV query failed (%s); continuing without vuln data", e)
197
+ return {}
198
+ except Exception as e: # defensive: transport may raise anything
199
+ logger.warning("OSV query raised unexpected error: %s", e)
200
+ return {}
201
+
202
+ if status != 200:
203
+ logger.warning("OSV returned HTTP %d; continuing without vuln data", status)
204
+ return {}
205
+
206
+ try:
207
+ data = json.loads(response)
208
+ except json.JSONDecodeError as e:
209
+ logger.warning("OSV returned invalid JSON: %s", e)
210
+ return {}
211
+
212
+ results = data.get("results") or []
213
+ out: dict[tuple[str, str, str], list[dict]] = {}
214
+
215
+ for dep, entry in zip(deps, results):
216
+ vulns = (entry or {}).get("vulns") or []
217
+ key = (dep.osv_ecosystem, dep.name, dep.version)
218
+ out[key] = vulns
219
+ self._cache_store(dep, vulns)
220
+
221
+ return out
@@ -0,0 +1,66 @@
1
+ """Scanner registry and health tracking for Gitrupt."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from gitrupt.scanners.base import Scanner, ScannerHealth, ScannerStatus
6
+
7
+
8
+ class ScannerRegistry:
9
+ """Tracks scanner availability, enablement, and health."""
10
+
11
+ def __init__(self) -> None:
12
+ self._scanners: list[tuple[Scanner, bool, bool]] = []
13
+
14
+ def register_adapter(self, scanner: Scanner, required: bool = False, enabled: bool = True) -> None:
15
+ """Register a scanner or adapter instance."""
16
+ self.register(scanner, required=required, enabled=enabled)
17
+
18
+ def register(self, scanner: Scanner, required: bool = False, enabled: bool = True) -> None:
19
+ """Register a scanner and its requirement flags."""
20
+ self._scanners.append((scanner, required, enabled))
21
+
22
+ def disable(self, scanner_name: str) -> None:
23
+ """Disable a registered scanner by name."""
24
+ for index, (scanner, required, enabled) in enumerate(self._scanners):
25
+ if scanner.name == scanner_name:
26
+ self._scanners[index] = (scanner, required, False)
27
+ return
28
+
29
+ def enable(self, scanner_name: str) -> None:
30
+ """Enable a registered scanner by name."""
31
+ for index, (scanner, required, enabled) in enumerate(self._scanners):
32
+ if scanner.name == scanner_name:
33
+ self._scanners[index] = (scanner, required, True)
34
+ return
35
+
36
+ @property
37
+ def health(self) -> list[ScannerHealth]:
38
+ """Return the health view for all registered scanners."""
39
+ health: list[ScannerHealth] = []
40
+ for scanner, required, enabled in self._scanners:
41
+ status = ScannerStatus.DISABLED if not enabled else (
42
+ ScannerStatus.AVAILABLE if scanner.is_available() else ScannerStatus.UNAVAILABLE
43
+ )
44
+ health.append(
45
+ ScannerHealth(
46
+ name=scanner.name,
47
+ status=status,
48
+ required=required,
49
+ available=scanner.is_available(),
50
+ )
51
+ )
52
+ return health
53
+
54
+ @property
55
+ def enabled_scanners(self) -> list[Scanner]:
56
+ """Return scanners that are enabled and available."""
57
+ scanners: list[Scanner] = []
58
+ for scanner, required, enabled in self._scanners:
59
+ if enabled and scanner.is_available():
60
+ scanners.append(scanner)
61
+ return scanners
62
+
63
+ @property
64
+ def scanners(self) -> list[Scanner]:
65
+ """Return all registered scanners."""
66
+ return [scanner for scanner, _, _ in self._scanners]