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
|
@@ -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]
|