detecti-cli 2.0.0__py3-none-any.whl

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