detecti-cli 2.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.
- detecti/__init__.py +0 -0
- detecti/cli.py +649 -0
- detecti/config.py +188 -0
- detecti/core/__init__.py +1 -0
- detecti/core/database/__init__.py +5 -0
- detecti/core/database/config_db.py +73 -0
- detecti/core/database/schema.py +136 -0
- detecti/core/database/storage.py +1388 -0
- detecti/core/engine.py +1032 -0
- detecti/core/models.py +278 -0
- detecti/data/config.sqlite +0 -0
- detecti/data/dbs/.gitkeep +2 -0
- detecti/data/dbs/example.com.sqlite +0 -0
- detecti/modules/__init__.py +29 -0
- detecti/modules/base.py +57 -0
- detecti/modules/censys.py +813 -0
- detecti/modules/crtsh.py +98 -0
- detecti/modules/exploitdb.py +138 -0
- detecti/modules/masscan.py +561 -0
- detecti/modules/nuclei.py +449 -0
- detecti/modules/nvd.py +300 -0
- detecti/modules/reverse_whois.py +225 -0
- detecti/modules/shodan.py +412 -0
- detecti/reporters/__init__.py +7 -0
- detecti/reporters/csv_reporter.py +74 -0
- detecti/reporters/html_reporter.py +356 -0
- detecti/reporters/json_reporter.py +26 -0
- detecti/reporters/markdown_reporter.py +203 -0
- detecti/utils/__init__.py +1 -0
- detecti/utils/http.py +294 -0
- detecti/utils/logger.py +378 -0
- detecti/utils/setup.py +453 -0
- detecti/web/__init__.py +6 -0
- detecti/web/api/__init__.py +1 -0
- detecti/web/api/auth.py +109 -0
- detecti/web/api/graph_builder.py +901 -0
- detecti/web/api/routes.py +1602 -0
- detecti/web/process_manager.py +283 -0
- detecti/web/server.py +183 -0
- detecti/web/static/android-chrome-192x192.png +0 -0
- detecti/web/static/android-chrome-512x512.png +0 -0
- detecti/web/static/apple-touch-icon.png +0 -0
- detecti/web/static/css/__init__.py +1 -0
- detecti/web/static/css/dashboard.css +3802 -0
- detecti/web/static/favicon-16x16.png +0 -0
- detecti/web/static/favicon-32x32.png +0 -0
- detecti/web/static/favicon.ico +0 -0
- detecti/web/static/img/DetecTI_Security_Logo.png +0 -0
- detecti/web/static/img/detecti-ico.png +0 -0
- detecti/web/static/index.html +677 -0
- detecti/web/static/js/__init__.py +1 -0
- detecti/web/static/js/api.js +177 -0
- detecti/web/static/js/cytoscape-cose-bilkent.js +458 -0
- detecti/web/static/js/cytoscape-dagre.js +397 -0
- detecti/web/static/js/cytoscape.min.js +31 -0
- detecti/web/static/js/dagre.min.js +3809 -0
- detecti/web/static/js/graph.js +7439 -0
- detecti/web/static/js/lucide.min.js +12 -0
- detecti/web/static/login.html +290 -0
- detecti/web/static/site.webmanifest +1 -0
- detecti_cli-2.0.0.dist-info/METADATA +554 -0
- detecti_cli-2.0.0.dist-info/RECORD +64 -0
- detecti_cli-2.0.0.dist-info/WHEEL +4 -0
- detecti_cli-2.0.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,901 @@
|
|
|
1
|
+
"""Graph data builder for Cytoscape.js visualization."""
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
from typing import Dict, List, Optional, Set
|
|
5
|
+
from detecti.core.database.storage import DatabaseManager
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GraphBuilder:
|
|
9
|
+
"""Builds Cytoscape.js graph data from SQLite database."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, db_manager: DatabaseManager):
|
|
12
|
+
self.db = db_manager
|
|
13
|
+
|
|
14
|
+
def build_graph(self, active_targets: Optional[List[str]] = None) -> Dict:
|
|
15
|
+
"""Build complete graph with nodes and edges for Cytoscape.js.
|
|
16
|
+
|
|
17
|
+
Host-Centric Architecture:
|
|
18
|
+
- Target Root connects directly to Host IPs via CONTAINS_TARGET
|
|
19
|
+
- Explicit FQDN targets connect to Target Root (CONTAINS_TARGET) and IP (RESOLVES_TO)
|
|
20
|
+
- All enumerated domains/subdomains are embedded as rich metadata in target_root
|
|
21
|
+
"""
|
|
22
|
+
nodes = []
|
|
23
|
+
edges = []
|
|
24
|
+
|
|
25
|
+
with sqlite3.connect(self.db.db_path) as conn:
|
|
26
|
+
# 1. Determine target root information (from scan_results)
|
|
27
|
+
target_name = None
|
|
28
|
+
target_type = None
|
|
29
|
+
try:
|
|
30
|
+
cursor = conn.execute("SELECT target, target_type FROM scan_results ORDER BY created_at DESC LIMIT 1")
|
|
31
|
+
row = cursor.fetchone()
|
|
32
|
+
if row:
|
|
33
|
+
target_name, target_type = row
|
|
34
|
+
except Exception:
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
# 2. Build domain & subdomain nodes (only explicit targets spawned as graph nodes)
|
|
38
|
+
domain_nodes, domain_edges, root_target_node, subdomain_to_ips, domain_to_ips, explicit_targets = self._build_domain_nodes(
|
|
39
|
+
conn, target_name, target_type, active_targets=active_targets
|
|
40
|
+
)
|
|
41
|
+
if root_target_node:
|
|
42
|
+
nodes.append(root_target_node)
|
|
43
|
+
nodes.extend(domain_nodes)
|
|
44
|
+
edges.extend(domain_edges)
|
|
45
|
+
|
|
46
|
+
# 3. Build IP nodes connected to target root or resolved by visible FQDNs
|
|
47
|
+
spawned_fqdn_ids = {n["data"]["id"] for n in domain_nodes}
|
|
48
|
+
targets_list = root_target_node["data"].get("targets_list", []) if root_target_node else []
|
|
49
|
+
ip_nodes, ip_edges = self._build_ip_nodes(
|
|
50
|
+
conn,
|
|
51
|
+
subdomain_to_ips,
|
|
52
|
+
domain_to_ips,
|
|
53
|
+
spawned_fqdn_ids=spawned_fqdn_ids,
|
|
54
|
+
target_name=target_name,
|
|
55
|
+
target_type=target_type,
|
|
56
|
+
targets_list=targets_list,
|
|
57
|
+
explicit_targets=explicit_targets,
|
|
58
|
+
root_target_node=root_target_node
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Determine which IPs to spawn: only explicit targets or if in discovery mode
|
|
62
|
+
# We NO LONGER spawn IPs just because a parent FQDN is marked.
|
|
63
|
+
is_discovery = (target_type == "discovery" or not explicit_targets)
|
|
64
|
+
visible_ip_nodes = [n for n in ip_nodes if is_discovery or n["data"]["ip"].lower() in explicit_targets or str(n["data"]["id"]).replace("ip_", "").lower() in explicit_targets]
|
|
65
|
+
|
|
66
|
+
connected_ip_ids = {n["data"]["id"] for n in visible_ip_nodes}
|
|
67
|
+
nodes.extend(visible_ip_nodes)
|
|
68
|
+
edges.extend(ip_edges)
|
|
69
|
+
|
|
70
|
+
# 4. Build service nodes connected to in-scope IPs
|
|
71
|
+
service_nodes, service_edges = self._build_service_nodes(conn)
|
|
72
|
+
visible_service_nodes = [n for n in service_nodes if any(e["data"]["target"] == n["data"]["id"] and e["data"]["source"] in connected_ip_ids for e in service_edges)]
|
|
73
|
+
visible_service_edges = [e for e in service_edges if e["data"]["source"] in connected_ip_ids]
|
|
74
|
+
nodes.extend(visible_service_nodes)
|
|
75
|
+
edges.extend(visible_service_edges)
|
|
76
|
+
|
|
77
|
+
# 5. Build vulnerability nodes connected to in-scope services/IPs
|
|
78
|
+
vuln_nodes, vuln_edges = self._build_vulnerability_nodes(conn)
|
|
79
|
+
visible_srv_and_ip_ids = connected_ip_ids.union({n["data"]["id"] for n in visible_service_nodes})
|
|
80
|
+
visible_vuln_edges = [e for e in vuln_edges if e["data"]["source"] in visible_srv_and_ip_ids]
|
|
81
|
+
visible_vuln_node_ids = {e["data"]["target"] for e in visible_vuln_edges}
|
|
82
|
+
visible_vuln_nodes = [n for n in vuln_nodes if n["data"]["id"] in visible_vuln_node_ids]
|
|
83
|
+
nodes.extend(visible_vuln_nodes)
|
|
84
|
+
edges.extend(visible_vuln_edges)
|
|
85
|
+
|
|
86
|
+
# --- EMBED PASSIVE DATA FOR FQDN INSPECTOR ---
|
|
87
|
+
# To support the inspector showing data without spawning the nodes visually:
|
|
88
|
+
# Build lookups for all passive data
|
|
89
|
+
ip_data_map = {n["data"]["id"]: n["data"] for n in ip_nodes}
|
|
90
|
+
srv_data_map = {n["data"]["id"]: n["data"] for n in service_nodes}
|
|
91
|
+
vuln_data_map = {n["data"]["id"]: n["data"] for n in vuln_nodes}
|
|
92
|
+
|
|
93
|
+
# Build relationship mappings (all edges, even if not spawned)
|
|
94
|
+
ip_to_srvs = {}
|
|
95
|
+
for e in service_edges:
|
|
96
|
+
src = e["data"]["source"]
|
|
97
|
+
tgt = e["data"]["target"]
|
|
98
|
+
if src not in ip_to_srvs: ip_to_srvs[src] = []
|
|
99
|
+
ip_to_srvs[src].append(tgt)
|
|
100
|
+
|
|
101
|
+
srv_or_ip_to_vulns = {}
|
|
102
|
+
for e in vuln_edges:
|
|
103
|
+
src = e["data"]["source"]
|
|
104
|
+
tgt = e["data"]["target"]
|
|
105
|
+
if src not in srv_or_ip_to_vulns: srv_or_ip_to_vulns[src] = []
|
|
106
|
+
srv_or_ip_to_vulns[src].append(tgt)
|
|
107
|
+
|
|
108
|
+
# For each spawned FQDN node, embed its unspawned passive data
|
|
109
|
+
for n in nodes:
|
|
110
|
+
if n["data"]["type"] in ("domain", "subdomain"):
|
|
111
|
+
nid = n["data"]["id"]
|
|
112
|
+
n_ips = []
|
|
113
|
+
n_srvs = []
|
|
114
|
+
n_vulns = []
|
|
115
|
+
|
|
116
|
+
# Get IPs for this FQDN
|
|
117
|
+
ip_ids = set()
|
|
118
|
+
if n["data"]["type"] == "domain":
|
|
119
|
+
dom_id = nid.replace("dom_", "")
|
|
120
|
+
ip_ids = domain_to_ips.get(int(dom_id) if dom_id.isdigit() else dom_id, set())
|
|
121
|
+
else:
|
|
122
|
+
sub_id = nid.replace("sub_", "")
|
|
123
|
+
ip_ids = subdomain_to_ips.get(int(sub_id) if sub_id.isdigit() else sub_id, set())
|
|
124
|
+
|
|
125
|
+
for ip_id in ip_ids:
|
|
126
|
+
ip_node_id = f"ip_{ip_id}"
|
|
127
|
+
if ip_node_id in ip_data_map:
|
|
128
|
+
n_ips.append(ip_data_map[ip_node_id])
|
|
129
|
+
|
|
130
|
+
for srv_id in ip_to_srvs.get(ip_node_id, []):
|
|
131
|
+
if srv_id in srv_data_map:
|
|
132
|
+
n_srvs.append(srv_data_map[srv_id])
|
|
133
|
+
for vuln_id in srv_or_ip_to_vulns.get(srv_id, []):
|
|
134
|
+
if vuln_id in vuln_data_map:
|
|
135
|
+
n_vulns.append(vuln_data_map[vuln_id])
|
|
136
|
+
|
|
137
|
+
for vuln_id in srv_or_ip_to_vulns.get(ip_node_id, []):
|
|
138
|
+
if vuln_id in vuln_data_map:
|
|
139
|
+
n_vulns.append(vuln_data_map[vuln_id])
|
|
140
|
+
|
|
141
|
+
n["data"]["passive_ips"] = n_ips
|
|
142
|
+
n["data"]["passive_services"] = n_srvs
|
|
143
|
+
n["data"]["passive_vulns"] = n_vulns
|
|
144
|
+
|
|
145
|
+
# Filter edges to only keep those where both source and target are spawned
|
|
146
|
+
spawned_node_ids = {n["data"]["id"] for n in nodes}
|
|
147
|
+
valid_edges = [e for e in edges if e["data"]["source"] in spawned_node_ids and e["data"]["target"] in spawned_node_ids]
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
"elements": {
|
|
151
|
+
"nodes": nodes,
|
|
152
|
+
"edges": valid_edges
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
def _build_domain_nodes(
|
|
157
|
+
self,
|
|
158
|
+
conn: sqlite3.Connection,
|
|
159
|
+
target_name: str | None = None,
|
|
160
|
+
target_type: str | None = None,
|
|
161
|
+
active_targets: Optional[List[str]] = None
|
|
162
|
+
) -> tuple[List[Dict], List[Dict], Dict | None, Dict[str, set], Dict[str, set]]:
|
|
163
|
+
"""Build domain and subdomain inventory and spawn nodes ONLY for explicit targets."""
|
|
164
|
+
nodes = []
|
|
165
|
+
edges = []
|
|
166
|
+
root_target_node = None
|
|
167
|
+
|
|
168
|
+
# Get all domains
|
|
169
|
+
cursor = conn.execute("SELECT id, name FROM domains ORDER BY name")
|
|
170
|
+
domains_list = cursor.fetchall()
|
|
171
|
+
|
|
172
|
+
# Get all subdomains
|
|
173
|
+
cursor_s = conn.execute("SELECT id, name FROM subdomains ORDER BY name")
|
|
174
|
+
all_subs_raw = cursor_s.fetchall()
|
|
175
|
+
|
|
176
|
+
targets_list = []
|
|
177
|
+
explicit_targets = set()
|
|
178
|
+
if target_name:
|
|
179
|
+
if target_type == "file":
|
|
180
|
+
from pathlib import Path
|
|
181
|
+
try:
|
|
182
|
+
from config import DETECTI_HOME
|
|
183
|
+
except ImportError:
|
|
184
|
+
DETECTI_HOME = Path.home() / ".detecti"
|
|
185
|
+
clean_path = str(target_name).strip()
|
|
186
|
+
candidates = [
|
|
187
|
+
Path(clean_path),
|
|
188
|
+
DETECTI_HOME / clean_path,
|
|
189
|
+
DETECTI_HOME / "data" / clean_path,
|
|
190
|
+
DETECTI_HOME / "data" / "targets" / clean_path,
|
|
191
|
+
DETECTI_HOME / "tests" / clean_path,
|
|
192
|
+
]
|
|
193
|
+
# Also try adding standard text extensions
|
|
194
|
+
if "." not in clean_path:
|
|
195
|
+
for ext in [".txt", ".scope", ".list"]:
|
|
196
|
+
candidates.append(Path(f"{clean_path}{ext}"))
|
|
197
|
+
candidates.append(DETECTI_HOME / f"{clean_path}{ext}")
|
|
198
|
+
candidates.append(DETECTI_HOME / "data" / f"{clean_path}{ext}")
|
|
199
|
+
|
|
200
|
+
file_obj = None
|
|
201
|
+
for cand in candidates:
|
|
202
|
+
if cand.exists() and cand.is_file():
|
|
203
|
+
file_obj = cand
|
|
204
|
+
break
|
|
205
|
+
|
|
206
|
+
if file_obj:
|
|
207
|
+
try:
|
|
208
|
+
targets_list = [line.strip() for line in file_obj.read_text(encoding="utf-8", errors="replace").splitlines() if line.strip() and not line.startswith("#")]
|
|
209
|
+
except Exception:
|
|
210
|
+
pass
|
|
211
|
+
|
|
212
|
+
if not targets_list:
|
|
213
|
+
try:
|
|
214
|
+
cursor_logs = conn.execute(
|
|
215
|
+
"SELECT DISTINCT input_target FROM scan_logs WHERE input_target IS NOT NULL AND input_target != ?",
|
|
216
|
+
(target_name,)
|
|
217
|
+
)
|
|
218
|
+
targets_list = [r[0].strip() for r in cursor_logs.fetchall() if r[0] and r[0].strip()]
|
|
219
|
+
except Exception:
|
|
220
|
+
pass
|
|
221
|
+
elif target_type in ("domain", "ip", "subdomain"):
|
|
222
|
+
targets_list = [target_name]
|
|
223
|
+
|
|
224
|
+
def _normalize_target_item(t: str) -> str:
|
|
225
|
+
t = str(t).strip().lower()
|
|
226
|
+
if t.startswith("http://"):
|
|
227
|
+
t = t[7:]
|
|
228
|
+
elif t.startswith("https://"):
|
|
229
|
+
t = t[8:]
|
|
230
|
+
if "/" in t:
|
|
231
|
+
t = t.split("/")[0]
|
|
232
|
+
if ":" in t:
|
|
233
|
+
if t.startswith("[") and "]" in t:
|
|
234
|
+
t = t.split("]")[0][1:]
|
|
235
|
+
elif t.count(":") == 1:
|
|
236
|
+
t = t.split(":")[0]
|
|
237
|
+
return t
|
|
238
|
+
|
|
239
|
+
explicit_targets = {_normalize_target_item(t) for t in targets_list if _normalize_target_item(t)}
|
|
240
|
+
if active_targets:
|
|
241
|
+
for at in active_targets:
|
|
242
|
+
norm = _normalize_target_item(at)
|
|
243
|
+
if norm:
|
|
244
|
+
explicit_targets.add(norm)
|
|
245
|
+
|
|
246
|
+
root_target_node = {
|
|
247
|
+
"data": {
|
|
248
|
+
"id": "target_root",
|
|
249
|
+
"label": target_name or "Target Root",
|
|
250
|
+
"type": "target",
|
|
251
|
+
"name": target_name or "Target Root",
|
|
252
|
+
"target_type": target_type or "query",
|
|
253
|
+
"targets_list": targets_list,
|
|
254
|
+
"is_root": True
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
# Query all subdomains and their resolved IPs
|
|
259
|
+
cursor_subs = conn.execute("""
|
|
260
|
+
SELECT s.id, s.name, s.domain_id, d.name as domain_name, si.ip_id, ip.ip
|
|
261
|
+
FROM subdomains s
|
|
262
|
+
JOIN domains d ON s.domain_id = d.id
|
|
263
|
+
LEFT JOIN subdomain_ips si ON s.id = si.subdomain_id
|
|
264
|
+
LEFT JOIN ip_addresses ip ON si.ip_id = ip.id
|
|
265
|
+
ORDER BY s.name ASC
|
|
266
|
+
""")
|
|
267
|
+
|
|
268
|
+
subdomain_to_ips: Dict[str, set] = {}
|
|
269
|
+
domain_to_ips: Dict[str, set] = {}
|
|
270
|
+
subdomain_info_map: Dict[str, Dict] = {}
|
|
271
|
+
domain_resolved_ips_map: Dict[str, list] = {}
|
|
272
|
+
|
|
273
|
+
for sub_id, sub_name, domain_id, domain_name, ip_id, ip_addr in cursor_subs.fetchall():
|
|
274
|
+
is_apex = (sub_name.strip().lower() == domain_name.strip().lower())
|
|
275
|
+
if ip_id:
|
|
276
|
+
subdomain_to_ips.setdefault(sub_id, set()).add(ip_id)
|
|
277
|
+
if is_apex:
|
|
278
|
+
domain_to_ips.setdefault(domain_id, set()).add(ip_id)
|
|
279
|
+
# Track domain resolved IPs (apex)
|
|
280
|
+
domain_ips_list = domain_resolved_ips_map.setdefault(domain_id, [])
|
|
281
|
+
if not any(r["id"] == f"ip_{ip_id}" for r in domain_ips_list):
|
|
282
|
+
domain_ips_list.append({"id": f"ip_{ip_id}", "ip": ip_addr})
|
|
283
|
+
|
|
284
|
+
if not is_apex:
|
|
285
|
+
if sub_id not in subdomain_info_map:
|
|
286
|
+
subdomain_info_map[sub_id] = {
|
|
287
|
+
"id": sub_id,
|
|
288
|
+
"name": sub_name,
|
|
289
|
+
"domain_id": domain_id,
|
|
290
|
+
"domain_name": domain_name,
|
|
291
|
+
"ips": [],
|
|
292
|
+
"resolved_ips": []
|
|
293
|
+
}
|
|
294
|
+
if ip_addr and ip_addr not in subdomain_info_map[sub_id]["ips"]:
|
|
295
|
+
subdomain_info_map[sub_id]["ips"].append(ip_addr)
|
|
296
|
+
subdomain_info_map[sub_id]["resolved_ips"].append({"id": f"ip_{ip_id}", "ip": ip_addr})
|
|
297
|
+
|
|
298
|
+
all_domains = [{"id": d[0], "name": d[1]} for d in domains_list]
|
|
299
|
+
all_subdomains = list(subdomain_info_map.values())
|
|
300
|
+
|
|
301
|
+
# Embed complete DNS inventory inside target_root for instant Asset Inspector access
|
|
302
|
+
if root_target_node:
|
|
303
|
+
root_target_node["data"]["all_domains"] = all_domains
|
|
304
|
+
root_target_node["data"]["all_subdomains"] = all_subdomains
|
|
305
|
+
root_target_node["data"]["total_domains"] = len(all_domains)
|
|
306
|
+
root_target_node["data"]["total_subdomains"] = len(all_subdomains)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# 2. Topologically hierarchical node spawning
|
|
311
|
+
domains_to_spawn = set()
|
|
312
|
+
subdomains_to_spawn = set()
|
|
313
|
+
|
|
314
|
+
# Determine which domains are explicit targets (or parents of explicit targets) to receive CONTAINS_TARGET
|
|
315
|
+
explicit_domains = set()
|
|
316
|
+
for domain_id, domain_name in domains_list:
|
|
317
|
+
if domain_name.lower() in explicit_targets or str(domain_id).lower() in explicit_targets:
|
|
318
|
+
domains_to_spawn.add(domain_id)
|
|
319
|
+
explicit_domains.add(domain_id)
|
|
320
|
+
|
|
321
|
+
for sub_id, sub_info in subdomain_info_map.items():
|
|
322
|
+
if sub_info["name"].lower() in explicit_targets or str(sub_id).lower() in explicit_targets:
|
|
323
|
+
subdomains_to_spawn.add(sub_id)
|
|
324
|
+
domains_to_spawn.add(sub_info["domain_id"])
|
|
325
|
+
explicit_domains.add(sub_info["domain_id"])
|
|
326
|
+
elif any(ip in explicit_targets for ip in sub_info["ips"]):
|
|
327
|
+
subdomains_to_spawn.add(sub_id)
|
|
328
|
+
domains_to_spawn.add(sub_info["domain_id"])
|
|
329
|
+
explicit_domains.add(sub_info["domain_id"])
|
|
330
|
+
|
|
331
|
+
cursor_all_ips = conn.execute("SELECT id, ip FROM ip_addresses")
|
|
332
|
+
ip_id_to_str = {str(r[0]): r[1].lower() for r in cursor_all_ips.fetchall()}
|
|
333
|
+
for domain_id, ip_ids in domain_to_ips.items():
|
|
334
|
+
if domain_id not in domains_to_spawn:
|
|
335
|
+
if any(ip_id_to_str.get(str(ip_id)) in explicit_targets or str(ip_id).lower() in explicit_targets for ip_id in ip_ids):
|
|
336
|
+
domains_to_spawn.add(domain_id)
|
|
337
|
+
explicit_domains.add(domain_id)
|
|
338
|
+
|
|
339
|
+
for domain_id, domain_name in domains_list:
|
|
340
|
+
if domain_id in domains_to_spawn:
|
|
341
|
+
dname_lower = domain_name.lower()
|
|
342
|
+
domain_subs = [s for s in all_subdomains if s.get("domain_id") == domain_id or s.get("domain_name", "").lower() == dname_lower]
|
|
343
|
+
node_data = {
|
|
344
|
+
"id": f"dom_{domain_id}",
|
|
345
|
+
"label": domain_name,
|
|
346
|
+
"type": "domain",
|
|
347
|
+
"name": domain_name,
|
|
348
|
+
"related_subdomains": domain_subs,
|
|
349
|
+
"subdomain_count": len(domain_subs),
|
|
350
|
+
"resolved_ips": domain_resolved_ips_map.get(domain_id, []),
|
|
351
|
+
"is_target": (dname_lower in explicit_targets),
|
|
352
|
+
"is_root": False
|
|
353
|
+
}
|
|
354
|
+
nodes.append({"data": node_data, "classes": "is-target" if node_data["is_target"] else ""})
|
|
355
|
+
|
|
356
|
+
if root_target_node:
|
|
357
|
+
if domain_id in explicit_domains:
|
|
358
|
+
edges.append({"data": {"id": f"e_target_dom_{domain_id}", "source": "target_root", "target": f"dom_{domain_id}", "label": "CONTAINS_TARGET"}})
|
|
359
|
+
else:
|
|
360
|
+
edges.append({"data": {"id": f"e_target_dom_{domain_id}", "source": "target_root", "target": f"dom_{domain_id}", "label": "MATCHES_DOMAIN"}})
|
|
361
|
+
|
|
362
|
+
for sub_id, sub_info in subdomain_info_map.items():
|
|
363
|
+
if sub_id in subdomains_to_spawn:
|
|
364
|
+
sname_lower = sub_info["name"].lower()
|
|
365
|
+
parent_dom_id = sub_info["domain_id"]
|
|
366
|
+
sub_related = [s for s in all_subdomains if s.get("domain_id") == parent_dom_id and s.get("id") != sub_id]
|
|
367
|
+
node_data = {
|
|
368
|
+
"id": f"sub_{sub_id}",
|
|
369
|
+
"label": sub_info["name"],
|
|
370
|
+
"type": "subdomain",
|
|
371
|
+
"name": sub_info["name"],
|
|
372
|
+
"domain_id": parent_dom_id,
|
|
373
|
+
"domain_name": sub_info["domain_name"],
|
|
374
|
+
"resolved_ips": sub_info.get("resolved_ips", []),
|
|
375
|
+
"related_subdomains": sub_related,
|
|
376
|
+
"is_target": (sname_lower in explicit_targets)
|
|
377
|
+
}
|
|
378
|
+
nodes.append({"data": node_data, "classes": "is-target" if node_data["is_target"] else ""})
|
|
379
|
+
|
|
380
|
+
parent_dom_id = sub_info["domain_id"]
|
|
381
|
+
edges.append({"data": {"id": f"e_dom_sub_{sub_id}", "source": f"dom_{parent_dom_id}", "target": f"sub_{sub_id}", "label": "HAS_SUBDOMAIN"}})
|
|
382
|
+
|
|
383
|
+
if root_target_node:
|
|
384
|
+
explore_leads = []
|
|
385
|
+
ip_stats = {}
|
|
386
|
+
cursor_ips = conn.execute("""
|
|
387
|
+
SELECT ip.id, ip.ip,
|
|
388
|
+
COUNT(DISTINCT s.id) as service_count,
|
|
389
|
+
COUNT(DISTINCT CASE WHEN s.sources LIKE '%masscan%' OR s.sources LIKE '%active%' THEN s.id END) as verified_service_count,
|
|
390
|
+
COUNT(DISTINCT v.id) as vuln_count,
|
|
391
|
+
MAX(CASE WHEN v.is_cisa_kev = 1 THEN 1 ELSE 0 END) as has_kev,
|
|
392
|
+
COUNT(DISTINCT CASE WHEN v.is_cisa_kev = 1 THEN v.id END) as kev_count,
|
|
393
|
+
COUNT(DISTINCT CASE WHEN v.severity = 'CRITICAL' THEN v.id END) as critical_count,
|
|
394
|
+
COUNT(DISTINCT CASE WHEN v.severity = 'HIGH' THEN v.id END) as high_count,
|
|
395
|
+
MAX(v.epss_score) as max_epss,
|
|
396
|
+
COUNT(DISTINCT CASE WHEN v.epss_score >= 0.20 THEN v.id END) as high_epss_count,
|
|
397
|
+
COUNT(DISTINCT e.id) as poc_count,
|
|
398
|
+
GROUP_CONCAT(DISTINCT s.id) as srv_ids,
|
|
399
|
+
GROUP_CONCAT(DISTINCT (CASE WHEN v.cve_id IS NOT NULL AND TRIM(v.cve_id) != '' AND UPPER(v.cve_id) != 'UNKNOWN' THEN LOWER(REPLACE(v.cve_id, ' ', '_')) ELSE v.id END)) as vuln_ids
|
|
400
|
+
FROM ip_addresses ip
|
|
401
|
+
LEFT JOIN services s ON ip.id = s.ip_id
|
|
402
|
+
LEFT JOIN vulnerabilities v ON ip.id = v.ip_id OR s.id = v.service_id
|
|
403
|
+
LEFT JOIN exploits e ON v.id = e.vulnerability_id
|
|
404
|
+
GROUP BY ip.id, ip.ip
|
|
405
|
+
""")
|
|
406
|
+
for row in cursor_ips.fetchall():
|
|
407
|
+
ip_id, ip, s_cnt, vs_cnt, v_cnt, has_kev, k_cnt, c_cnt, h_cnt, max_epss, h_epss_cnt, poc_cnt, srv_ids_str, vuln_ids_str = row
|
|
408
|
+
max_epss = max_epss or 0.0
|
|
409
|
+
|
|
410
|
+
s_list = [f"srv_{x}" for x in str(srv_ids_str).split(",")] if srv_ids_str else []
|
|
411
|
+
v_list = [f"vuln_{x}" for x in str(vuln_ids_str).split(",")] if vuln_ids_str else []
|
|
412
|
+
|
|
413
|
+
stats = {
|
|
414
|
+
"service_count": s_cnt,
|
|
415
|
+
"verified_service_count": vs_cnt,
|
|
416
|
+
"vuln_count": v_cnt,
|
|
417
|
+
"has_kev": bool(has_kev),
|
|
418
|
+
"kev_count": k_cnt,
|
|
419
|
+
"critical_count": c_cnt,
|
|
420
|
+
"high_count": h_cnt,
|
|
421
|
+
"max_epss": max_epss,
|
|
422
|
+
"high_epss_count": h_epss_cnt,
|
|
423
|
+
"poc_count": poc_cnt,
|
|
424
|
+
"service_ids": s_list,
|
|
425
|
+
"vuln_ids": v_list
|
|
426
|
+
}
|
|
427
|
+
ip_stats[ip_id] = stats
|
|
428
|
+
score = (k_cnt * 1000000) + (poc_cnt * 200000) + (h_epss_cnt * 100000) + (max_epss * 50000) + (c_cnt * 50000) + (h_cnt * 20000) + (vs_cnt * 5000) + (v_cnt * 1000) + (s_cnt * 100)
|
|
429
|
+
explore_leads.append({
|
|
430
|
+
"id": f"ip_{ip_id}",
|
|
431
|
+
"label": ip,
|
|
432
|
+
"display_name": ip,
|
|
433
|
+
"type": "ip",
|
|
434
|
+
"three_d_score": score,
|
|
435
|
+
**stats
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
subdomain_stats = {}
|
|
439
|
+
for sub_id, sub_info in subdomain_info_map.items():
|
|
440
|
+
s_stats = {"service_count": 0, "verified_service_count": 0, "vuln_count": 0, "has_kev": False, "kev_count": 0, "critical_count": 0, "high_count": 0, "max_epss": 0.0, "high_epss_count": 0, "poc_count": 0, "service_ids": set(), "vuln_ids": set()}
|
|
441
|
+
ips = subdomain_to_ips.get(sub_id, set())
|
|
442
|
+
for ip_id in ips:
|
|
443
|
+
if ip_id in ip_stats:
|
|
444
|
+
st = ip_stats[ip_id]
|
|
445
|
+
for k in ["service_count", "verified_service_count", "vuln_count", "kev_count", "critical_count", "high_count", "high_epss_count", "poc_count"]:
|
|
446
|
+
s_stats[k] += st[k]
|
|
447
|
+
s_stats["has_kev"] = s_stats["has_kev"] or st["has_kev"]
|
|
448
|
+
s_stats["max_epss"] = max(s_stats["max_epss"], st["max_epss"])
|
|
449
|
+
s_stats["service_ids"].update(st.get("service_ids", []))
|
|
450
|
+
s_stats["vuln_ids"].update(st.get("vuln_ids", []))
|
|
451
|
+
|
|
452
|
+
s_stats_final = {**s_stats, "service_ids": list(s_stats["service_ids"]), "vuln_ids": list(s_stats["vuln_ids"])}
|
|
453
|
+
subdomain_stats[sub_id] = s_stats_final
|
|
454
|
+
score = (s_stats["kev_count"] * 1000000) + (s_stats["poc_count"] * 200000) + (s_stats["high_epss_count"] * 100000) + (s_stats["max_epss"] * 50000) + (s_stats["critical_count"] * 50000) + (s_stats["high_count"] * 20000) + (s_stats["verified_service_count"] * 5000) + (s_stats["vuln_count"] * 1000) + (s_stats["service_count"] * 100)
|
|
455
|
+
explore_leads.append({
|
|
456
|
+
"id": f"sub_{sub_id}",
|
|
457
|
+
"label": sub_info["name"],
|
|
458
|
+
"display_name": sub_info["name"],
|
|
459
|
+
"type": "subdomain",
|
|
460
|
+
"three_d_score": score,
|
|
461
|
+
**s_stats_final
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
for domain_id, domain_name in domains_list:
|
|
465
|
+
d_stats = {"service_count": 0, "verified_service_count": 0, "vuln_count": 0, "has_kev": False, "kev_count": 0, "critical_count": 0, "high_count": 0, "max_epss": 0.0, "high_epss_count": 0, "poc_count": 0, "service_ids": set(), "vuln_ids": set()}
|
|
466
|
+
for ip_id in domain_to_ips.get(domain_id, set()):
|
|
467
|
+
if ip_id in ip_stats:
|
|
468
|
+
st = ip_stats[ip_id]
|
|
469
|
+
for k in ["service_count", "verified_service_count", "vuln_count", "kev_count", "critical_count", "high_count", "high_epss_count", "poc_count"]:
|
|
470
|
+
d_stats[k] += st[k]
|
|
471
|
+
d_stats["has_kev"] = d_stats["has_kev"] or st["has_kev"]
|
|
472
|
+
d_stats["max_epss"] = max(d_stats["max_epss"], st["max_epss"])
|
|
473
|
+
d_stats["service_ids"].update(st.get("service_ids", []))
|
|
474
|
+
d_stats["vuln_ids"].update(st.get("vuln_ids", []))
|
|
475
|
+
for sub_id, sub_info in subdomain_info_map.items():
|
|
476
|
+
if sub_info["domain_id"] == domain_id:
|
|
477
|
+
st = subdomain_stats.get(sub_id, {})
|
|
478
|
+
if st:
|
|
479
|
+
for k in ["service_count", "verified_service_count", "vuln_count", "kev_count", "critical_count", "high_count", "high_epss_count", "poc_count"]:
|
|
480
|
+
d_stats[k] += st.get(k, 0)
|
|
481
|
+
d_stats["has_kev"] = d_stats["has_kev"] or st.get("has_kev", False)
|
|
482
|
+
d_stats["max_epss"] = max(d_stats["max_epss"], st.get("max_epss", 0.0))
|
|
483
|
+
d_stats["service_ids"].update(st.get("service_ids", []))
|
|
484
|
+
d_stats["vuln_ids"].update(st.get("vuln_ids", []))
|
|
485
|
+
|
|
486
|
+
d_stats_final = {**d_stats, "service_ids": list(d_stats["service_ids"]), "vuln_ids": list(d_stats["vuln_ids"])}
|
|
487
|
+
score = (d_stats["kev_count"] * 1000000) + (d_stats["poc_count"] * 200000) + (d_stats["high_epss_count"] * 100000) + (d_stats["max_epss"] * 50000) + (d_stats["critical_count"] * 50000) + (d_stats["high_count"] * 20000) + (d_stats["verified_service_count"] * 5000) + (d_stats["vuln_count"] * 1000) + (d_stats["service_count"] * 100)
|
|
488
|
+
explore_leads.append({
|
|
489
|
+
"id": f"dom_{domain_id}",
|
|
490
|
+
"label": domain_name,
|
|
491
|
+
"display_name": domain_name,
|
|
492
|
+
"type": "domain",
|
|
493
|
+
"three_d_score": score,
|
|
494
|
+
**d_stats_final
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
root_target_node["data"]["explore_leads"] = explore_leads
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
return nodes, edges, root_target_node, subdomain_to_ips, domain_to_ips, explicit_targets
|
|
501
|
+
|
|
502
|
+
def _build_ip_nodes(
|
|
503
|
+
self,
|
|
504
|
+
conn: sqlite3.Connection,
|
|
505
|
+
subdomain_to_ips: Dict[str, set],
|
|
506
|
+
domain_to_ips: Dict[str, set],
|
|
507
|
+
spawned_fqdn_ids: Optional[Set[str]] = None,
|
|
508
|
+
target_name: Optional[str] = None,
|
|
509
|
+
target_type: Optional[str] = None,
|
|
510
|
+
targets_list: Optional[List[str]] = None,
|
|
511
|
+
explicit_targets: Optional[Set[str]] = None,
|
|
512
|
+
root_target_node: Optional[Dict] = None
|
|
513
|
+
) -> tuple[List[Dict], List[Dict]]:
|
|
514
|
+
nodes = []
|
|
515
|
+
edges = []
|
|
516
|
+
cols = {r[1] for r in conn.execute("PRAGMA table_info(ip_addresses)").fetchall()}
|
|
517
|
+
select_post = ", postal_code" if "postal_code" in cols else ", '' as postal_code"
|
|
518
|
+
select_geo = ", latitude, longitude" if ("latitude" in cols and "longitude" in cols) else ", NULL as latitude, NULL as longitude"
|
|
519
|
+
|
|
520
|
+
cursor = conn.execute(f"""
|
|
521
|
+
SELECT id, ip, org, country, city, region_code, asn {select_post} {select_geo}
|
|
522
|
+
FROM ip_addresses
|
|
523
|
+
""")
|
|
524
|
+
ip_rows = cursor.fetchall()
|
|
525
|
+
|
|
526
|
+
cursor_stats = conn.execute("""
|
|
527
|
+
SELECT ip.id,
|
|
528
|
+
GROUP_CONCAT(DISTINCT s.id) as srv_ids,
|
|
529
|
+
GROUP_CONCAT(DISTINCT (CASE WHEN v.cve_id IS NOT NULL AND TRIM(v.cve_id) != '' AND UPPER(v.cve_id) != 'UNKNOWN' THEN LOWER(REPLACE(v.cve_id, ' ', '_')) ELSE v.id END)) as vuln_ids
|
|
530
|
+
FROM ip_addresses ip
|
|
531
|
+
LEFT JOIN services s ON ip.id = s.ip_id
|
|
532
|
+
LEFT JOIN vulnerabilities v ON ip.id = v.ip_id OR s.id = v.service_id
|
|
533
|
+
GROUP BY ip.id
|
|
534
|
+
""")
|
|
535
|
+
ip_extra_stats = {}
|
|
536
|
+
for row in cursor_stats.fetchall():
|
|
537
|
+
ip_id_stat, srv_ids_str, vuln_ids_str = row
|
|
538
|
+
ip_extra_stats[ip_id_stat] = {
|
|
539
|
+
"service_ids": [f"srv_{x}" for x in str(srv_ids_str).split(",")] if srv_ids_str else [],
|
|
540
|
+
"vuln_ids": [f"vuln_{x}" for x in str(vuln_ids_str).split(",")] if vuln_ids_str else []
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
cursor_fqdns = conn.execute("""
|
|
544
|
+
SELECT DISTINCT si.ip_id, s.name
|
|
545
|
+
FROM subdomain_ips si
|
|
546
|
+
JOIN subdomains s ON si.subdomain_id = s.id
|
|
547
|
+
ORDER BY LENGTH(s.name) ASC, s.name ASC
|
|
548
|
+
""")
|
|
549
|
+
ip_to_fqdns: Dict[str, List[str]] = {}
|
|
550
|
+
for ip_id, fqdn in cursor_fqdns.fetchall():
|
|
551
|
+
if fqdn:
|
|
552
|
+
ip_to_fqdns.setdefault(str(ip_id), []).append(fqdn)
|
|
553
|
+
|
|
554
|
+
fqdn_set = spawned_fqdn_ids or set()
|
|
555
|
+
ips_resolved_by_visible_fqdns = set()
|
|
556
|
+
targets_set = set(t.strip().lower() for t in targets_list) if targets_list else set()
|
|
557
|
+
if explicit_targets:
|
|
558
|
+
targets_set.update(explicit_targets)
|
|
559
|
+
|
|
560
|
+
# Calculate which IPs already receive RESOLVES_TO from a visible FQDN node
|
|
561
|
+
for sub_id, ip_ids in subdomain_to_ips.items():
|
|
562
|
+
if f"sub_{sub_id}" in fqdn_set:
|
|
563
|
+
for ip_id in ip_ids:
|
|
564
|
+
ips_resolved_by_visible_fqdns.add(str(ip_id))
|
|
565
|
+
|
|
566
|
+
for dom_id, ip_ids in domain_to_ips.items():
|
|
567
|
+
if f"dom_{dom_id}" in fqdn_set:
|
|
568
|
+
for ip_id in ip_ids:
|
|
569
|
+
ips_resolved_by_visible_fqdns.add(str(ip_id))
|
|
570
|
+
|
|
571
|
+
# Check if scan mode is Threat Intel Query / Shodan Discovery Mode
|
|
572
|
+
is_query_discovery = (target_type in ("query", "shodan", "asn", "org", "network"))
|
|
573
|
+
|
|
574
|
+
for ip_id, ip, org, country, city, region_code, asn, postal_code, latitude, longitude in ip_rows:
|
|
575
|
+
fqdns = ip_to_fqdns.get(str(ip_id), [])
|
|
576
|
+
is_explicit_ip_tgt = bool((ip and ip.strip().lower() in targets_set) or str(ip_id) in targets_set)
|
|
577
|
+
extra = ip_extra_stats.get(ip_id, {"service_ids": [], "vuln_ids": []})
|
|
578
|
+
node_dict = {
|
|
579
|
+
"data": {
|
|
580
|
+
"id": f"ip_{ip_id}",
|
|
581
|
+
"label": ip,
|
|
582
|
+
"type": "ip",
|
|
583
|
+
"ip": ip,
|
|
584
|
+
"org": org or "Unknown",
|
|
585
|
+
"country": country or "Unknown",
|
|
586
|
+
"city": city or "",
|
|
587
|
+
"region_code": region_code or "",
|
|
588
|
+
"postal_code": postal_code or "",
|
|
589
|
+
"latitude": latitude,
|
|
590
|
+
"longitude": longitude,
|
|
591
|
+
"asn": asn or "Unknown",
|
|
592
|
+
"fqdns": fqdns,
|
|
593
|
+
"fqdn_count": len(fqdns),
|
|
594
|
+
"service_ids": extra["service_ids"],
|
|
595
|
+
"vuln_ids": extra["vuln_ids"],
|
|
596
|
+
"is_target": is_explicit_ip_tgt
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
if is_explicit_ip_tgt:
|
|
600
|
+
node_dict["classes"] = "is-target"
|
|
601
|
+
nodes.append(node_dict)
|
|
602
|
+
|
|
603
|
+
# Connect Target Root -> Host IP via CONTAINS_TARGET:
|
|
604
|
+
# 1. IP is explicitly listed as a direct target OR Query Discovery Mode.
|
|
605
|
+
# 2. Strict Lineage Rule: NEVER connect to root if it has a visible FQDN parent (Domain/Subdomain).
|
|
606
|
+
has_visible_fqdn_parent = str(ip_id) in ips_resolved_by_visible_fqdns
|
|
607
|
+
# Always connect to target_root if the IP has no FQDN parent, so it doesn't float!
|
|
608
|
+
should_connect_root_to_ip = (is_explicit_ip_tgt or is_query_discovery) and not has_visible_fqdn_parent
|
|
609
|
+
|
|
610
|
+
if root_target_node and should_connect_root_to_ip:
|
|
611
|
+
edges.append({
|
|
612
|
+
"data": {
|
|
613
|
+
"id": f"e_root_ip_{ip_id}",
|
|
614
|
+
"source": "target_root",
|
|
615
|
+
"target": f"ip_{ip_id}",
|
|
616
|
+
"label": "CONTAINS_TARGET"
|
|
617
|
+
}
|
|
618
|
+
})
|
|
619
|
+
|
|
620
|
+
# Connect FQDN targets -> IPs (RESOLVES_TO) ONLY if the node exists in graph.
|
|
621
|
+
# If the FQDN is an explicit target, we show ALL its resolved IPs.
|
|
622
|
+
# If the FQDN was merely spawned to support an explicitly targeted IP, we ONLY connect to the targeted IPs!
|
|
623
|
+
cursor_all_ips = conn.execute("SELECT id, ip FROM ip_addresses")
|
|
624
|
+
ip_id_to_str = {str(r[0]): r[1].lower() for r in cursor_all_ips.fetchall()}
|
|
625
|
+
|
|
626
|
+
# Re-verify if fqdns are explicit targets
|
|
627
|
+
cursor_fqdns_check = conn.execute("SELECT id, name FROM domains UNION SELECT id, name FROM subdomains")
|
|
628
|
+
fqdn_id_to_name = {f"dom_{r[0]}": r[1].lower() for r in cursor_fqdns_check.fetchall()}
|
|
629
|
+
cursor_fqdns_check = conn.execute("SELECT id, name FROM subdomains")
|
|
630
|
+
for r in cursor_fqdns_check.fetchall():
|
|
631
|
+
fqdn_id_to_name[f"sub_{r[0]}"] = r[1].lower()
|
|
632
|
+
|
|
633
|
+
for sub_id, ip_ids in subdomain_to_ips.items():
|
|
634
|
+
sub_node_id = f"sub_{sub_id}"
|
|
635
|
+
if sub_node_id in fqdn_set:
|
|
636
|
+
is_fqdn_targeted = fqdn_id_to_name.get(sub_node_id) in explicit_targets
|
|
637
|
+
for ip_id in ip_ids:
|
|
638
|
+
ip_str = ip_id_to_str.get(str(ip_id), "")
|
|
639
|
+
if True:
|
|
640
|
+
edges.append({"data": {"id": f"e_sub_ip_{sub_id}_{ip_id}", "source": sub_node_id, "target": f"ip_{ip_id}", "label": "RESOLVES_TO"}})
|
|
641
|
+
|
|
642
|
+
for dom_id, ip_ids in domain_to_ips.items():
|
|
643
|
+
dom_node_id = f"dom_{dom_id}"
|
|
644
|
+
if dom_node_id in fqdn_set:
|
|
645
|
+
is_fqdn_targeted = fqdn_id_to_name.get(dom_node_id) in explicit_targets
|
|
646
|
+
for ip_id in ip_ids:
|
|
647
|
+
ip_str = ip_id_to_str.get(str(ip_id), "")
|
|
648
|
+
if True:
|
|
649
|
+
edges.append({"data": {"id": f"e_dom_ip_{dom_id}_{ip_id}", "source": dom_node_id, "target": f"ip_{ip_id}", "label": "RESOLVES_TO"}})
|
|
650
|
+
|
|
651
|
+
# Embed all discovered Host IPs inside root_target_node for instant inspector access
|
|
652
|
+
if root_target_node:
|
|
653
|
+
all_ips_summary = [
|
|
654
|
+
{
|
|
655
|
+
"id": n["data"]["id"],
|
|
656
|
+
"ip": n["data"]["ip"],
|
|
657
|
+
"org": n["data"]["org"],
|
|
658
|
+
"country": n["data"]["country"],
|
|
659
|
+
"city": n["data"]["city"],
|
|
660
|
+
"asn": n["data"]["asn"],
|
|
661
|
+
"fqdns": n["data"]["fqdns"],
|
|
662
|
+
"fqdn_count": n["data"]["fqdn_count"]
|
|
663
|
+
}
|
|
664
|
+
for n in nodes
|
|
665
|
+
]
|
|
666
|
+
root_target_node["data"]["all_ips"] = all_ips_summary
|
|
667
|
+
root_target_node["data"]["total_ips"] = len(all_ips_summary)
|
|
668
|
+
|
|
669
|
+
return nodes, edges
|
|
670
|
+
|
|
671
|
+
def _build_service_nodes(self, conn: sqlite3.Connection) -> tuple[List[Dict], List[Dict]]:
|
|
672
|
+
"""Build service/port nodes with active scan differentiation."""
|
|
673
|
+
import json
|
|
674
|
+
nodes = []
|
|
675
|
+
edges = []
|
|
676
|
+
|
|
677
|
+
cursor = conn.execute("""
|
|
678
|
+
SELECT id, ip_id, port, protocol, service_name, product, version, banner, ssl, url, sources
|
|
679
|
+
FROM services
|
|
680
|
+
ORDER BY rowid ASC
|
|
681
|
+
""")
|
|
682
|
+
|
|
683
|
+
seen_services = {}
|
|
684
|
+
|
|
685
|
+
for service_id, ip_id, port, protocol, service_name, product, version, banner, ssl, url, sources_raw in cursor.fetchall():
|
|
686
|
+
proto_str = (protocol or "tcp").lower()
|
|
687
|
+
key = (ip_id, port, proto_str)
|
|
688
|
+
|
|
689
|
+
# Parse sources
|
|
690
|
+
sources_list = []
|
|
691
|
+
if sources_raw:
|
|
692
|
+
try:
|
|
693
|
+
parsed = json.loads(sources_raw)
|
|
694
|
+
if isinstance(parsed, list):
|
|
695
|
+
sources_list = parsed
|
|
696
|
+
else:
|
|
697
|
+
sources_list = [str(parsed)]
|
|
698
|
+
except Exception:
|
|
699
|
+
sources_list = [sources_raw]
|
|
700
|
+
|
|
701
|
+
has_masscan = any("masscan" in str(s).lower() for s in sources_list)
|
|
702
|
+
|
|
703
|
+
if key in seen_services:
|
|
704
|
+
# Merge into existing node data
|
|
705
|
+
existing_node = seen_services[key]
|
|
706
|
+
cur_sources = set(existing_node["data"]["sources"])
|
|
707
|
+
cur_sources.update(sources_list)
|
|
708
|
+
existing_node["data"]["sources"] = sorted(list(cur_sources))
|
|
709
|
+
if has_masscan:
|
|
710
|
+
existing_node["data"]["is_active_scan"] = True
|
|
711
|
+
existing_node["data"]["verified_active"] = True
|
|
712
|
+
if banner and not existing_node["data"]["banner"]:
|
|
713
|
+
existing_node["data"]["banner"] = banner
|
|
714
|
+
if service_name and (not existing_node["data"]["service"] or existing_node["data"]["service"] == "unknown"):
|
|
715
|
+
existing_node["data"]["service"] = service_name
|
|
716
|
+
continue
|
|
717
|
+
|
|
718
|
+
label = f"{port}/{proto_str}"
|
|
719
|
+
service_type = "https" if ssl else "http" if port in [80, 8080, 8000] else "service"
|
|
720
|
+
|
|
721
|
+
node_obj = {
|
|
722
|
+
"data": {
|
|
723
|
+
"id": f"srv_{service_id}",
|
|
724
|
+
"label": label,
|
|
725
|
+
"type": service_type,
|
|
726
|
+
"port": port,
|
|
727
|
+
"protocol": protocol,
|
|
728
|
+
"service": service_name or "unknown",
|
|
729
|
+
"product": product or "",
|
|
730
|
+
"version": version or "",
|
|
731
|
+
"banner": banner or "",
|
|
732
|
+
"ssl": bool(ssl),
|
|
733
|
+
"url": url or "",
|
|
734
|
+
"sources": sources_list,
|
|
735
|
+
"is_active_scan": has_masscan,
|
|
736
|
+
"verified_active": has_masscan,
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
seen_services[key] = node_obj
|
|
740
|
+
nodes.append(node_obj)
|
|
741
|
+
|
|
742
|
+
# Edge from IP to service
|
|
743
|
+
edges.append({
|
|
744
|
+
"data": {
|
|
745
|
+
"id": f"e_ip_srv_{ip_id}_{service_id}",
|
|
746
|
+
"source": f"ip_{ip_id}",
|
|
747
|
+
"target": f"srv_{service_id}",
|
|
748
|
+
"label": "EXPOSES",
|
|
749
|
+
"is_active_scan": has_masscan,
|
|
750
|
+
"verified_active": has_masscan
|
|
751
|
+
}
|
|
752
|
+
})
|
|
753
|
+
|
|
754
|
+
return nodes, edges
|
|
755
|
+
|
|
756
|
+
def _build_vulnerability_nodes(self, conn: sqlite3.Connection) -> tuple[List[Dict], List[Dict]]:
|
|
757
|
+
"""Build vulnerability nodes with risk indicators and PoC information.
|
|
758
|
+
|
|
759
|
+
Consolidates vulnerability nodes by CVE ID (or vuln ID) so that identical
|
|
760
|
+
vulnerabilities affecting multiple services or IPs share a single node on the graph
|
|
761
|
+
with incoming HAS_VULN edges from each affected service/IP.
|
|
762
|
+
"""
|
|
763
|
+
nodes = []
|
|
764
|
+
edges = []
|
|
765
|
+
seen_vuln_node_ids = set()
|
|
766
|
+
seen_edge_ids = set()
|
|
767
|
+
|
|
768
|
+
try:
|
|
769
|
+
# Ensure source column exists
|
|
770
|
+
try:
|
|
771
|
+
cols = [r[1] for r in conn.execute("PRAGMA table_info(vulnerabilities)").fetchall()]
|
|
772
|
+
has_source_col = "source" in cols
|
|
773
|
+
except Exception:
|
|
774
|
+
has_source_col = False
|
|
775
|
+
|
|
776
|
+
source_select = "v.source" if has_source_col else "'NVD' as source"
|
|
777
|
+
|
|
778
|
+
cursor = conn.execute(f"""
|
|
779
|
+
SELECT v.id, v.ip_id, v.service_id, v.cve_id, v.severity, v.cvss_score,
|
|
780
|
+
v.epss_score, v.is_cisa_kev, v.description, {source_select},
|
|
781
|
+
COUNT(e.id) as exploit_count,
|
|
782
|
+
ip.id as resolved_ip_id, ip.ip, ip.org, ip.country, ip.asn,
|
|
783
|
+
s.port, s.protocol, s.service_name, s.product, s.version, s.url, s.ssl
|
|
784
|
+
FROM vulnerabilities v
|
|
785
|
+
LEFT JOIN exploits e ON v.id = e.vulnerability_id
|
|
786
|
+
LEFT JOIN services s ON v.service_id = s.id
|
|
787
|
+
LEFT JOIN ip_addresses ip ON COALESCE(v.ip_id, s.ip_id) = ip.id
|
|
788
|
+
GROUP BY v.id, v.ip_id, v.service_id, v.cve_id, v.severity, v.cvss_score,
|
|
789
|
+
v.epss_score, v.is_cisa_kev, v.description, {source_select},
|
|
790
|
+
ip.id, ip.ip, ip.org, ip.country, ip.asn,
|
|
791
|
+
s.port, s.protocol, s.service_name, s.product, s.version, s.url, s.ssl
|
|
792
|
+
""")
|
|
793
|
+
|
|
794
|
+
for row in cursor.fetchall():
|
|
795
|
+
(vuln_id, ip_id, service_id, cve_id, severity, cvss_score, epss_score,
|
|
796
|
+
is_cisa_kev, description, vuln_source, exploit_count, resolved_ip_id, ip_address,
|
|
797
|
+
org, country, asn, port, protocol, service_name, product, version, url, ssl) = row
|
|
798
|
+
|
|
799
|
+
# Build vulnerability label - keep it simple with just CVE ID
|
|
800
|
+
label = cve_id or "Unknown CVE"
|
|
801
|
+
# Use normalized CVE node ID if available to merge identical CVEs into one node
|
|
802
|
+
clean_cve = (cve_id or "").strip()
|
|
803
|
+
node_id = f"vuln_{clean_cve.replace(' ', '_').lower()}" if clean_cve and clean_cve.upper() != "UNKNOWN" else f"vuln_{vuln_id}"
|
|
804
|
+
|
|
805
|
+
# Determine risk level for styling
|
|
806
|
+
risk_level = "low"
|
|
807
|
+
if is_cisa_kev:
|
|
808
|
+
risk_level = "critical"
|
|
809
|
+
elif epss_score and epss_score > 0.5:
|
|
810
|
+
risk_level = "high"
|
|
811
|
+
elif severity in ["CRITICAL", "HIGH"]:
|
|
812
|
+
risk_level = "high" if severity == "HIGH" else "critical"
|
|
813
|
+
elif severity == "MEDIUM":
|
|
814
|
+
risk_level = "medium"
|
|
815
|
+
|
|
816
|
+
# Only construct the node data once per unique CVE
|
|
817
|
+
if node_id not in seen_vuln_node_ids:
|
|
818
|
+
seen_vuln_node_ids.add(node_id)
|
|
819
|
+
|
|
820
|
+
# Get exploit details for this vulnerability
|
|
821
|
+
exploit_cursor = conn.execute("""
|
|
822
|
+
SELECT title, source, url, verified, author, date, exploit_type
|
|
823
|
+
FROM exploits WHERE vulnerability_id = ?
|
|
824
|
+
""", (vuln_id,))
|
|
825
|
+
|
|
826
|
+
exploits = []
|
|
827
|
+
for exploit_row in exploit_cursor.fetchall():
|
|
828
|
+
exploits.append({
|
|
829
|
+
"title": exploit_row[0],
|
|
830
|
+
"source": exploit_row[1],
|
|
831
|
+
"url": exploit_row[2],
|
|
832
|
+
"verified": bool(exploit_row[3]),
|
|
833
|
+
"author": exploit_row[4],
|
|
834
|
+
"date": exploit_row[5],
|
|
835
|
+
"exploit_type": exploit_row[6]
|
|
836
|
+
})
|
|
837
|
+
|
|
838
|
+
nodes.append({
|
|
839
|
+
"data": {
|
|
840
|
+
"id": node_id,
|
|
841
|
+
"label": label,
|
|
842
|
+
"type": "vulnerability",
|
|
843
|
+
"cve_id": cve_id or "Unknown",
|
|
844
|
+
"severity": severity or "UNKNOWN",
|
|
845
|
+
"cvss_score": cvss_score or 0,
|
|
846
|
+
"epss_score": epss_score or 0,
|
|
847
|
+
"is_cisa_kev": bool(is_cisa_kev),
|
|
848
|
+
"risk_level": risk_level,
|
|
849
|
+
"source": vuln_source or ("Nuclei" if not clean_cve.startswith("CVE-") else "NVD"),
|
|
850
|
+
"description": description or "",
|
|
851
|
+
"exploit_count": exploit_count,
|
|
852
|
+
"exploits": exploits,
|
|
853
|
+
"has_pocs": exploit_count > 0,
|
|
854
|
+
"ip": ip_address or "",
|
|
855
|
+
"ip_id": f"ip_{resolved_ip_id}" if resolved_ip_id else (f"ip_{ip_id}" if ip_id else None),
|
|
856
|
+
"org": org or "",
|
|
857
|
+
"country": country or "",
|
|
858
|
+
"asn": asn or "",
|
|
859
|
+
"service_id": f"srv_{service_id}" if service_id else None,
|
|
860
|
+
"port": port,
|
|
861
|
+
"protocol": protocol,
|
|
862
|
+
"service": service_name or "",
|
|
863
|
+
"product": product or "",
|
|
864
|
+
"version": version or "",
|
|
865
|
+
"url": url or "",
|
|
866
|
+
"ssl": bool(ssl) if ssl is not None else False
|
|
867
|
+
}
|
|
868
|
+
})
|
|
869
|
+
|
|
870
|
+
# Create HAS_VULN edge from Service or IP to the deduplicated Vulnerability node
|
|
871
|
+
vuln_sev = (severity or "UNKNOWN").upper()
|
|
872
|
+
if service_id:
|
|
873
|
+
edge_id = f"e_srv_vuln_{service_id}_{node_id}"
|
|
874
|
+
if edge_id not in seen_edge_ids:
|
|
875
|
+
seen_edge_ids.add(edge_id)
|
|
876
|
+
edges.append({
|
|
877
|
+
"data": {
|
|
878
|
+
"id": edge_id,
|
|
879
|
+
"source": f"srv_{service_id}",
|
|
880
|
+
"target": node_id,
|
|
881
|
+
"label": "HAS_VULN",
|
|
882
|
+
"vuln_severity": vuln_sev,
|
|
883
|
+
}
|
|
884
|
+
})
|
|
885
|
+
elif ip_id:
|
|
886
|
+
edge_id = f"e_ip_vuln_{ip_id}_{node_id}"
|
|
887
|
+
if edge_id not in seen_edge_ids:
|
|
888
|
+
seen_edge_ids.add(edge_id)
|
|
889
|
+
edges.append({
|
|
890
|
+
"data": {
|
|
891
|
+
"id": edge_id,
|
|
892
|
+
"source": f"ip_{ip_id}",
|
|
893
|
+
"target": node_id,
|
|
894
|
+
"label": "HAS_VULN",
|
|
895
|
+
"vuln_severity": vuln_sev,
|
|
896
|
+
}
|
|
897
|
+
})
|
|
898
|
+
except Exception as e:
|
|
899
|
+
print(f"Error building vulnerability nodes: {e}")
|
|
900
|
+
|
|
901
|
+
return nodes, edges
|