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,449 @@
1
+ """Nuclei vulnerability scanner runner module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import os
9
+ import shutil
10
+ import tempfile
11
+ from pathlib import Path
12
+ from typing import Any, Callable, Dict, List, Optional
13
+
14
+ logger = logging.getLogger("detecti.nuclei")
15
+
16
+
17
+ class NucleiRunner:
18
+ """Async Nuclei execution engine for vulnerability scanning."""
19
+
20
+ _update_lock: asyncio.Lock = asyncio.Lock()
21
+ _last_templates_update: float = 0.0
22
+
23
+ def __init__(self, binary_path: Optional[str] = None):
24
+ self.binary_path = binary_path or shutil.which("nuclei") or "/usr/bin/nuclei"
25
+
26
+ def is_available(self) -> bool:
27
+ """Check if nuclei binary exists and is executable."""
28
+ if not self.binary_path:
29
+ return False
30
+ p = Path(self.binary_path)
31
+ return p.exists() and os.access(str(p), os.X_OK)
32
+
33
+ def check_permissions(self) -> Dict[str, Any]:
34
+ """Verify binary availability."""
35
+ available = self.is_available()
36
+ return {
37
+ "available": available,
38
+ "binary_path": self.binary_path if available else None,
39
+ "can_run": available,
40
+ "message": (
41
+ "Nuclei engine ready"
42
+ if available
43
+ else "Nuclei binary not found on system (install with: go install -v github.com/projectdiscovery/nuclei/v3/cmd/nuclei@latest or download from GitHub releases)"
44
+ ),
45
+ }
46
+
47
+ async def update_templates(
48
+ self,
49
+ force: bool = False,
50
+ cooldown_seconds: float = 3600.0,
51
+ log_callback: Optional[Callable[[str, str], Any]] = None,
52
+ ) -> Dict[str, Any]:
53
+ """Update nuclei-templates to the latest release safely with lock and cooldown."""
54
+ if not self.is_available():
55
+ return {"success": False, "error": "Nuclei binary not found"}
56
+
57
+ import time
58
+ now = time.time()
59
+
60
+ async with NucleiRunner._update_lock:
61
+ # Check if updated recently unless forced
62
+ if not force and (now - NucleiRunner._last_templates_update) < cooldown_seconds:
63
+ msg = "Nuclei templates are already up to date (cached within cooldown)."
64
+ logger.info(msg)
65
+ if log_callback:
66
+ log_callback("info", msg)
67
+ return {"success": True, "updated": False, "message": msg}
68
+
69
+ logger.info("Executing Nuclei templates update (-update-templates)...")
70
+ if log_callback:
71
+ log_callback("info", "Checking and updating Nuclei community templates...")
72
+
73
+ try:
74
+ cmd = [self.binary_path, "-update-templates", "-duc"]
75
+ proc = await asyncio.create_subprocess_exec(
76
+ *cmd,
77
+ stdout=asyncio.subprocess.PIPE,
78
+ stderr=asyncio.subprocess.PIPE,
79
+ )
80
+ stdout_bytes, stderr_bytes = await asyncio.wait_for(proc.communicate(), timeout=60.0)
81
+ out_str = stdout_bytes.decode("utf-8", errors="replace") + stderr_bytes.decode("utf-8", errors="replace")
82
+
83
+ NucleiRunner._last_templates_update = time.time()
84
+ success = (proc.returncode == 0)
85
+
86
+ log_msg = f"Nuclei templates update finished: {out_str.strip().splitlines()[-1] if out_str.strip() else 'OK'}"
87
+ logger.info(log_msg)
88
+ if log_callback:
89
+ log_callback("success" if success else "warning", log_msg)
90
+
91
+ return {
92
+ "success": success,
93
+ "updated": True,
94
+ "output": out_str.strip(),
95
+ }
96
+ except asyncio.TimeoutError:
97
+ msg = "Nuclei templates update timed out after 60s (proceeding with existing templates)."
98
+ logger.warning(msg)
99
+ if log_callback:
100
+ log_callback("warning", msg)
101
+ return {"success": False, "error": msg}
102
+ except Exception as e:
103
+ msg = f"Error updating Nuclei templates: {e}"
104
+ logger.warning(msg)
105
+ if log_callback:
106
+ log_callback("warning", msg)
107
+ return {"success": False, "error": msg}
108
+
109
+ async def scan_targets(
110
+ self,
111
+ targets: List[str],
112
+ severities: Optional[List[str]] = None,
113
+ tags: Optional[List[str]] = None,
114
+ custom_tags: Optional[str] = None,
115
+ rate_limit: int = 150,
116
+ concurrency: int = 25,
117
+ custom_flags: Optional[str] = None,
118
+ timeout: Optional[float] = None,
119
+ idle_timeout: float = 90.0,
120
+ max_timeout: Optional[float] = 3600.0,
121
+ log_callback: Optional[Callable[[str, str], Any]] = None,
122
+ ) -> Dict[str, Any]:
123
+ """Execute nuclei against a list of formatted targets with real-time JSONL parsing,
124
+ anti-hang flags, an adaptive idle watchdog, and graceful SIGINT termination.
125
+ """
126
+ if not self.is_available():
127
+ return {
128
+ "success": False,
129
+ "targets": targets,
130
+ "findings": [],
131
+ "error": "Nuclei binary is not available on this system.",
132
+ }
133
+
134
+ if not targets:
135
+ return {
136
+ "success": True,
137
+ "targets": [],
138
+ "findings": [],
139
+ "error": None,
140
+ "total_findings": 0,
141
+ }
142
+
143
+ import time
144
+
145
+ # Normalize severities
146
+ sev_list = [s.strip().lower() for s in (severities or ["critical", "high"]) if s.strip()]
147
+ if not sev_list:
148
+ sev_list = ["critical", "high"]
149
+
150
+ # Normalize tags
151
+ all_tags: List[str] = []
152
+ if tags:
153
+ all_tags.extend([t.strip().lower() for t in tags if t.strip()])
154
+ if custom_tags:
155
+ all_tags.extend([t.strip().lower() for t in custom_tags.split(",") if t.strip()])
156
+ # Deduplicate tags preserving order
157
+ unique_tags = list(dict.fromkeys(all_tags))
158
+
159
+ # Write targets to a temporary file
160
+ with tempfile.NamedTemporaryFile("w+", delete=False, suffix="_nuclei_targets.txt") as tf:
161
+ target_file_path = tf.name
162
+ for t in targets:
163
+ tf.write(f"{t.strip()}\n")
164
+
165
+ # Core Command with Anti-Hang & Stats Heartbeat Flags:
166
+ # -timeout 5: Prevents hung sockets from stalling concurrency workers
167
+ # -retries 1: Avoids retry storms on dropped packets / unresponsive ports
168
+ # -mhe 3: Skips host after 3 consecutive failures to prevent scanning dead targets
169
+ # -stats -si 15: Emits progress heartbeat every 15s to keep idle watchdog active
170
+ cmd: List[str] = [
171
+ self.binary_path,
172
+ "-list", target_file_path,
173
+ "-jsonl",
174
+ "-severity", ",".join(sev_list),
175
+ "-rl", str(max(10, rate_limit)),
176
+ "-c", str(max(1, concurrency)),
177
+ "-timeout", "5",
178
+ "-retries", "1",
179
+ "-mhe", "3",
180
+ "-stats",
181
+ "-si", "15",
182
+ ]
183
+
184
+ if unique_tags:
185
+ cmd.extend(["-tags", ",".join(unique_tags)])
186
+
187
+ if custom_flags:
188
+ import shlex
189
+ try:
190
+ cmd.extend(shlex.split(custom_flags))
191
+ except Exception as e:
192
+ logger.warning(f"Error parsing custom flags '{custom_flags}': {e}")
193
+
194
+ findings: List[Dict[str, Any]] = []
195
+ raw_errors: List[str] = []
196
+ last_activity = [time.time()]
197
+
198
+ if log_callback:
199
+ log_callback("info", f"Starting Nuclei scan on {len(targets)} target(s) [Severities: {','.join(sev_list)}] (Rate: {rate_limit} req/s, Concurrency: {concurrency}, Stats Heartbeat: 15s)")
200
+
201
+ proc = None
202
+
203
+ async def _graceful_terminate(p):
204
+ """Send SIGINT to allow Nuclei to flush findings and close sockets cleanly."""
205
+ if p and p.returncode is None:
206
+ try:
207
+ import signal
208
+ p.send_signal(signal.SIGINT)
209
+ try:
210
+ await asyncio.wait_for(p.wait(), timeout=3.5)
211
+ except (asyncio.TimeoutError, Exception):
212
+ p.kill()
213
+ except Exception:
214
+ try:
215
+ p.kill()
216
+ except Exception:
217
+ pass
218
+
219
+ try:
220
+ logger.info(f"Executing Nuclei: {' '.join(cmd)}")
221
+ proc = await asyncio.create_subprocess_exec(
222
+ *cmd,
223
+ stdout=asyncio.subprocess.PIPE,
224
+ stderr=asyncio.subprocess.PIPE,
225
+ )
226
+
227
+ async def read_stdout():
228
+ assert proc.stdout is not None
229
+ while True:
230
+ line = await proc.stdout.readline()
231
+ if not line:
232
+ break
233
+ last_activity[0] = time.time()
234
+ line_str = line.decode("utf-8", errors="replace").strip()
235
+ if not line_str:
236
+ continue
237
+ try:
238
+ data = json.loads(line_str)
239
+ parsed_finding = self._normalize_finding(data)
240
+ if parsed_finding:
241
+ findings.append(parsed_finding)
242
+ if log_callback:
243
+ sev = parsed_finding.get("severity", "info").upper()
244
+ name = parsed_finding.get("name") or parsed_finding.get("template_id")
245
+ matched = parsed_finding.get("matched_at") or parsed_finding.get("host")
246
+ log_callback("warn" if sev in ["CRITICAL", "HIGH"] else "info", f"[{sev}] {name} on {matched}")
247
+ except json.JSONDecodeError:
248
+ if log_callback and ("[" in line_str or "ERR" in line_str or "Templates:" in line_str):
249
+ log_callback("info", line_str)
250
+
251
+ async def read_stderr():
252
+ assert proc.stderr is not None
253
+ while True:
254
+ line = await proc.stderr.readline()
255
+ if not line:
256
+ break
257
+ last_activity[0] = time.time()
258
+ err_str = line.decode("utf-8", errors="replace").strip()
259
+ if err_str:
260
+ # If it's a stats heartbeat line (e.g. [0:00:15] | Templates: ...), log as info/debug heartbeat
261
+ if "Templates:" in err_str or "Requests:" in err_str or "[stats]" in err_str.lower():
262
+ logger.debug(f"Nuclei stats heartbeat: {err_str}")
263
+ else:
264
+ raw_errors.append(err_str)
265
+ logger.debug(f"Nuclei stderr: {err_str}")
266
+
267
+ async def watchdog():
268
+ """Monitor stream activity and trigger graceful exit if idle or max timeout reached."""
269
+ start_t = time.time()
270
+ while proc.returncode is None:
271
+ await asyncio.sleep(2.0)
272
+ now_t = time.time()
273
+ # 1. Check Idle Watchdog (no response or activity on sockets for > idle_timeout)
274
+ if idle_timeout and (now_t - last_activity[0]) > idle_timeout:
275
+ logger.warning(f"Nuclei idle watchdog triggered: zero activity/heartbeat for >{idle_timeout}s.")
276
+ raise asyncio.TimeoutError(f"Nuclei scan idle for >{idle_timeout}s without response")
277
+ # 2. Check Absolute Max Timeout (safety cap)
278
+ if max_timeout and (now_t - start_t) > max_timeout:
279
+ logger.warning(f"Nuclei reached absolute max execution ceiling of {max_timeout}s.")
280
+ raise asyncio.TimeoutError(f"Nuclei scan reached max execution ceiling of {max_timeout}s")
281
+ # 3. Check legacy custom timeout if explicitly passed
282
+ if timeout and (now_t - start_t) > timeout:
283
+ logger.warning(f"Nuclei reached custom timeout of {timeout}s.")
284
+ raise asyncio.TimeoutError(f"Nuclei scan timed out after {timeout}s")
285
+
286
+ # Run streams, process wait, and watchdog concurrently
287
+ await asyncio.gather(
288
+ read_stdout(),
289
+ read_stderr(),
290
+ proc.wait(),
291
+ watchdog(),
292
+ )
293
+
294
+ if log_callback:
295
+ log_callback("success", f"Nuclei scan completed. Found {len(findings)} vulnerability issue(s).")
296
+
297
+ return {
298
+ "success": True,
299
+ "targets": targets,
300
+ "severities": sev_list,
301
+ "tags": unique_tags,
302
+ "findings": findings,
303
+ "total_findings": len(findings),
304
+ "error": None if not raw_errors else "\n".join(raw_errors[:5]),
305
+ }
306
+
307
+ except asyncio.TimeoutError as te:
308
+ await _graceful_terminate(proc)
309
+ msg = str(te) if str(te) else f"Nuclei scan timed out"
310
+ logger.warning(f"{msg}. Preserving {len(findings)} accumulated findings.")
311
+ if log_callback:
312
+ log_callback("warn" if findings else "error", f"{msg} ({len(findings)} findings preserved).")
313
+ return {
314
+ "success": len(findings) > 0,
315
+ "targets": targets,
316
+ "severities": sev_list,
317
+ "tags": unique_tags,
318
+ "findings": findings,
319
+ "total_findings": len(findings),
320
+ "error": msg if not findings else None,
321
+ "partial": True,
322
+ }
323
+ except asyncio.CancelledError:
324
+ await _graceful_terminate(proc)
325
+ logger.info(f"Nuclei scan cancelled by user. Preserving {len(findings)} accumulated findings.")
326
+ if log_callback:
327
+ log_callback("warn" if findings else "info", f"Nuclei scan cancelled by user ({len(findings)} findings preserved).")
328
+ return {
329
+ "success": len(findings) > 0,
330
+ "targets": targets,
331
+ "severities": sev_list,
332
+ "tags": unique_tags,
333
+ "findings": findings,
334
+ "total_findings": len(findings),
335
+ "error": "Scan cancelled by user",
336
+ "partial": True,
337
+ }
338
+ except Exception as e:
339
+ await _graceful_terminate(proc)
340
+ msg = f"Nuclei execution error: {str(e)}"
341
+ logger.error(msg, exc_info=True)
342
+ if log_callback:
343
+ log_callback("error", msg)
344
+ return {
345
+ "success": len(findings) > 0,
346
+ "targets": targets,
347
+ "severities": sev_list,
348
+ "tags": unique_tags,
349
+ "findings": findings,
350
+ "total_findings": len(findings),
351
+ "error": msg if not findings else None,
352
+ "partial": True,
353
+ }
354
+ finally:
355
+ if os.path.exists(target_file_path):
356
+ try:
357
+ os.remove(target_file_path)
358
+ except Exception:
359
+ pass
360
+
361
+ def _normalize_finding(self, record: Dict[str, Any]) -> Optional[Dict[str, Any]]:
362
+ """Normalize a Nuclei JSONL record into standard vulnerability dict."""
363
+ if not isinstance(record, dict):
364
+ return None
365
+
366
+ template_id = record.get("template-id") or record.get("templateID") or "unknown-template"
367
+ info = record.get("info") or {}
368
+
369
+ name = info.get("name") or template_id
370
+ severity = str(info.get("severity") or "info").upper()
371
+ description = info.get("description") or ""
372
+
373
+ # Classification
374
+ classification = info.get("classification") or {}
375
+ cve_id = None
376
+ cve_ids = classification.get("cve-id")
377
+ if isinstance(cve_ids, list) and cve_ids:
378
+ cve_id = str(cve_ids[0]).upper()
379
+ elif isinstance(cve_ids, str) and cve_ids.strip():
380
+ cve_id = cve_ids.strip().upper()
381
+ elif template_id.lower().startswith("cve-"):
382
+ cve_id = template_id.upper()
383
+
384
+ cwe_id = None
385
+ cwe_ids = classification.get("cwe-id")
386
+ if isinstance(cwe_ids, list) and cwe_ids:
387
+ cwe_id = str(cwe_ids[0]).upper()
388
+ elif isinstance(cwe_ids, str) and cwe_ids.strip():
389
+ cwe_id = cwe_ids.strip().upper()
390
+
391
+ cvss_score = classification.get("cvss-score")
392
+ if cvss_score is not None:
393
+ try:
394
+ cvss_score = float(cvss_score)
395
+ except (ValueError, TypeError):
396
+ cvss_score = None
397
+
398
+ epss_score = classification.get("epss-score")
399
+ if epss_score is not None:
400
+ try:
401
+ epss_score = float(epss_score)
402
+ except (ValueError, TypeError):
403
+ epss_score = None
404
+
405
+ matched_at = record.get("matched-at") or record.get("matched") or record.get("host") or ""
406
+ host = record.get("host") or ""
407
+ ip = record.get("ip") or ""
408
+ port = record.get("port")
409
+ if port is not None:
410
+ try:
411
+ port = int(port)
412
+ except (ValueError, TypeError):
413
+ port = None
414
+
415
+ # Extract PoC / Reference URLs
416
+ reference = info.get("reference") or []
417
+ references = []
418
+ if isinstance(reference, list):
419
+ references = [str(r) for r in reference if r]
420
+ elif isinstance(reference, str) and reference.strip():
421
+ references = [reference.strip()]
422
+
423
+ tags = info.get("tags") or []
424
+ if isinstance(tags, str):
425
+ tags = [t.strip() for t in tags.split(",") if t.strip()]
426
+
427
+ curl_command = record.get("curl-command") or ""
428
+
429
+ return {
430
+ "template_id": template_id,
431
+ "name": name,
432
+ "severity": severity,
433
+ "cve_id": cve_id or template_id,
434
+ "description": description,
435
+ "cwe_id": cwe_id,
436
+ "cwe_name": ", ".join(tags[:4]) if tags else None,
437
+ "cvss_score": cvss_score,
438
+ "epss_score": epss_score,
439
+ "host": host,
440
+ "ip": ip,
441
+ "port": port,
442
+ "matched_at": matched_at,
443
+ "references": references,
444
+ "tags": tags,
445
+ "curl_command": curl_command,
446
+ "timestamp": record.get("timestamp"),
447
+ "matcher_name": record.get("matcher-name"),
448
+ "extracted_results": record.get("extracted-results"),
449
+ }