exploitgraph 1.0.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.
- core/__init__.py +0 -0
- core/attack_graph.py +83 -0
- core/aws_client.py +284 -0
- core/config.py +83 -0
- core/console.py +469 -0
- core/context_engine.py +172 -0
- core/correlator.py +476 -0
- core/http_client.py +243 -0
- core/logger.py +97 -0
- core/module_loader.py +69 -0
- core/risk_engine.py +47 -0
- core/session_manager.py +254 -0
- exploitgraph-1.0.0.dist-info/METADATA +429 -0
- exploitgraph-1.0.0.dist-info/RECORD +42 -0
- exploitgraph-1.0.0.dist-info/WHEEL +5 -0
- exploitgraph-1.0.0.dist-info/entry_points.txt +2 -0
- exploitgraph-1.0.0.dist-info/licenses/LICENSE +21 -0
- exploitgraph-1.0.0.dist-info/top_level.txt +2 -0
- modules/__init__.py +0 -0
- modules/base.py +82 -0
- modules/cloud/__init__.py +0 -0
- modules/cloud/aws_credential_validator.py +340 -0
- modules/cloud/azure_enum.py +289 -0
- modules/cloud/cloudtrail_analyzer.py +494 -0
- modules/cloud/gcp_enum.py +272 -0
- modules/cloud/iam_enum.py +321 -0
- modules/cloud/iam_privilege_escalation.py +515 -0
- modules/cloud/metadata_check.py +315 -0
- modules/cloud/s3_enum.py +469 -0
- modules/discovery/__init__.py +0 -0
- modules/discovery/http_enum.py +235 -0
- modules/discovery/subdomain_enum.py +260 -0
- modules/exploitation/__init__.py +0 -0
- modules/exploitation/api_exploit.py +403 -0
- modules/exploitation/jwt_attack.py +346 -0
- modules/exploitation/ssrf_scanner.py +258 -0
- modules/reporting/__init__.py +0 -0
- modules/reporting/html_report.py +446 -0
- modules/reporting/json_export.py +107 -0
- modules/secrets/__init__.py +0 -0
- modules/secrets/file_secrets.py +358 -0
- modules/secrets/git_secrets.py +267 -0
core/http_client.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ExploitGraph - Centralised HTTP Client
|
|
3
|
+
All modules use this instead of calling requests.get() directly.
|
|
4
|
+
|
|
5
|
+
Provides:
|
|
6
|
+
- Automatic retry with exponential backoff (3 attempts)
|
|
7
|
+
- Consistent User-Agent header
|
|
8
|
+
- SSL verification control from config
|
|
9
|
+
- Connection pool reuse (session-based)
|
|
10
|
+
- Structured response object with helper methods
|
|
11
|
+
- Per-request timeout enforcement
|
|
12
|
+
- Silent-failure protection: never raises, always returns a Response
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
import time
|
|
16
|
+
from typing import Any, Optional
|
|
17
|
+
|
|
18
|
+
import requests
|
|
19
|
+
import urllib3
|
|
20
|
+
|
|
21
|
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
22
|
+
|
|
23
|
+
DEFAULT_TIMEOUT = 8
|
|
24
|
+
DEFAULT_UA = "ExploitGraph/1.2 (Security Research; +https://github.com/prajwal-infosec/ExploitGraph)"
|
|
25
|
+
MAX_RETRIES = 3
|
|
26
|
+
RETRY_BACKOFF = [0, 1, 2] # seconds before each attempt
|
|
27
|
+
RETRY_ON_STATUS = {429, 500, 502, 503, 504}
|
|
28
|
+
NO_RETRY_ON_STATUS = {400, 401, 403, 404, 405}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class EGResponse:
|
|
32
|
+
"""
|
|
33
|
+
Wrapper around requests.Response that never raises and provides
|
|
34
|
+
helper methods for the most common access patterns in modules.
|
|
35
|
+
"""
|
|
36
|
+
def __init__(self, resp: Optional[requests.Response], error: str = ""):
|
|
37
|
+
self._resp = resp
|
|
38
|
+
self._error = error
|
|
39
|
+
|
|
40
|
+
# ── Core properties ────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def ok(self) -> bool:
|
|
44
|
+
return self._resp is not None and self._resp.status_code < 400
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def status_code(self) -> int:
|
|
48
|
+
return self._resp.status_code if self._resp is not None else 0
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def text(self) -> str:
|
|
52
|
+
if self._resp is None:
|
|
53
|
+
return ""
|
|
54
|
+
try:
|
|
55
|
+
return self._resp.text
|
|
56
|
+
except Exception:
|
|
57
|
+
return ""
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def content(self) -> bytes:
|
|
61
|
+
if self._resp is None:
|
|
62
|
+
return b""
|
|
63
|
+
return self._resp.content or b""
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def error(self) -> str:
|
|
67
|
+
return self._error
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def headers(self) -> dict:
|
|
71
|
+
if self._resp is None:
|
|
72
|
+
return {}
|
|
73
|
+
return dict(self._resp.headers)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def url(self) -> str:
|
|
77
|
+
if self._resp is None:
|
|
78
|
+
return ""
|
|
79
|
+
return self._resp.url or ""
|
|
80
|
+
|
|
81
|
+
# ── Helper methods ─────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
def json(self, default: Any = None) -> Any:
|
|
84
|
+
"""Parse JSON body. Returns `default` on failure — never raises."""
|
|
85
|
+
if self._resp is None:
|
|
86
|
+
return default
|
|
87
|
+
try:
|
|
88
|
+
return self._resp.json()
|
|
89
|
+
except Exception:
|
|
90
|
+
return default
|
|
91
|
+
|
|
92
|
+
def contains(self, *strings: str) -> bool:
|
|
93
|
+
"""True if all strings are in the response text."""
|
|
94
|
+
body = self.text
|
|
95
|
+
return all(s in body for s in strings)
|
|
96
|
+
|
|
97
|
+
def header(self, name: str, default: str = "") -> str:
|
|
98
|
+
return self.headers.get(name, self.headers.get(name.lower(), default))
|
|
99
|
+
|
|
100
|
+
def __bool__(self) -> bool:
|
|
101
|
+
return self.ok
|
|
102
|
+
|
|
103
|
+
def __repr__(self) -> str:
|
|
104
|
+
if self._resp is None:
|
|
105
|
+
return f"<EGResponse ERROR: {self._error}>"
|
|
106
|
+
return f"<EGResponse {self.status_code} {self.url[:60]}>"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class HTTPClient:
|
|
110
|
+
"""
|
|
111
|
+
Thread-safe HTTP client using a shared requests.Session.
|
|
112
|
+
All ExploitGraph modules get one instance via the module-level `http` singleton.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(self, timeout: int = DEFAULT_TIMEOUT,
|
|
116
|
+
verify_ssl: bool = False,
|
|
117
|
+
user_agent: str = DEFAULT_UA):
|
|
118
|
+
self.timeout = timeout
|
|
119
|
+
self.verify_ssl = verify_ssl
|
|
120
|
+
self._session = self._make_session(user_agent)
|
|
121
|
+
|
|
122
|
+
def _make_session(self, user_agent: str) -> requests.Session:
|
|
123
|
+
s = requests.Session()
|
|
124
|
+
s.headers.update({
|
|
125
|
+
"User-Agent": user_agent,
|
|
126
|
+
"Accept": "application/json, text/html, */*",
|
|
127
|
+
})
|
|
128
|
+
# Connection pool: reuse up to 20 connections
|
|
129
|
+
adapter = requests.adapters.HTTPAdapter(
|
|
130
|
+
pool_connections=20,
|
|
131
|
+
pool_maxsize=50,
|
|
132
|
+
max_retries=0, # We handle retries ourselves
|
|
133
|
+
)
|
|
134
|
+
s.mount("http://", adapter)
|
|
135
|
+
s.mount("https://", adapter)
|
|
136
|
+
return s
|
|
137
|
+
|
|
138
|
+
def get(self, url: str, *,
|
|
139
|
+
headers: dict = None,
|
|
140
|
+
params: dict = None,
|
|
141
|
+
timeout: int = None,
|
|
142
|
+
allow_redirects: bool = True,
|
|
143
|
+
retries: int = MAX_RETRIES) -> EGResponse:
|
|
144
|
+
return self._request("GET", url, headers=headers, params=params,
|
|
145
|
+
timeout=timeout, allow_redirects=allow_redirects,
|
|
146
|
+
retries=retries)
|
|
147
|
+
|
|
148
|
+
def post(self, url: str, *,
|
|
149
|
+
json: Any = None,
|
|
150
|
+
data: Any = None,
|
|
151
|
+
headers: dict = None,
|
|
152
|
+
timeout: int = None,
|
|
153
|
+
retries: int = 1) -> EGResponse: # POST: fewer retries by default
|
|
154
|
+
return self._request("POST", url, json=json, data=data,
|
|
155
|
+
headers=headers, timeout=timeout, retries=retries)
|
|
156
|
+
|
|
157
|
+
def _request(self, method: str, url: str, *,
|
|
158
|
+
headers: dict = None,
|
|
159
|
+
params: dict = None,
|
|
160
|
+
json: Any = None,
|
|
161
|
+
data: Any = None,
|
|
162
|
+
timeout: int = None,
|
|
163
|
+
allow_redirects: bool = True,
|
|
164
|
+
retries: int = MAX_RETRIES) -> EGResponse:
|
|
165
|
+
"""
|
|
166
|
+
Execute HTTP request with retry + exponential backoff.
|
|
167
|
+
Never raises — always returns EGResponse.
|
|
168
|
+
"""
|
|
169
|
+
effective_timeout = timeout or self.timeout
|
|
170
|
+
kwargs = {
|
|
171
|
+
"headers": headers or {},
|
|
172
|
+
"params": params,
|
|
173
|
+
"timeout": effective_timeout,
|
|
174
|
+
"verify": self.verify_ssl,
|
|
175
|
+
"allow_redirects": allow_redirects,
|
|
176
|
+
}
|
|
177
|
+
if json is not None:
|
|
178
|
+
kwargs["json"] = json
|
|
179
|
+
if data is not None:
|
|
180
|
+
kwargs["data"] = data
|
|
181
|
+
|
|
182
|
+
last_error = ""
|
|
183
|
+
for attempt in range(retries):
|
|
184
|
+
if attempt > 0:
|
|
185
|
+
delay = RETRY_BACKOFF[min(attempt, len(RETRY_BACKOFF) - 1)]
|
|
186
|
+
if delay:
|
|
187
|
+
time.sleep(delay)
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
resp = self._session.request(method, url, **kwargs)
|
|
191
|
+
|
|
192
|
+
# Don't retry on definitive failure codes
|
|
193
|
+
if resp.status_code in NO_RETRY_ON_STATUS:
|
|
194
|
+
return EGResponse(resp)
|
|
195
|
+
|
|
196
|
+
# Retry on transient failures
|
|
197
|
+
if resp.status_code in RETRY_ON_STATUS and attempt < retries - 1:
|
|
198
|
+
last_error = f"HTTP {resp.status_code}"
|
|
199
|
+
continue
|
|
200
|
+
|
|
201
|
+
return EGResponse(resp)
|
|
202
|
+
|
|
203
|
+
except requests.exceptions.Timeout:
|
|
204
|
+
last_error = f"Timeout after {effective_timeout}s"
|
|
205
|
+
except requests.exceptions.ConnectionError as e:
|
|
206
|
+
last_error = f"Connection error: {_short_err(e)}"
|
|
207
|
+
except requests.exceptions.TooManyRedirects:
|
|
208
|
+
last_error = "Too many redirects"
|
|
209
|
+
break
|
|
210
|
+
except Exception as e:
|
|
211
|
+
last_error = f"Unexpected error: {_short_err(e)}"
|
|
212
|
+
break # Don't retry unknown errors
|
|
213
|
+
|
|
214
|
+
return EGResponse(None, last_error)
|
|
215
|
+
|
|
216
|
+
def update_config(self, timeout: int = None, verify_ssl: bool = None):
|
|
217
|
+
if timeout is not None:
|
|
218
|
+
self.timeout = timeout
|
|
219
|
+
if verify_ssl is not None:
|
|
220
|
+
self.verify_ssl = verify_ssl
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _short_err(e: Exception) -> str:
|
|
224
|
+
s = str(e)
|
|
225
|
+
return s[:120] + "..." if len(s) > 120 else s
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ── Global singleton ───────────────────────────────────────────────────────────
|
|
229
|
+
# All modules import this and call http.get(...) / http.post(...)
|
|
230
|
+
http = HTTPClient()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def configure(timeout: int = None, verify_ssl: bool = None, user_agent: str = None):
|
|
234
|
+
"""Called at startup from config.py to apply framework settings."""
|
|
235
|
+
global http
|
|
236
|
+
if user_agent:
|
|
237
|
+
http = HTTPClient(
|
|
238
|
+
timeout = timeout or http.timeout,
|
|
239
|
+
verify_ssl = verify_ssl if verify_ssl is not None else http.verify_ssl,
|
|
240
|
+
user_agent = user_agent,
|
|
241
|
+
)
|
|
242
|
+
else:
|
|
243
|
+
http.update_config(timeout=timeout, verify_ssl=verify_ssl)
|
core/logger.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""ExploitGraph - Structured Logger (colored console + rotating file)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from colorama import Fore, Style, init
|
|
6
|
+
|
|
7
|
+
init(autoreset=True)
|
|
8
|
+
LOG_DIR = Path(__file__).parent.parent / "logs"
|
|
9
|
+
LOG_DIR.mkdir(exist_ok=True)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ExploitGraphLogger:
|
|
13
|
+
def __init__(self):
|
|
14
|
+
self._quiet = False
|
|
15
|
+
self._flog = None
|
|
16
|
+
self._setup("startup")
|
|
17
|
+
|
|
18
|
+
def _setup(self, sid: str):
|
|
19
|
+
self._flog = logging.getLogger(f"eg_{sid}")
|
|
20
|
+
self._flog.setLevel(logging.DEBUG)
|
|
21
|
+
if not self._flog.handlers:
|
|
22
|
+
fh = logging.FileHandler(LOG_DIR / f"exploitgraph_{sid}.log", encoding="utf-8")
|
|
23
|
+
fh.setFormatter(logging.Formatter("%(asctime)s | %(levelname)s | %(message)s",
|
|
24
|
+
datefmt="%Y-%m-%d %H:%M:%S"))
|
|
25
|
+
self._flog.addHandler(fh)
|
|
26
|
+
|
|
27
|
+
def new_session(self, sid: str): self._setup(sid)
|
|
28
|
+
def set_quiet(self, q: bool): self._quiet = q
|
|
29
|
+
|
|
30
|
+
def _p(self, prefix, msg, color=""):
|
|
31
|
+
c = color or Fore.WHITE
|
|
32
|
+
if not self._quiet:
|
|
33
|
+
print(f"{c}{prefix}{Style.RESET_ALL} {msg}")
|
|
34
|
+
if self._flog:
|
|
35
|
+
self._flog.info(f"{prefix} {msg}")
|
|
36
|
+
|
|
37
|
+
def info(self, m): self._p("[*]", m, Fore.CYAN)
|
|
38
|
+
def success(self, m): self._p("[+]", m, Fore.GREEN)
|
|
39
|
+
def warning(self, m): self._p("[!]", m, Fore.YELLOW)
|
|
40
|
+
def error(self, m): self._p("[-]", m, Fore.RED)
|
|
41
|
+
def result(self, m): self._p("[>]", m, Fore.MAGENTA)
|
|
42
|
+
def step(self, m): self._p(" →", m, Fore.BLUE)
|
|
43
|
+
def newline(self): print()
|
|
44
|
+
|
|
45
|
+
def critical(self, m):
|
|
46
|
+
print(f"{Fore.WHITE}[{Fore.RED}CRITICAL{Fore.WHITE}]{Style.RESET_ALL} {Fore.RED}{m}{Style.RESET_ALL}")
|
|
47
|
+
if self._flog: self._flog.critical(m)
|
|
48
|
+
|
|
49
|
+
def found(self, m):
|
|
50
|
+
self._p("[FOUND]", m, Fore.GREEN)
|
|
51
|
+
|
|
52
|
+
def secret(self, label: str, value: str):
|
|
53
|
+
print(f" {Fore.RED}►{Style.RESET_ALL} {Fore.YELLOW}{label:<26}{Style.RESET_ALL}{Fore.WHITE}{value}{Style.RESET_ALL}")
|
|
54
|
+
if self._flog: self._flog.info(f"SECRET | {label}: {value}")
|
|
55
|
+
|
|
56
|
+
def kv(self, key: str, val: str, w: int = 28):
|
|
57
|
+
print(f" {Fore.CYAN}{key:<{w}}{Style.RESET_ALL}{val}")
|
|
58
|
+
|
|
59
|
+
def banner(self, title: str, width: int = 62):
|
|
60
|
+
bar = "═" * width
|
|
61
|
+
print(f"\n{Fore.RED}╔{bar}╗")
|
|
62
|
+
print(f"║{Fore.WHITE} {title.center(width-2)}{Fore.RED} ║")
|
|
63
|
+
print(f"╚{bar}╝{Style.RESET_ALL}")
|
|
64
|
+
|
|
65
|
+
def section(self, title: str):
|
|
66
|
+
print(f"\n{Fore.CYAN}{'─'*60}\n{Fore.WHITE}{title.upper()}{Style.RESET_ALL}\n{Fore.CYAN}{'─'*60}{Style.RESET_ALL}")
|
|
67
|
+
|
|
68
|
+
def phase(self, n: int, total: int, name: str):
|
|
69
|
+
print(f"\n{Fore.CYAN}{'─'*60}")
|
|
70
|
+
print(f"{Fore.CYAN}[{n}/{total}]{Fore.WHITE} {name.upper()}{Style.RESET_ALL}")
|
|
71
|
+
print(f"{Fore.CYAN}{'─'*60}{Style.RESET_ALL}")
|
|
72
|
+
|
|
73
|
+
def severity_badge(self, s: str) -> str:
|
|
74
|
+
m = {"CRITICAL": Fore.RED, "HIGH": Fore.YELLOW,
|
|
75
|
+
"MEDIUM": Fore.CYAN, "LOW": Fore.GREEN, "INFO": Fore.WHITE}
|
|
76
|
+
c = m.get(s.upper(), Fore.WHITE)
|
|
77
|
+
return f"{c}[{s}]{Style.RESET_ALL}"
|
|
78
|
+
|
|
79
|
+
def table(self, headers, rows, title=""):
|
|
80
|
+
try:
|
|
81
|
+
from tabulate import tabulate
|
|
82
|
+
if title: print(f"\n{Fore.YELLOW}{title}{Style.RESET_ALL}")
|
|
83
|
+
print(tabulate(rows, headers=headers, tablefmt="rounded_outline") if rows
|
|
84
|
+
else f" {Fore.CYAN}(no data){Style.RESET_ALL}")
|
|
85
|
+
except ImportError:
|
|
86
|
+
if title: print(f"\n{title}")
|
|
87
|
+
for r in rows: print(" " + " | ".join(str(c) for c in r))
|
|
88
|
+
|
|
89
|
+
# Module-facing aliases
|
|
90
|
+
def module_info(self, m): self.info(m)
|
|
91
|
+
def module_success(self, m): self.success(m)
|
|
92
|
+
def module_warn(self, m): self.warning(m)
|
|
93
|
+
def module_error(self, m): self.error(m)
|
|
94
|
+
def module_critical(self, m): self.critical(m)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
log = ExploitGraphLogger()
|
core/module_loader.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""ExploitGraph - Dynamic module discovery and loading."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import sys, importlib.util
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
from modules.base import BaseModule
|
|
7
|
+
|
|
8
|
+
MODULE_ROOT = Path(__file__).parent.parent / "modules"
|
|
9
|
+
CATEGORY_ORDER = ["discovery","cloud","secrets","exploitation","reporting","misc","custom"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ModuleLoader:
|
|
13
|
+
def __init__(self):
|
|
14
|
+
self._registry: dict[str, type[BaseModule]] = {}
|
|
15
|
+
self._errors: list[str] = []
|
|
16
|
+
|
|
17
|
+
def discover(self) -> int:
|
|
18
|
+
self._registry.clear(); self._errors.clear()
|
|
19
|
+
for cat_dir in sorted(MODULE_ROOT.iterdir()):
|
|
20
|
+
if not cat_dir.is_dir() or cat_dir.name.startswith("_"): continue
|
|
21
|
+
for py in sorted(cat_dir.glob("*.py")):
|
|
22
|
+
if not py.name.startswith("_"):
|
|
23
|
+
self._try_load(py, cat_dir.name)
|
|
24
|
+
return len(self._registry)
|
|
25
|
+
|
|
26
|
+
def _try_load(self, path: Path, category: str):
|
|
27
|
+
mod_name = f"_eg_{category}_{path.stem}"
|
|
28
|
+
try:
|
|
29
|
+
spec = importlib.util.spec_from_file_location(mod_name, path)
|
|
30
|
+
mod = importlib.util.module_from_spec(spec)
|
|
31
|
+
sys.modules[mod_name] = mod
|
|
32
|
+
spec.loader.exec_module(mod)
|
|
33
|
+
for attr in dir(mod):
|
|
34
|
+
obj = getattr(mod, attr)
|
|
35
|
+
if (isinstance(obj, type) and issubclass(obj, BaseModule)
|
|
36
|
+
and obj is not BaseModule and obj.NAME != "unnamed_module"):
|
|
37
|
+
self._registry[f"{category}/{obj.NAME}"] = obj
|
|
38
|
+
break
|
|
39
|
+
except Exception as e:
|
|
40
|
+
self._errors.append(f"{path.name}: {e}")
|
|
41
|
+
|
|
42
|
+
def get(self, path: str) -> Optional[type[BaseModule]]:
|
|
43
|
+
if path in self._registry: return self._registry[path]
|
|
44
|
+
matches = [k for k in self._registry if k.split("/")[-1] == path]
|
|
45
|
+
if len(matches) == 1: return self._registry[matches[0]]
|
|
46
|
+
if len(matches) > 1: raise ValueError(f"Ambiguous: {path} → {matches}")
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
def instantiate(self, path: str) -> Optional[BaseModule]:
|
|
50
|
+
cls = self.get(path)
|
|
51
|
+
return cls() if cls else None
|
|
52
|
+
|
|
53
|
+
def all_modules(self) -> dict[str, list[dict]]:
|
|
54
|
+
grouped = {c: [] for c in CATEGORY_ORDER}
|
|
55
|
+
for key, cls in self._registry.items():
|
|
56
|
+
cat = key.split("/")[0]
|
|
57
|
+
grouped.setdefault(cat, []).append({
|
|
58
|
+
"path": key, "name": cls.NAME, "description": cls.DESCRIPTION,
|
|
59
|
+
"severity": cls.SEVERITY, "author": cls.AUTHOR,
|
|
60
|
+
"version": cls.VERSION, "mitre": ", ".join(cls.MITRE),
|
|
61
|
+
})
|
|
62
|
+
return {k: v for k, v in grouped.items() if v}
|
|
63
|
+
|
|
64
|
+
def list_names(self) -> list[str]: return sorted(self._registry.keys())
|
|
65
|
+
def count(self) -> int: return len(self._registry)
|
|
66
|
+
def load_errors(self) -> list[str]: return self._errors
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
loader = ModuleLoader()
|
core/risk_engine.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""ExploitGraph - CVSS-style risk scoring engine."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
from core.session_manager import Session
|
|
6
|
+
|
|
7
|
+
SEVERITY_BASE = {"CRITICAL":9.0,"HIGH":7.0,"MEDIUM":5.0,"LOW":3.0,"INFO":1.0}
|
|
8
|
+
CLASSIFICATION = [(9.0,"CRITICAL"),(7.0,"HIGH"),(4.0,"MEDIUM"),(1.0,"LOW"),(0.0,"INFO")]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RiskEngine:
|
|
12
|
+
def score_finding(self, f: dict) -> tuple[float, str]:
|
|
13
|
+
if f.get("cvss_score", 0) > 0:
|
|
14
|
+
raw = float(f["cvss_score"])
|
|
15
|
+
else:
|
|
16
|
+
raw = SEVERITY_BASE.get(f.get("severity","INFO"), 1.0)
|
|
17
|
+
score = round(min(raw, 10.0), 1)
|
|
18
|
+
return score, self._classify(score)
|
|
19
|
+
|
|
20
|
+
def session_score(self, session: "Session") -> tuple[float, str]:
|
|
21
|
+
if not session.findings: return 0.0, "INFO"
|
|
22
|
+
scores = sorted([self.score_finding(f)[0] for f in session.findings], reverse=True)
|
|
23
|
+
if len(scores) == 1:
|
|
24
|
+
agg = scores[0]
|
|
25
|
+
else:
|
|
26
|
+
chain_bonus = min(len(session.graph_edges) * 0.2, 1.5)
|
|
27
|
+
agg = min((scores[0]*0.5 + sum(scores[1:])/len(scores[1:])*0.5) + chain_bonus, 10.0)
|
|
28
|
+
score = round(agg, 1)
|
|
29
|
+
return score, self._classify(score)
|
|
30
|
+
|
|
31
|
+
def score_breakdown(self, session: "Session") -> dict:
|
|
32
|
+
score, label = self.session_score(session)
|
|
33
|
+
sev = {k: 0 for k in ("CRITICAL","HIGH","MEDIUM","LOW","INFO")}
|
|
34
|
+
for f in session.findings:
|
|
35
|
+
s = f.get("severity","INFO")
|
|
36
|
+
sev[s] = sev.get(s, 0) + 1
|
|
37
|
+
return dict(session_score=score, classification=label, severity_counts=sev,
|
|
38
|
+
total_findings=len(session.findings), secrets_found=len(session.secrets),
|
|
39
|
+
graph_nodes=len(session.graph_nodes), graph_edges=len(session.graph_edges))
|
|
40
|
+
|
|
41
|
+
def _classify(self, score: float) -> str:
|
|
42
|
+
for threshold, label in CLASSIFICATION:
|
|
43
|
+
if score >= threshold: return label
|
|
44
|
+
return "INFO"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
risk_engine = RiskEngine()
|