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
detecti/utils/setup.py ADDED
@@ -0,0 +1,453 @@
1
+ """Automated Environment Setup, Prerequisite Diagnostics & Auto-Configuration for DetecTI-CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List, Optional, Tuple
11
+
12
+ from rich.console import Console
13
+ from rich.table import Table
14
+
15
+ from detecti.config import DETECTI_HOME
16
+
17
+ class SetupManager:
18
+ """Manages prerequisite diagnostics and automated system setup for DetecTI-CLI."""
19
+
20
+ REQUIRED_PYTHON_MODULES = [
21
+ ("httpx", "httpx"),
22
+ ("typer", "typer"),
23
+ ("rich", "rich"),
24
+ ("fastapi", "fastapi"),
25
+ ("uvicorn", "uvicorn"),
26
+ ("pydantic", "pydantic"),
27
+ ("pydantic_settings", "pydantic-settings"),
28
+ ("psutil", "psutil"),
29
+ ("tldextract", "tldextract"),
30
+ ("cve_searchsploit", "cve-searchsploit"),
31
+ ]
32
+
33
+ def __init__(self, console: Optional[Console] = None) -> None:
34
+ self.console = console or Console()
35
+ self.root_dir = Path(__file__).resolve().parent.parent
36
+
37
+ def check_all(self) -> Dict[str, Any]:
38
+ """Run all diagnostic checks across system, environment, binaries, and local databases."""
39
+ return {
40
+ "python_version": self.check_python_version(),
41
+ "python_modules": self.check_python_modules(),
42
+ "directories": self.check_directories(),
43
+ "env_file": self.check_env_file(),
44
+ "masscan": self.check_masscan(),
45
+ "nuclei": self.check_nuclei(),
46
+ "exploitdb": self.check_exploitdb(),
47
+ "demo_db": self.check_demo_database(),
48
+ }
49
+
50
+ def check_python_version(self) -> Dict[str, Any]:
51
+ """Check Python interpreter version (requires 3.11+)."""
52
+ ver = sys.version_info
53
+ is_ok = ver >= (3, 11)
54
+ ver_str = f"{ver.major}.{ver.minor}.{ver.micro}"
55
+ return {
56
+ "name": "Python Runtime",
57
+ "status": f"Python {ver_str}",
58
+ "ok": is_ok,
59
+ "required": ">= 3.11",
60
+ "message": "Python 3.11 or newer is required." if not is_ok else "Compatible version detected.",
61
+ }
62
+
63
+ def check_python_modules(self) -> Dict[str, Any]:
64
+ """Check availability of core Python packages."""
65
+ missing = []
66
+ installed = []
67
+ for mod_name, pkg_name in self.REQUIRED_PYTHON_MODULES:
68
+ try:
69
+ __import__(mod_name)
70
+ installed.append(pkg_name)
71
+ except ImportError:
72
+ missing.append(pkg_name)
73
+
74
+ is_ok = len(missing) == 0
75
+ return {
76
+ "name": "Python Dependencies",
77
+ "status": f"{len(installed)}/{len(self.REQUIRED_PYTHON_MODULES)} Installed",
78
+ "ok": is_ok,
79
+ "missing": missing,
80
+ "message": f"Missing packages: {', '.join(missing)}" if missing else "All core packages are installed.",
81
+ }
82
+
83
+ def check_directories(self) -> Dict[str, Any]:
84
+ """Ensure required operational directories exist."""
85
+ dirs = [
86
+ DETECTI_HOME / "data" / "dbs",
87
+ Path.cwd() / "reports",
88
+ ]
89
+ missing = [d for d in dirs if not d.exists()]
90
+ return {
91
+ "name": "Project Directories",
92
+ "status": "Ready" if not missing else f"Missing {len(missing)} folders",
93
+ "ok": len(missing) == 0,
94
+ "missing": [str(d) for d in missing],
95
+ "message": "Operational directories (./data/dbs, ./reports) are configured." if not missing else f"Folders need creation: {', '.join([str(d) for d in missing])}",
96
+ }
97
+
98
+ def check_env_file(self) -> Dict[str, Any]:
99
+ """Check if .env configuration file exists."""
100
+ env_path = DETECTI_HOME / ".env"
101
+ exists = env_path.is_file()
102
+ return {
103
+ "name": "Environment Configuration (.env)",
104
+ "status": "Configured (.env found)" if exists else "Missing (.env not found)",
105
+ "ok": exists,
106
+ "message": "Configured and active." if exists else ".env file not found (can be created from .env.example).",
107
+ }
108
+
109
+ def check_masscan(self) -> Dict[str, Any]:
110
+ """Check Masscan binary installation and Linux raw socket capabilities."""
111
+ masscan_path = shutil.which("masscan")
112
+ if not masscan_path:
113
+ return {
114
+ "name": "Masscan Active Port Scanner",
115
+ "status": "Not Installed",
116
+ "ok": False,
117
+ "path": None,
118
+ "has_caps": False,
119
+ "message": "Binary 'masscan' not found in PATH. Required for active port scanning in WebGUI.",
120
+ }
121
+
122
+ # Check raw socket capabilities or root execution
123
+ has_caps = False
124
+ is_root = hasattr(os, "geteuid") and os.geteuid() == 0
125
+ if is_root:
126
+ has_caps = True
127
+ else:
128
+ getcap_bin = shutil.which("getcap")
129
+ if getcap_bin:
130
+ try:
131
+ res = subprocess.run([getcap_bin, masscan_path], capture_output=True, text=True, check=False)
132
+ if "cap_net_raw" in res.stdout:
133
+ has_caps = True
134
+ except Exception:
135
+ pass
136
+
137
+ status_text = "Ready (Non-root caps active)" if has_caps else "Installed (Needs setcap capabilities)"
138
+ if is_root:
139
+ status_text = "Ready (Running as root)"
140
+
141
+ return {
142
+ "name": "Masscan Active Port Scanner",
143
+ "status": status_text,
144
+ "ok": masscan_path is not None,
145
+ "path": masscan_path,
146
+ "has_caps": has_caps,
147
+ "message": "Masscan is fully configured and ready for non-root execution." if has_caps else f"Masscan found at {masscan_path}, but raw socket capabilities should be set: sudo setcap cap_net_raw,cap_net_admin,cap_net_bind_service+eip {masscan_path}",
148
+ }
149
+
150
+ def check_nuclei(self) -> Dict[str, Any]:
151
+ """Check Nuclei active vulnerability scanner binary and templates."""
152
+ nuclei_path = shutil.which("nuclei")
153
+ if not nuclei_path:
154
+ return {
155
+ "name": "Nuclei Vulnerability Scanner",
156
+ "status": "Optional / Not Installed",
157
+ "ok": True, # Nuclei is optional but recommended
158
+ "installed": False,
159
+ "path": None,
160
+ "message": "Nuclei binary not found in PATH. Install to enable active vulnerability verification.",
161
+ }
162
+
163
+ # Check version
164
+ version = "Unknown"
165
+ try:
166
+ res = subprocess.run([nuclei_path, "-version"], capture_output=True, text=True, check=False, timeout=3)
167
+ for line in (res.stdout + res.stderr).splitlines():
168
+ if "nuclei" in line.lower() or "version" in line.lower():
169
+ version = line.strip()
170
+ break
171
+ except Exception:
172
+ pass
173
+
174
+ return {
175
+ "name": "Nuclei Vulnerability Scanner",
176
+ "status": f"Ready ({version})",
177
+ "ok": True,
178
+ "installed": True,
179
+ "path": nuclei_path,
180
+ "message": f"Nuclei is available at {nuclei_path}.",
181
+ }
182
+
183
+ def check_exploitdb(self) -> Dict[str, Any]:
184
+ """Check local ExploitDB searchsploit mapping database."""
185
+ try:
186
+ import cve_searchsploit as cs
187
+ mapping_path = Path(cs.pdir) / "exploitdb_mapping.json"
188
+ exists = mapping_path.is_file() and mapping_path.stat().st_size > 1024
189
+ return {
190
+ "name": "ExploitDB SearchSploit Cache",
191
+ "status": "Ready (Populated)" if exists else "Needs Update",
192
+ "ok": exists,
193
+ "path": str(mapping_path) if exists else None,
194
+ "message": "Local ExploitDB mapping cache is populated." if exists else "Run update-xdb to download local exploit mapping.",
195
+ }
196
+ except Exception as exc:
197
+ return {
198
+ "name": "ExploitDB SearchSploit Cache",
199
+ "status": "Error",
200
+ "ok": False,
201
+ "message": f"Could not inspect ExploitDB mapping: {exc}",
202
+ }
203
+
204
+ def check_demo_database(self) -> Dict[str, Any]:
205
+ """Check default demo graph database."""
206
+ db_path = DETECTI_HOME / "data" / "dbs" / "example.com.sqlite"
207
+ exists = db_path.is_file()
208
+ return {
209
+ "name": "Default Demo Graph Dataset",
210
+ "status": "Ready (example.com.sqlite)" if exists else "Missing",
211
+ "ok": exists,
212
+ "message": "Pre-packaged demo dataset available for instant DetecTIHound visualization." if exists else "Demo dataset missing.",
213
+ }
214
+
215
+ def render_diagnostics_table(self, checks: Dict[str, Any]) -> None:
216
+ """Render a formatted, high-contrast Rich diagnostics table."""
217
+ table = Table(title="DetecTI-CLI System & Environment Diagnostics", show_header=True, header_style="bold cyan")
218
+ table.add_column("Component / Subsystem", style="bold white", min_width=28)
219
+ table.add_column("Status", style="bold", min_width=24)
220
+ table.add_column("Diagnostic Details", style="dim")
221
+
222
+ for key, info in checks.items():
223
+ name = info.get("name", key)
224
+ status = info.get("status", "Unknown")
225
+ is_ok = info.get("ok", False)
226
+ msg = info.get("message", "")
227
+
228
+ if is_ok and "Needs" not in status:
229
+ status_styled = f"[bold green]✔ {status}[/bold green]"
230
+ elif "Optional" in status:
231
+ status_styled = f"[yellow]⚠ {status}[/yellow]"
232
+ elif not is_ok:
233
+ status_styled = f"[bold red]✘ {status}[/bold red]"
234
+ else:
235
+ status_styled = f"[yellow]⚠ {status}[/yellow]"
236
+
237
+ table.add_row(name, status_styled, msg)
238
+
239
+ self.console.print(table)
240
+
241
+ def run_automated_setup(self) -> bool:
242
+ """Run automated setup: creates directories, .env file, configures capabilities, and updates databases."""
243
+ self.console.print("\n[bold cyan]🚀 Starting DetecTI-CLI Automated Environment Setup...[/bold cyan]\n")
244
+
245
+ all_success = True
246
+
247
+ # Step 0: Dashboard Admin Password Setup
248
+ self.console.print("🔐 [bold white]Step 0/8: Configuring DetecTIHound Dashboard Admin...[/bold white]")
249
+ try:
250
+ import getpass
251
+ import sys
252
+ def update_env_jwt(password: str):
253
+ import hashlib
254
+ import re
255
+ jwt_secret = hashlib.sha256(password.encode('utf-8')).hexdigest()
256
+ env_path = DETECTI_HOME / '.env'
257
+ if env_path.exists():
258
+ with open(env_path, 'r') as f:
259
+ env_content = f.read()
260
+ if 'JWT_SECRET_KEY=' in env_content:
261
+ env_content = re.sub(r'JWT_SECRET_KEY=.*', f'JWT_SECRET_KEY={jwt_secret}', env_content)
262
+ else:
263
+ if env_content and not env_content.endswith('\n'):
264
+ env_content += '\n'
265
+ env_content += f'JWT_SECRET_KEY={jwt_secret}\n'
266
+ with open(env_path, 'w') as f:
267
+ f.write(env_content)
268
+ else:
269
+ with open(env_path, 'w') as f:
270
+ f.write(f'JWT_SECRET_KEY={jwt_secret}\n')
271
+ import os
272
+ sys.path.insert(0, str(self.root_dir))
273
+ from core.database.config_db import ConfigDBManager, get_password_hash
274
+
275
+ db_dir = DETECTI_HOME / "data" / "dbs"
276
+ db_dir.mkdir(parents=True, exist_ok=True)
277
+ config_db = ConfigDBManager(DETECTI_HOME / "data" / "config.sqlite")
278
+
279
+ if config_db.user_exists("admin"):
280
+ change = self.console.input(" [yellow]Admin user already exists. Do you want to change the password? (y/N): [/yellow]").strip().lower()
281
+ if change == 'y':
282
+ while True:
283
+ pwd1 = getpass.getpass(" Enter new password for 'admin': ")
284
+ pwd2 = getpass.getpass(" Confirm new password: ")
285
+ if pwd1 == pwd2 and len(pwd1) >= 4:
286
+ config_db.update_user_password("admin", get_password_hash(pwd1))
287
+ update_env_jwt(pwd1)
288
+ self.console.print(" [green]✔ Admin password updated successfully.[/green]")
289
+ break
290
+ elif len(pwd1) < 4:
291
+ self.console.print(" [red]Password must be at least 4 characters.[/red]")
292
+ else:
293
+ self.console.print(" [red]Passwords do not match. Try again.[/red]")
294
+ else:
295
+ self.console.print(" [green]✔ Admin configuration skipped.[/green]")
296
+ else:
297
+ self.console.print(" [cyan]Creating default 'admin' user for the web dashboard.[/cyan]")
298
+ while True:
299
+ pwd1 = getpass.getpass(" Enter password for 'admin': ")
300
+ pwd2 = getpass.getpass(" Confirm password: ")
301
+ if pwd1 == pwd2 and len(pwd1) >= 4:
302
+ config_db.create_user("admin", get_password_hash(pwd1))
303
+ update_env_jwt(pwd1)
304
+ self.console.print(" [green]✔ Admin user created successfully.[/green]")
305
+ break
306
+ elif len(pwd1) < 4:
307
+ self.console.print(" [red]Password must be at least 4 characters.[/red]")
308
+ else:
309
+ self.console.print(" [red]Passwords do not match. Try again.[/red]")
310
+ except Exception as e:
311
+ self.console.print(f" [red]⚠ Failed to configure admin: {e}[/red]")
312
+ import traceback
313
+ traceback.print_exc()
314
+
315
+ # Step 1: Create Directories & Copy Demo DB
316
+ self.console.print("📁 [bold white]Step 1/8: Initializing project directories...[/bold white]")
317
+ for d in [DETECTI_HOME / "data" / "dbs", Path.cwd() / "reports"]:
318
+ d.mkdir(parents=True, exist_ok=True)
319
+
320
+ demo_db_src = self.root_dir / "data" / "dbs" / "example.com.sqlite"
321
+ demo_db_dst = DETECTI_HOME / "data" / "dbs" / "example.com.sqlite"
322
+ if demo_db_src.exists() and not demo_db_dst.exists():
323
+ shutil.copy2(demo_db_src, demo_db_dst)
324
+ self.console.print(" [green]✔ Demo database (example.com.sqlite) initialized.[/green]")
325
+
326
+ self.console.print(" [green]✔ Operational directories verified (data/dbs, reports).[/green]")
327
+
328
+ # Step 2: Configure .env
329
+ self.console.print("\n⚙️ [bold white]Step 2/8: Checking environment configuration (.env)...[/bold white]")
330
+ env_file = DETECTI_HOME / ".env"
331
+ env_example = self.root_dir / ".env.example"
332
+ if not env_file.exists() and env_example.exists():
333
+ shutil.copy(env_example, env_file)
334
+ self.console.print(f" [green]✔ Created .env file at {env_file} from template.[/green]")
335
+ elif env_file.exists():
336
+ self.console.print(f" [green]✔ Existing .env file detected at {env_file} and preserved.[/green]")
337
+ else:
338
+ self.console.print(" [yellow]⚠ No .env template found. Skipped.[/yellow]")
339
+
340
+ # Step 3: Python dependencies check / install
341
+ self.console.print("\n🐍 [bold white]Step 3/8: Verifying Python dependencies...[/bold white]")
342
+ req_file = self.root_dir / "requirements.txt"
343
+ dep_check = self.check_python_modules()
344
+ if not dep_check["ok"] and req_file.exists():
345
+ self.console.print(f" [yellow]Installing missing dependencies: {', '.join(dep_check['missing'])}...[/yellow]")
346
+ try:
347
+ subprocess.run([sys.executable, "-m", "pip", "install", "-r", str(req_file)], check=True)
348
+ self.console.print(" [green]✔ Python dependencies installed successfully.[/green]")
349
+ except Exception as exc:
350
+ self.console.print(f" [red]✘ Failed to install Python dependencies: {exc}[/red]")
351
+ all_success = False
352
+ else:
353
+ self.console.print(" [green]✔ All Python core dependencies are satisfied.[/green]")
354
+
355
+ # Step 4: Masscan capabilities configuration
356
+ self.console.print("\n⚡ [bold white]Step 4/8: Configuring Masscan network capabilities...[/bold white]")
357
+ masscan_bin = shutil.which("masscan")
358
+ if masscan_bin:
359
+ is_root = hasattr(os, "geteuid") and os.geteuid() == 0
360
+ if is_root:
361
+ self.console.print(" [green]✔ Running as root: raw packet sockets are natively authorized.[/green]")
362
+ else:
363
+ setcap_bin = shutil.which("setcap")
364
+ if setcap_bin:
365
+ try:
366
+ cmd = ["sudo", setcap_bin, "cap_net_raw,cap_net_admin,cap_net_bind_service+eip", masscan_bin]
367
+ self.console.print(f" [cyan]Applying Linux capabilities via setcap...[/cyan]")
368
+ res = subprocess.run(cmd, check=False)
369
+ if res.returncode == 0:
370
+ self.console.print(" [green]✔ Granted non-root raw socket capabilities to masscan.[/green]")
371
+ else:
372
+ self.console.print(f" [yellow]⚠ Could not apply setcap automatically. Run manually if needed:[/yellow]\n sudo setcap cap_net_raw,cap_net_admin,cap_net_bind_service+eip {masscan_bin}")
373
+ except Exception as exc:
374
+ self.console.print(f" [yellow]⚠ Note: Run manually if non-root WebGUI scanning is needed:\n sudo setcap cap_net_raw,cap_net_admin,cap_net_bind_service+eip {masscan_bin}[/yellow]")
375
+ else:
376
+ self.console.print(f" [yellow]⚠ 'setcap' binary not found. Masscan may require root permissions to scan.[/yellow]")
377
+ else:
378
+ self.console.print(" [yellow]⚠ Masscan is not installed on this system.[/yellow]")
379
+ self.console.print(" [dim]Install on Linux with: sudo apt install -y masscan (or pacman/dnf)[/dim]")
380
+
381
+ # Step 5: ExploitDB Cache Update
382
+ self.console.print("\n💣 [bold white]Step 5/8: Initializing ExploitDB vulnerability mapping...[/bold white]")
383
+ try:
384
+ from modules.exploitdb import ExploitDBModule
385
+ ExploitDBModule.update_database()
386
+ self.console.print(" [green]✔ ExploitDB mapping database initialized & updated.[/green]")
387
+ except Exception as exc:
388
+ self.console.print(f" [yellow]⚠ ExploitDB update notice: {exc}[/yellow]")
389
+
390
+ # Step 6: Nuclei Templates Check
391
+ self.console.print("\n🛡️ [bold white]Step 6/8: Checking Nuclei vulnerability engine...[/bold white]")
392
+ nuclei_bin = shutil.which("nuclei")
393
+ if nuclei_bin:
394
+ try:
395
+ self.console.print(" [cyan]Updating Nuclei community vulnerability templates...[/cyan]")
396
+ subprocess.run([nuclei_bin, "-update-templates", "-silent"], check=False, timeout=15)
397
+ self.console.print(" [green]✔ Nuclei templates checked & updated.[/green]")
398
+ except Exception:
399
+ self.console.print(" [green]✔ Nuclei engine is active.[/green]")
400
+ else:
401
+ self.console.print(" [dim]Nuclei is optional and not currently installed.[/dim]")
402
+
403
+ # Step 7: Configure Global Executable & Install Source
404
+ self.console.print("\n🌍 [bold white]Step 7/8: Installing Source & Configuring Global Executable...[/bold white]")
405
+ try:
406
+ is_root = hasattr(os, "geteuid") and os.geteuid() == 0
407
+ if is_root:
408
+ bin_dir = Path("/usr/local/bin")
409
+ install_dir = Path("/opt/detecti-cli")
410
+ else:
411
+ bin_dir = Path.home() / ".local" / "bin"
412
+ install_dir = DETECTI_HOME / "app"
413
+
414
+ if self.root_dir.resolve() == install_dir.resolve():
415
+ self.console.print(f" [cyan]Running from installed location ({install_dir}). Skipping source copy.[/cyan]")
416
+ else:
417
+ self.console.print(f" [cyan]Copying source code to {install_dir}...[/cyan]")
418
+
419
+ # Remove old install dir if exists to ensure clean install
420
+ if install_dir.exists():
421
+ shutil.rmtree(install_dir)
422
+
423
+ # Copy source code, ignoring unnecessary/heavy folders
424
+ shutil.copytree(
425
+ self.root_dir,
426
+ install_dir,
427
+ ignore=shutil.ignore_patterns('.git', '__pycache__', 'node_modules', 'reports', '.pytest_cache')
428
+ )
429
+
430
+ bin_dir.mkdir(parents=True, exist_ok=True)
431
+ wrapper_path = bin_dir / "detecti-cli"
432
+
433
+ # The actual python script is now in the install_dir
434
+ target_script = install_dir / "cli.py"
435
+
436
+ wrapper_content = f"""#!/usr/bin/env bash
437
+ # DetecTI-CLI Global Wrapper
438
+ python3 "{target_script}" "$@"
439
+ """
440
+ wrapper_path.write_text(wrapper_content)
441
+ # Make it executable (chmod +x)
442
+ wrapper_path.chmod(wrapper_path.stat().st_mode | 0o111)
443
+
444
+ self.console.print(f" [green]✔ Source installed securely in {install_dir}[/green]")
445
+ self.console.print(f" [green]✔ Global executable created at {wrapper_path}[/green]")
446
+ if not is_root:
447
+ self.console.print(f" [cyan]ℹ Make sure {bin_dir} is in your PATH.[/cyan]")
448
+ except Exception as exc:
449
+ self.console.print(f" [red]✘ Failed to install source or create global executable: {exc}[/red]")
450
+ all_success = False
451
+
452
+ self.console.print("\n[bold green]✅ DetecTI-CLI setup routine completed![/bold green]\n")
453
+ return all_success
@@ -0,0 +1,6 @@
1
+ """DetecTI-CLI Web Server and Dashboard."""
2
+
3
+ from .server import create_app
4
+ from .process_manager import WebServerManager
5
+
6
+ __all__ = ["create_app", "WebServerManager"]
@@ -0,0 +1 @@
1
+ """API routes for DetecTI-CLI web dashboard."""
@@ -0,0 +1,109 @@
1
+ from datetime import datetime, timedelta
2
+ from typing import Optional
3
+ import os
4
+
5
+ from fastapi import APIRouter, Depends, HTTPException, status, Response, Request
6
+ from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
7
+ from pydantic import BaseModel
8
+ from jose import JWTError, jwt
9
+
10
+ from detecti.core.database.config_db import ConfigDBManager, verify_password
11
+
12
+ from dotenv import load_dotenv
13
+ load_dotenv()
14
+
15
+ # Secret key for JWT
16
+ SECRET_KEY = os.environ.get("JWT_SECRET_KEY")
17
+ if not SECRET_KEY:
18
+ # Emite um aviso ou falha se não houver chave (força rodar o setup)
19
+ raise ValueError("JWT_SECRET_KEY is missing from environment/ .env file. Please run the setup command.")
20
+ ALGORITHM = "HS256"
21
+ ACCESS_TOKEN_EXPIRE_MINUTES = 30 # 30 minutos
22
+
23
+ router = APIRouter(tags=["Auth"])
24
+
25
+ class Token(BaseModel):
26
+ access_token: str
27
+ token_type: str
28
+
29
+ def get_config_db():
30
+ from pathlib import Path
31
+ project_root = Path(__file__).parent.parent.parent
32
+ db_path = project_root / "data" / "config.sqlite"
33
+ return ConfigDBManager(db_path)
34
+
35
+ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
36
+ to_encode = data.copy()
37
+ if expires_delta:
38
+ expire = datetime.utcnow() + expires_delta
39
+ else:
40
+ expire = datetime.utcnow() + timedelta(minutes=15)
41
+ to_encode.update({"exp": expire})
42
+ encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
43
+ return encoded_jwt
44
+
45
+ # Use a custom dependency instead of OAuth2PasswordBearer directly
46
+ # to check the cookie if the header is not present
47
+ def get_current_user(request: Request, config_db: ConfigDBManager = Depends(get_config_db)):
48
+ token = request.cookies.get("detecti_token")
49
+ if not token:
50
+ # fallback to bearer
51
+ auth = request.headers.get("Authorization")
52
+ if auth and auth.startswith("Bearer "):
53
+ token = auth.split(" ")[1]
54
+
55
+ credentials_exception = HTTPException(
56
+ status_code=status.HTTP_401_UNAUTHORIZED,
57
+ detail="Could not validate credentials",
58
+ headers={"WWW-Authenticate": "Bearer"},
59
+ )
60
+ if not token:
61
+ raise credentials_exception
62
+
63
+ try:
64
+ payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
65
+ username: str = payload.get("sub")
66
+ if username is None:
67
+ raise credentials_exception
68
+ except JWTError:
69
+ raise credentials_exception
70
+
71
+ user = config_db.get_user(username)
72
+ if user is None:
73
+ raise credentials_exception
74
+ return user
75
+
76
+ @router.post("/login", response_model=Token)
77
+ async def login_for_access_token(
78
+ response: Response,
79
+ form_data: OAuth2PasswordRequestForm = Depends(),
80
+ config_db: ConfigDBManager = Depends(get_config_db)
81
+ ):
82
+ user = config_db.get_user(form_data.username)
83
+ if not user or not verify_password(form_data.password, user["password_hash"]):
84
+ raise HTTPException(
85
+ status_code=status.HTTP_401_UNAUTHORIZED,
86
+ detail="Incorrect username or password",
87
+ headers={"WWW-Authenticate": "Bearer"},
88
+ )
89
+
90
+ access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
91
+ access_token = create_access_token(
92
+ data={"sub": user["username"]}, expires_delta=access_token_expires
93
+ )
94
+
95
+ response.set_cookie(
96
+ key="detecti_token",
97
+ value=access_token,
98
+ httponly=True,
99
+ max_age=ACCESS_TOKEN_EXPIRE_MINUTES * 60,
100
+ expires=ACCESS_TOKEN_EXPIRE_MINUTES * 60,
101
+ samesite="lax"
102
+ )
103
+
104
+ return {"access_token": access_token, "token_type": "bearer"}
105
+
106
+ @router.post("/logout")
107
+ async def logout(response: Response):
108
+ response.delete_cookie("detecti_token")
109
+ return {"message": "Successfully logged out"}