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,1602 @@
|
|
|
1
|
+
"""REST API routes for DetecTI-CLI EASM dashboard."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import ipaddress
|
|
5
|
+
import json
|
|
6
|
+
import socket
|
|
7
|
+
import sqlite3
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
|
13
|
+
from fastapi.requests import Request
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
from detecti.core.database.storage import DatabaseManager
|
|
17
|
+
from detecti.reporters.html_reporter import HTMLReporter
|
|
18
|
+
from detecti.reporters.json_reporter import JSONReporter
|
|
19
|
+
from detecti.reporters.markdown_reporter import MarkdownReporter
|
|
20
|
+
from .graph_builder import GraphBuilder
|
|
21
|
+
|
|
22
|
+
router = APIRouter()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_db_manager(request: Request) -> Optional[DatabaseManager]:
|
|
26
|
+
"""Dependency to get database manager from app state."""
|
|
27
|
+
return getattr(request.app.state, "db_manager", None)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SelectDbRequest(BaseModel):
|
|
31
|
+
name: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DeleteDbRequest(BaseModel):
|
|
35
|
+
name: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@router.get("/databases")
|
|
39
|
+
async def list_databases(request: Request) -> Dict:
|
|
40
|
+
"""List all available SQLite databases in DETECTI_HOME/data/dbs/ and return the currently active one."""
|
|
41
|
+
try:
|
|
42
|
+
from config import DETECTI_HOME
|
|
43
|
+
except ImportError:
|
|
44
|
+
DETECTI_HOME = Path.home() / ".detecti"
|
|
45
|
+
|
|
46
|
+
data_dir = DETECTI_HOME / "data" / "dbs"
|
|
47
|
+
databases = []
|
|
48
|
+
|
|
49
|
+
current_db_path = getattr(request.app.state, "db_path", None)
|
|
50
|
+
current_db_name = Path(current_db_path).name if current_db_path else None
|
|
51
|
+
current_target_name = None
|
|
52
|
+
|
|
53
|
+
if data_dir.exists():
|
|
54
|
+
for db_file in sorted(data_dir.glob("*.sqlite")):
|
|
55
|
+
size_mb = db_file.stat().st_size / (1024 * 1024)
|
|
56
|
+
mod_time = datetime.fromtimestamp(db_file.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
|
|
57
|
+
|
|
58
|
+
clean_name = db_file.stem # Strip .sqlite
|
|
59
|
+
target = clean_name
|
|
60
|
+
try:
|
|
61
|
+
dm = DatabaseManager(db_file)
|
|
62
|
+
stats = dm.get_summary_stats()
|
|
63
|
+
if "target" in stats and stats["target"] and stats["target"] != "Unknown":
|
|
64
|
+
target = stats["target"]
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
is_curr = (db_file.name == current_db_name)
|
|
69
|
+
if is_curr:
|
|
70
|
+
current_target_name = target or clean_name
|
|
71
|
+
|
|
72
|
+
databases.append({
|
|
73
|
+
"filename": db_file.name,
|
|
74
|
+
"name": clean_name,
|
|
75
|
+
"clean_name": clean_name,
|
|
76
|
+
"target": target,
|
|
77
|
+
"display_name": target or clean_name,
|
|
78
|
+
"size_mb": round(size_mb, 2),
|
|
79
|
+
"modified": mod_time,
|
|
80
|
+
"is_current": is_curr
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
"current_db": current_db_name,
|
|
85
|
+
"current_target": current_target_name or (Path(current_db_path).stem if current_db_path else None),
|
|
86
|
+
"databases": databases
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@router.post("/databases/select")
|
|
91
|
+
async def select_database(req: SelectDbRequest, request: Request) -> Dict:
|
|
92
|
+
"""Switch active SQLite database in the web dashboard."""
|
|
93
|
+
dbs_dir = _get_dbs_dir()
|
|
94
|
+
db_name = req.name.strip()
|
|
95
|
+
if not db_name.endswith(".sqlite"):
|
|
96
|
+
filename = f"{db_name}.sqlite"
|
|
97
|
+
else:
|
|
98
|
+
filename = db_name
|
|
99
|
+
|
|
100
|
+
safe_filename = Path(filename).name
|
|
101
|
+
db_file = dbs_dir / safe_filename
|
|
102
|
+
if not db_file.exists():
|
|
103
|
+
# Case-insensitive or matching by stem
|
|
104
|
+
matches = [f for f in dbs_dir.glob("*.sqlite") if f.name.lower() == safe_filename.lower() or f.stem.lower() == db_name.lower()]
|
|
105
|
+
if matches:
|
|
106
|
+
db_file = matches[0]
|
|
107
|
+
safe_filename = db_file.name
|
|
108
|
+
else:
|
|
109
|
+
# Check absolute path
|
|
110
|
+
abs_file = Path(req.name)
|
|
111
|
+
if abs_file.exists() and abs_file.suffix == ".sqlite":
|
|
112
|
+
db_file = abs_file
|
|
113
|
+
else:
|
|
114
|
+
raise HTTPException(status_code=404, detail=f"Database '{req.name}' not found")
|
|
115
|
+
|
|
116
|
+
# Switch database in app state
|
|
117
|
+
request.app.state.db_manager = DatabaseManager(db_file)
|
|
118
|
+
request.app.state.db_path = str(db_file.resolve())
|
|
119
|
+
|
|
120
|
+
# Clear global state associated with previous DB
|
|
121
|
+
_target_registry.clear()
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
"success": True,
|
|
125
|
+
"message": f"Switched to database: {safe_filename}",
|
|
126
|
+
"new_active_db": safe_filename,
|
|
127
|
+
"current_db": db_file.name,
|
|
128
|
+
"clean_name": db_file.stem,
|
|
129
|
+
"db_path": str(db_file.resolve())
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _get_dbs_dir() -> Path:
|
|
134
|
+
try:
|
|
135
|
+
from config import DETECTI_HOME
|
|
136
|
+
except ImportError:
|
|
137
|
+
DETECTI_HOME = Path.home() / ".detecti"
|
|
138
|
+
base = DETECTI_HOME / "data" / "dbs"
|
|
139
|
+
if not base.exists():
|
|
140
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
return base
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
async def _perform_delete_database(target_raw: str, request: Request) -> Dict:
|
|
145
|
+
if not target_raw:
|
|
146
|
+
raise HTTPException(status_code=400, detail="Database name is required")
|
|
147
|
+
|
|
148
|
+
clean_target = target_raw.strip()
|
|
149
|
+
if not clean_target.endswith(".sqlite"):
|
|
150
|
+
filename = f"{clean_target}.sqlite"
|
|
151
|
+
else:
|
|
152
|
+
filename = clean_target
|
|
153
|
+
|
|
154
|
+
safe_filename = Path(filename).name
|
|
155
|
+
dbs_dir = _get_dbs_dir()
|
|
156
|
+
db_file = dbs_dir / safe_filename
|
|
157
|
+
|
|
158
|
+
if not db_file.exists():
|
|
159
|
+
# Case-insensitive match or matching by stem
|
|
160
|
+
matches = [f for f in dbs_dir.glob("*.sqlite") if f.name.lower() == safe_filename.lower() or f.stem.lower() == clean_target.lower()]
|
|
161
|
+
if matches:
|
|
162
|
+
db_file = matches[0]
|
|
163
|
+
safe_filename = db_file.name
|
|
164
|
+
else:
|
|
165
|
+
raise HTTPException(status_code=404, detail=f"Database '{safe_filename}' not found in {dbs_dir}")
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
if db_file.exists():
|
|
169
|
+
db_file.unlink()
|
|
170
|
+
wal_file = db_file.with_name(f"{db_file.name}-wal")
|
|
171
|
+
if wal_file.exists():
|
|
172
|
+
wal_file.unlink()
|
|
173
|
+
shm_file = db_file.with_name(f"{db_file.name}-shm")
|
|
174
|
+
if shm_file.exists():
|
|
175
|
+
shm_file.unlink()
|
|
176
|
+
except Exception as exc:
|
|
177
|
+
raise HTTPException(status_code=500, detail=f"Failed to delete database: {exc}")
|
|
178
|
+
|
|
179
|
+
# Check if this was the active database
|
|
180
|
+
current_db_path = getattr(request.app.state, "db_path", None)
|
|
181
|
+
was_current = False
|
|
182
|
+
new_active_db = None
|
|
183
|
+
if current_db_path and Path(current_db_path).resolve() == db_file.resolve():
|
|
184
|
+
was_current = True
|
|
185
|
+
remaining_dbs = sorted(dbs_dir.glob("*.sqlite"))
|
|
186
|
+
if remaining_dbs:
|
|
187
|
+
new_db_file = remaining_dbs[0]
|
|
188
|
+
request.app.state.db_manager = DatabaseManager(new_db_file)
|
|
189
|
+
request.app.state.db_path = str(new_db_file.resolve())
|
|
190
|
+
new_active_db = new_db_file.name
|
|
191
|
+
else:
|
|
192
|
+
request.app.state.db_manager = None
|
|
193
|
+
request.app.state.db_path = None
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
"success": True,
|
|
197
|
+
"deleted": safe_filename,
|
|
198
|
+
"clean_name": safe_filename[:-7] if safe_filename.endswith(".sqlite") else safe_filename,
|
|
199
|
+
"was_current": was_current,
|
|
200
|
+
"new_active_db": new_active_db
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@router.post("/databases/delete")
|
|
205
|
+
async def delete_database_post(req: DeleteDbRequest, request: Request) -> Dict:
|
|
206
|
+
"""Delete a SQLite database file via JSON POST body."""
|
|
207
|
+
return await _perform_delete_database(req.name, request)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@router.delete("/databases/{db_name}")
|
|
211
|
+
async def delete_database_by_param(db_name: str, request: Request) -> Dict:
|
|
212
|
+
"""Delete a SQLite database file via URL path parameter."""
|
|
213
|
+
return await _perform_delete_database(db_name, request)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@router.get("/summary")
|
|
217
|
+
async def get_summary(db: Optional[DatabaseManager] = Depends(get_db_manager)) -> Dict:
|
|
218
|
+
"""Get high-level metrics for dashboard sidebar."""
|
|
219
|
+
if not db or not Path(db.db_path).exists():
|
|
220
|
+
return {
|
|
221
|
+
"target": "No Database Selected",
|
|
222
|
+
"total_domains": 0,
|
|
223
|
+
"total_subdomains": 0,
|
|
224
|
+
"total_ips": 0,
|
|
225
|
+
"open_services": 0,
|
|
226
|
+
"verified_services": 0,
|
|
227
|
+
"total_vulnerabilities": 0,
|
|
228
|
+
"cisa_kev_count": 0,
|
|
229
|
+
"high_epss_count": 0,
|
|
230
|
+
"no_db": True
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
stats = db.get_summary_stats()
|
|
235
|
+
|
|
236
|
+
target_name = "Unknown"
|
|
237
|
+
try:
|
|
238
|
+
import sqlite3
|
|
239
|
+
with sqlite3.connect(db.db_path) as conn:
|
|
240
|
+
cursor = conn.execute("SELECT target FROM scan_results ORDER BY created_at DESC LIMIT 1")
|
|
241
|
+
row = cursor.fetchone()
|
|
242
|
+
if row:
|
|
243
|
+
target_name = row[0]
|
|
244
|
+
else:
|
|
245
|
+
first_domain = conn.execute("SELECT name FROM domains LIMIT 1").fetchone()
|
|
246
|
+
if first_domain:
|
|
247
|
+
target_name = first_domain[0]
|
|
248
|
+
else:
|
|
249
|
+
first_ip = conn.execute("SELECT ip FROM ip_addresses LIMIT 1").fetchone()
|
|
250
|
+
if first_ip:
|
|
251
|
+
target_name = first_ip[0]
|
|
252
|
+
except Exception as e:
|
|
253
|
+
print(f"Error getting target name: {e}")
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
"target": target_name,
|
|
257
|
+
"total_domains": stats.get("total_domains", 0),
|
|
258
|
+
"total_subdomains": stats.get("total_subdomains", 0),
|
|
259
|
+
"total_ips": stats.get("total_ips", 0),
|
|
260
|
+
"open_services": stats.get("open_services", 0),
|
|
261
|
+
"verified_services": stats.get("verified_services", 0),
|
|
262
|
+
"total_vulnerabilities": stats.get("total_vulnerabilities", 0),
|
|
263
|
+
"cisa_kev_count": stats.get("cisa_kev_count", 0),
|
|
264
|
+
"high_epss_count": stats.get("high_epss_count", 0),
|
|
265
|
+
"no_db": False
|
|
266
|
+
}
|
|
267
|
+
except Exception as e:
|
|
268
|
+
print(f"Summary API error: {e}")
|
|
269
|
+
raise HTTPException(status_code=500, detail=f"Failed to get summary: {str(e)}")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
_seen_items_for_auto_target = set()
|
|
273
|
+
|
|
274
|
+
@router.get("/graph")
|
|
275
|
+
async def get_graph_data(db: Optional[DatabaseManager] = Depends(get_db_manager)) -> Dict:
|
|
276
|
+
"""Generate Cytoscape.js graph data from database."""
|
|
277
|
+
global _seen_items_for_auto_target
|
|
278
|
+
|
|
279
|
+
if not db or not Path(db.db_path).exists():
|
|
280
|
+
return {"elements": {"nodes": [], "edges": []}}
|
|
281
|
+
|
|
282
|
+
# Auto-Target Logic for small datasets (<= 50 items)
|
|
283
|
+
# This evaluates items exactly once. If an item is added, it won't be re-added if the user removes it.
|
|
284
|
+
try:
|
|
285
|
+
with __import__('sqlite3').connect(db.db_path) as conn:
|
|
286
|
+
d_rows = conn.execute("SELECT name FROM domains").fetchall()
|
|
287
|
+
s_rows = conn.execute("SELECT name FROM subdomains").fetchall()
|
|
288
|
+
i_rows = conn.execute("SELECT ip FROM ip_addresses").fetchall()
|
|
289
|
+
|
|
290
|
+
total_passive = len(d_rows) + len(s_rows) + len(i_rows)
|
|
291
|
+
|
|
292
|
+
if 0 < total_passive <= 50:
|
|
293
|
+
for r in (d_rows + s_rows + i_rows):
|
|
294
|
+
item = r[0]
|
|
295
|
+
if item and item not in _seen_items_for_auto_target:
|
|
296
|
+
_seen_items_for_auto_target.add(item)
|
|
297
|
+
if item not in _target_registry:
|
|
298
|
+
_target_registry[item] = {
|
|
299
|
+
"ip": item,
|
|
300
|
+
"status": "idle",
|
|
301
|
+
"nuclei_status": "idle",
|
|
302
|
+
"ports_count": 0,
|
|
303
|
+
"vulns_count": 0,
|
|
304
|
+
"ports": [],
|
|
305
|
+
"error": None,
|
|
306
|
+
"last_scan": None,
|
|
307
|
+
"last_nuclei_scan": None
|
|
308
|
+
}
|
|
309
|
+
except Exception:
|
|
310
|
+
pass
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
active_target_keys = list(_target_registry.keys())
|
|
314
|
+
builder = GraphBuilder(db)
|
|
315
|
+
graph_data = builder.build_graph(active_targets=active_target_keys)
|
|
316
|
+
|
|
317
|
+
# Hydrate _target_registry with explicit targets discovered from graph generation
|
|
318
|
+
# This ensures CLI-passed targets are preserved across sessions
|
|
319
|
+
for node in graph_data.get("elements", {}).get("nodes", []):
|
|
320
|
+
if node.get("data", {}).get("is_target"):
|
|
321
|
+
t = node["data"].get("ip") or node["data"].get("name")
|
|
322
|
+
if t and t not in _target_registry:
|
|
323
|
+
target_type = "ip"
|
|
324
|
+
try:
|
|
325
|
+
import ipaddress
|
|
326
|
+
ipaddress.ip_address(t)
|
|
327
|
+
except ValueError:
|
|
328
|
+
target_type = "fqdn"
|
|
329
|
+
|
|
330
|
+
_target_registry[t] = {
|
|
331
|
+
"ip": t,
|
|
332
|
+
"target_type": target_type,
|
|
333
|
+
"status": "idle",
|
|
334
|
+
"nuclei_status": "idle",
|
|
335
|
+
"ports_count": 0,
|
|
336
|
+
"vulns_count": 0,
|
|
337
|
+
"ports": [],
|
|
338
|
+
"error": None,
|
|
339
|
+
"last_scan": None,
|
|
340
|
+
"last_nuclei_scan": None
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return graph_data
|
|
344
|
+
except Exception as e:
|
|
345
|
+
raise HTTPException(status_code=500, detail=f"Failed to build graph: {str(e)}")
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@router.get("/assets")
|
|
349
|
+
async def get_assets(db: Optional[DatabaseManager] = Depends(get_db_manager)) -> List[Dict]:
|
|
350
|
+
"""Get detailed asset list for tabular view."""
|
|
351
|
+
if not db or not Path(db.db_path).exists():
|
|
352
|
+
return []
|
|
353
|
+
|
|
354
|
+
try:
|
|
355
|
+
import sqlite3
|
|
356
|
+
assets = []
|
|
357
|
+
|
|
358
|
+
with sqlite3.connect(db.db_path) as conn:
|
|
359
|
+
cursor = conn.execute("""
|
|
360
|
+
SELECT ip.ip, ip.org, ip.country, ip.asn,
|
|
361
|
+
COUNT(DISTINCT s.id) as service_count,
|
|
362
|
+
COUNT(DISTINCT v.id) as vuln_count,
|
|
363
|
+
MAX(CASE WHEN v.is_cisa_kev = 1 THEN 1 ELSE 0 END) as has_kev
|
|
364
|
+
FROM ip_addresses ip
|
|
365
|
+
LEFT JOIN services s ON ip.id = s.ip_id
|
|
366
|
+
LEFT JOIN vulnerabilities v ON ip.id = v.ip_id
|
|
367
|
+
GROUP BY ip.id, ip.ip, ip.org, ip.country, ip.asn
|
|
368
|
+
ORDER BY vuln_count DESC, service_count DESC
|
|
369
|
+
""")
|
|
370
|
+
|
|
371
|
+
for row in cursor.fetchall():
|
|
372
|
+
assets.append({
|
|
373
|
+
"type": "ip",
|
|
374
|
+
"value": row[0],
|
|
375
|
+
"org": row[1] or "Unknown",
|
|
376
|
+
"country": row[2] or "Unknown",
|
|
377
|
+
"asn": row[3] or "Unknown",
|
|
378
|
+
"services": row[4],
|
|
379
|
+
"vulnerabilities": row[5],
|
|
380
|
+
"has_cisa_kev": bool(row[6])
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
return assets
|
|
384
|
+
except Exception as e:
|
|
385
|
+
raise HTTPException(status_code=500, detail=f"Failed to get assets: {str(e)}")
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
@router.get("/export")
|
|
389
|
+
async def export_graph_data(
|
|
390
|
+
format: str = Query("json", pattern="^(json|markdown|md|html|csv)$"),
|
|
391
|
+
db: Optional[DatabaseManager] = Depends(get_db_manager)
|
|
392
|
+
):
|
|
393
|
+
"""Export current scan results in JSON, Markdown or HTML format, matching CLI export structure."""
|
|
394
|
+
if not db or not Path(db.db_path).exists():
|
|
395
|
+
raise HTTPException(status_code=400, detail="No active database to export")
|
|
396
|
+
|
|
397
|
+
try:
|
|
398
|
+
scan_result = db.reconstruct_scan_result()
|
|
399
|
+
if not scan_result:
|
|
400
|
+
raise HTTPException(status_code=404, detail="No scan results found in the active database")
|
|
401
|
+
|
|
402
|
+
safe_target = "".join(c if c.isalnum() else "_" for c in scan_result.target)[:40]
|
|
403
|
+
timestamp = scan_result.started_at.strftime("%Y%m%d_%H%M%S")
|
|
404
|
+
|
|
405
|
+
if format in ("markdown", "md"):
|
|
406
|
+
md_content = MarkdownReporter.generate(scan_result)
|
|
407
|
+
filename = f"detecti_{safe_target}_{timestamp}.md"
|
|
408
|
+
return Response(
|
|
409
|
+
content=md_content,
|
|
410
|
+
media_type="text/markdown; charset=utf-8",
|
|
411
|
+
headers={
|
|
412
|
+
"Content-Disposition": f'attachment; filename="{filename}"'
|
|
413
|
+
}
|
|
414
|
+
)
|
|
415
|
+
elif format == "html":
|
|
416
|
+
html_content = HTMLReporter.generate(scan_result)
|
|
417
|
+
filename = f"detecti_{safe_target}_{timestamp}.html"
|
|
418
|
+
return Response(
|
|
419
|
+
content=html_content,
|
|
420
|
+
media_type="text/html; charset=utf-8",
|
|
421
|
+
headers={
|
|
422
|
+
"Content-Disposition": f'attachment; filename="{filename}"'
|
|
423
|
+
}
|
|
424
|
+
)
|
|
425
|
+
elif format == "csv":
|
|
426
|
+
from reporters.csv_reporter import CSVReporter
|
|
427
|
+
csv_content = CSVReporter.generate(scan_result)
|
|
428
|
+
filename = f"detecti_{safe_target}_{timestamp}.csv"
|
|
429
|
+
return Response(
|
|
430
|
+
content=csv_content,
|
|
431
|
+
media_type="text/csv; charset=utf-8",
|
|
432
|
+
headers={
|
|
433
|
+
"Content-Disposition": f'attachment; filename="{filename}"'
|
|
434
|
+
}
|
|
435
|
+
)
|
|
436
|
+
else:
|
|
437
|
+
json_content = JSONReporter.generate(scan_result)
|
|
438
|
+
filename = f"detecti_{safe_target}_{timestamp}.json"
|
|
439
|
+
return Response(
|
|
440
|
+
content=json_content,
|
|
441
|
+
media_type="application/json; charset=utf-8",
|
|
442
|
+
headers={
|
|
443
|
+
"Content-Disposition": f'attachment; filename="{filename}"'
|
|
444
|
+
}
|
|
445
|
+
)
|
|
446
|
+
except HTTPException:
|
|
447
|
+
raise
|
|
448
|
+
except Exception as e:
|
|
449
|
+
raise HTTPException(status_code=500, detail=f"Export failed: {str(e)}")
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
# ----------------------------------------------------------------------
|
|
453
|
+
# Target Management & Active Scan Endpoints
|
|
454
|
+
# ----------------------------------------------------------------------
|
|
455
|
+
|
|
456
|
+
from detecti.modules.masscan import (
|
|
457
|
+
MasscanRunner,
|
|
458
|
+
build_port_ranges_excluding,
|
|
459
|
+
filter_ports_excluding,
|
|
460
|
+
parse_port_spec_to_set,
|
|
461
|
+
calculate_dynamic_timeout,
|
|
462
|
+
)
|
|
463
|
+
from detecti.modules.nuclei import NucleiRunner
|
|
464
|
+
|
|
465
|
+
# In-memory target registry and running tasks tracking
|
|
466
|
+
_target_registry: Dict[str, Dict] = {}
|
|
467
|
+
_running_scan_tasks: Dict[str, asyncio.Task] = {}
|
|
468
|
+
_running_nuclei_tasks: Dict[str, asyncio.Task] = {}
|
|
469
|
+
_scan_log_history: List[Dict] = []
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _get_target_ports_partition(target: str, db: Optional[DatabaseManager]) -> tuple[set[int], set[int]]:
|
|
473
|
+
"""Retrieve verified active ports and unverified passive ports for an IP or FQDN/Domain/Subdomain from database.
|
|
474
|
+
|
|
475
|
+
Returns:
|
|
476
|
+
(verified_ports_set, unverified_passive_ports_set)
|
|
477
|
+
"""
|
|
478
|
+
verified_ports: set[int] = set()
|
|
479
|
+
unverified_passive_ports: set[int] = set()
|
|
480
|
+
if db and Path(db.db_path).exists():
|
|
481
|
+
with sqlite3.connect(db.db_path) as conn:
|
|
482
|
+
is_ip = False
|
|
483
|
+
try:
|
|
484
|
+
ipaddress.ip_address(target)
|
|
485
|
+
is_ip = True
|
|
486
|
+
except ValueError:
|
|
487
|
+
is_ip = False
|
|
488
|
+
|
|
489
|
+
ip_ids = []
|
|
490
|
+
if is_ip:
|
|
491
|
+
ip_row = conn.execute("SELECT id FROM ip_addresses WHERE ip = ?", (target,)).fetchone()
|
|
492
|
+
if ip_row:
|
|
493
|
+
ip_ids = [ip_row[0]]
|
|
494
|
+
else:
|
|
495
|
+
cursor = conn.execute("""
|
|
496
|
+
SELECT ip_id FROM subdomain_ips
|
|
497
|
+
JOIN subdomains ON subdomains.id = subdomain_ips.subdomain_id
|
|
498
|
+
WHERE LOWER(subdomains.name) = LOWER(?)
|
|
499
|
+
""", (target,))
|
|
500
|
+
ip_ids = [r[0] for r in cursor.fetchall()]
|
|
501
|
+
if not ip_ids:
|
|
502
|
+
cursor = conn.execute("""
|
|
503
|
+
SELECT ip_id FROM subdomain_ips
|
|
504
|
+
JOIN subdomains ON subdomains.id = subdomain_ips.subdomain_id
|
|
505
|
+
JOIN domains ON domains.id = subdomains.domain_id
|
|
506
|
+
WHERE LOWER(domains.name) = LOWER(?)
|
|
507
|
+
""", (target,))
|
|
508
|
+
ip_ids = [r[0] for r in cursor.fetchall()]
|
|
509
|
+
|
|
510
|
+
for ip_id in ip_ids:
|
|
511
|
+
services = conn.execute("SELECT port, sources FROM services WHERE ip_id = ?", (ip_id,)).fetchall()
|
|
512
|
+
for p_num, s_sources in services:
|
|
513
|
+
try:
|
|
514
|
+
p_val = int(p_num)
|
|
515
|
+
except (ValueError, TypeError):
|
|
516
|
+
continue
|
|
517
|
+
sources_list = []
|
|
518
|
+
if s_sources:
|
|
519
|
+
try:
|
|
520
|
+
sources_list = json.loads(s_sources)
|
|
521
|
+
if not isinstance(sources_list, list):
|
|
522
|
+
sources_list = [str(sources_list)]
|
|
523
|
+
except Exception:
|
|
524
|
+
sources_list = [s_sources]
|
|
525
|
+
|
|
526
|
+
is_verified = any(
|
|
527
|
+
isinstance(s, str) and ("masscan" in s.lower() or "active" in s.lower() or "nuclei" in s.lower())
|
|
528
|
+
for s in sources_list
|
|
529
|
+
)
|
|
530
|
+
if is_verified:
|
|
531
|
+
verified_ports.add(p_val)
|
|
532
|
+
else:
|
|
533
|
+
unverified_passive_ports.add(p_val)
|
|
534
|
+
return verified_ports, unverified_passive_ports
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
class TargetActionRequest(BaseModel):
|
|
538
|
+
ip: Optional[str] = None
|
|
539
|
+
target: Optional[str] = None
|
|
540
|
+
|
|
541
|
+
@property
|
|
542
|
+
def target_val(self) -> str:
|
|
543
|
+
return (self.target or self.ip or "").strip()
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
class ActiveScanRequest(BaseModel):
|
|
547
|
+
targets: Optional[List[str]] = None
|
|
548
|
+
preset: Optional[str] = "top100"
|
|
549
|
+
ports: Optional[str] = "--top-ports 100"
|
|
550
|
+
rate: Optional[int] = 1000
|
|
551
|
+
disable_ping: Optional[bool] = True
|
|
552
|
+
banners: Optional[bool] = True
|
|
553
|
+
custom_flags: Optional[str] = None
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
class NucleiScanRequest(BaseModel):
|
|
557
|
+
targets: Optional[List[str]] = None
|
|
558
|
+
severities: Optional[List[str]] = ["critical", "high"]
|
|
559
|
+
tags: Optional[List[str]] = None
|
|
560
|
+
custom_tags: Optional[str] = None
|
|
561
|
+
rate_limit: Optional[int] = 150
|
|
562
|
+
concurrency: Optional[int] = 25
|
|
563
|
+
custom_flags: Optional[str] = None
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
class CancelScanRequest(BaseModel):
|
|
567
|
+
target: Optional[str] = None
|
|
568
|
+
all: Optional[bool] = False
|
|
569
|
+
scan_type: Optional[str] = "all" # 'masscan', 'nuclei', or 'all'
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
class UnverifyServicesRequest(BaseModel):
|
|
573
|
+
service_ids: Optional[List[str]] = None
|
|
574
|
+
ip_addresses: Optional[List[str]] = None
|
|
575
|
+
all_services: Optional[bool] = False
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _append_scan_log(level: str, message: str, target: Optional[str] = None, db: Optional[DatabaseManager] = None, input_target: Optional[str] = None):
|
|
579
|
+
ts = datetime.now().strftime("%H:%M:%S")
|
|
580
|
+
entry = {
|
|
581
|
+
"timestamp": ts,
|
|
582
|
+
"level": level,
|
|
583
|
+
"message": message,
|
|
584
|
+
"target": target,
|
|
585
|
+
"input_target": input_target,
|
|
586
|
+
}
|
|
587
|
+
_scan_log_history.append(entry)
|
|
588
|
+
if len(_scan_log_history) > 200:
|
|
589
|
+
_scan_log_history.pop(0)
|
|
590
|
+
|
|
591
|
+
# Persist to active SQLite database
|
|
592
|
+
resolved_db = db or _resolve_db_manager(None, None, target=target)
|
|
593
|
+
if resolved_db and Path(resolved_db.db_path).exists():
|
|
594
|
+
try:
|
|
595
|
+
resolved_db.add_scan_log(level=level, message=message, target=target, timestamp=ts, input_target=input_target)
|
|
596
|
+
except Exception:
|
|
597
|
+
pass
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
@router.get("/targets")
|
|
601
|
+
async def list_targets() -> Dict:
|
|
602
|
+
"""List all currently marked targets (IPs and FQDNs) with their scan statuses."""
|
|
603
|
+
return {
|
|
604
|
+
"targets": list(_target_registry.values()),
|
|
605
|
+
"count": len(_target_registry),
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
@router.post("/targets/set")
|
|
610
|
+
async def set_target(req: TargetActionRequest) -> Dict:
|
|
611
|
+
"""Mark an IP or FQDN/Domain/Subdomain as an active target."""
|
|
612
|
+
target = req.target_val
|
|
613
|
+
if not target:
|
|
614
|
+
raise HTTPException(status_code=400, detail="Invalid target address or hostname")
|
|
615
|
+
|
|
616
|
+
target_type = "ip"
|
|
617
|
+
try:
|
|
618
|
+
ipaddress.ip_address(target)
|
|
619
|
+
except ValueError:
|
|
620
|
+
target_type = "fqdn"
|
|
621
|
+
|
|
622
|
+
if target not in _target_registry:
|
|
623
|
+
_target_registry[target] = {
|
|
624
|
+
"ip": target,
|
|
625
|
+
"target_type": target_type,
|
|
626
|
+
"status": "idle",
|
|
627
|
+
"nuclei_status": "idle",
|
|
628
|
+
"ports_count": 0,
|
|
629
|
+
"ports": [],
|
|
630
|
+
"vulns_count": 0,
|
|
631
|
+
"error": None,
|
|
632
|
+
"added_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
633
|
+
"last_scan": None,
|
|
634
|
+
"last_nuclei_scan": None,
|
|
635
|
+
}
|
|
636
|
+
_append_scan_log("info", f"Target {target} ({target_type.upper()}) added to active targets.", target=target)
|
|
637
|
+
|
|
638
|
+
return {
|
|
639
|
+
"success": True,
|
|
640
|
+
"target": _target_registry[target],
|
|
641
|
+
"total_targets": len(_target_registry),
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
@router.post("/targets/remove")
|
|
646
|
+
async def remove_target(req: TargetActionRequest) -> Dict:
|
|
647
|
+
"""Remove a target from the marked targets list."""
|
|
648
|
+
target = req.target_val
|
|
649
|
+
if target in _running_scan_tasks:
|
|
650
|
+
task = _running_scan_tasks[target]
|
|
651
|
+
if not task.done():
|
|
652
|
+
task.cancel()
|
|
653
|
+
_running_scan_tasks.pop(target, None)
|
|
654
|
+
|
|
655
|
+
if target in _running_nuclei_tasks:
|
|
656
|
+
task = _running_nuclei_tasks[target]
|
|
657
|
+
if not task.done():
|
|
658
|
+
task.cancel()
|
|
659
|
+
_running_nuclei_tasks.pop(target, None)
|
|
660
|
+
|
|
661
|
+
if target in _target_registry:
|
|
662
|
+
del _target_registry[target]
|
|
663
|
+
_append_scan_log("info", f"Target {target} removed from targets.", target=target)
|
|
664
|
+
|
|
665
|
+
return {
|
|
666
|
+
"success": True,
|
|
667
|
+
"removed": target,
|
|
668
|
+
"total_targets": len(_target_registry),
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
@router.post("/targets/clear")
|
|
673
|
+
async def clear_all_targets() -> Dict:
|
|
674
|
+
"""Remove all targets and cancel running scans."""
|
|
675
|
+
for ip, task in list(_running_scan_tasks.items()):
|
|
676
|
+
if not task.done():
|
|
677
|
+
task.cancel()
|
|
678
|
+
_running_scan_tasks.clear()
|
|
679
|
+
|
|
680
|
+
for ip, task in list(_running_nuclei_tasks.items()):
|
|
681
|
+
if not task.done():
|
|
682
|
+
task.cancel()
|
|
683
|
+
_running_nuclei_tasks.clear()
|
|
684
|
+
|
|
685
|
+
count = len(_target_registry)
|
|
686
|
+
_target_registry.clear()
|
|
687
|
+
_append_scan_log("info", "All targets cleared.")
|
|
688
|
+
return {
|
|
689
|
+
"success": True,
|
|
690
|
+
"cleared_count": count,
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _resolve_db_manager(request: Optional[Request] = None, db: Optional[DatabaseManager] = None, target: Optional[str] = None) -> Optional[DatabaseManager]:
|
|
695
|
+
if db and Path(db.db_path).exists():
|
|
696
|
+
return db
|
|
697
|
+
if request:
|
|
698
|
+
app_db = getattr(request.app.state, "db_manager", None)
|
|
699
|
+
if app_db and Path(app_db.db_path).exists():
|
|
700
|
+
return app_db
|
|
701
|
+
if target:
|
|
702
|
+
cand = _get_dbs_dir() / f"{target}.sqlite"
|
|
703
|
+
if cand.exists():
|
|
704
|
+
return DatabaseManager(cand)
|
|
705
|
+
cand_raw = _get_dbs_dir() / target
|
|
706
|
+
if cand_raw.exists():
|
|
707
|
+
return DatabaseManager(cand_raw)
|
|
708
|
+
dbs_dir = _get_dbs_dir()
|
|
709
|
+
if dbs_dir.exists():
|
|
710
|
+
dbs = sorted(list(dbs_dir.glob("*.sqlite")))
|
|
711
|
+
if dbs:
|
|
712
|
+
return DatabaseManager(dbs[0])
|
|
713
|
+
return None
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
@router.post("/services/unverify")
|
|
717
|
+
async def unverify_services_endpoint(
|
|
718
|
+
req: UnverifyServicesRequest,
|
|
719
|
+
request: Request,
|
|
720
|
+
target: Optional[str] = Query(None, description="Active target name"),
|
|
721
|
+
db: Optional[DatabaseManager] = Depends(get_db_manager),
|
|
722
|
+
) -> Dict:
|
|
723
|
+
"""Remove active verification status (Masscan source) from specified services, IPs, or root nodes.
|
|
724
|
+
|
|
725
|
+
Allows analysts to reset services back to passive state for targeted re-validation without losing assets.
|
|
726
|
+
"""
|
|
727
|
+
active_db = _resolve_db_manager(request, db, target)
|
|
728
|
+
if not active_db or not Path(active_db.db_path).exists():
|
|
729
|
+
raise HTTPException(status_code=404, detail="No active scan database found")
|
|
730
|
+
|
|
731
|
+
res = active_db.unverify_services(
|
|
732
|
+
service_ids=req.service_ids,
|
|
733
|
+
ip_addresses=req.ip_addresses,
|
|
734
|
+
all_services=bool(req.all_services)
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
# Synchronize in-memory target registry ports count
|
|
738
|
+
for ip, t_info in _target_registry.items():
|
|
739
|
+
v_ports, _ = _get_target_ports_partition(ip, active_db)
|
|
740
|
+
t_info["ports_count"] = len(v_ports)
|
|
741
|
+
|
|
742
|
+
# Log to live console
|
|
743
|
+
target_label = target or "Active Target"
|
|
744
|
+
_append_scan_log(
|
|
745
|
+
"info",
|
|
746
|
+
f"[Service Reset] Removed 'Confirmed Active' status from {res.get('unverified_count', 0)} service(s) to allow fresh re-validation.",
|
|
747
|
+
target=target_label
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
return res
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
@router.get("/scan/check-permissions")
|
|
754
|
+
async def check_scan_permissions() -> Dict:
|
|
755
|
+
"""Verify Masscan and Nuclei binary availability and execution permissions."""
|
|
756
|
+
masscan_runner = MasscanRunner()
|
|
757
|
+
nuclei_runner = NucleiRunner()
|
|
758
|
+
return {
|
|
759
|
+
"masscan": masscan_runner.check_permissions(),
|
|
760
|
+
"nuclei": nuclei_runner.check_permissions(),
|
|
761
|
+
"available": masscan_runner.is_available(),
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
@router.post("/scan/active")
|
|
766
|
+
async def start_active_scan(
|
|
767
|
+
req: ActiveScanRequest,
|
|
768
|
+
request: Request,
|
|
769
|
+
db: Optional[DatabaseManager] = Depends(get_db_manager),
|
|
770
|
+
) -> Dict:
|
|
771
|
+
"""Trigger background active port scan with Masscan against marked targets."""
|
|
772
|
+
runner = MasscanRunner()
|
|
773
|
+
if not runner.is_available():
|
|
774
|
+
raise HTTPException(
|
|
775
|
+
status_code=503,
|
|
776
|
+
detail="Masscan binary not found on server. Install masscan and grant raw packet capabilities.",
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
# Determine targets to scan
|
|
780
|
+
target_ips = req.targets if req.targets else list(_target_registry.keys())
|
|
781
|
+
if not target_ips:
|
|
782
|
+
raise HTTPException(status_code=400, detail="No IP targets selected or marked for scanning.")
|
|
783
|
+
|
|
784
|
+
ports_arg = req.ports or "--top-ports 100"
|
|
785
|
+
rate_arg = req.rate or 1000
|
|
786
|
+
pn_arg = req.disable_ping if req.disable_ping is not None else True
|
|
787
|
+
banners_arg = req.banners if req.banners is not None else True
|
|
788
|
+
flags_arg = req.custom_flags
|
|
789
|
+
|
|
790
|
+
# Auto-register IPs if not yet marked
|
|
791
|
+
for ip in target_ips:
|
|
792
|
+
if ip not in _target_registry:
|
|
793
|
+
_target_registry[ip] = {
|
|
794
|
+
"ip": ip,
|
|
795
|
+
"status": "idle",
|
|
796
|
+
"nuclei_status": "idle",
|
|
797
|
+
"ports_count": 0,
|
|
798
|
+
"ports": [],
|
|
799
|
+
"vulns_count": 0,
|
|
800
|
+
"error": None,
|
|
801
|
+
"added_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
802
|
+
"last_scan": None,
|
|
803
|
+
"last_nuclei_scan": None,
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
async def _run_single_target_scan(ip_to_scan: str):
|
|
807
|
+
try:
|
|
808
|
+
_target_registry[ip_to_scan]["status"] = "scanning"
|
|
809
|
+
_target_registry[ip_to_scan]["error"] = None
|
|
810
|
+
|
|
811
|
+
# Resolve active DB
|
|
812
|
+
active_db = db
|
|
813
|
+
if not active_db or not Path(active_db.db_path).exists():
|
|
814
|
+
current_db_path = getattr(request.app.state, "db_path", None)
|
|
815
|
+
if current_db_path and Path(current_db_path).exists():
|
|
816
|
+
active_db = DatabaseManager(Path(current_db_path))
|
|
817
|
+
request.app.state.db_manager = active_db
|
|
818
|
+
else:
|
|
819
|
+
dbs_dir = _get_dbs_dir()
|
|
820
|
+
if dbs_dir.exists():
|
|
821
|
+
existing_dbs = list(dbs_dir.glob("*.sqlite"))
|
|
822
|
+
if existing_dbs:
|
|
823
|
+
active_db = DatabaseManager(existing_dbs[0])
|
|
824
|
+
request.app.state.db_manager = active_db
|
|
825
|
+
request.app.state.db_path = str(existing_dbs[0].resolve())
|
|
826
|
+
|
|
827
|
+
# Resolve target to IP if FQDN
|
|
828
|
+
scan_ip = ip_to_scan
|
|
829
|
+
try:
|
|
830
|
+
ipaddress.ip_address(ip_to_scan)
|
|
831
|
+
except ValueError:
|
|
832
|
+
resolved_ip = None
|
|
833
|
+
try:
|
|
834
|
+
addr_info = socket.getaddrinfo(ip_to_scan, None, socket.AF_UNSPEC)
|
|
835
|
+
if addr_info:
|
|
836
|
+
resolved_ip = addr_info[0][4][0]
|
|
837
|
+
except Exception:
|
|
838
|
+
pass
|
|
839
|
+
|
|
840
|
+
if active_db and Path(active_db.db_path).exists():
|
|
841
|
+
with sqlite3.connect(active_db.db_path) as conn:
|
|
842
|
+
if not resolved_ip:
|
|
843
|
+
row = conn.execute("""
|
|
844
|
+
SELECT ip_addresses.ip FROM ip_addresses
|
|
845
|
+
JOIN subdomain_ips ON subdomain_ips.ip_id = ip_addresses.id
|
|
846
|
+
JOIN subdomains ON subdomains.id = subdomain_ips.subdomain_id
|
|
847
|
+
WHERE LOWER(subdomains.name) = LOWER(?)
|
|
848
|
+
""", (ip_to_scan,)).fetchone()
|
|
849
|
+
if row:
|
|
850
|
+
resolved_ip = row[0]
|
|
851
|
+
|
|
852
|
+
# If resolved via DNS, immediately persist and bind in database
|
|
853
|
+
if resolved_ip:
|
|
854
|
+
# 1. Ensure subdomain node exists
|
|
855
|
+
sub_row = conn.execute("SELECT id FROM subdomains WHERE LOWER(name) = LOWER(?)", (ip_to_scan,)).fetchone()
|
|
856
|
+
if sub_row:
|
|
857
|
+
sub_id = sub_row[0]
|
|
858
|
+
else:
|
|
859
|
+
dom_id = None
|
|
860
|
+
for d_id, d_name in conn.execute("SELECT id, name FROM domains").fetchall():
|
|
861
|
+
d_clean = d_name.lower().strip()
|
|
862
|
+
if ip_to_scan.lower() == d_clean or ip_to_scan.lower().endswith(f".{d_clean}"):
|
|
863
|
+
dom_id = d_id
|
|
864
|
+
break
|
|
865
|
+
if not dom_id:
|
|
866
|
+
dom_id = str(uuid.uuid4())
|
|
867
|
+
conn.execute("INSERT OR IGNORE INTO domains (id, name) VALUES (?, ?)", (dom_id, ip_to_scan))
|
|
868
|
+
d_fetch = conn.execute("SELECT id FROM domains WHERE LOWER(name) = LOWER(?)", (ip_to_scan,)).fetchone()
|
|
869
|
+
if d_fetch:
|
|
870
|
+
dom_id = d_fetch[0]
|
|
871
|
+
|
|
872
|
+
sub_id = str(uuid.uuid4())
|
|
873
|
+
conn.execute("INSERT OR IGNORE INTO subdomains (id, domain_id, name) VALUES (?, ?, ?)", (sub_id, dom_id, ip_to_scan))
|
|
874
|
+
s_fetch = conn.execute("SELECT id FROM subdomains WHERE LOWER(name) = LOWER(?)", (ip_to_scan,)).fetchone()
|
|
875
|
+
if s_fetch:
|
|
876
|
+
sub_id = s_fetch[0]
|
|
877
|
+
|
|
878
|
+
# 2. Ensure IP exists
|
|
879
|
+
ip_row = conn.execute("SELECT id FROM ip_addresses WHERE ip = ?", (resolved_ip,)).fetchone()
|
|
880
|
+
if ip_row:
|
|
881
|
+
cur_ip_id = ip_row[0]
|
|
882
|
+
else:
|
|
883
|
+
cur_ip_id = str(uuid.uuid4())
|
|
884
|
+
conn.execute("""
|
|
885
|
+
INSERT INTO ip_addresses (id, ip, org, country, asn)
|
|
886
|
+
VALUES (?, ?, ?, ?, ?)
|
|
887
|
+
""", (cur_ip_id, resolved_ip, "Active Target", "Unknown", "Unknown"))
|
|
888
|
+
|
|
889
|
+
# 3. Ensure direct RESOLVES_TO link in subdomain_ips
|
|
890
|
+
conn.execute("""
|
|
891
|
+
INSERT OR IGNORE INTO subdomain_ips (subdomain_id, ip_id)
|
|
892
|
+
VALUES (?, ?)
|
|
893
|
+
""", (sub_id, cur_ip_id))
|
|
894
|
+
conn.commit()
|
|
895
|
+
|
|
896
|
+
if resolved_ip:
|
|
897
|
+
scan_ip = resolved_ip
|
|
898
|
+
_append_scan_log("info", f"[Masscan] Target FQDN '{ip_to_scan}' resolved to IP {scan_ip} (persisted & linked in graph).", target=ip_to_scan)
|
|
899
|
+
|
|
900
|
+
# 1. Inspect existing ports for target in database
|
|
901
|
+
verified_ports, unverified_passive_ports = _get_target_ports_partition(ip_to_scan, active_db)
|
|
902
|
+
|
|
903
|
+
# Check if this is an "All Ports" (0-65535) scan
|
|
904
|
+
clean_ports_arg = (ports_arg or "").strip().lower()
|
|
905
|
+
if clean_ports_arg.startswith("-p"):
|
|
906
|
+
clean_ports_arg = clean_ports_arg[2:].strip()
|
|
907
|
+
is_all_ports = clean_ports_arg in ("-", "all", "0-65535", "1-65535", "-p0-65535", "-p1-65535")
|
|
908
|
+
|
|
909
|
+
accumulated_open_ports = []
|
|
910
|
+
|
|
911
|
+
if is_all_ports:
|
|
912
|
+
# -------------------------------------------------------------
|
|
913
|
+
# 2-PHASE PIPELINE FOR ALL PORTS (0-65535)
|
|
914
|
+
# -------------------------------------------------------------
|
|
915
|
+
total_batch_targets = len(target_ips)
|
|
916
|
+
|
|
917
|
+
# Phase 1: High-Priority Scan on Passive Unverified Ports
|
|
918
|
+
phase1_ports = unverified_passive_ports - verified_ports
|
|
919
|
+
if phase1_ports:
|
|
920
|
+
p1_spec = ",".join(str(p) for p in sorted(phase1_ports))
|
|
921
|
+
p1_timeout = calculate_dynamic_timeout(p1_spec, rate=rate_arg, num_targets=total_batch_targets, min_timeout=45.0)
|
|
922
|
+
_append_scan_log(
|
|
923
|
+
"info",
|
|
924
|
+
f"[Masscan Phase 1 (Priority)] Found {len(phase1_ports)} unverified passive port(s) [{p1_spec}] on {ip_to_scan}. Scanning immediately (timeout: {p1_timeout}s, batch size: {total_batch_targets})...",
|
|
925
|
+
target=ip_to_scan
|
|
926
|
+
)
|
|
927
|
+
p1_res = await runner.scan_target(
|
|
928
|
+
target_ip=scan_ip,
|
|
929
|
+
ports=p1_spec,
|
|
930
|
+
rate=rate_arg,
|
|
931
|
+
disable_ping=pn_arg,
|
|
932
|
+
banners=banners_arg,
|
|
933
|
+
custom_flags=flags_arg,
|
|
934
|
+
timeout=p1_timeout,
|
|
935
|
+
num_targets=total_batch_targets,
|
|
936
|
+
)
|
|
937
|
+
p1_found = p1_res.get("ports") or p1_res.get("open_ports") or []
|
|
938
|
+
if p1_found:
|
|
939
|
+
accumulated_open_ports.extend(p1_found)
|
|
940
|
+
if active_db and Path(active_db.db_path).exists():
|
|
941
|
+
m_info = active_db.merge_active_scan_services(ip_to_scan, p1_found)
|
|
942
|
+
_append_scan_log(
|
|
943
|
+
"success",
|
|
944
|
+
f"[Masscan Phase 1 Complete] Verified {len(p1_found)} port(s) on {ip_to_scan} ({m_info.get('added_services', 0)} new, {m_info.get('updated_services', 0)} confirmed active).",
|
|
945
|
+
target=ip_to_scan
|
|
946
|
+
)
|
|
947
|
+
# Re-partition verified ports
|
|
948
|
+
verified_ports, unverified_passive_ports = _get_target_ports_partition(ip_to_scan, active_db)
|
|
949
|
+
else:
|
|
950
|
+
_append_scan_log(
|
|
951
|
+
"info",
|
|
952
|
+
f"[Masscan Phase 1 Skip] No unverified passive ports awaiting priority confirmation on {ip_to_scan}. Skipping Phase 1 and advancing to full range sweep.",
|
|
953
|
+
target=ip_to_scan
|
|
954
|
+
)
|
|
955
|
+
|
|
956
|
+
# Phase 2: Sweep remaining ports of the 0-65535 range
|
|
957
|
+
all_excluded = verified_ports | phase1_ports
|
|
958
|
+
p2_spec = build_port_ranges_excluding(0, 65535, all_excluded)
|
|
959
|
+
remaining_ports_count = max(0, 65536 - len(all_excluded))
|
|
960
|
+
|
|
961
|
+
if remaining_ports_count == 0:
|
|
962
|
+
_append_scan_log(
|
|
963
|
+
"info",
|
|
964
|
+
f"[Masscan Phase 2 Skip] All 65,536 ports on {ip_to_scan} have already been tested or confirmed active in database. Skipping Phase 2 sweep.",
|
|
965
|
+
target=ip_to_scan
|
|
966
|
+
)
|
|
967
|
+
else:
|
|
968
|
+
p2_timeout = calculate_dynamic_timeout(p2_spec, rate=rate_arg, num_targets=total_batch_targets, min_timeout=120.0)
|
|
969
|
+
ex_summary = ", ".join(str(p) for p in sorted(all_excluded)[:10]) + ("..." if len(all_excluded) > 10 else "")
|
|
970
|
+
_append_scan_log(
|
|
971
|
+
"info",
|
|
972
|
+
f"[Masscan Phase 2 (Sweep)] Sweeping remaining {remaining_ports_count:,} ports on {ip_to_scan} (rate: {rate_arg} pps, dynamic timeout: {p2_timeout}s, batch size: {total_batch_targets}, excluding {len(all_excluded)} already-tested/confirmed ports: [{ex_summary}])...",
|
|
973
|
+
target=ip_to_scan
|
|
974
|
+
)
|
|
975
|
+
|
|
976
|
+
p2_res = await runner.scan_target(
|
|
977
|
+
target_ip=scan_ip,
|
|
978
|
+
ports=p2_spec,
|
|
979
|
+
rate=rate_arg,
|
|
980
|
+
disable_ping=pn_arg,
|
|
981
|
+
banners=banners_arg,
|
|
982
|
+
custom_flags=flags_arg,
|
|
983
|
+
timeout=p2_timeout,
|
|
984
|
+
num_targets=total_batch_targets,
|
|
985
|
+
)
|
|
986
|
+
|
|
987
|
+
p2_found = p2_res.get("ports") or p2_res.get("open_ports") or []
|
|
988
|
+
if p2_found:
|
|
989
|
+
accumulated_open_ports.extend(p2_found)
|
|
990
|
+
if active_db and Path(active_db.db_path).exists():
|
|
991
|
+
m_info = active_db.merge_active_scan_services(ip_to_scan, p2_found)
|
|
992
|
+
_append_scan_log(
|
|
993
|
+
"success",
|
|
994
|
+
f"[Masscan Phase 2] Discovered {len(p2_found)} additional open port(s) on {ip_to_scan}.",
|
|
995
|
+
target=ip_to_scan
|
|
996
|
+
)
|
|
997
|
+
|
|
998
|
+
# Deduplicate accumulated open ports by port and proto
|
|
999
|
+
unique_ports = {}
|
|
1000
|
+
for p in accumulated_open_ports:
|
|
1001
|
+
k = (p.get("port"), (p.get("protocol") or "tcp").lower())
|
|
1002
|
+
unique_ports[k] = p
|
|
1003
|
+
final_open_ports = list(unique_ports.values())
|
|
1004
|
+
|
|
1005
|
+
is_success = (len(final_open_ports) > 0) or (remaining_ports_count == 0) or p2_res.get("success", False)
|
|
1006
|
+
if is_success:
|
|
1007
|
+
_target_registry[ip_to_scan]["status"] = "completed"
|
|
1008
|
+
_target_registry[ip_to_scan]["ports_count"] = len(final_open_ports)
|
|
1009
|
+
_target_registry[ip_to_scan]["ports"] = final_open_ports
|
|
1010
|
+
_target_registry[ip_to_scan]["last_scan"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
1011
|
+
_append_scan_log(
|
|
1012
|
+
"success",
|
|
1013
|
+
f"[Masscan Full Sweep Complete] Scan on {ip_to_scan} completed: {len(final_open_ports)} total verified active port(s).",
|
|
1014
|
+
target=ip_to_scan
|
|
1015
|
+
)
|
|
1016
|
+
else:
|
|
1017
|
+
err_msg = p2_res.get("error", "Scan finished without open ports")
|
|
1018
|
+
_target_registry[ip_to_scan]["status"] = "completed" if len(final_open_ports) > 0 else "failed"
|
|
1019
|
+
_target_registry[ip_to_scan]["ports_count"] = len(final_open_ports)
|
|
1020
|
+
_target_registry[ip_to_scan]["ports"] = final_open_ports
|
|
1021
|
+
_target_registry[ip_to_scan]["error"] = err_msg if not final_open_ports else None
|
|
1022
|
+
_append_scan_log(
|
|
1023
|
+
"warning" if final_open_ports else "error",
|
|
1024
|
+
f"[Masscan] Sweep on {ip_to_scan} ended: {err_msg} ({len(final_open_ports)} ports preserved).",
|
|
1025
|
+
target=ip_to_scan
|
|
1026
|
+
)
|
|
1027
|
+
|
|
1028
|
+
else:
|
|
1029
|
+
# -------------------------------------------------------------
|
|
1030
|
+
# STANDARD SCAN WITH SMART VERIFIED PORT EXCLUSION
|
|
1031
|
+
# -------------------------------------------------------------
|
|
1032
|
+
total_batch_targets = len(target_ips)
|
|
1033
|
+
filtered_ports, remaining_count, excluded_count, actual_ex = filter_ports_excluding(ports_arg, verified_ports)
|
|
1034
|
+
|
|
1035
|
+
if not filtered_ports or remaining_count == 0:
|
|
1036
|
+
ex_list_str = ", ".join(str(p) for p in sorted(actual_ex)[:15]) + ("..." if len(actual_ex) > 15 else "")
|
|
1037
|
+
_target_registry[ip_to_scan]["status"] = "completed"
|
|
1038
|
+
_target_registry[ip_to_scan]["ports_count"] = len(verified_ports)
|
|
1039
|
+
_target_registry[ip_to_scan]["last_scan"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
1040
|
+
_append_scan_log(
|
|
1041
|
+
"success",
|
|
1042
|
+
f"[Masscan Smart Skip] Scan skipped on {ip_to_scan}: 100% of requested ports [{ports_arg}] ({len(actual_ex)} port(s): [{ex_list_str}]) are already verified as Confirmed Active in database. Redundant probing skipped to reduce target load.",
|
|
1043
|
+
target=ip_to_scan
|
|
1044
|
+
)
|
|
1045
|
+
return
|
|
1046
|
+
|
|
1047
|
+
std_timeout = calculate_dynamic_timeout(filtered_ports, rate=rate_arg, num_targets=total_batch_targets, min_timeout=60.0)
|
|
1048
|
+
|
|
1049
|
+
if excluded_count > 0:
|
|
1050
|
+
ex_list_str = ", ".join(str(p) for p in sorted(actual_ex)[:10]) + ("..." if len(actual_ex) > 10 else "")
|
|
1051
|
+
_append_scan_log(
|
|
1052
|
+
"info",
|
|
1053
|
+
f"[Masscan Smart Filter] Excluded {excluded_count} port(s) [{ex_list_str}] because they are already Confirmed Active. Scanning {remaining_count} remaining unverified port(s) on {ip_to_scan} ({filtered_ports}, {rate_arg} pps, timeout: {std_timeout}s, batch size: {total_batch_targets})...",
|
|
1054
|
+
target=ip_to_scan
|
|
1055
|
+
)
|
|
1056
|
+
else:
|
|
1057
|
+
_append_scan_log(
|
|
1058
|
+
"info",
|
|
1059
|
+
f"Starting active scan on {ip_to_scan} ({filtered_ports}, {rate_arg} pps, timeout: {std_timeout}s, batch size: {total_batch_targets})...",
|
|
1060
|
+
target=ip_to_scan
|
|
1061
|
+
)
|
|
1062
|
+
|
|
1063
|
+
scan_res = await runner.scan_target(
|
|
1064
|
+
target_ip=scan_ip,
|
|
1065
|
+
ports=filtered_ports,
|
|
1066
|
+
rate=rate_arg,
|
|
1067
|
+
disable_ping=pn_arg,
|
|
1068
|
+
banners=banners_arg,
|
|
1069
|
+
custom_flags=flags_arg,
|
|
1070
|
+
timeout=std_timeout,
|
|
1071
|
+
num_targets=total_batch_targets,
|
|
1072
|
+
)
|
|
1073
|
+
|
|
1074
|
+
open_ports = scan_res.get("ports") or scan_res.get("open_ports") or []
|
|
1075
|
+
if open_ports and active_db and Path(active_db.db_path).exists():
|
|
1076
|
+
merge_info = active_db.merge_active_scan_services(ip_to_scan, open_ports)
|
|
1077
|
+
_append_scan_log(
|
|
1078
|
+
"success",
|
|
1079
|
+
f"Persisted {len(open_ports)} verified port(s) to database ({merge_info.get('added_services', 0)} new, {merge_info.get('updated_services', 0)} verified).",
|
|
1080
|
+
target=ip_to_scan,
|
|
1081
|
+
)
|
|
1082
|
+
|
|
1083
|
+
# Re-query total verified ports count
|
|
1084
|
+
v_ports, _ = _get_target_ports_partition(ip_to_scan, active_db)
|
|
1085
|
+
total_verified_count = len(v_ports) if v_ports else len(open_ports)
|
|
1086
|
+
|
|
1087
|
+
if scan_res.get("success") or open_ports:
|
|
1088
|
+
_target_registry[ip_to_scan]["status"] = "completed"
|
|
1089
|
+
_target_registry[ip_to_scan]["ports_count"] = total_verified_count
|
|
1090
|
+
_target_registry[ip_to_scan]["ports"] = open_ports
|
|
1091
|
+
_target_registry[ip_to_scan]["last_scan"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
1092
|
+
_append_scan_log(
|
|
1093
|
+
"success",
|
|
1094
|
+
f"Scan on {ip_to_scan} completed: {len(open_ports)} open port(s) newly verified ({total_verified_count} total active).",
|
|
1095
|
+
target=ip_to_scan
|
|
1096
|
+
)
|
|
1097
|
+
else:
|
|
1098
|
+
err_msg = scan_res.get("error", "Unknown scan error")
|
|
1099
|
+
_target_registry[ip_to_scan]["status"] = "failed"
|
|
1100
|
+
_target_registry[ip_to_scan]["ports_count"] = total_verified_count
|
|
1101
|
+
_target_registry[ip_to_scan]["ports"] = open_ports
|
|
1102
|
+
_target_registry[ip_to_scan]["error"] = err_msg
|
|
1103
|
+
_append_scan_log(
|
|
1104
|
+
"warning" if open_ports else "error",
|
|
1105
|
+
f"Scan on {ip_to_scan} ended with warning/timeout: {err_msg} ({total_verified_count} ports preserved).",
|
|
1106
|
+
target=ip_to_scan
|
|
1107
|
+
)
|
|
1108
|
+
|
|
1109
|
+
except asyncio.CancelledError:
|
|
1110
|
+
_target_registry[ip_to_scan]["status"] = "idle"
|
|
1111
|
+
_append_scan_log("warning", f"Scan on {ip_to_scan} was cancelled by user.", target=ip_to_scan)
|
|
1112
|
+
except Exception as ex:
|
|
1113
|
+
_target_registry[ip_to_scan]["status"] = "failed"
|
|
1114
|
+
_target_registry[ip_to_scan]["error"] = str(ex)
|
|
1115
|
+
_append_scan_log("error", f"Unexpected error scanning {ip_to_scan}: {str(ex)}", target=ip_to_scan)
|
|
1116
|
+
finally:
|
|
1117
|
+
_running_scan_tasks.pop(ip_to_scan, None)
|
|
1118
|
+
|
|
1119
|
+
# Spawn background task for each target
|
|
1120
|
+
for ip in target_ips:
|
|
1121
|
+
# Cancel previous running task on same IP if exists
|
|
1122
|
+
if ip in _running_scan_tasks and not _running_scan_tasks[ip].done():
|
|
1123
|
+
_running_scan_tasks[ip].cancel()
|
|
1124
|
+
|
|
1125
|
+
task = asyncio.create_task(_run_single_target_scan(ip))
|
|
1126
|
+
_running_scan_tasks[ip] = task
|
|
1127
|
+
|
|
1128
|
+
return {
|
|
1129
|
+
"success": True,
|
|
1130
|
+
"message": f"Active scan dispatched for {len(target_ips)} target(s).",
|
|
1131
|
+
"targets": target_ips,
|
|
1132
|
+
"ports": ports_arg,
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
# ----------------------------------------------------------------------
|
|
1137
|
+
# Nuclei Vulnerability Scan Endpoints
|
|
1138
|
+
# ----------------------------------------------------------------------
|
|
1139
|
+
|
|
1140
|
+
def _get_verified_active_services_for_ip(ip: str, db: Optional[DatabaseManager]) -> List[Dict[str, Any]]:
|
|
1141
|
+
"""Retrieve only verified active services (discovered/validated by active scan or containing active sources) for a given IP."""
|
|
1142
|
+
active_services = []
|
|
1143
|
+
if db and Path(db.db_path).exists():
|
|
1144
|
+
import sqlite3
|
|
1145
|
+
with sqlite3.connect(db.db_path) as conn:
|
|
1146
|
+
ip_row = conn.execute("SELECT id FROM ip_addresses WHERE ip = ?", (ip,)).fetchone()
|
|
1147
|
+
if ip_row:
|
|
1148
|
+
ip_id = ip_row[0]
|
|
1149
|
+
services = conn.execute(
|
|
1150
|
+
"SELECT port, protocol, service_name, url, ssl, sources, banner FROM services WHERE ip_id = ?",
|
|
1151
|
+
(ip_id,)
|
|
1152
|
+
).fetchall()
|
|
1153
|
+
def _get_verified_active_services_for_target(target: str, db: Optional[DatabaseManager]) -> List[Dict[str, Any]]:
|
|
1154
|
+
"""Retrieve all verified active services for an IP or FQDN/Domain/Subdomain from database."""
|
|
1155
|
+
active_services = []
|
|
1156
|
+
if db and Path(db.db_path).exists():
|
|
1157
|
+
with sqlite3.connect(db.db_path) as conn:
|
|
1158
|
+
is_ip = False
|
|
1159
|
+
try:
|
|
1160
|
+
ipaddress.ip_address(target)
|
|
1161
|
+
is_ip = True
|
|
1162
|
+
except ValueError:
|
|
1163
|
+
is_ip = False
|
|
1164
|
+
|
|
1165
|
+
ip_ids = []
|
|
1166
|
+
if is_ip:
|
|
1167
|
+
ip_row = conn.execute("SELECT id FROM ip_addresses WHERE ip = ?", (target,)).fetchone()
|
|
1168
|
+
if ip_row:
|
|
1169
|
+
ip_ids = [ip_row[0]]
|
|
1170
|
+
else:
|
|
1171
|
+
cursor = conn.execute("""
|
|
1172
|
+
SELECT ip_id FROM subdomain_ips
|
|
1173
|
+
JOIN subdomains ON subdomains.id = subdomain_ips.subdomain_id
|
|
1174
|
+
WHERE LOWER(subdomains.name) = LOWER(?)
|
|
1175
|
+
""", (target,))
|
|
1176
|
+
ip_ids = [r[0] for r in cursor.fetchall()]
|
|
1177
|
+
if not ip_ids:
|
|
1178
|
+
cursor = conn.execute("""
|
|
1179
|
+
SELECT ip_id FROM subdomain_ips
|
|
1180
|
+
JOIN subdomains ON subdomains.id = subdomain_ips.subdomain_id
|
|
1181
|
+
JOIN domains ON domains.id = subdomains.domain_id
|
|
1182
|
+
WHERE LOWER(domains.name) = LOWER(?)
|
|
1183
|
+
""", (target,))
|
|
1184
|
+
ip_ids = [r[0] for r in cursor.fetchall()]
|
|
1185
|
+
|
|
1186
|
+
for ip_id in ip_ids:
|
|
1187
|
+
services = conn.execute(
|
|
1188
|
+
"SELECT port, protocol, service_name, url, ssl, sources, banner FROM services WHERE ip_id = ?",
|
|
1189
|
+
(ip_id,)
|
|
1190
|
+
).fetchall()
|
|
1191
|
+
for port, proto, s_name, s_url, s_ssl, s_sources, s_banner in services:
|
|
1192
|
+
sources_list = []
|
|
1193
|
+
if s_sources:
|
|
1194
|
+
try:
|
|
1195
|
+
sources_list = json.loads(s_sources)
|
|
1196
|
+
if not isinstance(sources_list, list):
|
|
1197
|
+
sources_list = [str(sources_list)]
|
|
1198
|
+
except Exception:
|
|
1199
|
+
sources_list = [s_sources]
|
|
1200
|
+
|
|
1201
|
+
is_verified_active = any(
|
|
1202
|
+
isinstance(s, str) and ("masscan" in s.lower() or "active" in s.lower() or "nuclei" in s.lower())
|
|
1203
|
+
for s in sources_list
|
|
1204
|
+
)
|
|
1205
|
+
|
|
1206
|
+
if is_verified_active:
|
|
1207
|
+
active_services.append({
|
|
1208
|
+
"port": port,
|
|
1209
|
+
"protocol": proto or "tcp",
|
|
1210
|
+
"service_name": s_name,
|
|
1211
|
+
"url": s_url,
|
|
1212
|
+
"ssl": bool(s_ssl),
|
|
1213
|
+
"sources": sources_list,
|
|
1214
|
+
"banner": s_banner,
|
|
1215
|
+
})
|
|
1216
|
+
return active_services
|
|
1217
|
+
|
|
1218
|
+
|
|
1219
|
+
def _get_verified_active_services_for_ip(ip: str, db: Optional[DatabaseManager]) -> List[Dict[str, Any]]:
|
|
1220
|
+
return _get_verified_active_services_for_target(ip, db)
|
|
1221
|
+
|
|
1222
|
+
|
|
1223
|
+
def _format_nuclei_targets_from_services(target: str, services: List[Dict[str, Any]]) -> List[str]:
|
|
1224
|
+
"""Format verified active services or FQDN into Nuclei endpoint URLs/host-ports."""
|
|
1225
|
+
formatted_targets: List[str] = []
|
|
1226
|
+
if target.startswith("http://") or target.startswith("https://"):
|
|
1227
|
+
formatted_targets.append(target.strip())
|
|
1228
|
+
|
|
1229
|
+
for svc in services:
|
|
1230
|
+
port = svc["port"]
|
|
1231
|
+
s_url = svc.get("url")
|
|
1232
|
+
s_ssl = svc.get("ssl", False)
|
|
1233
|
+
if s_url and str(s_url).startswith("http"):
|
|
1234
|
+
formatted_targets.append(str(s_url).strip())
|
|
1235
|
+
elif s_ssl or port in [443, 8443, 9443]:
|
|
1236
|
+
formatted_targets.append(f"https://{target}:{port}")
|
|
1237
|
+
elif port in [80, 8080, 8000, 8888]:
|
|
1238
|
+
formatted_targets.append(f"http://{target}:{port}")
|
|
1239
|
+
else:
|
|
1240
|
+
formatted_targets.append(f"{target}:{port}")
|
|
1241
|
+
|
|
1242
|
+
# Fallback for FQDNs / URLs if no explicit ports are mapped
|
|
1243
|
+
if not formatted_targets and not target.startswith("http"):
|
|
1244
|
+
try:
|
|
1245
|
+
ipaddress.ip_address(target)
|
|
1246
|
+
except ValueError:
|
|
1247
|
+
formatted_targets = [f"https://{target}", f"http://{target}"]
|
|
1248
|
+
|
|
1249
|
+
return list(dict.fromkeys(formatted_targets))
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
def _format_nuclei_targets_for_ip(ip: str, db: Optional[DatabaseManager]) -> List[str]:
|
|
1253
|
+
"""Format a target and its verified active services into Nuclei scan targets."""
|
|
1254
|
+
active_services = _get_verified_active_services_for_target(ip, db)
|
|
1255
|
+
return _format_nuclei_targets_from_services(ip, active_services)
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
@router.post("/scan/nuclei")
|
|
1259
|
+
async def start_nuclei_scan(
|
|
1260
|
+
req: NucleiScanRequest,
|
|
1261
|
+
request: Request,
|
|
1262
|
+
db: Optional[DatabaseManager] = Depends(get_db_manager),
|
|
1263
|
+
) -> Dict:
|
|
1264
|
+
"""Trigger asynchronous Nuclei vulnerability scan against marked targets/services."""
|
|
1265
|
+
runner = NucleiRunner()
|
|
1266
|
+
if not runner.is_available():
|
|
1267
|
+
raise HTTPException(
|
|
1268
|
+
status_code=503,
|
|
1269
|
+
detail="Nuclei binary not found on server. Ensure nuclei is installed in PATH.",
|
|
1270
|
+
)
|
|
1271
|
+
|
|
1272
|
+
target_ips = req.targets if req.targets else list(_target_registry.keys())
|
|
1273
|
+
if not target_ips:
|
|
1274
|
+
raise HTTPException(status_code=400, detail="No targets selected or marked for Nuclei scan.")
|
|
1275
|
+
|
|
1276
|
+
# Resolve active database
|
|
1277
|
+
active_db = db
|
|
1278
|
+
if not active_db or not Path(active_db.db_path).exists():
|
|
1279
|
+
current_db_path = getattr(request.app.state, "db_path", None)
|
|
1280
|
+
if current_db_path and Path(current_db_path).exists():
|
|
1281
|
+
active_db = DatabaseManager(Path(current_db_path))
|
|
1282
|
+
request.app.state.db_manager = active_db
|
|
1283
|
+
else:
|
|
1284
|
+
dbs_dir = _get_dbs_dir()
|
|
1285
|
+
if dbs_dir.exists():
|
|
1286
|
+
existing_dbs = list(dbs_dir.glob("*.sqlite"))
|
|
1287
|
+
if existing_dbs:
|
|
1288
|
+
active_db = DatabaseManager(existing_dbs[0])
|
|
1289
|
+
request.app.state.db_manager = active_db
|
|
1290
|
+
request.app.state.db_path = str(existing_dbs[0].resolve())
|
|
1291
|
+
|
|
1292
|
+
async def _run_single_nuclei_scan(target_to_scan: str):
|
|
1293
|
+
try:
|
|
1294
|
+
if target_to_scan in _target_registry:
|
|
1295
|
+
_target_registry[target_to_scan]["nuclei_status"] = "scanning"
|
|
1296
|
+
|
|
1297
|
+
is_ip = False
|
|
1298
|
+
try:
|
|
1299
|
+
ipaddress.ip_address(target_to_scan)
|
|
1300
|
+
is_ip = True
|
|
1301
|
+
except ValueError:
|
|
1302
|
+
is_ip = False
|
|
1303
|
+
|
|
1304
|
+
# 1. Check if there are already verified active services discovered by Masscan / active scan
|
|
1305
|
+
active_services = _get_verified_active_services_for_target(target_to_scan, active_db)
|
|
1306
|
+
|
|
1307
|
+
if not active_services:
|
|
1308
|
+
if not is_ip:
|
|
1309
|
+
# FQDN target behind CDN/Reverse Proxy -> Scan web endpoints directly with Nuclei
|
|
1310
|
+
formatted_endpoints = _format_nuclei_targets_from_services(target_to_scan, [])
|
|
1311
|
+
_append_scan_log(
|
|
1312
|
+
"info",
|
|
1313
|
+
f"[Nuclei] FQDN target {target_to_scan} (Reverse Proxy / Virtual Host). Dispatching web scan directly against {', '.join(formatted_endpoints)}...",
|
|
1314
|
+
target=target_to_scan
|
|
1315
|
+
)
|
|
1316
|
+
else:
|
|
1317
|
+
# 2. Check if IP target has unverified passive ports mapped in database
|
|
1318
|
+
verified_ports, unverified_passive_ports = _get_target_ports_partition(target_to_scan, active_db)
|
|
1319
|
+
|
|
1320
|
+
if not unverified_passive_ports:
|
|
1321
|
+
if target_to_scan in _target_registry:
|
|
1322
|
+
_target_registry[target_to_scan]["nuclei_status"] = "completed"
|
|
1323
|
+
_target_registry[target_to_scan]["vulns_count"] = 0
|
|
1324
|
+
_append_scan_log(
|
|
1325
|
+
"warning",
|
|
1326
|
+
f"[Nuclei Smart Skip] Skipped vulnerability scan on {target_to_scan}: IP has no 'Confirmed Active' services and 0 mapped passive ports in database. Run a Masscan port scan first or add services to enable Nuclei scanning.",
|
|
1327
|
+
target=target_to_scan
|
|
1328
|
+
)
|
|
1329
|
+
return
|
|
1330
|
+
|
|
1331
|
+
# Target has unverified passive ports -> Request Masscan verification strictly on these passive ports
|
|
1332
|
+
ports_to_verify = ",".join(str(p) for p in sorted(unverified_passive_ports))
|
|
1333
|
+
_append_scan_log(
|
|
1334
|
+
"info",
|
|
1335
|
+
f"[Nuclei Pre-Scan] Target {target_to_scan} has {len(unverified_passive_ports)} unverified passive port(s) [{ports_to_verify}]. Requesting Masscan verification strictly on these ports before Nuclei execution...",
|
|
1336
|
+
target=target_to_scan
|
|
1337
|
+
)
|
|
1338
|
+
|
|
1339
|
+
masscan_runner = MasscanRunner()
|
|
1340
|
+
if masscan_runner.is_available():
|
|
1341
|
+
if target_to_scan in _target_registry:
|
|
1342
|
+
_target_registry[target_to_scan]["status"] = "scanning"
|
|
1343
|
+
|
|
1344
|
+
m_res = await masscan_runner.scan_target(
|
|
1345
|
+
target_ip=target_to_scan,
|
|
1346
|
+
ports=ports_to_verify,
|
|
1347
|
+
rate=1000,
|
|
1348
|
+
disable_ping=True,
|
|
1349
|
+
banners=True,
|
|
1350
|
+
)
|
|
1351
|
+
|
|
1352
|
+
open_ports = m_res.get("ports") or m_res.get("open_ports") or []
|
|
1353
|
+
if open_ports:
|
|
1354
|
+
if target_to_scan in _target_registry:
|
|
1355
|
+
_target_registry[target_to_scan]["status"] = "completed"
|
|
1356
|
+
_target_registry[target_to_scan]["ports_count"] = len(open_ports)
|
|
1357
|
+
_target_registry[target_to_scan]["ports"] = open_ports
|
|
1358
|
+
_target_registry[target_to_scan]["last_scan"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
1359
|
+
|
|
1360
|
+
if active_db and Path(active_db.db_path).exists():
|
|
1361
|
+
active_db.merge_active_scan_services(target_to_scan, open_ports)
|
|
1362
|
+
|
|
1363
|
+
_append_scan_log(
|
|
1364
|
+
"success",
|
|
1365
|
+
f"[Nuclei Pre-Scan] Masscan verified {len(open_ports)} of {len(unverified_passive_ports)} passive port(s) as Confirmed Active on {target_to_scan}. Proceeding with Nuclei vulnerability scan.",
|
|
1366
|
+
target=target_to_scan
|
|
1367
|
+
)
|
|
1368
|
+
else:
|
|
1369
|
+
if target_to_scan in _target_registry:
|
|
1370
|
+
_target_registry[target_to_scan]["status"] = "completed"
|
|
1371
|
+
_append_scan_log(
|
|
1372
|
+
"warning",
|
|
1373
|
+
f"[Nuclei Pre-Scan] Masscan verification returned 0 open ports for passive ports [{ports_to_verify}] on {target_to_scan}.",
|
|
1374
|
+
target=target_to_scan
|
|
1375
|
+
)
|
|
1376
|
+
else:
|
|
1377
|
+
_append_scan_log(
|
|
1378
|
+
"warning",
|
|
1379
|
+
f"[Nuclei Pre-Scan] Masscan binary not available to verify passive ports [{ports_to_verify}] on {target_to_scan}.",
|
|
1380
|
+
target=target_to_scan
|
|
1381
|
+
)
|
|
1382
|
+
|
|
1383
|
+
# Re-query verified active services after Masscan execution
|
|
1384
|
+
active_services = _get_verified_active_services_for_target(target_to_scan, active_db)
|
|
1385
|
+
|
|
1386
|
+
if not active_services:
|
|
1387
|
+
if target_to_scan in _target_registry:
|
|
1388
|
+
_target_registry[target_to_scan]["nuclei_status"] = "completed"
|
|
1389
|
+
_target_registry[target_to_scan]["vulns_count"] = 0
|
|
1390
|
+
_append_scan_log(
|
|
1391
|
+
"warning",
|
|
1392
|
+
f"[Nuclei Smart Skip] Skipped vulnerability scan on {target_to_scan}: None of the target's passive ports responded as 'Confirmed Active' during Masscan verification.",
|
|
1393
|
+
target=target_to_scan
|
|
1394
|
+
)
|
|
1395
|
+
return
|
|
1396
|
+
|
|
1397
|
+
formatted_endpoints = _format_nuclei_targets_from_services(target_to_scan, active_services)
|
|
1398
|
+
else:
|
|
1399
|
+
formatted_endpoints = _format_nuclei_targets_from_services(target_to_scan, active_services)
|
|
1400
|
+
|
|
1401
|
+
_append_scan_log(
|
|
1402
|
+
"info",
|
|
1403
|
+
f"[Nuclei] Dispatching scan on {target_to_scan} ({len(formatted_endpoints)} endpoint(s): {', '.join(formatted_endpoints[:3])})...",
|
|
1404
|
+
target=target_to_scan
|
|
1405
|
+
)
|
|
1406
|
+
|
|
1407
|
+
def _log_stream(level: str, msg: str):
|
|
1408
|
+
_append_scan_log(level, f"[Nuclei] {msg}", target=target_to_scan)
|
|
1409
|
+
|
|
1410
|
+
scan_res = await runner.scan_targets(
|
|
1411
|
+
targets=formatted_endpoints,
|
|
1412
|
+
severities=req.severities,
|
|
1413
|
+
tags=req.tags,
|
|
1414
|
+
custom_tags=req.custom_tags,
|
|
1415
|
+
rate_limit=req.rate_limit or 150,
|
|
1416
|
+
concurrency=req.concurrency or 25,
|
|
1417
|
+
custom_flags=req.custom_flags,
|
|
1418
|
+
idle_timeout=90.0,
|
|
1419
|
+
max_timeout=3600.0,
|
|
1420
|
+
log_callback=_log_stream,
|
|
1421
|
+
)
|
|
1422
|
+
|
|
1423
|
+
findings = scan_res.get("findings", [])
|
|
1424
|
+
if scan_res.get("success") or findings:
|
|
1425
|
+
if target_to_scan in _target_registry:
|
|
1426
|
+
_target_registry[target_to_scan]["nuclei_status"] = "completed"
|
|
1427
|
+
_target_registry[target_to_scan]["vulns_count"] = len(findings)
|
|
1428
|
+
_target_registry[target_to_scan]["last_nuclei_scan"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
1429
|
+
|
|
1430
|
+
if active_db and Path(active_db.db_path).exists() and findings:
|
|
1431
|
+
merge_info = active_db.merge_nuclei_findings(findings, fallback_ip=target_to_scan)
|
|
1432
|
+
if scan_res.get("partial"):
|
|
1433
|
+
_append_scan_log(
|
|
1434
|
+
"warning",
|
|
1435
|
+
f"[Nuclei] Scan on {target_to_scan} ended with preserved data: {len(findings)} vulnerability issue(s) persisted ({merge_info.get('added_vulnerabilities', 0)} new, {merge_info.get('updated_vulnerabilities', 0)} updated in graph).",
|
|
1436
|
+
target=target_to_scan
|
|
1437
|
+
)
|
|
1438
|
+
else:
|
|
1439
|
+
_append_scan_log(
|
|
1440
|
+
"success",
|
|
1441
|
+
f"[Nuclei] Scan on {target_to_scan} completed: {len(findings)} vulnerability issue(s) discovered. ({merge_info.get('added_vulnerabilities', 0)} new, {merge_info.get('updated_vulnerabilities', 0)} updated in graph).",
|
|
1442
|
+
target=target_to_scan
|
|
1443
|
+
)
|
|
1444
|
+
else:
|
|
1445
|
+
_append_scan_log(
|
|
1446
|
+
"success",
|
|
1447
|
+
f"[Nuclei] Scan on {target_to_scan} completed. {len(findings)} vulnerability issue(s) identified.",
|
|
1448
|
+
target=target_to_scan
|
|
1449
|
+
)
|
|
1450
|
+
else:
|
|
1451
|
+
err_msg = scan_res.get("error", "Unknown Nuclei execution error")
|
|
1452
|
+
if target_to_scan in _target_registry:
|
|
1453
|
+
_target_registry[target_to_scan]["nuclei_status"] = "failed"
|
|
1454
|
+
_append_scan_log("error", f"[Nuclei] Scan on {target_to_scan} failed: {err_msg}", target=target_to_scan)
|
|
1455
|
+
|
|
1456
|
+
except asyncio.CancelledError:
|
|
1457
|
+
if target_to_scan in _target_registry:
|
|
1458
|
+
_target_registry[target_to_scan]["nuclei_status"] = "idle"
|
|
1459
|
+
_append_scan_log("warning", f"[Nuclei] Scan on {target_to_scan} cancelled.", target=target_to_scan)
|
|
1460
|
+
except Exception as ex:
|
|
1461
|
+
if target_to_scan in _target_registry:
|
|
1462
|
+
_target_registry[target_to_scan]["nuclei_status"] = "failed"
|
|
1463
|
+
_append_scan_log("error", f"[Nuclei] Unexpected error scanning {target_to_scan}: {str(ex)}", target=target_to_scan)
|
|
1464
|
+
finally:
|
|
1465
|
+
_running_nuclei_tasks.pop(target_to_scan, None)
|
|
1466
|
+
|
|
1467
|
+
# Ensure Nuclei community templates are updated safely (once with mutex lock + cooldown)
|
|
1468
|
+
if runner.is_available():
|
|
1469
|
+
def _tpl_log(lvl: str, m: str):
|
|
1470
|
+
_append_scan_log(lvl, f"[Nuclei Engine] {m}")
|
|
1471
|
+
await runner.update_templates(cooldown_seconds=3600.0, log_callback=_tpl_log)
|
|
1472
|
+
|
|
1473
|
+
for ip in target_ips:
|
|
1474
|
+
if ip in _running_nuclei_tasks and not _running_nuclei_tasks[ip].done():
|
|
1475
|
+
_running_nuclei_tasks[ip].cancel()
|
|
1476
|
+
|
|
1477
|
+
task = asyncio.create_task(_run_single_nuclei_scan(ip))
|
|
1478
|
+
_running_nuclei_tasks[ip] = task
|
|
1479
|
+
|
|
1480
|
+
return {
|
|
1481
|
+
"success": True,
|
|
1482
|
+
"message": f"Nuclei vulnerability scan dispatched for {len(target_ips)} target(s).",
|
|
1483
|
+
"targets": target_ips,
|
|
1484
|
+
"severities": req.severities,
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
|
|
1488
|
+
@router.post("/scan/cancel")
|
|
1489
|
+
async def cancel_active_scan(req: CancelScanRequest) -> Dict:
|
|
1490
|
+
"""Cancel running active scan or Nuclei scan for a specific target or all targets."""
|
|
1491
|
+
cancelled = []
|
|
1492
|
+
scan_type = req.scan_type or "all"
|
|
1493
|
+
|
|
1494
|
+
if req.all or not req.target:
|
|
1495
|
+
if scan_type in ["all", "masscan"]:
|
|
1496
|
+
for ip, task in list(_running_scan_tasks.items()):
|
|
1497
|
+
if not task.done():
|
|
1498
|
+
task.cancel()
|
|
1499
|
+
cancelled.append(f"masscan:{ip}")
|
|
1500
|
+
if ip in _target_registry:
|
|
1501
|
+
_target_registry[ip]["status"] = "idle"
|
|
1502
|
+
_running_scan_tasks.clear()
|
|
1503
|
+
|
|
1504
|
+
if scan_type in ["all", "nuclei"]:
|
|
1505
|
+
for ip, task in list(_running_nuclei_tasks.items()):
|
|
1506
|
+
if not task.done():
|
|
1507
|
+
task.cancel()
|
|
1508
|
+
cancelled.append(f"nuclei:{ip}")
|
|
1509
|
+
if ip in _target_registry:
|
|
1510
|
+
_target_registry[ip]["nuclei_status"] = "idle"
|
|
1511
|
+
_running_nuclei_tasks.clear()
|
|
1512
|
+
|
|
1513
|
+
_append_scan_log("info", "All running scans cancelled.")
|
|
1514
|
+
else:
|
|
1515
|
+
ip = req.target.strip()
|
|
1516
|
+
if scan_type in ["all", "masscan"] and ip in _running_scan_tasks:
|
|
1517
|
+
task = _running_scan_tasks[ip]
|
|
1518
|
+
if not task.done():
|
|
1519
|
+
task.cancel()
|
|
1520
|
+
cancelled.append(f"masscan:{ip}")
|
|
1521
|
+
_running_scan_tasks.pop(ip, None)
|
|
1522
|
+
if ip in _target_registry:
|
|
1523
|
+
_target_registry[ip]["status"] = "idle"
|
|
1524
|
+
_append_scan_log("info", f"Active port scan on {ip} cancelled.", target=ip)
|
|
1525
|
+
|
|
1526
|
+
if scan_type in ["all", "nuclei"] and ip in _running_nuclei_tasks:
|
|
1527
|
+
task = _running_nuclei_tasks[ip]
|
|
1528
|
+
if not task.done():
|
|
1529
|
+
task.cancel()
|
|
1530
|
+
cancelled.append(f"nuclei:{ip}")
|
|
1531
|
+
_running_nuclei_tasks.pop(ip, None)
|
|
1532
|
+
if ip in _target_registry:
|
|
1533
|
+
_target_registry[ip]["nuclei_status"] = "idle"
|
|
1534
|
+
_append_scan_log("info", f"Nuclei scan on {ip} cancelled.", target=ip)
|
|
1535
|
+
|
|
1536
|
+
return {
|
|
1537
|
+
"success": True,
|
|
1538
|
+
"cancelled_targets": cancelled,
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
|
|
1542
|
+
@router.get("/scan/logs")
|
|
1543
|
+
async def get_scan_logs_endpoint(
|
|
1544
|
+
request: Request,
|
|
1545
|
+
limit: int = Query(100, ge=1, le=500),
|
|
1546
|
+
target: Optional[str] = Query(None),
|
|
1547
|
+
db: Optional[DatabaseManager] = Depends(get_db_manager),
|
|
1548
|
+
) -> Dict:
|
|
1549
|
+
"""Retrieve scan activity logs from database with fallback to memory buffer."""
|
|
1550
|
+
active_db = _resolve_db_manager(request, db, target=target)
|
|
1551
|
+
if active_db and Path(active_db.db_path).exists():
|
|
1552
|
+
try:
|
|
1553
|
+
db_logs = active_db.get_scan_logs(limit=limit, target=target)
|
|
1554
|
+
if db_logs:
|
|
1555
|
+
return {
|
|
1556
|
+
"logs": db_logs,
|
|
1557
|
+
"total": len(db_logs),
|
|
1558
|
+
"source": "sqlite",
|
|
1559
|
+
}
|
|
1560
|
+
except Exception:
|
|
1561
|
+
pass
|
|
1562
|
+
|
|
1563
|
+
# Fallback to in-memory history
|
|
1564
|
+
filtered = _scan_log_history
|
|
1565
|
+
if target:
|
|
1566
|
+
filtered = [l for l in _scan_log_history if l.get("target") == target]
|
|
1567
|
+
|
|
1568
|
+
return {
|
|
1569
|
+
"logs": filtered[-limit:],
|
|
1570
|
+
"total": len(filtered[-limit:]),
|
|
1571
|
+
"source": "memory",
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
|
|
1575
|
+
@router.get("/scan/status")
|
|
1576
|
+
async def get_scan_status(
|
|
1577
|
+
request: Request,
|
|
1578
|
+
db: Optional[DatabaseManager] = Depends(get_db_manager),
|
|
1579
|
+
) -> Dict:
|
|
1580
|
+
"""Get real-time scan status, target registry, and recent activity logs."""
|
|
1581
|
+
running_masscan = sum(1 for t in _target_registry.values() if t.get("status") == "scanning")
|
|
1582
|
+
running_nuclei = sum(1 for t in _target_registry.values() if t.get("nuclei_status") == "scanning")
|
|
1583
|
+
|
|
1584
|
+
# Fetch logs from DB if available, else memory buffer
|
|
1585
|
+
recent_logs = _scan_log_history[-50:]
|
|
1586
|
+
active_db = _resolve_db_manager(request, db)
|
|
1587
|
+
if active_db and Path(active_db.db_path).exists():
|
|
1588
|
+
try:
|
|
1589
|
+
db_logs = active_db.get_scan_logs(limit=60)
|
|
1590
|
+
if db_logs:
|
|
1591
|
+
recent_logs = db_logs
|
|
1592
|
+
except Exception:
|
|
1593
|
+
pass
|
|
1594
|
+
|
|
1595
|
+
return {
|
|
1596
|
+
"running_scans": running_masscan + running_nuclei,
|
|
1597
|
+
"running_masscan": running_masscan,
|
|
1598
|
+
"running_nuclei": running_nuclei,
|
|
1599
|
+
"targets": list(_target_registry.values()),
|
|
1600
|
+
"total_targets": len(_target_registry),
|
|
1601
|
+
"recent_logs": recent_logs,
|
|
1602
|
+
}
|