detecti-cli 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- detecti/__init__.py +0 -0
- detecti/cli.py +649 -0
- detecti/config.py +188 -0
- detecti/core/__init__.py +1 -0
- detecti/core/database/__init__.py +5 -0
- detecti/core/database/config_db.py +73 -0
- detecti/core/database/schema.py +136 -0
- detecti/core/database/storage.py +1388 -0
- detecti/core/engine.py +1032 -0
- detecti/core/models.py +278 -0
- detecti/data/config.sqlite +0 -0
- detecti/data/dbs/.gitkeep +2 -0
- detecti/data/dbs/example.com.sqlite +0 -0
- detecti/modules/__init__.py +29 -0
- detecti/modules/base.py +57 -0
- detecti/modules/censys.py +813 -0
- detecti/modules/crtsh.py +98 -0
- detecti/modules/exploitdb.py +138 -0
- detecti/modules/masscan.py +561 -0
- detecti/modules/nuclei.py +449 -0
- detecti/modules/nvd.py +300 -0
- detecti/modules/reverse_whois.py +225 -0
- detecti/modules/shodan.py +412 -0
- detecti/reporters/__init__.py +7 -0
- detecti/reporters/csv_reporter.py +74 -0
- detecti/reporters/html_reporter.py +356 -0
- detecti/reporters/json_reporter.py +26 -0
- detecti/reporters/markdown_reporter.py +203 -0
- detecti/utils/__init__.py +1 -0
- detecti/utils/http.py +294 -0
- detecti/utils/logger.py +378 -0
- detecti/utils/setup.py +453 -0
- detecti/web/__init__.py +6 -0
- detecti/web/api/__init__.py +1 -0
- detecti/web/api/auth.py +109 -0
- detecti/web/api/graph_builder.py +901 -0
- detecti/web/api/routes.py +1602 -0
- detecti/web/process_manager.py +283 -0
- detecti/web/server.py +183 -0
- detecti/web/static/android-chrome-192x192.png +0 -0
- detecti/web/static/android-chrome-512x512.png +0 -0
- detecti/web/static/apple-touch-icon.png +0 -0
- detecti/web/static/css/__init__.py +1 -0
- detecti/web/static/css/dashboard.css +3802 -0
- detecti/web/static/favicon-16x16.png +0 -0
- detecti/web/static/favicon-32x32.png +0 -0
- detecti/web/static/favicon.ico +0 -0
- detecti/web/static/img/DetecTI_Security_Logo.png +0 -0
- detecti/web/static/img/detecti-ico.png +0 -0
- detecti/web/static/index.html +677 -0
- detecti/web/static/js/__init__.py +1 -0
- detecti/web/static/js/api.js +177 -0
- detecti/web/static/js/cytoscape-cose-bilkent.js +458 -0
- detecti/web/static/js/cytoscape-dagre.js +397 -0
- detecti/web/static/js/cytoscape.min.js +31 -0
- detecti/web/static/js/dagre.min.js +3809 -0
- detecti/web/static/js/graph.js +7439 -0
- detecti/web/static/js/lucide.min.js +12 -0
- detecti/web/static/login.html +290 -0
- detecti/web/static/site.webmanifest +1 -0
- detecti_cli-2.0.0.dist-info/METADATA +554 -0
- detecti_cli-2.0.0.dist-info/RECORD +64 -0
- detecti_cli-2.0.0.dist-info/WHEEL +4 -0
- detecti_cli-2.0.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,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
|
+
|