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,1388 @@
1
+ """SQLite storage manager for DetecTI-CLI EASM data persistence."""
2
+
3
+ import json
4
+ import sqlite3
5
+ import uuid
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Any, Dict, List, Optional, Set
9
+
10
+ from detecti.core.models import (
11
+ CISAKEVData,
12
+ EPSSData,
13
+ ExploitData,
14
+ Finding,
15
+ FindingType,
16
+ HostResult,
17
+ PortData,
18
+ ScanResult,
19
+ SeverityLevel,
20
+ VulnerabilityData,
21
+ )
22
+ from .schema import SCHEMA_SQL
23
+
24
+
25
+ def _is_in_scope(hostname: str, target_scopes: Set[str]) -> bool:
26
+ if not hostname or not target_scopes:
27
+ return True
28
+ h = str(hostname).strip().lower()
29
+ if h.startswith("*."):
30
+ h = h[2:]
31
+ if h.startswith("http://"):
32
+ h = h[7:]
33
+ elif h.startswith("https://"):
34
+ h = h[8:]
35
+ if "/" in h:
36
+ h = h.split("/")[0]
37
+ if ":" in h:
38
+ h = h.split(":")[0]
39
+
40
+ for scope in target_scopes:
41
+ s = str(scope).strip().lower()
42
+ if s.startswith("*."):
43
+ s = s[2:]
44
+ if s.startswith("http://"):
45
+ s = s[7:]
46
+ elif s.startswith("https://"):
47
+ s = s[8:]
48
+ if "/" in s:
49
+ s = s.split("/")[0]
50
+ if ":" in s:
51
+ s = s.split(":")[0]
52
+
53
+ if h == s or h.endswith(f".{s}"):
54
+ return True
55
+ return False
56
+
57
+
58
+ class DatabaseManager:
59
+ """Manages SQLite database operations for EASM scan results."""
60
+
61
+ def __init__(self, db_path: Path):
62
+ """Initialize database manager with path to SQLite file."""
63
+ self.db_path = Path(db_path)
64
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
65
+ self._init_database()
66
+
67
+ def _init_database(self) -> None:
68
+ """Initialize database schema if it doesn't exist and run schema migrations."""
69
+ with sqlite3.connect(self.db_path) as conn:
70
+ conn.executescript(SCHEMA_SQL)
71
+
72
+ # Auto-migrate: ensure source column exists in vulnerabilities table
73
+ try:
74
+ cols = [row[1] for row in conn.execute("PRAGMA table_info(vulnerabilities)").fetchall()]
75
+ if "source" not in cols:
76
+ conn.execute("ALTER TABLE vulnerabilities ADD COLUMN source TEXT")
77
+ except Exception:
78
+ pass
79
+
80
+ # Auto-migrate: ensure postal_code, latitude, longitude exist in ip_addresses table
81
+ try:
82
+ ip_cols = [row[1] for row in conn.execute("PRAGMA table_info(ip_addresses)").fetchall()]
83
+ if "postal_code" not in ip_cols:
84
+ conn.execute("ALTER TABLE ip_addresses ADD COLUMN postal_code TEXT")
85
+ if "latitude" not in ip_cols:
86
+ conn.execute("ALTER TABLE ip_addresses ADD COLUMN latitude REAL")
87
+ if "longitude" not in ip_cols:
88
+ conn.execute("ALTER TABLE ip_addresses ADD COLUMN longitude REAL")
89
+ except Exception:
90
+ pass
91
+
92
+ # Auto-clean: deduplicate any existing redundant services per (ip_id, port, protocol)
93
+ self._deduplicate_services(conn)
94
+
95
+ conn.commit()
96
+
97
+ def _deduplicate_services(self, conn: sqlite3.Connection) -> None:
98
+ """Merge and clean up duplicate service entries for the same (ip_id, port, protocol)."""
99
+ try:
100
+ duplicates = conn.execute("""
101
+ SELECT ip_id, port, LOWER(protocol), COUNT(*)
102
+ FROM services
103
+ GROUP BY ip_id, port, LOWER(protocol)
104
+ HAVING COUNT(*) > 1
105
+ """).fetchall()
106
+
107
+ for ip_id, port, proto, cnt in duplicates:
108
+ rows = conn.execute("""
109
+ SELECT id, sources, banner, service_name, product, version, url, ssl
110
+ FROM services
111
+ WHERE ip_id = ? AND port = ? AND LOWER(protocol) = ?
112
+ ORDER BY rowid ASC
113
+ """, (ip_id, port, proto)).fetchall()
114
+
115
+ if not rows:
116
+ continue
117
+
118
+ primary_id = rows[0][0]
119
+ merged_sources = set()
120
+ merged_banner = ""
121
+ merged_name = ""
122
+ merged_prod = ""
123
+ merged_ver = ""
124
+ merged_url = ""
125
+ merged_ssl = 0
126
+ dup_ids = [r[0] for r in rows[1:]]
127
+
128
+ for r in rows:
129
+ cur_id, cur_sources_raw, cur_banner, cur_name, cur_prod, cur_ver, cur_url, cur_ssl = r
130
+ if cur_sources_raw:
131
+ try:
132
+ s_list = json.loads(cur_sources_raw)
133
+ if isinstance(s_list, list):
134
+ merged_sources.update(s_list)
135
+ else:
136
+ merged_sources.add(str(s_list))
137
+ except Exception:
138
+ merged_sources.add(cur_sources_raw)
139
+ if cur_banner and not merged_banner:
140
+ merged_banner = cur_banner
141
+ if cur_name and not merged_name and not str(cur_name).startswith("service-"):
142
+ merged_name = cur_name
143
+ if cur_prod and not merged_prod:
144
+ merged_prod = cur_prod
145
+ if cur_ver and not merged_ver:
146
+ merged_ver = cur_ver
147
+ if cur_url and not merged_url:
148
+ merged_url = cur_url
149
+ if cur_ssl:
150
+ merged_ssl = 1
151
+
152
+ # Re-link vulnerabilities from duplicate service rows to primary_id
153
+ if dup_ids:
154
+ placeholders = ",".join("?" for _ in dup_ids)
155
+ conn.execute(f"UPDATE vulnerabilities SET service_id = ? WHERE service_id IN ({placeholders})", [primary_id] + dup_ids)
156
+ conn.execute(f"DELETE FROM services WHERE id IN ({placeholders})", dup_ids)
157
+
158
+ conn.execute("""
159
+ UPDATE services
160
+ SET sources = ?, banner = ?, service_name = ?, product = ?, version = ?, url = ?, ssl = ?
161
+ WHERE id = ?
162
+ """, (
163
+ json.dumps(sorted(list(merged_sources))) if merged_sources else None,
164
+ merged_banner,
165
+ merged_name or f"service-{port}",
166
+ merged_prod,
167
+ merged_ver,
168
+ merged_url,
169
+ merged_ssl,
170
+ primary_id
171
+ ))
172
+ except Exception:
173
+ pass
174
+
175
+ def _get_or_create_domain(self, conn: sqlite3.Connection, domain_name: str) -> str:
176
+ """Get existing domain ID or create new domain record."""
177
+ cursor = conn.execute("SELECT id FROM domains WHERE name = ?", (domain_name,))
178
+ row = cursor.fetchone()
179
+ if row:
180
+ return row[0]
181
+
182
+ domain_id = str(uuid.uuid4())
183
+ conn.execute(
184
+ "INSERT INTO domains (id, name) VALUES (?, ?)",
185
+ (domain_id, domain_name)
186
+ )
187
+ return domain_id
188
+
189
+ def _get_or_create_ip(self, conn: sqlite3.Connection, host: HostResult) -> str:
190
+ """Get existing IP ID or create new IP record."""
191
+ cursor = conn.execute("SELECT id FROM ip_addresses WHERE ip = ?", (host.ip,))
192
+ row = cursor.fetchone()
193
+ if row:
194
+ # Update existing record with new metadata
195
+ conn.execute("""
196
+ UPDATE ip_addresses
197
+ SET asn = COALESCE(?, asn),
198
+ org = COALESCE(?, org),
199
+ country = COALESCE(?, country),
200
+ city = COALESCE(?, city),
201
+ region_code = COALESCE(?, region_code),
202
+ postal_code = COALESCE(?, postal_code),
203
+ latitude = COALESCE(?, latitude),
204
+ longitude = COALESCE(?, longitude)
205
+ WHERE ip = ?
206
+ """, (host.asn, host.org, host.country_name, host.city, host.region_code, host.postal_code, host.latitude, host.longitude, host.ip))
207
+ return row[0]
208
+
209
+ ip_id = str(uuid.uuid4())
210
+ conn.execute("""
211
+ INSERT INTO ip_addresses (id, ip, asn, org, country, city, region_code, postal_code, latitude, longitude)
212
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
213
+ """, (ip_id, host.ip, host.asn, host.org, host.country_name, host.city, host.region_code, host.postal_code, host.latitude, host.longitude))
214
+ return ip_id
215
+
216
+ def _store_subdomains(
217
+ self,
218
+ conn: sqlite3.Connection,
219
+ findings: List[Finding],
220
+ target_scopes: Optional[Set[str]] = None,
221
+ hosts: Optional[List[HostResult]] = None,
222
+ ) -> Dict[str, str]:
223
+ """Store subdomain findings in database and return subdomain_name -> subdomain_id mapping."""
224
+ subdomain_map = {}
225
+
226
+ # Helper to register any candidate subdomain
227
+ def _register_subdomain_candidate(raw_name: str) -> None:
228
+ if not raw_name:
229
+ return
230
+ cand = raw_name.strip().lower()
231
+
232
+ # Sanitize URL artifacts into pure FQDNs
233
+ if cand.startswith("http://"):
234
+ cand = cand[7:]
235
+ elif cand.startswith("https://"):
236
+ cand = cand[8:]
237
+ if "/" in cand:
238
+ cand = cand.split("/")[0]
239
+ if ":" in cand:
240
+ if cand.startswith("[") and "]" in cand:
241
+ cand = cand.split("]")[0][1:]
242
+ elif cand.count(":") == 1:
243
+ cand = cand.split(":")[0]
244
+
245
+ if cand.startswith("*."):
246
+ cand = cand[2:]
247
+
248
+ if target_scopes and not _is_in_scope(cand, target_scopes):
249
+ return
250
+
251
+ if '.' in cand and ' ' not in cand and not cand.replace('.', '').isdigit():
252
+ try:
253
+ import tldextract
254
+ ext = tldextract.extract(cand)
255
+ domain = ext.registered_domain if ext.registered_domain else '.'.join(cand.split('.')[-2:])
256
+ except Exception:
257
+ parts = cand.split('.')
258
+ domain = '.'.join(parts[-2:]) if len(parts) >= 2 else cand
259
+
260
+ if domain:
261
+ if target_scopes and not _is_in_scope(domain, target_scopes):
262
+ return
263
+
264
+ domain_id = self._get_or_create_domain(conn, domain)
265
+
266
+ cursor = conn.execute(
267
+ "SELECT id FROM subdomains WHERE domain_id = ? AND name = ?",
268
+ (domain_id, cand)
269
+ )
270
+ row = cursor.fetchone()
271
+ if row:
272
+ subdomain_id = row[0]
273
+ else:
274
+ subdomain_id = str(uuid.uuid4())
275
+ conn.execute("""
276
+ INSERT INTO subdomains (id, domain_id, name)
277
+ VALUES (?, ?, ?)
278
+ """, (subdomain_id, domain_id, cand))
279
+ subdomain_map[cand] = subdomain_id
280
+
281
+ # 1. Register subdomains from FindingType.SUBDOMAIN, ASSOCIATED_DOMAIN and targets
282
+ for finding in findings:
283
+ if finding.type in (FindingType.SUBDOMAIN, FindingType.ASSOCIATED_DOMAIN) and finding.value:
284
+ _register_subdomain_candidate(finding.value)
285
+ if finding.target:
286
+ _register_subdomain_candidate(finding.target)
287
+ if finding.type == FindingType.HOST_INFO and finding.host_info:
288
+ for hname in finding.host_info.hostnames:
289
+ _register_subdomain_candidate(hname)
290
+ for dname in finding.host_info.domains:
291
+ _register_subdomain_candidate(dname)
292
+
293
+ # 2. Register subdomains from hosts.hostnames and hosts.domains
294
+ if hosts:
295
+ for host in hosts:
296
+ if host.hostnames:
297
+ for hname in host.hostnames:
298
+ _register_subdomain_candidate(hname)
299
+ if host.domains:
300
+ for dname in host.domains:
301
+ _register_subdomain_candidate(dname)
302
+
303
+ return subdomain_map
304
+
305
+ def _store_services(self, conn: sqlite3.Connection, ip_id: str, host: HostResult) -> Dict[str, str]:
306
+ """Store services for a host and return service_id mapping with strict deduplication."""
307
+ service_ids = {}
308
+ seen_ports = set()
309
+
310
+ for port in host.ports:
311
+ port_key = (port.port, (port.transport or "tcp").lower())
312
+ if port_key in seen_ports:
313
+ continue
314
+ seen_ports.add(port_key)
315
+
316
+ # Check if this service already exists for this IP
317
+ cursor = conn.execute("""
318
+ SELECT id, sources, banner, service_name, product, version, url, ssl
319
+ FROM services
320
+ WHERE ip_id = ? AND port = ? AND LOWER(protocol) = LOWER(?)
321
+ """, (ip_id, port.port, port.transport or "tcp"))
322
+ existing = cursor.fetchone()
323
+
324
+ if existing:
325
+ service_id, cur_sources_raw, cur_banner, cur_name, cur_prod, cur_ver, cur_url, cur_ssl = existing
326
+ merged_sources = set()
327
+ if cur_sources_raw:
328
+ try:
329
+ p_sources = json.loads(cur_sources_raw)
330
+ if isinstance(p_sources, list):
331
+ merged_sources.update(p_sources)
332
+ else:
333
+ merged_sources.add(str(p_sources))
334
+ except Exception:
335
+ merged_sources.add(cur_sources_raw)
336
+ if port.sources:
337
+ merged_sources.update(port.sources)
338
+
339
+ new_banner = port.banner if port.banner else (cur_banner or "")
340
+ new_name = port.service if (port.service and not str(port.service).startswith("service-")) else (cur_name or "")
341
+ new_prod = port.product if port.product else (cur_prod or "")
342
+ new_ver = port.version if port.version else (cur_ver or "")
343
+ new_url = port.url if port.url else (cur_url or "")
344
+ new_ssl = 1 if (port.ssl or cur_ssl) else 0
345
+
346
+ conn.execute("""
347
+ UPDATE services
348
+ SET sources = ?, banner = ?, service_name = ?, product = ?, version = ?, url = ?, ssl = ?
349
+ WHERE id = ?
350
+ """, (json.dumps(sorted(list(merged_sources))), new_banner, new_name, new_prod, new_ver, new_url, new_ssl, service_id))
351
+ else:
352
+ service_id = str(uuid.uuid4())
353
+ sources_json = json.dumps(port.sources) if port.sources else None
354
+ conn.execute("""
355
+ INSERT INTO services (id, ip_id, port, protocol, service_name, product, version, banner, url, ssl, sources)
356
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
357
+ """, (
358
+ service_id, ip_id, port.port, port.transport, port.service,
359
+ port.product, port.version, port.banner, port.url, port.ssl, sources_json
360
+ ))
361
+
362
+ service_ids[f"{port.port}/{port.transport}"] = service_id
363
+ service_ids[f"{port.port}"] = service_id
364
+
365
+ return service_ids
366
+
367
+ def _store_vulnerabilities(self, conn: sqlite3.Connection, ip_id: str, host: HostResult, service_ids: Dict[str, str]) -> None:
368
+ """Store vulnerabilities for a host, linking to services when possible."""
369
+ for vuln in host.vulnerabilities:
370
+ vuln_id = str(uuid.uuid4())
371
+
372
+ # Serialize CISA KEV data if present
373
+ cisa_kev_json = None
374
+ if vuln.cisa_kev:
375
+ cisa_kev_json = json.dumps(vuln.cisa_kev.model_dump())
376
+
377
+ # Get EPSS data
378
+ epss_score = vuln.epss.epss_score if vuln.epss else None
379
+ epss_percentile = vuln.epss.epss_percentile if vuln.epss else None
380
+
381
+ # Try to associate vulnerability with a specific service
382
+ # This creates the HOST -> Service -> Vulnerability relationship
383
+ service_id = None
384
+
385
+ # Look for service associations based on vulnerability metadata
386
+ # This could be enhanced with more sophisticated matching logic
387
+ if hasattr(vuln, 'metadata') and vuln.metadata:
388
+ # Check if vulnerability metadata contains port information
389
+ vuln_port = vuln.metadata.get('port')
390
+ if vuln_port:
391
+ # Find matching service by port
392
+ for port_key, sid in service_ids.items():
393
+ if str(vuln_port) in port_key:
394
+ service_id = sid
395
+ break
396
+
397
+ # If no specific service match, try to associate with common vulnerable services
398
+ if not service_id and service_ids:
399
+ # For web vulnerabilities, associate with HTTP/HTTPS services
400
+ if any(keyword in (vuln.description or "").lower() for keyword in ["web", "http", "ssl", "tls", "apache", "nginx", "iis"]):
401
+ # Find HTTP/HTTPS service
402
+ for port_key, sid in service_ids.items():
403
+ if any(port in port_key for port in ["80/", "443/", "8080/", "8443/"]):
404
+ service_id = sid
405
+ break
406
+
407
+ # For SSH vulnerabilities, associate with SSH service
408
+ elif any(keyword in (vuln.description or "").lower() for keyword in ["ssh", "openssh"]):
409
+ for port_key, sid in service_ids.items():
410
+ if "22/" in port_key:
411
+ service_id = sid
412
+ break
413
+
414
+ # For other cases, associate with the first available service if only one exists
415
+ elif len(service_ids) == 1:
416
+ service_id = list(service_ids.values())[0]
417
+
418
+ # Serialize exploit data if present
419
+ exploits_json = None
420
+ if vuln.exploits:
421
+ exploits_json = json.dumps([exp.model_dump() for exp in vuln.exploits])
422
+
423
+ # Get CVSS score and severity
424
+ cvss_score = getattr(vuln, 'cvss_score', None)
425
+ cvss_version = getattr(vuln, 'cvss_version', None)
426
+ severity = vuln.cvss_severity.value if hasattr(getattr(vuln, 'cvss_severity', None), 'value') else str(getattr(vuln, 'cvss_severity', 'UNKNOWN'))
427
+ cwe_id = getattr(vuln, 'cwe_id', None)
428
+ cwe_name = getattr(vuln, 'cwe_name', None)
429
+ is_cisa_kev = getattr(vuln, 'in_cisa_kev', False)
430
+ source = getattr(vuln, 'source', None) or "Unknown"
431
+
432
+ conn.execute("""
433
+ INSERT INTO vulnerabilities (
434
+ id, service_id, ip_id, cve_id, severity, cvss_score, cvss_version,
435
+ description, cwe_id, cwe_name, epss_score, epss_percentile,
436
+ is_cisa_kev, cisa_kev_data, source
437
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
438
+ """, (
439
+ vuln_id, service_id, ip_id, vuln.cve_id, severity, cvss_score, cvss_version,
440
+ vuln.description, cwe_id, cwe_name, epss_score, epss_percentile,
441
+ is_cisa_kev, cisa_kev_json, source
442
+ ))
443
+
444
+ # Store individual exploits in the exploits table for detailed querying
445
+ if vuln.exploits:
446
+ for exp in vuln.exploits:
447
+ exploit_id = str(uuid.uuid4())
448
+ conn.execute("""
449
+ INSERT INTO exploits (id, vulnerability_id, title, source, url, verified, author, date, exploit_type)
450
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
451
+ """, (
452
+ exploit_id, vuln_id, exp.title, exp.source, exp.url,
453
+ getattr(exp, 'verified', False), getattr(exp, 'author', None),
454
+ getattr(exp, 'date', None), getattr(exp, 'exploit_type', None)
455
+ ))
456
+
457
+ def save_scan_result(self, result: ScanResult) -> None:
458
+ """Save a complete ScanResult into the SQLite database."""
459
+ with sqlite3.connect(self.db_path) as conn:
460
+ # Store scan metadata
461
+ modules_json = json.dumps(result.modules_run)
462
+ conn.execute("""
463
+ INSERT INTO scan_results (
464
+ id, target, target_type, started_at, completed_at, elapsed_seconds,
465
+ modules_run, total_findings, total_hosts
466
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
467
+ """, (
468
+ result.scan_id, result.target, result.target_type,
469
+ result.started_at.isoformat(),
470
+ result.completed_at.isoformat() if result.completed_at else None,
471
+ result.elapsed_seconds, modules_json, len(result.findings), len(result.hosts)
472
+ ))
473
+
474
+ # Determine in-scope target roots
475
+ target_scopes: Set[str] = set()
476
+ clean_target = result.target.strip().lower()
477
+ if clean_target.startswith("http://"):
478
+ clean_target = clean_target[7:]
479
+ elif clean_target.startswith("https://"):
480
+ clean_target = clean_target[8:]
481
+ if "/" in clean_target:
482
+ clean_target = clean_target.split("/")[0]
483
+ if ":" in clean_target:
484
+ clean_target = clean_target.split(":")[0]
485
+
486
+ if result.target_type == "domain":
487
+ try:
488
+ import tldextract
489
+ ext = tldextract.extract(clean_target)
490
+ if ext.registered_domain:
491
+ target_scopes.add(ext.registered_domain.lower())
492
+ except Exception:
493
+ pass
494
+ target_scopes.add(clean_target)
495
+ elif result.target_type == "file":
496
+ from pathlib import Path
497
+ fpath = Path(result.target)
498
+ if fpath.exists() and fpath.is_file():
499
+ for line in fpath.read_text().splitlines():
500
+ l_clean = line.strip().lower()
501
+ if l_clean and not l_clean.startswith("#"):
502
+ if l_clean.startswith("http://"):
503
+ l_clean = l_clean[7:]
504
+ elif l_clean.startswith("https://"):
505
+ l_clean = l_clean[8:]
506
+ if "/" in l_clean:
507
+ l_clean = l_clean.split("/")[0]
508
+ if ":" in l_clean:
509
+ l_clean = l_clean.split(":")[0]
510
+ if "." in l_clean and not l_clean.replace(".", "").isdigit():
511
+ try:
512
+ import tldextract
513
+ ext = tldextract.extract(l_clean)
514
+ if ext.registered_domain:
515
+ target_scopes.add(ext.registered_domain.lower())
516
+ except Exception:
517
+ pass
518
+ target_scopes.add(l_clean)
519
+
520
+ if not target_scopes:
521
+ for f in result.findings:
522
+ if f.type == FindingType.SUBDOMAIN and f.value:
523
+ try:
524
+ import tldextract
525
+ ext = tldextract.extract(f.value)
526
+ if ext.registered_domain:
527
+ target_scopes.add(ext.registered_domain.lower())
528
+ except Exception:
529
+ pass
530
+
531
+ # Store subdomain findings and get mapping
532
+ subdomain_map = self._store_subdomains(conn, result.findings, target_scopes, result.hosts)
533
+
534
+ # Store host data
535
+ ip_map = {} # ip -> ip_id mapping
536
+ for host in result.hosts:
537
+ ip_id = self._get_or_create_ip(conn, host)
538
+ ip_map[host.ip] = ip_id
539
+
540
+ service_ids = self._store_services(conn, ip_id, host)
541
+ self._store_vulnerabilities(conn, ip_id, host, service_ids)
542
+
543
+ # Link all hostnames associated with this host to the IP
544
+ if host.hostnames:
545
+ for hname in host.hostnames:
546
+ hname_clean = hname.strip().lower()
547
+ if hname_clean.startswith("*."):
548
+ hname_clean = hname_clean[2:]
549
+ if hname_clean in subdomain_map:
550
+ conn.execute("""
551
+ INSERT OR IGNORE INTO subdomain_ips (subdomain_id, ip_id)
552
+ VALUES (?, ?)
553
+ """, (subdomain_map[hname_clean], ip_id))
554
+
555
+ # Map specific authoritative DNS resolutions & finding associations (subdomain -> IP)
556
+ for finding in result.findings:
557
+ hip = finding.host_ip or (finding.host_info.ip if finding.host_info else None)
558
+ if hip and hip in ip_map:
559
+ target_ip_id = ip_map[hip]
560
+
561
+ # 1. Authoritative hostnames tied to this specific host finding
562
+ if finding.host_info and finding.host_info.hostnames:
563
+ for hname in finding.host_info.hostnames:
564
+ hname_clean = hname.strip().lower()
565
+ if hname_clean.startswith("*."):
566
+ hname_clean = hname_clean[2:]
567
+ if hname_clean in subdomain_map:
568
+ conn.execute("""
569
+ INSERT OR IGNORE INTO subdomain_ips (subdomain_id, ip_id)
570
+ VALUES (?, ?)
571
+ """, (subdomain_map[hname_clean], target_ip_id))
572
+
573
+ # 2. Subdomain finding with explicit host_ip
574
+ if finding.type == FindingType.SUBDOMAIN and finding.value and finding.host_ip == hip:
575
+ sub_val = finding.value.strip().lower()
576
+ if sub_val.startswith("*."):
577
+ sub_val = sub_val[2:]
578
+ if sub_val in subdomain_map:
579
+ conn.execute("""
580
+ INSERT OR IGNORE INTO subdomain_ips (subdomain_id, ip_id)
581
+ VALUES (?, ?)
582
+ """, (subdomain_map[sub_val], target_ip_id))
583
+
584
+ conn.commit()
585
+
586
+ def store_scan_result(self, result: ScanResult) -> None:
587
+ """Alias for save_scan_result."""
588
+ self.save_scan_result(result)
589
+
590
+ def get_summary_stats(self) -> Dict[str, int]:
591
+ """Get summary statistics for the database."""
592
+ with sqlite3.connect(self.db_path) as conn:
593
+ stats = {}
594
+
595
+ try:
596
+ # Get target from scan results
597
+ cursor = conn.execute("SELECT target FROM scan_results ORDER BY created_at DESC LIMIT 1")
598
+ row = cursor.fetchone()
599
+ if row:
600
+ stats['target'] = row[0]
601
+ except Exception:
602
+ stats['target'] = "Unknown"
603
+
604
+ try:
605
+ # Count domains and subdomains
606
+ cursor = conn.execute("SELECT COUNT(*) FROM domains")
607
+ stats['total_domains'] = cursor.fetchone()[0]
608
+ except Exception:
609
+ stats['total_domains'] = 0
610
+
611
+ try:
612
+ cursor = conn.execute("SELECT COUNT(*) FROM subdomains")
613
+ stats['total_subdomains'] = cursor.fetchone()[0]
614
+ except Exception:
615
+ stats['total_subdomains'] = 0
616
+
617
+ try:
618
+ # Count IPs and services
619
+ cursor = conn.execute("SELECT COUNT(*) FROM ip_addresses")
620
+ stats['total_ips'] = cursor.fetchone()[0]
621
+ except Exception:
622
+ stats['total_ips'] = 0
623
+
624
+ try:
625
+ cursor = conn.execute("SELECT COUNT(*) FROM services")
626
+ stats['open_services'] = cursor.fetchone()[0]
627
+ except Exception:
628
+ stats['open_services'] = 0
629
+
630
+ try:
631
+ # Count verified active services (strictly requiring active verification like Masscan/Nuclei/Active)
632
+ cursor = conn.execute("SELECT sources FROM services")
633
+ verified_count = 0
634
+ for (s_raw,) in cursor.fetchall():
635
+ s_list = []
636
+ if s_raw:
637
+ try:
638
+ s_list = json.loads(s_raw)
639
+ if not isinstance(s_list, list):
640
+ s_list = [str(s_list)]
641
+ except Exception:
642
+ s_list = [s_raw]
643
+ is_active = any(
644
+ isinstance(s, str) and ("masscan" in s.lower() or "active" in s.lower() or "nuclei" in s.lower())
645
+ for s in s_list
646
+ )
647
+ if is_active:
648
+ verified_count += 1
649
+ stats['verified_services'] = verified_count
650
+ except Exception:
651
+ stats['verified_services'] = 0
652
+
653
+ try:
654
+ # Count unique vulnerabilities by CVE ID
655
+ cursor = conn.execute("SELECT COUNT(DISTINCT cve_id) FROM vulnerabilities")
656
+ stats['total_vulnerabilities'] = cursor.fetchone()[0]
657
+ except Exception:
658
+ stats['total_vulnerabilities'] = 0
659
+
660
+ try:
661
+ cursor = conn.execute("SELECT COUNT(DISTINCT cve_id) FROM vulnerabilities WHERE is_cisa_kev = 1")
662
+ stats['cisa_kev_count'] = cursor.fetchone()[0]
663
+ except Exception:
664
+ stats['cisa_kev_count'] = 0
665
+
666
+ try:
667
+ cursor = conn.execute("SELECT COUNT(DISTINCT cve_id) FROM vulnerabilities WHERE epss_score > 0.5")
668
+ stats['high_epss_count'] = cursor.fetchone()[0]
669
+ except Exception:
670
+ stats['high_epss_count'] = 0
671
+
672
+ return stats
673
+
674
+ def reconstruct_scan_result(self) -> Optional[ScanResult]:
675
+ """Reconstruct a complete ScanResult model from the database."""
676
+ with sqlite3.connect(self.db_path) as conn:
677
+ conn.row_factory = sqlite3.Row
678
+
679
+ # Fetch scan metadata
680
+ scan_row = conn.execute("SELECT * FROM scan_results ORDER BY created_at DESC LIMIT 1").fetchone()
681
+ if not scan_row:
682
+ # Check if there are ip_addresses or domains to construct a basic scan
683
+ first_ip = conn.execute("SELECT ip FROM ip_addresses LIMIT 1").fetchone()
684
+ first_domain = conn.execute("SELECT name FROM domains LIMIT 1").fetchone()
685
+ target = first_domain['name'] if first_domain else (first_ip['ip'] if first_ip else "unknown")
686
+ target_type = "domain" if first_domain else "ip"
687
+ scan_id = str(uuid.uuid4())
688
+ started_at = datetime.now(timezone.utc)
689
+ completed_at = started_at
690
+ elapsed_seconds = 0.0
691
+ modules_run = []
692
+ else:
693
+ target = scan_row['target']
694
+ target_type = scan_row['target_type']
695
+ scan_id = scan_row['id']
696
+ try:
697
+ started_at = datetime.fromisoformat(scan_row['started_at'])
698
+ except Exception:
699
+ started_at = datetime.now(timezone.utc)
700
+ try:
701
+ completed_at = datetime.fromisoformat(scan_row['completed_at']) if scan_row['completed_at'] else None
702
+ except Exception:
703
+ completed_at = None
704
+ elapsed_seconds = scan_row['elapsed_seconds'] or 0.0
705
+ try:
706
+ modules_run = json.loads(scan_row['modules_run']) if scan_row['modules_run'] else []
707
+ except Exception:
708
+ modules_run = []
709
+
710
+ # Fetch Hosts
711
+ hosts = []
712
+ ip_rows = conn.execute("SELECT * FROM ip_addresses").fetchall()
713
+ for ip_row in ip_rows:
714
+ ip_id = ip_row['id']
715
+ ip_addr = ip_row['ip']
716
+
717
+ # Hostnames
718
+ hostnames = [r[0] for r in conn.execute("""
719
+ SELECT s.name FROM subdomains s
720
+ JOIN subdomain_ips si ON s.id = si.subdomain_id
721
+ WHERE si.ip_id = ?
722
+ """, (ip_id,)).fetchall()]
723
+
724
+ # Domains
725
+ domains = [r[0] for r in conn.execute("""
726
+ SELECT DISTINCT d.name FROM domains d
727
+ JOIN subdomains s ON d.id = s.domain_id
728
+ JOIN subdomain_ips si ON s.id = si.subdomain_id
729
+ WHERE si.ip_id = ?
730
+ """, (ip_id,)).fetchall()]
731
+
732
+ # Services
733
+ ports = []
734
+ service_rows = conn.execute("SELECT * FROM services WHERE ip_id = ?", (ip_id,)).fetchall()
735
+ for s_row in service_rows:
736
+ sources = []
737
+ if s_row['sources']:
738
+ try:
739
+ sources = json.loads(s_row['sources'])
740
+ except Exception:
741
+ sources = [s_row['sources']]
742
+
743
+ ports.append(PortData(
744
+ port=s_row['port'],
745
+ transport=s_row['protocol'] or 'tcp',
746
+ service=s_row['service_name'],
747
+ product=s_row['product'],
748
+ version=s_row['version'],
749
+ banner=s_row['banner'],
750
+ url=s_row['url'],
751
+ ssl=bool(s_row['ssl']),
752
+ sources=sources
753
+ ))
754
+
755
+ # Vulnerabilities
756
+ vulns = []
757
+ vuln_rows = conn.execute("SELECT * FROM vulnerabilities WHERE ip_id = ?", (ip_id,)).fetchall()
758
+ for v_row in vuln_rows:
759
+ exploits = []
760
+ exp_rows = conn.execute("SELECT * FROM exploits WHERE vulnerability_id = ?", (v_row['id'],)).fetchall()
761
+ for e_row in exp_rows:
762
+ exploits.append(ExploitData(
763
+ title=e_row['title'],
764
+ source=e_row['source'],
765
+ url=e_row['url'],
766
+ verified=bool(e_row['verified']),
767
+ author=e_row['author'],
768
+ date=e_row['date'],
769
+ exploit_type=e_row['exploit_type']
770
+ ))
771
+
772
+ cisa_kev = None
773
+ if v_row['cisa_kev_data']:
774
+ try:
775
+ cisa_kev = CISAKEVData(**json.loads(v_row['cisa_kev_data']))
776
+ except Exception:
777
+ cisa_kev = CISAKEVData(in_cisa_kev=True)
778
+ elif v_row['is_cisa_kev']:
779
+ cisa_kev = CISAKEVData(in_cisa_kev=True)
780
+
781
+ epss = None
782
+ if v_row['epss_score'] is not None:
783
+ epss = EPSSData(
784
+ epss_score=v_row['epss_score'],
785
+ epss_percentile=v_row['epss_percentile'] or 0.0
786
+ )
787
+
788
+ sev_str = v_row['severity'] or "UNKNOWN"
789
+ sev = SeverityLevel(sev_str) if sev_str in SeverityLevel._value2member_map_ else SeverityLevel.UNKNOWN
790
+
791
+ vulns.append(VulnerabilityData(
792
+ cve_id=v_row['cve_id'],
793
+ cvss_score=v_row['cvss_score'],
794
+ cvss_version=v_row['cvss_version'],
795
+ cvss_severity=sev,
796
+ description=v_row['description'],
797
+ cwe_id=v_row['cwe_id'],
798
+ cwe_name=v_row['cwe_name'],
799
+ epss=epss,
800
+ cisa_kev=cisa_kev,
801
+ exploits=exploits
802
+ ))
803
+
804
+ hosts.append(HostResult(
805
+ ip=ip_addr,
806
+ hostnames=hostnames,
807
+ domains=domains,
808
+ org=ip_row['org'],
809
+ asn=ip_row['asn'],
810
+ country_name=ip_row['country'],
811
+ city=ip_row['city'],
812
+ region_code=ip_row['region_code'],
813
+ ports=ports,
814
+ vulnerabilities=vulns
815
+ ))
816
+
817
+ # Findings
818
+ findings = []
819
+ for s_row in conn.execute("SELECT name FROM subdomains").fetchall():
820
+ findings.append(Finding(
821
+ type=FindingType.SUBDOMAIN,
822
+ target=target,
823
+ value=s_row['name'],
824
+ source="recon"
825
+ ))
826
+
827
+ result = ScanResult(
828
+ scan_id=scan_id,
829
+ target=target,
830
+ target_type=target_type,
831
+ started_at=started_at,
832
+ completed_at=completed_at,
833
+ elapsed_seconds=elapsed_seconds,
834
+ modules_run=modules_run,
835
+ hosts=hosts,
836
+ findings=findings
837
+ )
838
+ result.calculate_summary()
839
+ return result
840
+
841
+ def merge_active_scan_services(self, target: str, open_ports: List[Dict[str, Any]]) -> Dict[str, Any]:
842
+ """Merge Masscan active scan results into the database with deduplication.
843
+
844
+ - If target is FQDN/domain/subdomain: locates or resolves associated IP(s) and applies open ports.
845
+ - If service exists on the IP: updates sources (appends 'Masscan' to mark as Confirmed Active) and updates banner.
846
+ - If service is new on the IP: creates new service entry with source 'Masscan' (Confirmed Active).
847
+ """
848
+ import uuid
849
+ import json
850
+ import ipaddress
851
+ import socket
852
+
853
+ added_services = 0
854
+ updated_services = 0
855
+ target = target.strip()
856
+
857
+ with sqlite3.connect(self.db_path) as conn:
858
+ is_ip = False
859
+ try:
860
+ ipaddress.ip_address(target)
861
+ is_ip = True
862
+ except ValueError:
863
+ is_ip = False
864
+
865
+ ip_ids = []
866
+ if is_ip:
867
+ cursor = conn.execute("SELECT id FROM ip_addresses WHERE ip = ?", (target,))
868
+ row = cursor.fetchone()
869
+ if row:
870
+ ip_ids.append(row[0])
871
+ else:
872
+ new_ip_id = str(uuid.uuid4())
873
+ conn.execute("""
874
+ INSERT INTO ip_addresses (id, ip, org, country, asn)
875
+ VALUES (?, ?, ?, ?, ?)
876
+ """, (new_ip_id, target, "Active Target", "Unknown", "Unknown"))
877
+ ip_ids.append(new_ip_id)
878
+ else:
879
+ # 1. Authoritative DNS resolution for FQDN
880
+ resolved_raw_ips = []
881
+ try:
882
+ addr_info = socket.getaddrinfo(target, None, socket.AF_UNSPEC)
883
+ if addr_info:
884
+ resolved_raw_ips = list(dict.fromkeys([ai[4][0] for ai in addr_info if ai and ai[4]]))
885
+ except Exception:
886
+ pass
887
+
888
+ # 2. Ensure subdomain node exists in database
889
+ sub_row = conn.execute("SELECT id, domain_id FROM subdomains WHERE LOWER(name) = LOWER(?)", (target,)).fetchone()
890
+ if sub_row:
891
+ sub_id, dom_id = sub_row[0], sub_row[1]
892
+ else:
893
+ # Find matching parent domain
894
+ dom_id = None
895
+ for d_id, d_name in conn.execute("SELECT id, name FROM domains").fetchall():
896
+ d_clean = d_name.lower().strip()
897
+ if target.lower() == d_clean or target.lower().endswith(f".{d_clean}"):
898
+ dom_id = d_id
899
+ break
900
+ if not dom_id:
901
+ dom_id = str(uuid.uuid4())
902
+ conn.execute("INSERT OR IGNORE INTO domains (id, name) VALUES (?, ?)", (dom_id, target))
903
+ d_fetch = conn.execute("SELECT id FROM domains WHERE LOWER(name) = LOWER(?)", (target,)).fetchone()
904
+ if d_fetch:
905
+ dom_id = d_fetch[0]
906
+
907
+ sub_id = str(uuid.uuid4())
908
+ conn.execute("INSERT OR IGNORE INTO subdomains (id, domain_id, name) VALUES (?, ?, ?)", (sub_id, dom_id, target))
909
+ s_fetch = conn.execute("SELECT id FROM subdomains WHERE LOWER(name) = LOWER(?)", (target,)).fetchone()
910
+ if s_fetch:
911
+ sub_id = s_fetch[0]
912
+
913
+ # 3. For each resolved IP: create IP node if new, and bind RESOLVES_TO via subdomain_ips
914
+ for res_ip in resolved_raw_ips:
915
+ ip_row = conn.execute("SELECT id FROM ip_addresses WHERE ip = ?", (res_ip,)).fetchone()
916
+ if ip_row:
917
+ cur_ip_id = ip_row[0]
918
+ else:
919
+ cur_ip_id = str(uuid.uuid4())
920
+ conn.execute("""
921
+ INSERT INTO ip_addresses (id, ip, org, country, asn)
922
+ VALUES (?, ?, ?, ?, ?)
923
+ """, (cur_ip_id, res_ip, "Active Target", "Unknown", "Unknown"))
924
+
925
+ if cur_ip_id not in ip_ids:
926
+ ip_ids.append(cur_ip_id)
927
+
928
+ # Ensure direct link between FQDN and IP
929
+ conn.execute("""
930
+ INSERT OR IGNORE INTO subdomain_ips (subdomain_id, ip_id)
931
+ VALUES (?, ?)
932
+ """, (sub_id, cur_ip_id))
933
+
934
+ # 4. If DNS resolution was offline/empty, fallback to any existing database links
935
+ if not ip_ids:
936
+ sub_cursor = conn.execute("""
937
+ SELECT ip_addresses.id FROM ip_addresses
938
+ JOIN subdomain_ips ON subdomain_ips.ip_id = ip_addresses.id
939
+ JOIN subdomains ON subdomains.id = subdomain_ips.subdomain_id
940
+ WHERE LOWER(subdomains.name) = LOWER(?)
941
+ """, (target,))
942
+ for r in sub_cursor.fetchall():
943
+ ip_ids.append(r[0])
944
+
945
+ # 4. Iterate through discovered ports for each associated IP
946
+ for ip_id in set(ip_ids):
947
+ for p in open_ports:
948
+ port_num = int(p.get("port", 0))
949
+ if port_num <= 0:
950
+ continue
951
+ proto = (p.get("protocol") or "tcp").lower()
952
+ service_name = p.get("service_name") or f"service-{port_num}"
953
+ product = p.get("product") or ""
954
+ version = p.get("version") or ""
955
+ banner = p.get("banner") or ""
956
+ ssl_flag = bool(p.get("ssl", False) or port_num == 443)
957
+
958
+ # Check if this service already exists for this IP
959
+ s_cursor = conn.execute("""
960
+ SELECT id, sources, banner, service_name, product, version, ssl
961
+ FROM services
962
+ WHERE ip_id = ? AND port = ? AND (LOWER(protocol) = LOWER(?) OR protocol IS NULL OR protocol = '')
963
+ """, (ip_id, port_num, proto))
964
+ existing_svc = s_cursor.fetchone()
965
+
966
+ if not existing_svc:
967
+ s_cursor = conn.execute("""
968
+ SELECT id, sources, banner, service_name, product, version, ssl
969
+ FROM services
970
+ WHERE ip_id = ? AND port = ?
971
+ """, (ip_id, port_num))
972
+ existing_svc = s_cursor.fetchone()
973
+
974
+ if existing_svc:
975
+ svc_id, cur_sources_raw, cur_banner, cur_name, cur_prod, cur_ver, cur_ssl = existing_svc
976
+ sources_list = []
977
+ if cur_sources_raw:
978
+ try:
979
+ sources_list = json.loads(cur_sources_raw)
980
+ if not isinstance(sources_list, list):
981
+ sources_list = [str(sources_list)]
982
+ except Exception:
983
+ sources_list = [cur_sources_raw]
984
+
985
+ if "Masscan" not in sources_list:
986
+ sources_list.append("Masscan")
987
+
988
+ # Update banner if active scan discovered a banner (override if new banner found)
989
+ new_banner = banner if banner else (cur_banner or "")
990
+ new_name = cur_name if (cur_name and not cur_name.startswith("service-")) else service_name
991
+ new_prod = product if product else (cur_prod or "")
992
+ new_ver = version if version else (cur_ver or "")
993
+ new_ssl = cur_ssl or (1 if ssl_flag else 0)
994
+
995
+ conn.execute("""
996
+ UPDATE services
997
+ SET sources = ?, banner = ?, service_name = ?, product = ?, version = ?, ssl = ?
998
+ WHERE id = ?
999
+ """, (json.dumps(sources_list), new_banner, new_name, new_prod, new_ver, new_ssl, svc_id))
1000
+ updated_services += 1
1001
+ else:
1002
+ # Insert new service
1003
+ new_svc_id = str(uuid.uuid4())
1004
+ sources_json = json.dumps(["Masscan"])
1005
+ conn.execute("""
1006
+ INSERT INTO services (id, ip_id, port, protocol, service_name, product, version, banner, ssl, sources)
1007
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1008
+ """, (new_svc_id, ip_id, port_num, proto, service_name, product, version, banner, 1 if ssl_flag else 0, sources_json))
1009
+ added_services += 1
1010
+
1011
+ # 3. Update scan_results metadata if present
1012
+ try:
1013
+ scan_row = conn.execute("SELECT id, modules_run FROM scan_results ORDER BY started_at DESC LIMIT 1").fetchone()
1014
+ if scan_row:
1015
+ scan_id, cur_modules_raw = scan_row
1016
+ modules_list = []
1017
+ if cur_modules_raw:
1018
+ try:
1019
+ modules_list = json.loads(cur_modules_raw)
1020
+ if not isinstance(modules_list, list):
1021
+ modules_list = [str(modules_list)]
1022
+ except Exception:
1023
+ modules_list = [cur_modules_raw]
1024
+
1025
+ if "masscan" not in modules_list and "Masscan" not in modules_list:
1026
+ modules_list.append("masscan")
1027
+
1028
+ # Count total services as findings
1029
+ total_svc = conn.execute("SELECT COUNT(*) FROM services").fetchone()[0]
1030
+ total_hosts = conn.execute("SELECT COUNT(*) FROM ip_addresses").fetchone()[0]
1031
+
1032
+ conn.execute("""
1033
+ UPDATE scan_results
1034
+ SET modules_run = ?, total_findings = ?, total_hosts = ?, completed_at = ?
1035
+ WHERE id = ?
1036
+ """, (
1037
+ json.dumps(modules_list),
1038
+ total_svc,
1039
+ total_hosts,
1040
+ datetime.now(timezone.utc).isoformat(),
1041
+ scan_id
1042
+ ))
1043
+ except Exception as e:
1044
+ pass # Non-fatal if scan_results doesn't exist yet
1045
+
1046
+ conn.commit()
1047
+
1048
+ return {
1049
+ "target": target,
1050
+ "ip": target,
1051
+ "added_services": added_services,
1052
+ "updated_services": updated_services,
1053
+ "total_open": len(open_ports),
1054
+ }
1055
+
1056
+ def unverify_services(
1057
+ self,
1058
+ service_ids: Optional[List[str]] = None,
1059
+ ip_addresses: Optional[List[str]] = None,
1060
+ all_services: bool = False
1061
+ ) -> Dict[str, Any]:
1062
+ """Remove Masscan / active verification status from specified services or IPs.
1063
+
1064
+ Preserves service metadata, ports, banners, and passive sources so they can be re-validated.
1065
+ """
1066
+ import json
1067
+ unverified_count = 0
1068
+ affected_ids = []
1069
+
1070
+ with sqlite3.connect(self.db_path) as conn:
1071
+ query = "SELECT id, sources FROM services WHERE 1=1"
1072
+ params = []
1073
+
1074
+ if not all_services:
1075
+ conditions = []
1076
+ if service_ids:
1077
+ id_candidates = set()
1078
+ for s in service_ids:
1079
+ if s:
1080
+ s_str = str(s).strip()
1081
+ id_candidates.add(s_str)
1082
+ if s_str.startswith("srv_"):
1083
+ id_candidates.add(s_str[4:])
1084
+ else:
1085
+ id_candidates.add(f"srv_{s_str}")
1086
+ if id_candidates:
1087
+ placeholders = ",".join("?" for _ in id_candidates)
1088
+ conditions.append(f"id IN ({placeholders})")
1089
+ params.extend(list(id_candidates))
1090
+
1091
+ if ip_addresses:
1092
+ ip_candidates = set()
1093
+ for ip in ip_addresses:
1094
+ if ip:
1095
+ ip_str = str(ip).strip()
1096
+ ip_candidates.add(ip_str)
1097
+ if ip_str.startswith("ip_"):
1098
+ ip_candidates.add(ip_str[3:])
1099
+ else:
1100
+ ip_candidates.add(f"ip_{ip_str}")
1101
+ if ip_candidates:
1102
+ ip_placeholders = ",".join("?" for _ in ip_candidates)
1103
+ conditions.append(f"ip_id IN (SELECT id FROM ip_addresses WHERE ip IN ({ip_placeholders}) OR id IN ({ip_placeholders}))")
1104
+ params.extend(list(ip_candidates))
1105
+ params.extend(list(ip_candidates))
1106
+
1107
+ if conditions:
1108
+ query += f" AND ({' OR '.join(conditions)})"
1109
+ else:
1110
+ return {"success": True, "unverified_count": 0, "affected_service_ids": []}
1111
+
1112
+ cursor = conn.execute(query, params)
1113
+ rows = cursor.fetchall()
1114
+
1115
+ for svc_id, cur_sources_raw in rows:
1116
+ sources_list = []
1117
+ if cur_sources_raw:
1118
+ try:
1119
+ parsed = json.loads(cur_sources_raw)
1120
+ if isinstance(parsed, list):
1121
+ sources_list = parsed
1122
+ else:
1123
+ sources_list = [str(parsed)]
1124
+ except Exception:
1125
+ sources_list = [cur_sources_raw]
1126
+
1127
+ had_masscan = any("masscan" in str(s).lower() or "active" in str(s).lower() for s in sources_list)
1128
+ if had_masscan or not sources_list:
1129
+ new_sources = [s for s in sources_list if "masscan" not in str(s).lower() and "active" not in str(s).lower()]
1130
+ if not new_sources:
1131
+ new_sources = ["Passive"]
1132
+
1133
+ conn.execute("UPDATE services SET sources = ? WHERE id = ?", (json.dumps(new_sources), svc_id))
1134
+ unverified_count += 1
1135
+ affected_ids.append(f"srv_{svc_id}")
1136
+
1137
+ conn.commit()
1138
+
1139
+ return {
1140
+ "success": True,
1141
+ "unverified_count": unverified_count,
1142
+ "affected_service_ids": affected_ids
1143
+ }
1144
+
1145
+ def merge_nuclei_findings(
1146
+ self,
1147
+ findings: List[Dict[str, Any]],
1148
+ fallback_ip: Optional[str] = None
1149
+ ) -> Dict[str, Any]:
1150
+ """Atomically persist or update Nuclei vulnerability findings into SQLite.
1151
+
1152
+ - Matches findings with existing IP and Service nodes in database.
1153
+ - Updates timestamp / description if vulnerability node already exists (deduplication).
1154
+ - Inserts new vulnerability records with correct severity, CVSS, and EPSS metrics.
1155
+ - Inserts PoC/references into exploits table.
1156
+ """
1157
+ if not self.db_path.exists():
1158
+ raise FileNotFoundError(f"Database {self.db_path} does not exist.")
1159
+
1160
+ added_vulns = 0
1161
+ updated_vulns = 0
1162
+
1163
+ with sqlite3.connect(self.db_path) as conn:
1164
+ # Map IPs to ip_id
1165
+ ip_row_map = {row[1]: row[0] for row in conn.execute("SELECT id, ip FROM ip_addresses").fetchall()}
1166
+
1167
+ # Map (ip_id, port) to service_id
1168
+ service_row_map = {}
1169
+ for sid, iid, port, proto in conn.execute("SELECT id, ip_id, port, protocol FROM services").fetchall():
1170
+ service_row_map[(iid, port)] = sid
1171
+ service_row_map[(iid, f"{port}/{proto}")] = sid
1172
+
1173
+ for f in findings:
1174
+ raw_ip = f.get("ip") or fallback_ip or ""
1175
+ host = f.get("host") or ""
1176
+ port = f.get("port")
1177
+
1178
+ # If raw_ip is hostname/url, extract clean IP or try matching
1179
+ ip_id = None
1180
+ if raw_ip and raw_ip in ip_row_map:
1181
+ ip_id = ip_row_map[raw_ip]
1182
+ elif fallback_ip and fallback_ip in ip_row_map:
1183
+ ip_id = ip_row_map[fallback_ip]
1184
+
1185
+ if not ip_id:
1186
+ target_candidate = raw_ip or fallback_ip or host
1187
+ if target_candidate:
1188
+ clean_candidate = target_candidate.replace("https://", "").replace("http://", "").split(":")[0].strip()
1189
+ sub_ip_row = conn.execute("""
1190
+ SELECT ip_addresses.id FROM ip_addresses
1191
+ JOIN subdomain_ips ON subdomain_ips.ip_id = ip_addresses.id
1192
+ JOIN subdomains ON subdomains.id = subdomain_ips.subdomain_id
1193
+ WHERE LOWER(subdomains.name) = LOWER(?)
1194
+ """, (clean_candidate,)).fetchone()
1195
+ if sub_ip_row:
1196
+ ip_id = sub_ip_row[0]
1197
+ else:
1198
+ try:
1199
+ addr_info = socket.getaddrinfo(clean_candidate, None, socket.AF_UNSPEC)
1200
+ if addr_info:
1201
+ res_ip = addr_info[0][4][0]
1202
+ ip_row = conn.execute("SELECT id FROM ip_addresses WHERE ip = ?", (res_ip,)).fetchone()
1203
+ if ip_row:
1204
+ ip_id = ip_row[0]
1205
+ else:
1206
+ ip_id = str(uuid.uuid4())
1207
+ conn.execute("""
1208
+ INSERT INTO ip_addresses (id, ip, org, country, asn)
1209
+ VALUES (?, ?, ?, ?, ?)
1210
+ """, (ip_id, res_ip, "Active Target", "Unknown", "Unknown"))
1211
+ ip_row_map[res_ip] = ip_id
1212
+
1213
+ # Ensure subdomain and link
1214
+ s_row = conn.execute("SELECT id FROM subdomains WHERE LOWER(name) = LOWER(?)", (clean_candidate,)).fetchone()
1215
+ if s_row:
1216
+ s_id = s_row[0]
1217
+ else:
1218
+ s_id = str(uuid.uuid4())
1219
+ d_row = conn.execute("SELECT id FROM domains LIMIT 1").fetchone()
1220
+ d_id = d_row[0] if d_row else None
1221
+ conn.execute("INSERT OR IGNORE INTO subdomains (id, domain_id, name) VALUES (?, ?, ?)", (s_id, d_id, clean_candidate))
1222
+ conn.execute("INSERT OR IGNORE INTO subdomain_ips (subdomain_id, ip_id) VALUES (?, ?)", (s_id, ip_id))
1223
+ except Exception:
1224
+ pass
1225
+
1226
+ if not ip_id and len(ip_row_map) == 1:
1227
+ ip_id = list(ip_row_map.values())[0]
1228
+
1229
+ # Match service_id if port is known
1230
+ service_id = None
1231
+ if ip_id and port:
1232
+ service_id = service_row_map.get((ip_id, port))
1233
+ if not service_id:
1234
+ # Try matching just port in services table
1235
+ s_row = conn.execute("SELECT id FROM services WHERE ip_id = ? AND port = ?", (ip_id, port)).fetchone()
1236
+ if s_row:
1237
+ service_id = s_row[0]
1238
+ else:
1239
+ # Create service dynamically so finding is anchored to port
1240
+ new_svc_id = str(uuid.uuid4())
1241
+ conn.execute("""
1242
+ INSERT INTO services (id, ip_id, port, protocol, service_name, ssl, sources)
1243
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1244
+ """, (new_svc_id, ip_id, port, "tcp", f"service-{port}", 1 if port == 443 else 0, json.dumps(["Nuclei"])))
1245
+ service_id = new_svc_id
1246
+ service_row_map[(ip_id, port)] = new_svc_id
1247
+
1248
+ if service_id:
1249
+ # Ensure service sources include active verification
1250
+ svc_row = conn.execute("SELECT sources FROM services WHERE id = ?", (service_id,)).fetchone()
1251
+ if svc_row:
1252
+ cur_sources_raw = svc_row[0]
1253
+ sources_list = []
1254
+ if cur_sources_raw:
1255
+ try:
1256
+ sources_list = json.loads(cur_sources_raw)
1257
+ if not isinstance(sources_list, list):
1258
+ sources_list = [str(sources_list)]
1259
+ except Exception:
1260
+ sources_list = [cur_sources_raw]
1261
+ if "Nuclei" not in sources_list:
1262
+ sources_list.append("Nuclei")
1263
+ conn.execute("UPDATE services SET sources = ? WHERE id = ?", (json.dumps(sources_list), service_id))
1264
+
1265
+ cve_id = (f.get("cve_id") or f.get("template_id") or "UNKNOWN").strip()
1266
+ severity = (f.get("severity") or "INFO").upper()
1267
+ description = f.get("description") or f.get("name") or ""
1268
+ cwe_id = f.get("cwe_id")
1269
+ cwe_name = f.get("cwe_name")
1270
+ cvss_score = f.get("cvss_score")
1271
+ epss_score = f.get("epss_score")
1272
+
1273
+ # Check if this vulnerability record already exists for this service/ip
1274
+ query = "SELECT id, description, created_at FROM vulnerabilities WHERE cve_id = ?"
1275
+ params: List[Any] = [cve_id]
1276
+ if service_id:
1277
+ query += " AND service_id = ?"
1278
+ params.append(service_id)
1279
+ elif ip_id:
1280
+ query += " AND ip_id = ?"
1281
+ params.append(ip_id)
1282
+
1283
+ existing_vuln = conn.execute(query, params).fetchone()
1284
+
1285
+ if existing_vuln:
1286
+ vuln_id = existing_vuln[0]
1287
+ # Update timestamp and description if new one has more details
1288
+ new_desc = description if len(description) > len(existing_vuln[1] or "") else existing_vuln[1]
1289
+ conn.execute("""
1290
+ UPDATE vulnerabilities
1291
+ SET severity = ?, cvss_score = COALESCE(?, cvss_score), description = ?, source = COALESCE(source, 'Nuclei'), created_at = ?
1292
+ WHERE id = ?
1293
+ """, (severity, cvss_score, new_desc, datetime.now(timezone.utc).isoformat(), vuln_id))
1294
+ updated_vulns += 1
1295
+ else:
1296
+ vuln_id = str(uuid.uuid4())
1297
+ conn.execute("""
1298
+ INSERT INTO vulnerabilities (
1299
+ id, ip_id, service_id, cve_id, severity, cvss_score, cvss_version,
1300
+ description, cwe_id, cwe_name, epss_score, epss_percentile, is_cisa_kev, source, created_at
1301
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1302
+ """, (
1303
+ vuln_id, ip_id, service_id, cve_id, severity, cvss_score, "3.1" if cvss_score else None,
1304
+ description, cwe_id, cwe_name, epss_score, None, 0, "Nuclei", datetime.now(timezone.utc).isoformat()
1305
+ ))
1306
+ added_vulns += 1
1307
+
1308
+ # Insert references/exploits if provided
1309
+ for ref_url in f.get("references", []):
1310
+ if ref_url and isinstance(ref_url, str):
1311
+ exploit_exists = conn.execute("SELECT id FROM exploits WHERE vulnerability_id = ? AND url = ?", (vuln_id, ref_url)).fetchone()
1312
+ if not exploit_exists:
1313
+ conn.execute("""
1314
+ INSERT INTO exploits (id, vulnerability_id, title, source, url, verified, created_at)
1315
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1316
+ """, (
1317
+ str(uuid.uuid4()), vuln_id, f.get("name") or cve_id, "Nuclei",
1318
+ ref_url, 1, datetime.now(timezone.utc).isoformat()
1319
+ ))
1320
+
1321
+ conn.commit()
1322
+
1323
+ return {
1324
+ "added_vulnerabilities": added_vulns,
1325
+ "updated_vulnerabilities": updated_vulns,
1326
+ "total_processed": len(findings),
1327
+ }
1328
+
1329
+ def add_scan_log(self, level: str, message: str, target: Optional[str] = None, timestamp: Optional[str] = None, input_target: Optional[str] = None) -> int:
1330
+ """Insert a scan execution log entry into SQLite."""
1331
+ if not timestamp:
1332
+ timestamp = datetime.now().strftime("%H:%M:%S")
1333
+ with sqlite3.connect(self.db_path) as conn:
1334
+ cur = conn.execute("""
1335
+ INSERT INTO scan_logs (timestamp, level, message, target, input_target)
1336
+ VALUES (?, ?, ?, ?, ?)
1337
+ """, (timestamp, level, message, target, input_target))
1338
+ conn.commit()
1339
+ return cur.lastrowid or 0
1340
+
1341
+ def get_scan_logs(self, limit: int = 150, target: Optional[str] = None) -> List[Dict]:
1342
+ """Retrieve recent scan execution logs from SQLite."""
1343
+ with sqlite3.connect(self.db_path) as conn:
1344
+ conn.row_factory = sqlite3.Row
1345
+ if target:
1346
+ rows = conn.execute("""
1347
+ SELECT id, timestamp, level, message, target, input_target, created_at
1348
+ FROM scan_logs
1349
+ WHERE target = ? OR input_target = ?
1350
+ ORDER BY id ASC
1351
+ LIMIT ?
1352
+ """, (target, target, limit)).fetchall()
1353
+ else:
1354
+ rows = conn.execute("""
1355
+ SELECT id, timestamp, level, message, target, input_target, created_at
1356
+ FROM (
1357
+ SELECT id, timestamp, level, message, target, input_target, created_at
1358
+ FROM scan_logs
1359
+ ORDER BY id DESC
1360
+ LIMIT ?
1361
+ )
1362
+ ORDER BY id ASC
1363
+ """, (limit,)).fetchall()
1364
+
1365
+ return [
1366
+ {
1367
+ "id": row["id"],
1368
+ "timestamp": row["timestamp"],
1369
+ "level": row["level"],
1370
+ "message": row["message"],
1371
+ "target": row["target"],
1372
+ "input_target": row["input_target"],
1373
+ }
1374
+ for row in rows
1375
+ ]
1376
+
1377
+ @staticmethod
1378
+ def get_db_path_for_target(target: str, data_dir: Optional[Path] = None) -> Path:
1379
+ """Generate standardized database path for a target."""
1380
+ if data_dir is None:
1381
+ data_dir = Path.cwd() / "data" / "dbs"
1382
+
1383
+ # Sanitize target name for filename
1384
+ safe_target = "".join(c if c.isalnum() or c in ".-_" else "_" for c in target)
1385
+ safe_target = safe_target[:50] # Limit length
1386
+
1387
+ return data_dir / f"{safe_target}.sqlite"
1388
+