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.
Files changed (64) hide show
  1. detecti/__init__.py +0 -0
  2. detecti/cli.py +649 -0
  3. detecti/config.py +188 -0
  4. detecti/core/__init__.py +1 -0
  5. detecti/core/database/__init__.py +5 -0
  6. detecti/core/database/config_db.py +73 -0
  7. detecti/core/database/schema.py +136 -0
  8. detecti/core/database/storage.py +1388 -0
  9. detecti/core/engine.py +1032 -0
  10. detecti/core/models.py +278 -0
  11. detecti/data/config.sqlite +0 -0
  12. detecti/data/dbs/.gitkeep +2 -0
  13. detecti/data/dbs/example.com.sqlite +0 -0
  14. detecti/modules/__init__.py +29 -0
  15. detecti/modules/base.py +57 -0
  16. detecti/modules/censys.py +813 -0
  17. detecti/modules/crtsh.py +98 -0
  18. detecti/modules/exploitdb.py +138 -0
  19. detecti/modules/masscan.py +561 -0
  20. detecti/modules/nuclei.py +449 -0
  21. detecti/modules/nvd.py +300 -0
  22. detecti/modules/reverse_whois.py +225 -0
  23. detecti/modules/shodan.py +412 -0
  24. detecti/reporters/__init__.py +7 -0
  25. detecti/reporters/csv_reporter.py +74 -0
  26. detecti/reporters/html_reporter.py +356 -0
  27. detecti/reporters/json_reporter.py +26 -0
  28. detecti/reporters/markdown_reporter.py +203 -0
  29. detecti/utils/__init__.py +1 -0
  30. detecti/utils/http.py +294 -0
  31. detecti/utils/logger.py +378 -0
  32. detecti/utils/setup.py +453 -0
  33. detecti/web/__init__.py +6 -0
  34. detecti/web/api/__init__.py +1 -0
  35. detecti/web/api/auth.py +109 -0
  36. detecti/web/api/graph_builder.py +901 -0
  37. detecti/web/api/routes.py +1602 -0
  38. detecti/web/process_manager.py +283 -0
  39. detecti/web/server.py +183 -0
  40. detecti/web/static/android-chrome-192x192.png +0 -0
  41. detecti/web/static/android-chrome-512x512.png +0 -0
  42. detecti/web/static/apple-touch-icon.png +0 -0
  43. detecti/web/static/css/__init__.py +1 -0
  44. detecti/web/static/css/dashboard.css +3802 -0
  45. detecti/web/static/favicon-16x16.png +0 -0
  46. detecti/web/static/favicon-32x32.png +0 -0
  47. detecti/web/static/favicon.ico +0 -0
  48. detecti/web/static/img/DetecTI_Security_Logo.png +0 -0
  49. detecti/web/static/img/detecti-ico.png +0 -0
  50. detecti/web/static/index.html +677 -0
  51. detecti/web/static/js/__init__.py +1 -0
  52. detecti/web/static/js/api.js +177 -0
  53. detecti/web/static/js/cytoscape-cose-bilkent.js +458 -0
  54. detecti/web/static/js/cytoscape-dagre.js +397 -0
  55. detecti/web/static/js/cytoscape.min.js +31 -0
  56. detecti/web/static/js/dagre.min.js +3809 -0
  57. detecti/web/static/js/graph.js +7439 -0
  58. detecti/web/static/js/lucide.min.js +12 -0
  59. detecti/web/static/login.html +290 -0
  60. detecti/web/static/site.webmanifest +1 -0
  61. detecti_cli-2.0.0.dist-info/METADATA +554 -0
  62. detecti_cli-2.0.0.dist-info/RECORD +64 -0
  63. detecti_cli-2.0.0.dist-info/WHEEL +4 -0
  64. 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