pentool 0.2.3__tar.gz → 0.2.4__tar.gz
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-0.2.3 → pentool-0.2.4}/PKG-INFO +6 -1
- {pentool-0.2.3 → pentool-0.2.4}/README.md +5 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/__init__.py +1 -1
- {pentool-0.2.3 → pentool-0.2.4}/pentool/api/target_api.py +2 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/modules/target.py +44 -5
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/app.py +6 -0
- pentool-0.2.4/pentool/tui/dialogs/comment_dialog.py +61 -0
- pentool-0.2.4/pentool/tui/dialogs/comment_dialog.tcss +25 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/dashboard/live_dashboard.py +18 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/proxy/screen.py +31 -46
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/target/screen.py +8 -8
- {pentool-0.2.3 → pentool-0.2.4}/pentool.egg-info/PKG-INFO +6 -1
- {pentool-0.2.3 → pentool-0.2.4}/pentool.egg-info/SOURCES.txt +2 -0
- {pentool-0.2.3 → pentool-0.2.4}/pyproject.toml +1 -1
- {pentool-0.2.3 → pentool-0.2.4}/LICENSE +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/__main__.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/api/__init__.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/api/base_api.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/api/comparer_api.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/api/decoder_api.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/api/intruder_api.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/api/proxy_api.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/api/repeater_api.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/api/sequencer_api.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/api/spider_api.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/cli/__init__.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/cli/main.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/cli/project.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/cli/proxy.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/cli/scan.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/core/__init__.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/core/config.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/core/crash_reporter.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/core/database.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/core/event_bus.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/core/events.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/core/features.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/core/license.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/core/logging.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/core/plugin_manager.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/core/project.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/core/storage_interface.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/core/updater.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/core/utils.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/modules/__init__.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/modules/comparer.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/modules/decoder.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/modules/intruder.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/modules/match_replace.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/modules/proxy.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/modules/repeater.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/modules/sequencer.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/modules/spider.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/modules/websocket_handler.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/plugins/__init__.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/plugins/builtin/__init__.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/plugins/example_plugin.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/plugins/example_plugin.tcss +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/services/__init__.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/services/base_service.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/services/intruder_service.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/services/proxy_service.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/services/repeater_service.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/services/scan_service.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/storage/__init__.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/storage/http_storage.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/storage/large_body_handler.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/storage/lru_cache.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/t.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/__init__.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/constants.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/dialogs/__init__.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/dialogs/cert_dialog.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/dialogs/cert_dialog.tcss +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/dialogs/file_selector.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/dialogs/file_selector.tcss +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/dialogs/load_from_proxy.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/dialogs/load_from_proxy.tcss +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/dialogs/match_replace_dialog.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/dialogs/match_replace_dialog.tcss +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/dialogs/project_dialog.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/dialogs/project_dialog.tcss +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/dialogs/scope_dialog.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/dialogs/scope_dialog.tcss +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/messages.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/mixins/__init__.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/mixins/app_mixin.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/mixins/dialog_cancel.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/mixins/request_context_menu.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/project_manager.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/__init__.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/base.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/comparer/__init__.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/comparer/screen.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/comparer/screen.tcss +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/dashboard/__init__.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/dashboard/screen.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/dashboard/screen.tcss +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/decoder/__init__.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/decoder/screen.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/decoder/screen.tcss +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/extensions/__init__.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/extensions/screen.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/extensions/screen.tcss +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/intruder/__init__.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/intruder/screen.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/intruder/screen.tcss +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/proxy/__init__.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/proxy/screen.tcss +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/repeater/__init__.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/repeater/screen.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/repeater/screen.tcss +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/scanner_unavailable.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/sequencer/__init__.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/sequencer/screen.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/sequencer/screen.tcss +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/settings/__init__.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/settings/hotkeys.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/settings/hotkeys.tcss +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/settings/screen.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/settings/screen.tcss +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/spider/__init__.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/spider/screen.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/spider/screen.tcss +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/target/__init__.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/target/screen.tcss +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/terminal/__init__.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/terminal/screen.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/screens/terminal/screen.tcss +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/widgets/__init__.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/widgets/activity_indicator.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/widgets/activity_indicator.tcss +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/widgets/context_menu.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/widgets/context_menu.tcss +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/widgets/filter_bar.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/widgets/filter_bar.tcss +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/widgets/inspector_panel.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/widgets/inspector_panel.tcss +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/widgets/menu.tcss +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/widgets/menu_bar.tcss +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/widgets/module_tabs.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/widgets/module_tabs.tcss +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/widgets/nice_checkbox.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/widgets/payload_drop_zone.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/widgets/payload_drop_zone.tcss +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/widgets/request_editor.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/widgets/request_editor.tcss +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/widgets/resize_handle.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/widgets/resize_handle.tcss +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/widgets/search_bar.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/widgets/search_bar.tcss +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/widgets/statusbar.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/widgets/statusbar.tcss +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/widgets/toolbar_button.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/tui/widgets/toolbar_button.tcss +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/utils/__init__.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/utils/cert.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/utils/coder.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/utils/copy_as.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/utils/diff.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/utils/http_client.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/utils/parser.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool/utils/terminal_check.py +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool.egg-info/dependency_links.txt +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool.egg-info/entry_points.txt +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool.egg-info/requires.txt +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/pentool.egg-info/top_level.txt +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/setup.cfg +0 -0
- {pentool-0.2.3 → pentool-0.2.4}/tests/test_user_workflow.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pentool
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Modern Web Pentesting TUI - Professional security testing tool
|
|
5
5
|
Author-email: Pentool Team <dev@pentool.pro>
|
|
6
6
|
License: AGPL-3.0
|
|
@@ -130,6 +130,11 @@ Fast, transparent, and built for real-world testing.
|
|
|
130
130
|
# Install
|
|
131
131
|
pip install pentool
|
|
132
132
|
|
|
133
|
+
# Start a 14-day PRO trial (unlocks Scanner + other PRO features)
|
|
134
|
+
# Run this BEFORE first launching the TUI — if the TUI is already open,
|
|
135
|
+
# restart it afterwards so it picks up the new license.
|
|
136
|
+
pentool license trial
|
|
137
|
+
|
|
133
138
|
# Launch TUI
|
|
134
139
|
pentool
|
|
135
140
|
|
|
@@ -84,6 +84,11 @@ Fast, transparent, and built for real-world testing.
|
|
|
84
84
|
# Install
|
|
85
85
|
pip install pentool
|
|
86
86
|
|
|
87
|
+
# Start a 14-day PRO trial (unlocks Scanner + other PRO features)
|
|
88
|
+
# Run this BEFORE first launching the TUI — if the TUI is already open,
|
|
89
|
+
# restart it afterwards so it picks up the new license.
|
|
90
|
+
pentool license trial
|
|
91
|
+
|
|
87
92
|
# Launch TUI
|
|
88
93
|
pentool
|
|
89
94
|
|
|
@@ -83,6 +83,8 @@ class TargetAPI(ExportableAPI):
|
|
|
83
83
|
try:
|
|
84
84
|
node = SiteNode.from_dict(node_dict)
|
|
85
85
|
self._sitemap._nodes.setdefault(host, {})[node.path] = node
|
|
86
|
+
if node.in_scope:
|
|
87
|
+
self._sitemap._scope_hosts.add(self._sitemap._norm_host(host))
|
|
86
88
|
loaded += 1
|
|
87
89
|
except Exception as exc:
|
|
88
90
|
logger.warning("TargetAPI.import_project_data: skip node: %s", exc)
|
|
@@ -66,6 +66,20 @@ class SiteMap:
|
|
|
66
66
|
self._db_path = db_path
|
|
67
67
|
# host -> path -> SiteNode
|
|
68
68
|
self._nodes: dict[str, dict[str, SiteNode]] = {}
|
|
69
|
+
# Scope is tracked independently of _nodes (normalized, port-stripped
|
|
70
|
+
# host names) so that adding a host to scope from Proxy works even
|
|
71
|
+
# if Target hasn't seen any traffic for it yet (or saw it under a
|
|
72
|
+
# host:port key that doesn't match the port-less host the user/Proxy
|
|
73
|
+
# refers to). Previously set_in_scope() was a no-op unless the exact
|
|
74
|
+
# host string already existed as a node — a host added to scope
|
|
75
|
+
# before any matching traffic arrived (or under a different
|
|
76
|
+
# host:port key) silently never got flagged, with no error raised.
|
|
77
|
+
self._scope_hosts: set[str] = set()
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def _norm_host(host: str) -> str:
|
|
81
|
+
"""Normalize a host for scope comparisons: lowercase, no port."""
|
|
82
|
+
return host.split(":")[0].strip().lower()
|
|
69
83
|
|
|
70
84
|
def add_request(self, req: "ParsedRequest") -> None:
|
|
71
85
|
try:
|
|
@@ -79,6 +93,7 @@ class SiteMap:
|
|
|
79
93
|
|
|
80
94
|
now = datetime.now(timezone.utc)
|
|
81
95
|
host_map = self._nodes.setdefault(host, {})
|
|
96
|
+
in_scope = self._norm_host(host) in self._scope_hosts
|
|
82
97
|
|
|
83
98
|
if path in host_map:
|
|
84
99
|
node = host_map[path]
|
|
@@ -92,6 +107,7 @@ class SiteMap:
|
|
|
92
107
|
methods={req.method},
|
|
93
108
|
request_count=1,
|
|
94
109
|
last_seen=now,
|
|
110
|
+
in_scope=in_scope,
|
|
95
111
|
)
|
|
96
112
|
|
|
97
113
|
def get_tree(self) -> dict[str, list[SiteNode]]:
|
|
@@ -107,18 +123,38 @@ class SiteMap:
|
|
|
107
123
|
return sorted(self._nodes.get(host, {}).values(), key=lambda n: n.path)
|
|
108
124
|
|
|
109
125
|
def get_scope(self) -> list[str]:
|
|
110
|
-
|
|
126
|
+
# Union of hosts flagged via nodes (legacy/DB-loaded state) and hosts
|
|
127
|
+
# registered in _scope_hosts before any matching node existed.
|
|
128
|
+
node_hosts = {h for h, paths in self._nodes.items() if any(n.in_scope for n in paths.values())}
|
|
129
|
+
explicit_hosts = {h for h in self._nodes if self._norm_host(h) in self._scope_hosts}
|
|
130
|
+
return sorted(node_hosts | explicit_hosts)
|
|
111
131
|
|
|
112
132
|
def get_request_count(self, host: str) -> int:
|
|
113
133
|
return sum(n.request_count for n in self._nodes.get(host, {}).values())
|
|
114
134
|
|
|
115
135
|
def set_in_scope(self, host: str, in_scope: bool) -> None:
|
|
116
|
-
"""Include/exclude a host from Scope.
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
136
|
+
"""Include/exclude a host from Scope.
|
|
137
|
+
|
|
138
|
+
Tracks the (normalized, port-stripped) host in `_scope_hosts`
|
|
139
|
+
independently of whether a matching node already exists in
|
|
140
|
+
`_nodes` — a host added to scope before any traffic for it has
|
|
141
|
+
been seen (or seen under a different host:port key) is still
|
|
142
|
+
remembered and will be applied to any node for that host,
|
|
143
|
+
present now or added later via add_request().
|
|
144
|
+
"""
|
|
145
|
+
norm = self._norm_host(host)
|
|
146
|
+
if in_scope:
|
|
147
|
+
self._scope_hosts.add(norm)
|
|
148
|
+
else:
|
|
149
|
+
self._scope_hosts.discard(norm)
|
|
150
|
+
for h, paths in self._nodes.items():
|
|
151
|
+
if self._norm_host(h) == norm:
|
|
152
|
+
for node in paths.values():
|
|
153
|
+
node.in_scope = in_scope
|
|
120
154
|
|
|
121
155
|
def is_in_scope(self, host: str) -> bool:
|
|
156
|
+
if self._norm_host(host) in self._scope_hosts:
|
|
157
|
+
return True
|
|
122
158
|
paths = self._nodes.get(host, {})
|
|
123
159
|
return any(n.in_scope for n in paths.values())
|
|
124
160
|
|
|
@@ -172,11 +208,14 @@ class SiteMap:
|
|
|
172
208
|
in_scope=bool(row["in_scope"]),
|
|
173
209
|
)
|
|
174
210
|
self._nodes.setdefault(node.host, {})[node.path] = node
|
|
211
|
+
if node.in_scope:
|
|
212
|
+
self._scope_hosts.add(self._norm_host(node.host))
|
|
175
213
|
except Exception as exc:
|
|
176
214
|
logger.error("SiteMap.load error: %s", exc)
|
|
177
215
|
|
|
178
216
|
def clear(self) -> None:
|
|
179
217
|
self._nodes.clear()
|
|
218
|
+
self._scope_hosts.clear()
|
|
180
219
|
|
|
181
220
|
def export_json(self) -> dict:
|
|
182
221
|
return {
|
|
@@ -1017,6 +1017,12 @@ class PentoolApp(App):
|
|
|
1017
1017
|
# out by that reload, making a simple "Add to Scope" click
|
|
1018
1018
|
# from Proxy appear to clear the whole Target tree.
|
|
1019
1019
|
target._refresh_tree()
|
|
1020
|
+
# Persist the scope change right away — without this, the
|
|
1021
|
+
# in-memory-only mutation above can be silently discarded by a
|
|
1022
|
+
# later full api.load() from the DB (e.g. on project import),
|
|
1023
|
+
# making the scope change from Proxy appear to have never
|
|
1024
|
+
# happened once the project is reloaded.
|
|
1025
|
+
self.run_worker(api.save(), exclusive=False)
|
|
1020
1026
|
except Exception as e:
|
|
1021
1027
|
logger.debug("on_sync_scope_to_target: %s", e)
|
|
1022
1028
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""CommentDialog — view/edit the comment for a proxy history request.
|
|
2
|
+
|
|
3
|
+
Standardized on the same compact layout as the other dialogs (ToolbarButton
|
|
4
|
+
flat buttons, compact Input, CSS in a sibling .tcss file) instead of the
|
|
5
|
+
default textual.widgets.Button/Input, which render with full-size borders
|
|
6
|
+
and looked oversized compared to the rest of the app.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from textual import on
|
|
14
|
+
from textual.app import ComposeResult
|
|
15
|
+
from textual.binding import Binding
|
|
16
|
+
from textual.containers import Horizontal, Vertical
|
|
17
|
+
from textual.screen import ModalScreen
|
|
18
|
+
from textual.widgets import Input, Label
|
|
19
|
+
|
|
20
|
+
from pentool.tui.widgets.toolbar_button import ToolbarButton
|
|
21
|
+
|
|
22
|
+
_CSS = (Path(__file__).parent / "comment_dialog.tcss").read_text(encoding="utf-8")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CommentDialog(ModalScreen[str | None]):
|
|
26
|
+
"""Dialog: view/edit the comment for the currently-selected request."""
|
|
27
|
+
|
|
28
|
+
DEFAULT_CSS = _CSS
|
|
29
|
+
|
|
30
|
+
BINDINGS = [Binding("escape", "dismiss(None)", "Cancel")]
|
|
31
|
+
|
|
32
|
+
def __init__(self, initial_comment: str = "") -> None:
|
|
33
|
+
super().__init__()
|
|
34
|
+
self._initial_comment = initial_comment
|
|
35
|
+
|
|
36
|
+
def compose(self) -> ComposeResult:
|
|
37
|
+
with Vertical(id="dialog"):
|
|
38
|
+
yield Label("Comment:", id="comment-label")
|
|
39
|
+
yield Input(
|
|
40
|
+
value=self._initial_comment,
|
|
41
|
+
placeholder="Add comment...",
|
|
42
|
+
id="comment-dialog-input",
|
|
43
|
+
compact=True,
|
|
44
|
+
)
|
|
45
|
+
with Horizontal(id="buttons"):
|
|
46
|
+
yield ToolbarButton("Save", "btn-save")
|
|
47
|
+
yield ToolbarButton("Cancel", "btn-cancel")
|
|
48
|
+
|
|
49
|
+
def on_mount(self) -> None:
|
|
50
|
+
self.query_one("#comment-dialog-input", Input).focus()
|
|
51
|
+
|
|
52
|
+
@on(ToolbarButton.Pressed, "#btn-save")
|
|
53
|
+
def on_btn_save(self, _: ToolbarButton.Pressed) -> None:
|
|
54
|
+
self.dismiss(self.query_one("#comment-dialog-input", Input).value)
|
|
55
|
+
|
|
56
|
+
@on(ToolbarButton.Pressed, "#btn-cancel")
|
|
57
|
+
def on_btn_cancel(self, _: ToolbarButton.Pressed) -> None:
|
|
58
|
+
self.dismiss(None)
|
|
59
|
+
|
|
60
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
61
|
+
self.dismiss(event.value)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
CommentDialog {
|
|
2
|
+
align: center middle;
|
|
3
|
+
}
|
|
4
|
+
CommentDialog #dialog {
|
|
5
|
+
width: 60;
|
|
6
|
+
height: auto;
|
|
7
|
+
border: round $primary;
|
|
8
|
+
background: $surface;
|
|
9
|
+
padding: 1 2;
|
|
10
|
+
}
|
|
11
|
+
CommentDialog #comment-label {
|
|
12
|
+
margin-bottom: 1;
|
|
13
|
+
}
|
|
14
|
+
CommentDialog #comment-dialog-input {
|
|
15
|
+
margin-bottom: 1;
|
|
16
|
+
}
|
|
17
|
+
CommentDialog #buttons {
|
|
18
|
+
layout: horizontal;
|
|
19
|
+
height: 1;
|
|
20
|
+
align: right middle;
|
|
21
|
+
margin-top: 1;
|
|
22
|
+
}
|
|
23
|
+
CommentDialog #buttons ToolbarButton {
|
|
24
|
+
margin-left: 1;
|
|
25
|
+
}
|
|
@@ -277,8 +277,17 @@ class HeatmapWidget(Widget):
|
|
|
277
277
|
yield Static("[bold]┌─ HOST HEATMAP ─[/bold]")
|
|
278
278
|
yield Static("", id="heatmap-body")
|
|
279
279
|
|
|
280
|
+
_MAX_HOSTS = 500
|
|
281
|
+
|
|
280
282
|
def increment_host(self, host: str) -> None:
|
|
281
283
|
self._host_counts[host] = self._host_counts.get(host, 0) + 1
|
|
284
|
+
if len(self._host_counts) > self._MAX_HOSTS:
|
|
285
|
+
# Only the top 6 are ever displayed — keep a generous top slice
|
|
286
|
+
# and drop the long tail of one-off hosts.
|
|
287
|
+
keep = dict(
|
|
288
|
+
sorted(self._host_counts.items(), key=lambda x: -x[1])[: self._MAX_HOSTS // 2]
|
|
289
|
+
)
|
|
290
|
+
self._host_counts = keep
|
|
282
291
|
self._update_display()
|
|
283
292
|
|
|
284
293
|
def _update_display(self) -> None:
|
|
@@ -541,6 +550,8 @@ class MiniSiteMap(Widget):
|
|
|
541
550
|
yield Static("[bold]┌─ SITE MAP ─[/bold]")
|
|
542
551
|
yield Static("", id="sitemap-body")
|
|
543
552
|
|
|
553
|
+
_MAX_PATHS = 500
|
|
554
|
+
|
|
544
555
|
def add_url(self, url: str) -> None:
|
|
545
556
|
"""Add a URL to the mini-map."""
|
|
546
557
|
try:
|
|
@@ -550,6 +561,13 @@ class MiniSiteMap(Widget):
|
|
|
550
561
|
parts = [p for p in parsed.path.split("/") if p]
|
|
551
562
|
key = "/" + "/".join(parts[:2]) if parts else "/"
|
|
552
563
|
self._paths[key] = self._paths.get(key, 0) + 1
|
|
564
|
+
if len(self._paths) > self._MAX_PATHS:
|
|
565
|
+
# Only the top 8 are ever displayed — keep a generous top
|
|
566
|
+
# slice and drop the long tail of one-off paths.
|
|
567
|
+
keep = dict(
|
|
568
|
+
sorted(self._paths.items(), key=lambda x: -x[1])[: self._MAX_PATHS // 2]
|
|
569
|
+
)
|
|
570
|
+
self._paths = keep
|
|
553
571
|
self._update_display()
|
|
554
572
|
except Exception:
|
|
555
573
|
pass
|
|
@@ -95,16 +95,16 @@ def _row_to_record(r: dict) -> tuple:
|
|
|
95
95
|
else:
|
|
96
96
|
time_str = "-"
|
|
97
97
|
|
|
98
|
-
# Prepend color dot
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
# marked/commented requests are visible in the list without opening them.
|
|
98
|
+
# Prepend color dot and/or 💬 comment marker to Host column — both are
|
|
99
|
+
# left-aligned prefixes so marked/commented requests are visible in the
|
|
100
|
+
# list without opening them.
|
|
102
101
|
host = str(r.get("host", "") or "")
|
|
103
102
|
color = str(r.get("color", "") or "")
|
|
104
103
|
dot = _COLOR_DOTS.get(color, "")
|
|
105
104
|
comment = str(r.get("comment", "") or "")
|
|
106
|
-
comment_marker = "
|
|
107
|
-
|
|
105
|
+
comment_marker = "💬 " if comment.strip() else ""
|
|
106
|
+
prefix = f"{dot} " if dot else ""
|
|
107
|
+
host_display = f"{prefix}{comment_marker}{host}"
|
|
108
108
|
|
|
109
109
|
return (
|
|
110
110
|
r.get("id", 0),
|
|
@@ -257,6 +257,11 @@ class ProxyScreen(RequestContextMenuMixin, AppMixin, Widget):
|
|
|
257
257
|
# Debounce: batch rapid row appends into one incremental add_rows() call
|
|
258
258
|
self._pending_append_rows: list[tuple] = [] # (req, row_id) pairs
|
|
259
259
|
self._debounce_timer = None
|
|
260
|
+
# Cap on in-memory _rows_cache size — beyond this, oldest loaded rows
|
|
261
|
+
# are dropped and _history_oldest_offset is bumped so "scroll up to
|
|
262
|
+
# load more" can re-fetch them from storage on demand instead of
|
|
263
|
+
# keeping the whole session's history resident in memory forever.
|
|
264
|
+
self._ROWS_CACHE_MAX = 5000
|
|
260
265
|
# "Showing N of M" + scroll-up-to-load-more state (HTTP History only)
|
|
261
266
|
self._history_total: int = 0 # total rows matching current filters (from COUNT(*))
|
|
262
267
|
self._history_oldest_offset: int = 0 # how many older rows are NOT yet loaded (above what's in _rows_cache)
|
|
@@ -837,6 +842,24 @@ class ProxyScreen(RequestContextMenuMixin, AppMixin, Widget):
|
|
|
837
842
|
table.scroll_end(animate=False)
|
|
838
843
|
except Exception as exc:
|
|
839
844
|
logger.debug("PROXY SCREEN: _flush_pending_rows: %s", exc)
|
|
845
|
+
# Cap unbounded growth of the in-memory cache during very long
|
|
846
|
+
# sessions: drop the oldest rows once we exceed the cap and mark
|
|
847
|
+
# them as available again via _history_oldest_offset so scrolling
|
|
848
|
+
# up re-fetches them from storage instead of leaking memory.
|
|
849
|
+
if len(self._rows_cache) > self._ROWS_CACHE_MAX:
|
|
850
|
+
overflow = len(self._rows_cache) - self._ROWS_CACHE_MAX
|
|
851
|
+
self._rows_cache = self._rows_cache[overflow:]
|
|
852
|
+
self._history_oldest_offset += overflow
|
|
853
|
+
try:
|
|
854
|
+
table = self.query_one("#request-list", DataTable)
|
|
855
|
+
arrow = _rows_to_arrow(self._rows_cache)
|
|
856
|
+
table.backend = ArrowBackend(arrow)
|
|
857
|
+
table._ordered_columns = None
|
|
858
|
+
table._clear_caches()
|
|
859
|
+
table._require_update_dimensions = True
|
|
860
|
+
table.scroll_end(animate=False)
|
|
861
|
+
except Exception as exc:
|
|
862
|
+
logger.debug("PROXY SCREEN: _rows_cache trim rebuild failed: %s", exc)
|
|
840
863
|
self._update_history_count_label()
|
|
841
864
|
|
|
842
865
|
def _select_row(self, row_idx: int) -> None:
|
|
@@ -2087,53 +2110,15 @@ class ProxyScreen(RequestContextMenuMixin, AppMixin, Widget):
|
|
|
2087
2110
|
req_id = self._selected_req_id
|
|
2088
2111
|
if not req_id:
|
|
2089
2112
|
return
|
|
2090
|
-
from
|
|
2113
|
+
from pentool.tui.dialogs.comment_dialog import CommentDialog
|
|
2091
2114
|
|
|
2092
2115
|
initial_comment = initial_comment if initial_comment is not None else self._current_comment
|
|
2093
2116
|
|
|
2094
|
-
class CommentDialog(ModalScreen):
|
|
2095
|
-
DEFAULT_CSS = """
|
|
2096
|
-
CommentDialog > Vertical {
|
|
2097
|
-
width: 60;
|
|
2098
|
-
height: auto;
|
|
2099
|
-
border: round $primary;
|
|
2100
|
-
padding: 1 2;
|
|
2101
|
-
background: $panel;
|
|
2102
|
-
}
|
|
2103
|
-
CommentDialog Input { margin: 1 0; }
|
|
2104
|
-
CommentDialog Horizontal { align: center middle; height: auto; }
|
|
2105
|
-
CommentDialog Button { margin: 0 1; }
|
|
2106
|
-
"""
|
|
2107
|
-
|
|
2108
|
-
def compose(self) -> ComposeResult:
|
|
2109
|
-
with Vertical():
|
|
2110
|
-
yield Label("Comment:")
|
|
2111
|
-
yield Input(
|
|
2112
|
-
value=initial_comment,
|
|
2113
|
-
placeholder="Add comment...",
|
|
2114
|
-
id="comment-dialog-input",
|
|
2115
|
-
)
|
|
2116
|
-
with Horizontal():
|
|
2117
|
-
yield Button("Save", id="save", variant="primary")
|
|
2118
|
-
yield Button("Cancel", id="cancel")
|
|
2119
|
-
|
|
2120
|
-
def on_mount(self) -> None:
|
|
2121
|
-
self.query_one("#comment-dialog-input", Input).focus()
|
|
2122
|
-
|
|
2123
|
-
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
2124
|
-
if event.button.id == "save":
|
|
2125
|
-
self.dismiss(self.query_one("#comment-dialog-input", Input).value)
|
|
2126
|
-
else:
|
|
2127
|
-
self.dismiss(None)
|
|
2128
|
-
|
|
2129
|
-
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
2130
|
-
self.dismiss(event.value)
|
|
2131
|
-
|
|
2132
2117
|
def _on_comment(comment: str | None) -> None:
|
|
2133
2118
|
if comment is not None:
|
|
2134
2119
|
self.run_worker(self._save_comment(req_id, comment))
|
|
2135
2120
|
|
|
2136
|
-
self.app.push_screen(CommentDialog(), _on_comment)
|
|
2121
|
+
self.app.push_screen(CommentDialog(initial_comment), _on_comment)
|
|
2137
2122
|
|
|
2138
2123
|
async def _save_comment(self, request_id: int, comment: str) -> None:
|
|
2139
2124
|
"""Save comment to database."""
|
|
@@ -93,14 +93,14 @@ class TargetScreen(Widget):
|
|
|
93
93
|
try:
|
|
94
94
|
api = self._target_api
|
|
95
95
|
if api is not None:
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
#
|
|
96
|
+
# Defense in depth: SiteMap now tracks scope in its own
|
|
97
|
+
# _scope_hosts set (independent of whether a node exists
|
|
98
|
+
# yet) and load() re-seeds it from the DB's in_scope
|
|
99
|
+
# column — so this manual save/restore is no longer the
|
|
100
|
+
# only thing keeping scope alive across a reload. Kept
|
|
101
|
+
# anyway in case _target_api is swapped for a fresh
|
|
102
|
+
# instance (whose _scope_hosts would start empty) between
|
|
103
|
+
# scope changes and the reload.
|
|
104
104
|
for host in api.sitemap.get_hosts():
|
|
105
105
|
if api.sitemap.is_in_scope(host):
|
|
106
106
|
scope_hosts.add(host)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pentool
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Modern Web Pentesting TUI - Professional security testing tool
|
|
5
5
|
Author-email: Pentool Team <dev@pentool.pro>
|
|
6
6
|
License: AGPL-3.0
|
|
@@ -130,6 +130,11 @@ Fast, transparent, and built for real-world testing.
|
|
|
130
130
|
# Install
|
|
131
131
|
pip install pentool
|
|
132
132
|
|
|
133
|
+
# Start a 14-day PRO trial (unlocks Scanner + other PRO features)
|
|
134
|
+
# Run this BEFORE first launching the TUI — if the TUI is already open,
|
|
135
|
+
# restart it afterwards so it picks up the new license.
|
|
136
|
+
pentool license trial
|
|
137
|
+
|
|
133
138
|
# Launch TUI
|
|
134
139
|
pentool
|
|
135
140
|
|
|
@@ -72,6 +72,8 @@ pentool/tui/project_manager.py
|
|
|
72
72
|
pentool/tui/dialogs/__init__.py
|
|
73
73
|
pentool/tui/dialogs/cert_dialog.py
|
|
74
74
|
pentool/tui/dialogs/cert_dialog.tcss
|
|
75
|
+
pentool/tui/dialogs/comment_dialog.py
|
|
76
|
+
pentool/tui/dialogs/comment_dialog.tcss
|
|
75
77
|
pentool/tui/dialogs/file_selector.py
|
|
76
78
|
pentool/tui/dialogs/file_selector.tcss
|
|
77
79
|
pentool/tui/dialogs/load_from_proxy.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|