pentool 1.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.
- pentool/__init__.py +4 -0
- pentool/__main__.py +17 -0
- pentool/api/__init__.py +45 -0
- pentool/api/base_api.py +52 -0
- pentool/api/comparer_api.py +23 -0
- pentool/api/decoder_api.py +28 -0
- pentool/api/intruder_api.py +209 -0
- pentool/api/proxy_api.py +319 -0
- pentool/api/repeater_api.py +118 -0
- pentool/api/scanner_api.py +336 -0
- pentool/api/sequencer_api.py +21 -0
- pentool/api/spider_api.py +126 -0
- pentool/api/target_api.py +121 -0
- pentool/cli/__init__.py +1 -0
- pentool/cli/main.py +100 -0
- pentool/cli/project.py +90 -0
- pentool/cli/proxy.py +185 -0
- pentool/cli/scan.py +118 -0
- pentool/core/__init__.py +1 -0
- pentool/core/config.py +184 -0
- pentool/core/crash_reporter.py +75 -0
- pentool/core/database.py +139 -0
- pentool/core/event_bus.py +232 -0
- pentool/core/events.py +164 -0
- pentool/core/features.py +410 -0
- pentool/core/license.py +294 -0
- pentool/core/logging.py +59 -0
- pentool/core/plugin_manager.py +332 -0
- pentool/core/project.py +37 -0
- pentool/core/storage_interface.py +311 -0
- pentool/core/updater.py +96 -0
- pentool/core/utils.py +61 -0
- pentool/modules/__init__.py +1 -0
- pentool/modules/comparer.py +177 -0
- pentool/modules/decoder.py +281 -0
- pentool/modules/intruder.py +432 -0
- pentool/modules/intruder_turbo.py +190 -0
- pentool/modules/match_replace.py +146 -0
- pentool/modules/proxy.py +803 -0
- pentool/modules/repeater.py +237 -0
- pentool/modules/scanner/__init__.py +7 -0
- pentool/modules/scanner/ai_analyzer.py +230 -0
- pentool/modules/scanner/base.py +173 -0
- pentool/modules/scanner/baseline.py +101 -0
- pentool/modules/scanner/checks/__init__.py +47 -0
- pentool/modules/scanner/checks/broken_auth.py +231 -0
- pentool/modules/scanner/checks/cors.py +209 -0
- pentool/modules/scanner/checks/dom_xss.py +114 -0
- pentool/modules/scanner/checks/graphql.py +110 -0
- pentool/modules/scanner/checks/header_injection.py +262 -0
- pentool/modules/scanner/checks/headers.py +75 -0
- pentool/modules/scanner/checks/helpers.py +358 -0
- pentool/modules/scanner/checks/info_leak.py +68 -0
- pentool/modules/scanner/checks/jwt_none.py +238 -0
- pentool/modules/scanner/checks/lfi.py +186 -0
- pentool/modules/scanner/checks/nosql_injection.py +92 -0
- pentool/modules/scanner/checks/oauth.py +123 -0
- pentool/modules/scanner/checks/open_redirect.py +168 -0
- pentool/modules/scanner/checks/path_traversal.py +229 -0
- pentool/modules/scanner/checks/prototype_pollution.py +88 -0
- pentool/modules/scanner/checks/rce.py +338 -0
- pentool/modules/scanner/checks/sensitive_data.py +74 -0
- pentool/modules/scanner/checks/sqli.py +546 -0
- pentool/modules/scanner/checks/ssrf.py +273 -0
- pentool/modules/scanner/checks/ssti.py +353 -0
- pentool/modules/scanner/checks/xss.py +622 -0
- pentool/modules/scanner/checks/xxe.py +240 -0
- pentool/modules/scanner/engine.py +407 -0
- pentool/modules/scanner/fingerprint.py +169 -0
- pentool/modules/scanner/helpers.py +94 -0
- pentool/modules/scanner/mutator.py +378 -0
- pentool/modules/scanner/oob.py +104 -0
- pentool/modules/scanner/passive.py +114 -0
- pentool/modules/scanner/payloads.py +224 -0
- pentool/modules/scanner/report.py +117 -0
- pentool/modules/sequencer.py +500 -0
- pentool/modules/spider.py +702 -0
- pentool/modules/target.py +190 -0
- pentool/modules/websocket_handler.py +259 -0
- pentool/plugins/__init__.py +7 -0
- pentool/plugins/builtin/__init__.py +1 -0
- pentool/plugins/builtin/payloads_pro.py +404 -0
- pentool/plugins/builtin/reports_pro.py +450 -0
- pentool/plugins/builtin/scanner_pro.py +308 -0
- pentool/plugins/example_plugin.py +75 -0
- pentool/project_to_txt.py +67 -0
- pentool/services/__init__.py +1 -0
- pentool/services/base_service.py +64 -0
- pentool/services/intruder_service.py +108 -0
- pentool/services/proxy_service.py +232 -0
- pentool/services/repeater_service.py +111 -0
- pentool/services/scan_service.py +340 -0
- pentool/storage/__init__.py +0 -0
- pentool/storage/http_storage.py +522 -0
- pentool/storage/large_body_handler.py +54 -0
- pentool/storage/lru_cache.py +49 -0
- pentool/t.py +213 -0
- pentool/tui/__init__.py +1 -0
- pentool/tui/app.py +1438 -0
- pentool/tui/constants.py +79 -0
- pentool/tui/dialogs/__init__.py +0 -0
- pentool/tui/dialogs/cert_dialog.py +81 -0
- pentool/tui/dialogs/file_selector.py +227 -0
- pentool/tui/dialogs/load_from_proxy.py +125 -0
- pentool/tui/dialogs/match_replace_dialog.py +229 -0
- pentool/tui/dialogs/project_dialog.py +82 -0
- pentool/tui/dialogs/scope_dialog.py +225 -0
- pentool/tui/messages.py +121 -0
- pentool/tui/mixins/__init__.py +0 -0
- pentool/tui/mixins/app_mixin.py +61 -0
- pentool/tui/mixins/dialog_cancel.py +25 -0
- pentool/tui/mixins/request_context_menu.py +285 -0
- pentool/tui/mixins/tab_rename.py +101 -0
- pentool/tui/screens/__init__.py +31 -0
- pentool/tui/screens/comparer/__init__.py +3 -0
- pentool/tui/screens/comparer/screen.py +233 -0
- pentool/tui/screens/dashboard/__init__.py +2 -0
- pentool/tui/screens/dashboard/live_dashboard.py +741 -0
- pentool/tui/screens/dashboard/screen.py +820 -0
- pentool/tui/screens/decoder/__init__.py +3 -0
- pentool/tui/screens/decoder/screen.py +311 -0
- pentool/tui/screens/extensions/__init__.py +3 -0
- pentool/tui/screens/extensions/screen.py +24 -0
- pentool/tui/screens/intruder/__init__.py +3 -0
- pentool/tui/screens/intruder/screen.py +1362 -0
- pentool/tui/screens/proxy/__init__.py +3 -0
- pentool/tui/screens/proxy/screen.py +1664 -0
- pentool/tui/screens/repeater/__init__.py +3 -0
- pentool/tui/screens/repeater/screen.py +646 -0
- pentool/tui/screens/scanner/__init__.py +3 -0
- pentool/tui/screens/scanner/screen.py +1984 -0
- pentool/tui/screens/sequencer/__init__.py +3 -0
- pentool/tui/screens/sequencer/screen.py +520 -0
- pentool/tui/screens/settings/__init__.py +5 -0
- pentool/tui/screens/settings/hotkeys.py +134 -0
- pentool/tui/screens/settings/screen.py +543 -0
- pentool/tui/screens/spider/__init__.py +3 -0
- pentool/tui/screens/spider/screen.py +385 -0
- pentool/tui/screens/target/__init__.py +4 -0
- pentool/tui/screens/target/screen.py +361 -0
- pentool/tui/screens/terminal/__init__.py +5 -0
- pentool/tui/screens/terminal/screen.py +181 -0
- pentool/tui/widgets/__init__.py +1 -0
- pentool/tui/widgets/context_menu.py +148 -0
- pentool/tui/widgets/filter_bar.py +206 -0
- pentool/tui/widgets/inspector_panel.py +112 -0
- pentool/tui/widgets/menu.py +86 -0
- pentool/tui/widgets/menu_bar.py +238 -0
- pentool/tui/widgets/module_tabs.py +73 -0
- pentool/tui/widgets/payload_drop_zone.py +66 -0
- pentool/tui/widgets/request_editor.py +504 -0
- pentool/tui/widgets/resize_handle.py +116 -0
- pentool/tui/widgets/search_bar.py +113 -0
- pentool/tui/widgets/statusbar.py +99 -0
- pentool/tui/widgets/toolbar_button.py +76 -0
- pentool/utils/__init__.py +1 -0
- pentool/utils/cert.py +361 -0
- pentool/utils/coder.py +170 -0
- pentool/utils/copy_as.py +251 -0
- pentool/utils/diff.py +91 -0
- pentool/utils/http_client.py +175 -0
- pentool/utils/parser.py +227 -0
- pentool/utils/terminal_check.py +32 -0
- pentool-1.0.0.dist-info/METADATA +155 -0
- pentool-1.0.0.dist-info/RECORD +169 -0
- pentool-1.0.0.dist-info/WHEEL +5 -0
- pentool-1.0.0.dist-info/entry_points.txt +2 -0
- pentool-1.0.0.dist-info/licenses/LICENSE +34 -0
- pentool-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
"""Dashboard — стартовый экран приложения."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from collections import deque
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
|
|
11
|
+
from textual import on, work
|
|
12
|
+
from textual.app import ComposeResult
|
|
13
|
+
from textual.binding import Binding
|
|
14
|
+
from textual.containers import Horizontal, Vertical
|
|
15
|
+
from textual.timer import Timer
|
|
16
|
+
from textual.widget import Widget
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
_CSS = (Path(__file__).parent / "screen.tcss").read_text(encoding="utf-8")
|
|
20
|
+
|
|
21
|
+
from textual.widgets import (
|
|
22
|
+
RichLog,
|
|
23
|
+
Static,
|
|
24
|
+
TabbedContent,
|
|
25
|
+
TabPane,
|
|
26
|
+
Tree,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from pentool.tui.widgets.toolbar_button import ToolbarButton
|
|
30
|
+
from pentool.tui.screens.dashboard.live_dashboard import LiveDashboardTab
|
|
31
|
+
|
|
32
|
+
from pentool.core.logging import get_logger
|
|
33
|
+
|
|
34
|
+
logger = get_logger(__name__)
|
|
35
|
+
|
|
36
|
+
# Модульные константы
|
|
37
|
+
_LOGO = """\
|
|
38
|
+
[bold green] ██████╗ [/][bold cyan]███████╗[/][bold green]███╗ ██╗[/][bold cyan]████████╗[/][bold green] ██████╗ ██████╗ ██╗[/]
|
|
39
|
+
[bold green] ██╔══██╗[/][bold cyan]██╔════╝[/][bold green]████╗ ██║[/][bold cyan]╚══██╔══╝[/][bold green]██╔═══██╗██╔═══██╗██║[/]
|
|
40
|
+
[bold green] ██████╔╝[/][bold cyan]█████╗ [/][bold green]██╔██╗ ██║[/][bold cyan] ██║ [/][bold green]██║ ██║██║ ██║██║[/]
|
|
41
|
+
[bold green] ██╔═══╝ [/][bold cyan]██╔══╝ [/][bold green]██║╚██╗██║[/][bold cyan] ██║ [/][bold green]██║ ██║██║ ██║██║[/]
|
|
42
|
+
[bold green] ██║ [/][bold cyan]███████╗[/][bold green]██║ ╚████║[/][bold cyan] ██║ [/][bold green]╚██████╔╝╚██████╔╝███████╗[/]
|
|
43
|
+
[bold green] ╚═╝ [/][bold cyan]╚══════╝[/][bold green]╚═╝ ╚═══╝[/][bold cyan] ╚═╝ [/][bold green] ╚═════╝ ╚═════╝ ╚══════╝[/]
|
|
44
|
+
[dim green]──────────────────── Web Security Testing Platform v1.0 ─────────────────────[/]
|
|
45
|
+
[dim] by @sudores (aka DoctorX) [/]"""
|
|
46
|
+
|
|
47
|
+
_BOOT_LINES = [
|
|
48
|
+
("[dim green]", "> Mounting kernel modules..."),
|
|
49
|
+
("[dim green]", "> Loading OWASP Top 10 payload database..."),
|
|
50
|
+
("[bold green]", "> [OK] SQLi / XSS / SSTI / LFI / RCE / SSRF engine READY"),
|
|
51
|
+
("[dim green]", "> Initializing passive scanner hooks..."),
|
|
52
|
+
("[bold green]", "> [OK] Passive scanner ARMED"),
|
|
53
|
+
("[dim green]", "> Starting spider engine (depth=3, pages=100)..."),
|
|
54
|
+
("[bold green]", "> [OK] Spider engine READY"),
|
|
55
|
+
("[dim green]", "> Loading certificate authority..."),
|
|
56
|
+
("[bold green]", "> [OK] MITM proxy engine READY"),
|
|
57
|
+
("[bold green]", "> All systems GO. Happy hacking! 🔒"),
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
_SPARK_CHARS = " ▁▂▃▄▅▆▇█"
|
|
61
|
+
|
|
62
|
+
_SEV_COLORS = {
|
|
63
|
+
"critical": "bold red",
|
|
64
|
+
"high": "red",
|
|
65
|
+
"medium": "yellow",
|
|
66
|
+
"low": "green",
|
|
67
|
+
"info": "blue",
|
|
68
|
+
}
|
|
69
|
+
_SEV_ICONS = {
|
|
70
|
+
"critical": "💀",
|
|
71
|
+
"high": "🔴",
|
|
72
|
+
"medium": "🟡",
|
|
73
|
+
"low": "🟢",
|
|
74
|
+
"info": "🔵",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
_THREAT_CHARS = "█"
|
|
78
|
+
_THREAT_COLORS = ["green", "green", "yellow", "yellow", "red", "red", "bold red", "bold red", "bold red", "bold red"]
|
|
79
|
+
|
|
80
|
+
def _sparkline(values: list[int], width: int = 30, max_val: int | None = None) -> str:
|
|
81
|
+
"""Строит sparkline из значений."""
|
|
82
|
+
if not values:
|
|
83
|
+
return " " * width
|
|
84
|
+
mv = max_val or max(values) or 1
|
|
85
|
+
vals = list(values)[-width:]
|
|
86
|
+
if len(vals) < width:
|
|
87
|
+
vals = [0] * (width - len(vals)) + vals
|
|
88
|
+
result = ""
|
|
89
|
+
for v in vals:
|
|
90
|
+
idx = int((v / mv) * (len(_SPARK_CHARS) - 1))
|
|
91
|
+
result += _SPARK_CHARS[min(idx, len(_SPARK_CHARS) - 1)]
|
|
92
|
+
return result
|
|
93
|
+
|
|
94
|
+
def _bar(value: int, max_val: int, width: int = 20, color: str = "green") -> str:
|
|
95
|
+
"""Горизонтальный bar с цветом."""
|
|
96
|
+
if max_val == 0:
|
|
97
|
+
filled = 0
|
|
98
|
+
else:
|
|
99
|
+
filled = int((value / max_val) * width)
|
|
100
|
+
filled = max(0, min(width, filled))
|
|
101
|
+
bar = "█" * filled + "░" * (width - filled)
|
|
102
|
+
return f"[{color}]{bar}[/{color}]"
|
|
103
|
+
|
|
104
|
+
def _threat_gauge(level: int, width: int = 40) -> str:
|
|
105
|
+
"""Threat level gauge (0–100)."""
|
|
106
|
+
level = max(0, min(100, level))
|
|
107
|
+
filled = int((level / 100) * width)
|
|
108
|
+
color = _THREAT_COLORS[min(9, level // 10)]
|
|
109
|
+
bar = "█" * filled + "░" * (width - filled)
|
|
110
|
+
label = f" {level:3d}%"
|
|
111
|
+
if level == 0:
|
|
112
|
+
label_color = "dim"
|
|
113
|
+
threat_word = "NONE"
|
|
114
|
+
elif level < 20:
|
|
115
|
+
label_color = "green"
|
|
116
|
+
threat_word = "LOW"
|
|
117
|
+
elif level < 40:
|
|
118
|
+
label_color = "yellow"
|
|
119
|
+
threat_word = "MEDIUM"
|
|
120
|
+
elif level < 70:
|
|
121
|
+
label_color = "red"
|
|
122
|
+
threat_word = "HIGH"
|
|
123
|
+
else:
|
|
124
|
+
label_color = "bold red"
|
|
125
|
+
threat_word = "CRITICAL"
|
|
126
|
+
return f"[{color}]{bar}[/{color}][{label_color}]{label} [{threat_word}][/{label_color}]"
|
|
127
|
+
|
|
128
|
+
def _matrix_cell(count: int) -> str:
|
|
129
|
+
"""Ячейка матрицы severity для heatmap."""
|
|
130
|
+
if count == 0:
|
|
131
|
+
return "[dim]·[/dim]"
|
|
132
|
+
elif count < 3:
|
|
133
|
+
return "[green]▪[/green]"
|
|
134
|
+
elif count < 10:
|
|
135
|
+
return "[yellow]▪[/yellow]"
|
|
136
|
+
elif count < 30:
|
|
137
|
+
return "[red]▪[/red]"
|
|
138
|
+
else:
|
|
139
|
+
return "[bold red]▪[/bold red]"
|
|
140
|
+
|
|
141
|
+
class LiveChart(Vertical):
|
|
142
|
+
"""ASCII sparkline-график с живым обновлением."""
|
|
143
|
+
|
|
144
|
+
DEFAULT_CSS = _CSS
|
|
145
|
+
|
|
146
|
+
def __init__(self, title: str, color: str = "green", unit: str = "req/s", chart_id: str = "", **kwargs):
|
|
147
|
+
super().__init__(id=chart_id or None, **kwargs)
|
|
148
|
+
self._title = title
|
|
149
|
+
self._color = color
|
|
150
|
+
self._unit = unit
|
|
151
|
+
self._history: deque[int] = deque([0] * 60, maxlen=60)
|
|
152
|
+
self._total = 0
|
|
153
|
+
self._peak = 0
|
|
154
|
+
self._last_rate = 0
|
|
155
|
+
self._summary: str = "" # дополнительная информационная строка
|
|
156
|
+
|
|
157
|
+
def compose(self) -> ComposeResult:
|
|
158
|
+
yield Static(f"┌─ {self._title} ─", id="chart-title")
|
|
159
|
+
yield Static(" ", id="chart-spark")
|
|
160
|
+
yield Static(" ", id="chart-axis")
|
|
161
|
+
yield Static(" ", id="chart-stats")
|
|
162
|
+
yield Static(" ", id="chart-meta")
|
|
163
|
+
yield Static(" ", id="chart-summary")
|
|
164
|
+
|
|
165
|
+
def set_summary(self, text: str) -> None:
|
|
166
|
+
"""Установить дополнительную строку с агрегированной информацией."""
|
|
167
|
+
self._summary = text
|
|
168
|
+
try:
|
|
169
|
+
self.query_one("#chart-summary", Static).update(text)
|
|
170
|
+
except Exception:
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
def on_mount(self) -> None:
|
|
174
|
+
self.call_after_refresh(self._render_chart)
|
|
175
|
+
|
|
176
|
+
def push(self, value: int) -> None:
|
|
177
|
+
"""Добавить новое значение и перерисовать."""
|
|
178
|
+
self._history.append(value)
|
|
179
|
+
self._total += value
|
|
180
|
+
if value > self._peak:
|
|
181
|
+
self._peak = value
|
|
182
|
+
self._last_rate = value
|
|
183
|
+
self._render_chart()
|
|
184
|
+
|
|
185
|
+
def _render_chart(self) -> None:
|
|
186
|
+
vals = list(self._history)
|
|
187
|
+
mv = max(vals) if vals else 1
|
|
188
|
+
spark = _sparkline(vals, width=50, max_val=max(mv, 1))
|
|
189
|
+
axis = "[dim]└" + "─" * 48 + "60s[/dim]"
|
|
190
|
+
avg = sum(vals) / max(len([v for v in vals if v > 0]), 1)
|
|
191
|
+
stats = (
|
|
192
|
+
f"[{self._color}]now:[/{self._color}][bold] {self._last_rate:4d}[/bold] "
|
|
193
|
+
f"[dim]avg:[/dim][bold] {avg:5.1f}[/bold] "
|
|
194
|
+
f"[dim]peak:[/dim][bold] {self._peak:4d}[/bold] "
|
|
195
|
+
f"[dim]total:[/dim][bold] {self._total:,}[/bold] {self._unit}"
|
|
196
|
+
)
|
|
197
|
+
try:
|
|
198
|
+
self.query_one("#chart-spark", Static).update(f"[{self._color}]{spark}[/{self._color}]")
|
|
199
|
+
self.query_one("#chart-axis", Static).update(axis)
|
|
200
|
+
self.query_one("#chart-stats", Static).update(stats)
|
|
201
|
+
self.query_one("#chart-meta", Static).update(
|
|
202
|
+
f"[dim]scale: 0–{mv} {self._unit} │ window: 60s[/dim]"
|
|
203
|
+
)
|
|
204
|
+
except Exception:
|
|
205
|
+
pass
|
|
206
|
+
|
|
207
|
+
class ThreatMeter(Vertical):
|
|
208
|
+
"""Threat Level визуальный gauge."""
|
|
209
|
+
|
|
210
|
+
DEFAULT_CSS = _CSS
|
|
211
|
+
|
|
212
|
+
def __init__(self, **kwargs):
|
|
213
|
+
super().__init__(**kwargs)
|
|
214
|
+
self._level = 0
|
|
215
|
+
self._counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
|
216
|
+
self._top_finding = "—"
|
|
217
|
+
|
|
218
|
+
def compose(self) -> ComposeResult:
|
|
219
|
+
yield Static("┌─ THREAT LEVEL ─", id="tm-title")
|
|
220
|
+
yield Static(" ", id="tm-gauge")
|
|
221
|
+
yield Static(" ", id="tm-breakdown")
|
|
222
|
+
yield Static(" ", id="tm-top")
|
|
223
|
+
yield Static("[dim]Based on active findings in current session[/dim]", id="tm-hint")
|
|
224
|
+
|
|
225
|
+
def on_mount(self) -> None:
|
|
226
|
+
self.call_after_refresh(self._refresh_display)
|
|
227
|
+
|
|
228
|
+
def update_findings(self, counts: dict[str, int], top_finding: str = "") -> None:
|
|
229
|
+
"""Обновить с новыми данными о находках."""
|
|
230
|
+
self._counts = counts
|
|
231
|
+
self._top_finding = top_finding or "—"
|
|
232
|
+
level = (
|
|
233
|
+
counts.get("critical", 0) * 25 +
|
|
234
|
+
counts.get("high", 0) * 10 +
|
|
235
|
+
counts.get("medium", 0) * 3 +
|
|
236
|
+
counts.get("low", 0) * 1
|
|
237
|
+
)
|
|
238
|
+
self._level = min(100, level)
|
|
239
|
+
self._refresh_display()
|
|
240
|
+
|
|
241
|
+
def _refresh_display(self) -> None:
|
|
242
|
+
try:
|
|
243
|
+
gauge = _threat_gauge(self._level, width=42)
|
|
244
|
+
c = self._counts
|
|
245
|
+
breakdown = (
|
|
246
|
+
f"[bold red]CRIT:{c.get('critical',0):3d}[/bold red] "
|
|
247
|
+
f"[red]HIGH:{c.get('high',0):3d}[/red] "
|
|
248
|
+
f"[yellow]MED:{c.get('medium',0):4d}[/yellow] "
|
|
249
|
+
f"[green]LOW:{c.get('low',0):4d}[/green] "
|
|
250
|
+
f"[blue]INFO:{c.get('info',0):3d}[/blue]"
|
|
251
|
+
)
|
|
252
|
+
top = f"[dim]Top finding: [/dim][yellow]{self._top_finding[:55]}[/yellow]"
|
|
253
|
+
self.query_one("#tm-gauge", Static).update(gauge)
|
|
254
|
+
self.query_one("#tm-breakdown", Static).update(breakdown)
|
|
255
|
+
self.query_one("#tm-top", Static).update(top)
|
|
256
|
+
except Exception:
|
|
257
|
+
pass
|
|
258
|
+
|
|
259
|
+
class SeverityMatrix(Vertical):
|
|
260
|
+
"""Heatmap матрица находок по типу × severity."""
|
|
261
|
+
|
|
262
|
+
DEFAULT_CSS = _CSS
|
|
263
|
+
|
|
264
|
+
_VULN_TYPES = ["SQLi", "XSS", "SSTI", "LFI", "RCE", "Redir", "SSRF", "XXE", "Hdrs", "Info"]
|
|
265
|
+
_SEV_KEYS = ["critical", "high", "medium", "low", "info"]
|
|
266
|
+
|
|
267
|
+
def __init__(self, **kwargs):
|
|
268
|
+
super().__init__(**kwargs)
|
|
269
|
+
self._matrix: dict[str, dict[str, int]] = {
|
|
270
|
+
vt: {s: 0 for s in self._SEV_KEYS}
|
|
271
|
+
for vt in self._VULN_TYPES
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
def compose(self) -> ComposeResult:
|
|
275
|
+
yield Static(" ", id="mx-body", markup=True)
|
|
276
|
+
|
|
277
|
+
def on_mount(self) -> None:
|
|
278
|
+
self.call_after_refresh(self._refresh_display)
|
|
279
|
+
|
|
280
|
+
def add_finding(self, vuln_type: str, severity: str) -> None:
|
|
281
|
+
type_map = {
|
|
282
|
+
"sqli": "SQLi", "xss": "XSS", "ssti": "SSTI",
|
|
283
|
+
"lfi": "LFI", "rce": "RCE", "open_redirect": "Redir", "ssrf": "SSRF",
|
|
284
|
+
"xxe": "XXE",
|
|
285
|
+
"missing_security_header": "Hdrs", "missing_security_headers": "Hdrs",
|
|
286
|
+
"info_leak": "Info",
|
|
287
|
+
}
|
|
288
|
+
vt_key = type_map.get(vuln_type.lower(), "Info")
|
|
289
|
+
if vt_key in self._matrix and severity in self._matrix[vt_key]:
|
|
290
|
+
self._matrix[vt_key][severity] += 1
|
|
291
|
+
self._refresh_display()
|
|
292
|
+
|
|
293
|
+
def set_matrix(self, matrix: dict) -> None:
|
|
294
|
+
for vt, counts in matrix.items():
|
|
295
|
+
if vt in self._matrix:
|
|
296
|
+
self._matrix[vt].update(counts)
|
|
297
|
+
self._refresh_display()
|
|
298
|
+
|
|
299
|
+
def _build_text(self) -> str:
|
|
300
|
+
lines = []
|
|
301
|
+
lines.append("[bold yellow]┌─ VULNERABILITY MATRIX ─[/bold yellow]")
|
|
302
|
+
lines.append("[dim]TYPE CRIT HIGH MED LOW INFO TOTAL[/dim]")
|
|
303
|
+
lines.append("[dim]" + "─" * 56 + "[/dim]")
|
|
304
|
+
for vt in self._VULN_TYPES:
|
|
305
|
+
counts = self._matrix.get(vt, {})
|
|
306
|
+
total = sum(counts.values())
|
|
307
|
+
cells = ""
|
|
308
|
+
for sev in self._SEV_KEYS:
|
|
309
|
+
cnt = counts.get(sev, 0)
|
|
310
|
+
cells += f" {_matrix_cell(cnt)}[dim]{cnt:3d}[/dim] "
|
|
311
|
+
tc = "bold red" if counts.get("critical", 0) > 0 else (
|
|
312
|
+
"red" if counts.get("high", 0) > 0 else (
|
|
313
|
+
"yellow" if counts.get("medium", 0) > 0 else "dim"))
|
|
314
|
+
lines.append(f"[cyan]{vt:<6}[/cyan]{cells}[{tc}]{total:3d}[/{tc}]")
|
|
315
|
+
lines.append("[dim]· none [/dim][green]▪ 1–2 [/green][yellow]▪ 3–9 [/yellow][red]▪ 10–29 [/red][bold red]▪ 30+[/bold red]")
|
|
316
|
+
return "\n".join(lines)
|
|
317
|
+
|
|
318
|
+
def _refresh_display(self) -> None:
|
|
319
|
+
try:
|
|
320
|
+
self.query_one("#mx-body", Static).update(self._build_text())
|
|
321
|
+
except Exception:
|
|
322
|
+
pass
|
|
323
|
+
|
|
324
|
+
class DashboardScreen(Widget):
|
|
325
|
+
"""Dashboard — стартовый экран приложения."""
|
|
326
|
+
|
|
327
|
+
DEFAULT_CSS = _CSS
|
|
328
|
+
|
|
329
|
+
BINDINGS = [
|
|
330
|
+
Binding("r", "refresh_dash", "Refresh", show=True),
|
|
331
|
+
]
|
|
332
|
+
|
|
333
|
+
def __init__(self, **kwargs) -> None:
|
|
334
|
+
super().__init__(**kwargs)
|
|
335
|
+
self._stats = {"requests": 0, "findings": 0, "hosts": 0, "req_rate": 0}
|
|
336
|
+
self._finding_counts: dict[str, int] = {
|
|
337
|
+
"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0
|
|
338
|
+
}
|
|
339
|
+
self._top_finding = "—"
|
|
340
|
+
self._req_history: list[int] = []
|
|
341
|
+
self._find_history: list[int] = []
|
|
342
|
+
self._ticker: Timer | None = None
|
|
343
|
+
self._req_bucket = 0 # запросы за текущую секунду
|
|
344
|
+
self._find_bucket = 0 # находки за текущую секунду
|
|
345
|
+
|
|
346
|
+
def compose(self) -> ComposeResult:
|
|
347
|
+
with TabbedContent(id="dashboard-tabs"):
|
|
348
|
+
with TabPane("Overview", id="dash-tab-overview"):
|
|
349
|
+
yield from self._compose_overview()
|
|
350
|
+
with TabPane("🔴 Live", id="dash-tab-live"):
|
|
351
|
+
yield LiveDashboardTab(id="live-dashboard")
|
|
352
|
+
|
|
353
|
+
def _compose_overview(self) -> ComposeResult:
|
|
354
|
+
with Horizontal(id="logo-bar"):
|
|
355
|
+
yield Static(_LOGO, id="logo", markup=True)
|
|
356
|
+
|
|
357
|
+
with Horizontal(id="top-row"):
|
|
358
|
+
with Horizontal(id="charts-col"):
|
|
359
|
+
yield LiveChart(
|
|
360
|
+
"HTTP REQUESTS / sec",
|
|
361
|
+
color="green",
|
|
362
|
+
unit="req/s",
|
|
363
|
+
chart_id="chart-requests",
|
|
364
|
+
)
|
|
365
|
+
yield LiveChart(
|
|
366
|
+
"FINDINGS / sec",
|
|
367
|
+
color="red",
|
|
368
|
+
unit="findings",
|
|
369
|
+
chart_id="chart-findings",
|
|
370
|
+
)
|
|
371
|
+
yield ThreatMeter(id="threat-meter")
|
|
372
|
+
|
|
373
|
+
with Horizontal(id="main-area"):
|
|
374
|
+
# Левая колонка — Projects (высокая)
|
|
375
|
+
with Vertical(id="left-pane"):
|
|
376
|
+
yield Static("┌─ PROJECTS ─", id="pp-title")
|
|
377
|
+
yield Tree("Recent projects:", id="project-tree")
|
|
378
|
+
with Horizontal(id="pp-actions"):
|
|
379
|
+
yield ToolbarButton("+ New", "btn-new-project")
|
|
380
|
+
yield ToolbarButton("📂 Open", "btn-open-project")
|
|
381
|
+
yield ToolbarButton("💾 Save", "btn-save-project")
|
|
382
|
+
yield Static(
|
|
383
|
+
"[dim]"
|
|
384
|
+
"Shift+P Proxy Shift+R Repeater Shift+I Intruder\n"
|
|
385
|
+
"Shift+S Scanner Shift+T Target Shift+L Spider\n"
|
|
386
|
+
"Shift+D Decoder Shift+C Comparer Shift+E Settings\n"
|
|
387
|
+
"Ctrl+N New Ctrl+O Open Ctrl+S Save Ctrl+Q Quit"
|
|
388
|
+
"[/dim]",
|
|
389
|
+
id="hk-panel",
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
# Правая колонка — Feed+Status вверху, Matrix внизу
|
|
393
|
+
with Vertical(id="right-col"):
|
|
394
|
+
with Horizontal(id="mid-row"):
|
|
395
|
+
with Vertical(id="feed-panel"):
|
|
396
|
+
yield Static("┌─ LIVE FEED ─", id="feed-panel-title")
|
|
397
|
+
yield RichLog(id="feed-log", highlight=True, markup=True, wrap=False, max_lines=300)
|
|
398
|
+
with Vertical(id="status-panel"):
|
|
399
|
+
yield Static("┌─ STATUS ─", id="status-panel-title")
|
|
400
|
+
yield Static("[dim]●[/dim] Proxy: [dim]STOPPED[/dim]", id="led-proxy-bar", classes="led-item")
|
|
401
|
+
yield Static("[dim]●[/dim] Passive: [dim]OFF[/dim]", id="led-passive-bar", classes="led-item")
|
|
402
|
+
yield Static("[dim]●[/dim] Active: [dim]IDLE[/dim]", id="led-scan-bar", classes="led-item")
|
|
403
|
+
yield Static("[dim]●[/dim] Spider: [dim]IDLE[/dim]", id="led-spider-bar", classes="led-item")
|
|
404
|
+
yield Static("[dim]●[/dim] Threads: [dim]—[/dim]", id="led-threads-bar", classes="led-item")
|
|
405
|
+
with Vertical(id="matrix-col"):
|
|
406
|
+
yield SeverityMatrix(id="vuln-matrix")
|
|
407
|
+
|
|
408
|
+
def on_mount(self) -> None:
|
|
409
|
+
self._ticker = self.set_interval(1.0, self._tick)
|
|
410
|
+
try:
|
|
411
|
+
feed = self.query_one("#feed-log", RichLog)
|
|
412
|
+
feed.write("[dim cyan]ℹ Live feed ready. Waiting for events...[/dim cyan]")
|
|
413
|
+
except Exception:
|
|
414
|
+
pass
|
|
415
|
+
self._load_stats_bg()
|
|
416
|
+
self._populate_projects()
|
|
417
|
+
self._boot_animate()
|
|
418
|
+
|
|
419
|
+
@on(ToolbarButton.Pressed, "#btn-new-project")
|
|
420
|
+
def on_btn_new_project(self, _: ToolbarButton.Pressed) -> None:
|
|
421
|
+
self._new_project_dialog()
|
|
422
|
+
|
|
423
|
+
@on(ToolbarButton.Pressed, "#btn-open-project")
|
|
424
|
+
def on_btn_open_project(self, _: ToolbarButton.Pressed) -> None:
|
|
425
|
+
self._open_project_dialog()
|
|
426
|
+
|
|
427
|
+
@on(ToolbarButton.Pressed, "#btn-save-project")
|
|
428
|
+
def on_btn_save_project(self, _: ToolbarButton.Pressed) -> None:
|
|
429
|
+
self._save_project_dialog()
|
|
430
|
+
|
|
431
|
+
def _populate_projects(self) -> None:
|
|
432
|
+
try:
|
|
433
|
+
tree = self.query_one("#project-tree", Tree)
|
|
434
|
+
tree.clear()
|
|
435
|
+
root = tree.root
|
|
436
|
+
root.label = "[dim]Recent projects:[/dim]"
|
|
437
|
+
cfg = getattr(self.app, "_cfg", None)
|
|
438
|
+
recent = (getattr(cfg, "recent_projects", None) or []) if cfg else []
|
|
439
|
+
current = getattr(self.app, "_project_path", None)
|
|
440
|
+
for path in recent[:8]:
|
|
441
|
+
basename = os.path.basename(path)
|
|
442
|
+
proj_name = os.path.splitext(basename)[0]
|
|
443
|
+
ts = ""
|
|
444
|
+
try:
|
|
445
|
+
mtime = os.path.getmtime(path)
|
|
446
|
+
ts = datetime.fromtimestamp(mtime).strftime("%m/%d %H:%M")
|
|
447
|
+
except Exception:
|
|
448
|
+
pass
|
|
449
|
+
exists = os.path.exists(path)
|
|
450
|
+
is_active = (path == current)
|
|
451
|
+
|
|
452
|
+
if is_active:
|
|
453
|
+
node_label = f"[bold cyan]{proj_name}[/bold cyan] [bold green]●[/bold green]" + (f" [dim]{ts}[/dim]" if ts else "")
|
|
454
|
+
elif exists:
|
|
455
|
+
node_label = f"[cyan]{proj_name}[/cyan]" + (f" [dim]{ts}[/dim]" if ts else "")
|
|
456
|
+
else:
|
|
457
|
+
node_label = f"[dim]{proj_name} (missing)[/dim]"
|
|
458
|
+
|
|
459
|
+
# Добавляем как узел с дочерним листом-путём
|
|
460
|
+
node = root.add(node_label, data=path)
|
|
461
|
+
node.add_leaf(f"[dim]{path}[/dim]", data=path)
|
|
462
|
+
if is_active:
|
|
463
|
+
node.expand()
|
|
464
|
+
|
|
465
|
+
if not root.children:
|
|
466
|
+
root.add_leaf("[dim]No recent projects — use Open to load[/dim]")
|
|
467
|
+
root.expand()
|
|
468
|
+
except Exception:
|
|
469
|
+
pass
|
|
470
|
+
|
|
471
|
+
def on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
|
|
472
|
+
"""Открыть проект при выборе из дерева."""
|
|
473
|
+
node = event.node
|
|
474
|
+
path = getattr(node, "data", None)
|
|
475
|
+
if not path or not isinstance(path, str):
|
|
476
|
+
return
|
|
477
|
+
if not os.path.exists(path):
|
|
478
|
+
self.app.notify(f"File not found: {path}", severity="warning", timeout=3)
|
|
479
|
+
return
|
|
480
|
+
switch_fn = getattr(self.app, "_switch_project_db", None)
|
|
481
|
+
if switch_fn:
|
|
482
|
+
switch_fn(path, is_new=False)
|
|
483
|
+
else:
|
|
484
|
+
self.app.notify(f"Opened: {os.path.basename(path)}", timeout=3)
|
|
485
|
+
|
|
486
|
+
@work(thread=True)
|
|
487
|
+
def _boot_animate(self) -> None:
|
|
488
|
+
time.sleep(0.2)
|
|
489
|
+
for color, line in _BOOT_LINES:
|
|
490
|
+
time.sleep(0.12)
|
|
491
|
+
try:
|
|
492
|
+
self.app.call_from_thread(self._boot_write, color, line)
|
|
493
|
+
except Exception:
|
|
494
|
+
break
|
|
495
|
+
time.sleep(0.3)
|
|
496
|
+
try:
|
|
497
|
+
self.app.call_from_thread(self._boot_write, "[dim]", "─" * 48)
|
|
498
|
+
except Exception:
|
|
499
|
+
pass
|
|
500
|
+
|
|
501
|
+
def _boot_write(self, color: str, line: str) -> None:
|
|
502
|
+
ts = datetime.now().strftime("%H:%M:%S")
|
|
503
|
+
try:
|
|
504
|
+
log = self.query_one("#feed-log", RichLog)
|
|
505
|
+
log.write(f"[dim green]{ts}[/dim green] {color}{line}[/]")
|
|
506
|
+
except Exception:
|
|
507
|
+
pass
|
|
508
|
+
|
|
509
|
+
def _tick(self) -> None:
|
|
510
|
+
"""Каждую секунду пушим данные в графики."""
|
|
511
|
+
try:
|
|
512
|
+
rps = self._req_bucket
|
|
513
|
+
fps = self._find_bucket
|
|
514
|
+
self._req_bucket = 0
|
|
515
|
+
self._find_bucket = 0
|
|
516
|
+
|
|
517
|
+
self.query_one("#chart-requests", LiveChart).push(rps)
|
|
518
|
+
self.query_one("#chart-findings", LiveChart).push(fps)
|
|
519
|
+
except Exception:
|
|
520
|
+
pass
|
|
521
|
+
|
|
522
|
+
@work
|
|
523
|
+
async def _load_stats_bg(self) -> None:
|
|
524
|
+
try:
|
|
525
|
+
db_path = getattr(self.app, "_db_path", "") or ""
|
|
526
|
+
if not db_path:
|
|
527
|
+
return
|
|
528
|
+
data = await self._fetch_stats(db_path)
|
|
529
|
+
self._apply_stats(data)
|
|
530
|
+
except Exception as exc:
|
|
531
|
+
logger.debug("_load_stats_bg: %s", exc)
|
|
532
|
+
|
|
533
|
+
async def _fetch_stats(self, db_path: str) -> dict:
|
|
534
|
+
from pentool.api.scanner_api import ScannerAPI
|
|
535
|
+
from pentool.storage.http_storage import HttpStorage
|
|
536
|
+
result: dict = {
|
|
537
|
+
"requests": 0, "hosts": 0,
|
|
538
|
+
"findings": {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0},
|
|
539
|
+
"top_finding": "—",
|
|
540
|
+
}
|
|
541
|
+
try:
|
|
542
|
+
storage = HttpStorage()
|
|
543
|
+
await storage.init_db(db_path)
|
|
544
|
+
try:
|
|
545
|
+
result["requests"] = await storage.count()
|
|
546
|
+
finally:
|
|
547
|
+
await storage.close()
|
|
548
|
+
except Exception as exc:
|
|
549
|
+
logger.debug("_fetch_stats http_history: %s", exc)
|
|
550
|
+
try:
|
|
551
|
+
api = ScannerAPI(db_path=db_path)
|
|
552
|
+
result["hosts"] = await api.get_host_count()
|
|
553
|
+
scanner_stats = await api.get_stats()
|
|
554
|
+
result["findings"] = scanner_stats["findings"]
|
|
555
|
+
result["top_finding"] = scanner_stats["top_finding"]
|
|
556
|
+
except Exception as exc:
|
|
557
|
+
logger.debug("_fetch_stats scanner_stats: %s", exc)
|
|
558
|
+
return result
|
|
559
|
+
|
|
560
|
+
def _apply_stats(self, data: dict) -> None:
|
|
561
|
+
"""Применить загруженную статистику к виджетам."""
|
|
562
|
+
self._finding_counts = data.get("findings", self._finding_counts)
|
|
563
|
+
self._top_finding = data.get("top_finding", "—")
|
|
564
|
+
|
|
565
|
+
try:
|
|
566
|
+
self.query_one("#threat-meter", ThreatMeter).update_findings(
|
|
567
|
+
self._finding_counts, self._top_finding
|
|
568
|
+
)
|
|
569
|
+
except Exception as exc:
|
|
570
|
+
logger.debug("_apply_stats ThreatMeter: %s", exc)
|
|
571
|
+
|
|
572
|
+
try:
|
|
573
|
+
matrix = self.query_one("#vuln-matrix", SeverityMatrix)
|
|
574
|
+
matrix._matrix = {
|
|
575
|
+
vt: {s: 0 for s in SeverityMatrix._SEV_KEYS}
|
|
576
|
+
for vt in SeverityMatrix._VULN_TYPES
|
|
577
|
+
}
|
|
578
|
+
matrix._refresh_display()
|
|
579
|
+
except Exception as exc:
|
|
580
|
+
logger.debug("_apply_stats SeverityMatrix: %s", exc)
|
|
581
|
+
|
|
582
|
+
total = data.get("requests", 0)
|
|
583
|
+
hosts = data.get("hosts", 0)
|
|
584
|
+
total_findings = sum(self._finding_counts.values())
|
|
585
|
+
self.log_activity(
|
|
586
|
+
f"Session: {total:,} requests · {hosts} hosts · {total_findings} findings",
|
|
587
|
+
"ok"
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
try:
|
|
591
|
+
fc = self._finding_counts
|
|
592
|
+
self.query_one("#chart-requests", LiveChart).set_summary(
|
|
593
|
+
f"[dim]Total: [/dim][bold]{total:,}[/bold][dim] reqs · [/dim][bold]{hosts}[/bold][dim] hosts[/dim]"
|
|
594
|
+
)
|
|
595
|
+
self.query_one("#chart-findings", LiveChart).set_summary(
|
|
596
|
+
f"[bold red]{fc.get('critical',0)}[/bold red][dim] crit [/dim]"
|
|
597
|
+
f"[red]{fc.get('high',0)}[/red][dim] high [/dim]"
|
|
598
|
+
f"[yellow]{fc.get('medium',0)}[/yellow][dim] med [/dim]"
|
|
599
|
+
f"[green]{fc.get('low',0)}[/green][dim] low [/dim]"
|
|
600
|
+
f"[blue]{fc.get('info',0)}[/blue][dim] info[/dim]"
|
|
601
|
+
)
|
|
602
|
+
except Exception as exc:
|
|
603
|
+
logger.debug("_apply_stats chart_summary: %s", exc)
|
|
604
|
+
|
|
605
|
+
def action_refresh_dash(self) -> None:
|
|
606
|
+
"""Обновить всю статистику с диска."""
|
|
607
|
+
self._load_stats_bg()
|
|
608
|
+
self.log_activity("Dashboard refreshed", "info")
|
|
609
|
+
|
|
610
|
+
def _feed_write(self, text: str) -> None:
|
|
611
|
+
"""Запись в RichLog live-ленты."""
|
|
612
|
+
try:
|
|
613
|
+
self.query_one("#feed-log", RichLog).write(text)
|
|
614
|
+
except Exception:
|
|
615
|
+
pass
|
|
616
|
+
|
|
617
|
+
def _set_led_bar(self, widget_id: str, color: str, label: str) -> None:
|
|
618
|
+
try:
|
|
619
|
+
self.query_one(f"#{widget_id}", Static).update(
|
|
620
|
+
f"[{color}]●[/{color}] {label}"
|
|
621
|
+
)
|
|
622
|
+
except Exception:
|
|
623
|
+
pass
|
|
624
|
+
|
|
625
|
+
def push_request(self, method: str, url: str, status: int = 0) -> None:
|
|
626
|
+
"""Зарегистрировать HTTP-запрос (из прокси)."""
|
|
627
|
+
self._req_bucket += 1
|
|
628
|
+
self._stats["requests"] += 1
|
|
629
|
+
ts = datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
|
630
|
+
method_color = {"GET": "green", "POST": "yellow", "PUT": "cyan", "DELETE": "red", "PATCH": "magenta"}.get(method.upper(), "white")
|
|
631
|
+
status_color = "green" if 200 <= status < 300 else ("yellow" if 300 <= status < 400 else ("red" if status >= 400 else "dim"))
|
|
632
|
+
status_str = f"[{status_color}]{status}[/{status_color}]" if status else "[dim]···[/dim]"
|
|
633
|
+
short_url = url[:60] + "…" if len(url) > 60 else url
|
|
634
|
+
self._feed_write(
|
|
635
|
+
f"[dim]{ts}[/dim] [{method_color}]{method:<7}[/{method_color}] "
|
|
636
|
+
f"{status_str} [dim]{short_url}[/dim]"
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
def add_finding(self, finding) -> None:
|
|
640
|
+
"""Добавить finding (из пассивного/активного сканера)."""
|
|
641
|
+
self._find_bucket += 1
|
|
642
|
+
sev = getattr(finding, "severity", "info")
|
|
643
|
+
vuln_type = getattr(finding, "type", "unknown")
|
|
644
|
+
url = getattr(finding, "url", "")
|
|
645
|
+
name = getattr(finding, "name", vuln_type)
|
|
646
|
+
|
|
647
|
+
self._finding_counts[sev] = self._finding_counts.get(sev, 0) + 1
|
|
648
|
+
self._top_finding = name
|
|
649
|
+
|
|
650
|
+
try:
|
|
651
|
+
self.query_one("#threat-meter", ThreatMeter).update_findings(
|
|
652
|
+
self._finding_counts, self._top_finding
|
|
653
|
+
)
|
|
654
|
+
except Exception:
|
|
655
|
+
pass
|
|
656
|
+
try:
|
|
657
|
+
self.query_one("#vuln-matrix", SeverityMatrix).add_finding(vuln_type, sev)
|
|
658
|
+
except Exception:
|
|
659
|
+
pass
|
|
660
|
+
|
|
661
|
+
try:
|
|
662
|
+
fc = self._finding_counts
|
|
663
|
+
self.query_one("#chart-findings", LiveChart).set_summary(
|
|
664
|
+
f"[bold red]{fc.get('critical',0)}[/bold red][dim] crit [/dim]"
|
|
665
|
+
f"[red]{fc.get('high',0)}[/red][dim] high [/dim]"
|
|
666
|
+
f"[yellow]{fc.get('medium',0)}[/yellow][dim] med [/dim]"
|
|
667
|
+
f"[green]{fc.get('low',0)}[/green][dim] low [/dim]"
|
|
668
|
+
f"[blue]{fc.get('info',0)}[/blue][dim] info[/dim]"
|
|
669
|
+
)
|
|
670
|
+
except Exception:
|
|
671
|
+
pass
|
|
672
|
+
|
|
673
|
+
ts = datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
|
674
|
+
icon = _SEV_ICONS.get(sev, "●")
|
|
675
|
+
color = _SEV_COLORS.get(sev, "white")
|
|
676
|
+
short_url = url[:45] + "…" if len(url) > 45 else url
|
|
677
|
+
self._feed_write(
|
|
678
|
+
f"[dim]{ts}[/dim] {icon} [{color}]{sev.upper():<8}[/{color}] "
|
|
679
|
+
f"[bold]{vuln_type}[/bold] [dim]{short_url}[/dim]"
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
def log_activity(self, message: str, level: str = "info") -> None:
|
|
683
|
+
"""Системное сообщение в live feed."""
|
|
684
|
+
ts = datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
|
685
|
+
colors = {"info": "cyan", "warning": "yellow", "error": "red", "ok": "bold green"}
|
|
686
|
+
color = colors.get(level, "white")
|
|
687
|
+
prefix = {"info": "ℹ", "warning": "⚠", "error": "✗", "ok": "✓"}.get(level, "·")
|
|
688
|
+
self._feed_write(f"[dim]{ts}[/dim] [{color}]{prefix} {message}[/{color}]")
|
|
689
|
+
|
|
690
|
+
def update_proxy_status(self, running: bool, port: int = 8080) -> None:
|
|
691
|
+
"""Обновить статус прокси."""
|
|
692
|
+
if running:
|
|
693
|
+
self._set_led_bar("led-proxy-bar", "bold green", f"Proxy: [bold green]:{port}[/bold green]")
|
|
694
|
+
else:
|
|
695
|
+
self._set_led_bar("led-proxy-bar", "dim", f"Proxy: [dim]STOPPED[/dim]")
|
|
696
|
+
msg = f"Proxy {'started' if running else 'stopped'} on :{port}"
|
|
697
|
+
self.log_activity(msg, "ok" if running else "warning")
|
|
698
|
+
|
|
699
|
+
def update_passive_status(self, enabled: bool) -> None:
|
|
700
|
+
if enabled:
|
|
701
|
+
self._set_led_bar("led-passive-bar", "bold cyan", "Passive: [bold cyan]ARMED[/bold cyan]")
|
|
702
|
+
else:
|
|
703
|
+
self._set_led_bar("led-passive-bar", "dim", "Passive: [dim]OFF[/dim]")
|
|
704
|
+
self.log_activity(f"Passive scanner {'armed' if enabled else 'disabled'}", "ok" if enabled else "warning")
|
|
705
|
+
|
|
706
|
+
def update_scan_status(self, scanning: bool, progress: int = 0, threads: int = 0) -> None:
|
|
707
|
+
if scanning:
|
|
708
|
+
self._set_led_bar("led-scan-bar", "bold yellow", f"Active: [bold yellow]SCANNING {progress}%[/bold yellow]")
|
|
709
|
+
if threads > 0:
|
|
710
|
+
self._set_led_bar("led-threads-bar", "cyan", f"Threads: [cyan]{threads} active[/cyan]")
|
|
711
|
+
else:
|
|
712
|
+
self._set_led_bar("led-scan-bar", "dim", "Active: [dim]IDLE[/dim]")
|
|
713
|
+
self._set_led_bar("led-threads-bar", "dim", "Threads: [dim]—[/dim]")
|
|
714
|
+
|
|
715
|
+
def update_spider_status(self, running: bool, pages: int = 0) -> None:
|
|
716
|
+
if running:
|
|
717
|
+
self._set_led_bar("led-spider-bar", "bold magenta", f"Spider: [bold magenta]CRAWLING {pages}p[/bold magenta]")
|
|
718
|
+
else:
|
|
719
|
+
self._set_led_bar("led-spider-bar", "dim", "Spider: [dim]IDLE[/dim]")
|
|
720
|
+
|
|
721
|
+
def refresh_stats(self) -> None:
|
|
722
|
+
self._load_stats_bg()
|
|
723
|
+
|
|
724
|
+
def _new_project_dialog(self) -> None:
|
|
725
|
+
"""Создать новый проект — выбрать путь для новой .db базы данных."""
|
|
726
|
+
from pentool.tui.dialogs.file_selector import FileSelectorDialog, FileSelectorMode
|
|
727
|
+
|
|
728
|
+
def _on_path(path: str | None) -> None:
|
|
729
|
+
if not path:
|
|
730
|
+
return
|
|
731
|
+
if not path.endswith(".db"):
|
|
732
|
+
path = path + ".db"
|
|
733
|
+
switch_fn = getattr(self.app, "_switch_project_db", None)
|
|
734
|
+
if switch_fn:
|
|
735
|
+
switch_fn(path, is_new=True)
|
|
736
|
+
self._populate_projects()
|
|
737
|
+
|
|
738
|
+
self.app.push_screen(
|
|
739
|
+
FileSelectorDialog(
|
|
740
|
+
mode=FileSelectorMode.SAVE,
|
|
741
|
+
title="New Project — Choose Location",
|
|
742
|
+
start_dir=os.path.expanduser("~"),
|
|
743
|
+
),
|
|
744
|
+
_on_path,
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
def _open_project_dialog(self) -> None:
|
|
748
|
+
"""Открыть существующую .db базу данных как проект."""
|
|
749
|
+
from pentool.tui.dialogs.file_selector import FileSelectorDialog, FileSelectorMode
|
|
750
|
+
current = getattr(self.app, "_project_path", None)
|
|
751
|
+
start_dir = os.path.dirname(current) if current else os.path.expanduser("~")
|
|
752
|
+
|
|
753
|
+
def _on_path(path: str | None) -> None:
|
|
754
|
+
if not path:
|
|
755
|
+
return
|
|
756
|
+
if not os.path.exists(path):
|
|
757
|
+
self.app.notify(f"File not found: {path}", severity="error", timeout=4)
|
|
758
|
+
return
|
|
759
|
+
switch_fn = getattr(self.app, "_switch_project_db", None)
|
|
760
|
+
if switch_fn:
|
|
761
|
+
switch_fn(path, is_new=False)
|
|
762
|
+
self._populate_projects()
|
|
763
|
+
|
|
764
|
+
self.app.push_screen(
|
|
765
|
+
FileSelectorDialog(
|
|
766
|
+
mode=FileSelectorMode.OPEN,
|
|
767
|
+
title="Open Project",
|
|
768
|
+
start_dir=start_dir,
|
|
769
|
+
filter_ext=[".db"],
|
|
770
|
+
),
|
|
771
|
+
_on_path,
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
def _save_project_dialog(self) -> None:
|
|
775
|
+
"""Сохранить (скопировать) текущую .db в другое место."""
|
|
776
|
+
from pentool.tui.dialogs.file_selector import FileSelectorDialog, FileSelectorMode
|
|
777
|
+
current = getattr(self.app, "_project_path", None)
|
|
778
|
+
cfg = getattr(self.app, "_cfg", None)
|
|
779
|
+
src_db = (current or (cfg.db_path if cfg else None) or os.path.expanduser("~"))
|
|
780
|
+
start_dir = os.path.dirname(src_db) if src_db else os.path.expanduser("~")
|
|
781
|
+
|
|
782
|
+
def _on_path(path: str | None) -> None:
|
|
783
|
+
if not path:
|
|
784
|
+
return
|
|
785
|
+
if not path.endswith(".db"):
|
|
786
|
+
path = path + ".db"
|
|
787
|
+
import shutil
|
|
788
|
+
src = (cfg.db_path if cfg else None) or src_db
|
|
789
|
+
try:
|
|
790
|
+
shutil.copy2(src, path)
|
|
791
|
+
self.app._project_path = path # type: ignore
|
|
792
|
+
update_fn = getattr(self.app, "_update_project_name", None)
|
|
793
|
+
if update_fn:
|
|
794
|
+
update_fn(path)
|
|
795
|
+
self.app.notify(f"Saved to {os.path.basename(path)}", timeout=3)
|
|
796
|
+
self._populate_projects()
|
|
797
|
+
self.log_activity(f"Project saved: {path}", "ok")
|
|
798
|
+
except Exception as e:
|
|
799
|
+
self.app.notify(f"Save failed: {e}", severity="error", timeout=4)
|
|
800
|
+
|
|
801
|
+
self.app.push_screen(
|
|
802
|
+
FileSelectorDialog(
|
|
803
|
+
mode=FileSelectorMode.SAVE,
|
|
804
|
+
title="Save Project As",
|
|
805
|
+
start_dir=start_dir,
|
|
806
|
+
),
|
|
807
|
+
_on_path,
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
def action_new_project(self) -> None:
|
|
811
|
+
try:
|
|
812
|
+
self.app.action_new_project() # type: ignore
|
|
813
|
+
except Exception:
|
|
814
|
+
pass
|
|
815
|
+
|
|
816
|
+
def action_save_project(self) -> None:
|
|
817
|
+
try:
|
|
818
|
+
self.app.action_save_project() # type: ignore
|
|
819
|
+
except Exception:
|
|
820
|
+
pass
|