pentool 0.2.2__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.2 → pentool-0.2.4}/PKG-INFO +6 -1
- {pentool-0.2.2 → pentool-0.2.4}/README.md +5 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/__init__.py +1 -1
- {pentool-0.2.2 → pentool-0.2.4}/pentool/api/target_api.py +2 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/core/database.py +36 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/modules/proxy.py +48 -3
- {pentool-0.2.2 → pentool-0.2.4}/pentool/modules/target.py +44 -5
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/app.py +52 -4
- 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.2 → pentool-0.2.4}/pentool/tui/dialogs/match_replace_dialog.py +1 -1
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/project_manager.py +121 -64
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/dashboard/live_dashboard.py +18 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/intruder/screen.py +1 -1
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/proxy/screen.py +215 -64
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/proxy/screen.tcss +0 -8
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/repeater/screen.py +2 -21
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/settings/screen.py +7 -4
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/spider/screen.py +1 -1
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/target/screen.py +8 -8
- pentool-0.2.4/pentool/tui/widgets/nice_checkbox.py +35 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/request_editor.py +44 -15
- {pentool-0.2.2 → pentool-0.2.4}/pentool.egg-info/PKG-INFO +6 -1
- {pentool-0.2.2 → pentool-0.2.4}/pentool.egg-info/SOURCES.txt +3 -0
- {pentool-0.2.2 → pentool-0.2.4}/pyproject.toml +1 -1
- {pentool-0.2.2 → pentool-0.2.4}/LICENSE +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/__main__.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/api/__init__.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/api/base_api.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/api/comparer_api.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/api/decoder_api.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/api/intruder_api.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/api/proxy_api.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/api/repeater_api.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/api/sequencer_api.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/api/spider_api.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/cli/__init__.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/cli/main.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/cli/project.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/cli/proxy.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/cli/scan.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/core/__init__.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/core/config.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/core/crash_reporter.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/core/event_bus.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/core/events.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/core/features.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/core/license.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/core/logging.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/core/plugin_manager.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/core/project.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/core/storage_interface.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/core/updater.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/core/utils.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/modules/__init__.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/modules/comparer.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/modules/decoder.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/modules/intruder.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/modules/match_replace.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/modules/repeater.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/modules/sequencer.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/modules/spider.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/modules/websocket_handler.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/plugins/__init__.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/plugins/builtin/__init__.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/plugins/example_plugin.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/plugins/example_plugin.tcss +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/services/__init__.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/services/base_service.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/services/intruder_service.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/services/proxy_service.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/services/repeater_service.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/services/scan_service.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/storage/__init__.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/storage/http_storage.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/storage/large_body_handler.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/storage/lru_cache.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/t.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/__init__.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/constants.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/dialogs/__init__.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/dialogs/cert_dialog.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/dialogs/cert_dialog.tcss +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/dialogs/file_selector.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/dialogs/file_selector.tcss +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/dialogs/load_from_proxy.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/dialogs/load_from_proxy.tcss +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/dialogs/match_replace_dialog.tcss +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/dialogs/project_dialog.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/dialogs/project_dialog.tcss +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/dialogs/scope_dialog.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/dialogs/scope_dialog.tcss +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/messages.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/mixins/__init__.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/mixins/app_mixin.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/mixins/dialog_cancel.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/mixins/request_context_menu.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/__init__.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/base.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/comparer/__init__.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/comparer/screen.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/comparer/screen.tcss +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/dashboard/__init__.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/dashboard/screen.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/dashboard/screen.tcss +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/decoder/__init__.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/decoder/screen.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/decoder/screen.tcss +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/extensions/__init__.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/extensions/screen.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/extensions/screen.tcss +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/intruder/__init__.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/intruder/screen.tcss +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/proxy/__init__.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/repeater/__init__.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/repeater/screen.tcss +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/scanner_unavailable.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/sequencer/__init__.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/sequencer/screen.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/sequencer/screen.tcss +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/settings/__init__.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/settings/hotkeys.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/settings/hotkeys.tcss +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/settings/screen.tcss +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/spider/__init__.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/spider/screen.tcss +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/target/__init__.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/target/screen.tcss +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/terminal/__init__.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/terminal/screen.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/terminal/screen.tcss +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/__init__.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/activity_indicator.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/activity_indicator.tcss +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/context_menu.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/context_menu.tcss +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/filter_bar.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/filter_bar.tcss +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/inspector_panel.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/inspector_panel.tcss +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/menu.tcss +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/menu_bar.tcss +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/module_tabs.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/module_tabs.tcss +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/payload_drop_zone.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/payload_drop_zone.tcss +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/request_editor.tcss +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/resize_handle.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/resize_handle.tcss +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/search_bar.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/search_bar.tcss +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/statusbar.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/statusbar.tcss +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/toolbar_button.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/toolbar_button.tcss +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/utils/__init__.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/utils/cert.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/utils/coder.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/utils/copy_as.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/utils/diff.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/utils/http_client.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/utils/parser.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool/utils/terminal_check.py +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool.egg-info/dependency_links.txt +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool.egg-info/entry_points.txt +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool.egg-info/requires.txt +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/pentool.egg-info/top_level.txt +0 -0
- {pentool-0.2.2 → pentool-0.2.4}/setup.cfg +0 -0
- {pentool-0.2.2 → 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)
|
|
@@ -95,6 +95,14 @@ CREATE TABLE IF NOT EXISTS site_map (
|
|
|
95
95
|
);
|
|
96
96
|
|
|
97
97
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_site_map_host_path ON site_map (host, path);
|
|
98
|
+
|
|
99
|
+
-- Generic per-project key/value settings (e.g. "proxy.enforce_scope").
|
|
100
|
+
-- Lives in the project .db file itself so it travels with the project,
|
|
101
|
+
-- unlike pentool's global ~/.config/pentool/config.yaml.
|
|
102
|
+
CREATE TABLE IF NOT EXISTS project_settings (
|
|
103
|
+
key TEXT PRIMARY KEY,
|
|
104
|
+
value TEXT NOT NULL DEFAULT ''
|
|
105
|
+
);
|
|
98
106
|
"""
|
|
99
107
|
|
|
100
108
|
|
|
@@ -170,3 +178,31 @@ def init_db_sync(db_path: str) -> None:
|
|
|
170
178
|
db_path: Path to the SQLite file.
|
|
171
179
|
"""
|
|
172
180
|
asyncio.run(init_db(db_path))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
async def get_project_setting(db_path: str, key: str, default: str | None = None) -> str | None:
|
|
184
|
+
"""Read a single per-project key/value setting from `project_settings`.
|
|
185
|
+
|
|
186
|
+
Returns `default` if the key is missing or the table/column doesn't
|
|
187
|
+
exist yet (e.g. a brand-new .db before init_db() has run).
|
|
188
|
+
"""
|
|
189
|
+
try:
|
|
190
|
+
async with aiosqlite.connect(db_path) as db:
|
|
191
|
+
async with db.execute(
|
|
192
|
+
"SELECT value FROM project_settings WHERE key = ?", (key,)
|
|
193
|
+
) as cur:
|
|
194
|
+
row = await cur.fetchone()
|
|
195
|
+
return row[0] if row else default
|
|
196
|
+
except Exception:
|
|
197
|
+
return default
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
async def set_project_setting(db_path: str, key: str, value: str) -> None:
|
|
201
|
+
"""Upsert a single per-project key/value setting into `project_settings`."""
|
|
202
|
+
async with aiosqlite.connect(db_path) as db:
|
|
203
|
+
await db.execute(
|
|
204
|
+
"INSERT INTO project_settings (key, value) VALUES (?, ?) "
|
|
205
|
+
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
206
|
+
(key, value),
|
|
207
|
+
)
|
|
208
|
+
await db.commit()
|
|
@@ -150,6 +150,14 @@ class ProxyServer:
|
|
|
150
150
|
|
|
151
151
|
self.intercept_enabled: bool = False
|
|
152
152
|
self.scope: list[str] = [] # Empty = intercept everything
|
|
153
|
+
# When False (default), scope is informational only — ALL traffic is
|
|
154
|
+
# captured/shown regardless of self.scope. Scope filtering (dropping
|
|
155
|
+
# out-of-scope requests from capture) only takes effect when this is
|
|
156
|
+
# explicitly turned on via the "Skip out-of-scope" toggle button.
|
|
157
|
+
# Without this flag, a leftover/stale `scope` list (e.g. saved from a
|
|
158
|
+
# previous project) would silently blackhole all non-matching traffic
|
|
159
|
+
# the moment the proxy starts, with no visible indication why.
|
|
160
|
+
self.enforce_scope: bool = False
|
|
153
161
|
|
|
154
162
|
# Match/Replace via dedicated engine
|
|
155
163
|
self._match_replace_engine = MatchReplaceEngine()
|
|
@@ -205,7 +213,10 @@ class ProxyServer:
|
|
|
205
213
|
self._running = False
|
|
206
214
|
if self._server:
|
|
207
215
|
self._server.close()
|
|
208
|
-
|
|
216
|
+
try:
|
|
217
|
+
await asyncio.wait_for(self._server.wait_closed(), timeout=2.0)
|
|
218
|
+
except asyncio.TimeoutError:
|
|
219
|
+
logger.debug("ProxyServer.stop: wait_closed timeout, continuing")
|
|
209
220
|
self._server = None
|
|
210
221
|
# Close singleton HTTP client
|
|
211
222
|
if self._http_client:
|
|
@@ -214,6 +225,25 @@ class ProxyServer:
|
|
|
214
225
|
except Exception as e:
|
|
215
226
|
logger.warning("ProxyServer.stop: http_client.close() error: %s", e)
|
|
216
227
|
self._http_client = None
|
|
228
|
+
# Cancel all remaining active tasks (open TLS tunnels, keep-alive
|
|
229
|
+
# connections, intercept waits) so the event loop can exit promptly.
|
|
230
|
+
# Without this, _handle_connect loops on _READ_TIMEOUT=30s and
|
|
231
|
+
# _INTERCEPT_TIMEOUT=300s — the proxy thread would not exit for up to
|
|
232
|
+
# 5 minutes after stop(), blocking project switches and restarts.
|
|
233
|
+
loop = asyncio.get_running_loop()
|
|
234
|
+
current = asyncio.current_task()
|
|
235
|
+
pending = [t for t in asyncio.all_tasks(loop) if t is not current and not t.done()]
|
|
236
|
+
if pending:
|
|
237
|
+
logger.debug("ProxyServer.stop: cancelling %d active task(s)", len(pending))
|
|
238
|
+
for task in pending:
|
|
239
|
+
task.cancel()
|
|
240
|
+
try:
|
|
241
|
+
await asyncio.wait_for(
|
|
242
|
+
asyncio.gather(*pending, return_exceptions=True),
|
|
243
|
+
timeout=2.0,
|
|
244
|
+
)
|
|
245
|
+
except asyncio.TimeoutError:
|
|
246
|
+
logger.debug("ProxyServer.stop: task cancellation timed out")
|
|
217
247
|
logger.info("Proxy stopped")
|
|
218
248
|
|
|
219
249
|
async def serve_forever(self) -> None:
|
|
@@ -296,6 +326,19 @@ class ProxyServer:
|
|
|
296
326
|
else:
|
|
297
327
|
self.intercept_enabled = enabled
|
|
298
328
|
|
|
329
|
+
def set_enforce_scope(self, enabled: bool) -> None:
|
|
330
|
+
"""Thread-safe setting of enforce_scope flag from any thread.
|
|
331
|
+
|
|
332
|
+
Same rationale as set_intercept — the proxy loop reads this flag
|
|
333
|
+
in _handle_http/_handle_connect, so writes from the TUI thread must
|
|
334
|
+
go through call_soon_threadsafe to avoid a torn read.
|
|
335
|
+
"""
|
|
336
|
+
loop = self._loop
|
|
337
|
+
if loop is not None and loop.is_running():
|
|
338
|
+
loop.call_soon_threadsafe(setattr, self, "enforce_scope", enabled)
|
|
339
|
+
else:
|
|
340
|
+
self.enforce_scope = enabled
|
|
341
|
+
|
|
299
342
|
def _find_request(self, req_id: str) -> InterceptedRequest | None:
|
|
300
343
|
with self._requests_lock:
|
|
301
344
|
for r in reversed(self.requests):
|
|
@@ -367,7 +410,8 @@ class ProxyServer:
|
|
|
367
410
|
await writer.drain()
|
|
368
411
|
|
|
369
412
|
# If host is out of scope — just tunnel without interception
|
|
370
|
-
|
|
413
|
+
# (only enforced when the user turned on "Skip out-of-scope")
|
|
414
|
+
if self.enforce_scope and not self.is_in_scope(domain):
|
|
371
415
|
await self._tunnel_raw(domain, host_port, reader, writer)
|
|
372
416
|
return
|
|
373
417
|
|
|
@@ -491,7 +535,8 @@ class ProxyServer:
|
|
|
491
535
|
req.method, req.url, is_https, self.intercept_enabled, self.is_in_scope(host))
|
|
492
536
|
|
|
493
537
|
# Check scope (for HTTP; HTTPS already checked in _handle_connect)
|
|
494
|
-
|
|
538
|
+
# (only enforced when the user turned on "Skip out-of-scope")
|
|
539
|
+
if self.enforce_scope and not is_https and not self.is_in_scope(host):
|
|
495
540
|
logger.debug("PROXY: _handle_http: host %s out of scope, forwarding direct", host)
|
|
496
541
|
return await self._forward_direct(req, writer)
|
|
497
542
|
|
|
@@ -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 {
|
|
@@ -701,6 +701,14 @@ class PentoolApp(App):
|
|
|
701
701
|
pass
|
|
702
702
|
self.query_one(ContentSwitcher).current = f"screen-{module_id}"
|
|
703
703
|
self._active_module = module_id
|
|
704
|
+
# _update_proxy_screen_labels() only refreshes the ProxyScreen label
|
|
705
|
+
# when it IS the active module — so if the proxy was stopped/started
|
|
706
|
+
# while the user was on a different tab (e.g. right after creating a
|
|
707
|
+
# new project, which force-stops the proxy while Dashboard is shown),
|
|
708
|
+
# the label update was skipped and stayed stale until the next actual
|
|
709
|
+
# start/stop toggle. Refresh it explicitly on every switch into Proxy.
|
|
710
|
+
if module_id == "proxy":
|
|
711
|
+
self._update_proxy_screen_labels()
|
|
704
712
|
|
|
705
713
|
def get_proxy(self) -> ProxyServer | None:
|
|
706
714
|
return self._proxy
|
|
@@ -758,6 +766,21 @@ class PentoolApp(App):
|
|
|
758
766
|
if self._proxy.is_running:
|
|
759
767
|
self._stop_proxy()
|
|
760
768
|
else:
|
|
769
|
+
if not self._project_loaded:
|
|
770
|
+
# Project DB switch/open (auto-open at startup, New/Open
|
|
771
|
+
# Project) is still finishing in the background — starting
|
|
772
|
+
# the proxy now would race HttpStorage.switch_db() (its
|
|
773
|
+
# connection may be mid-close/reopen) and silently lose or
|
|
774
|
+
# fail to persist the first captured requests. _project_loaded
|
|
775
|
+
# is set as soon as the DB switch itself completes (see
|
|
776
|
+
# ProjectManager._do_switch) — the other screens may still be
|
|
777
|
+
# reloading, but that's independent of Proxy.
|
|
778
|
+
self.notify(
|
|
779
|
+
"Проект ещё открывается — подождите пару секунд перед запуском Proxy",
|
|
780
|
+
severity="warning",
|
|
781
|
+
timeout=4,
|
|
782
|
+
)
|
|
783
|
+
return
|
|
761
784
|
self._start_proxy()
|
|
762
785
|
|
|
763
786
|
def action_toggle_intercept(self) -> None:
|
|
@@ -877,13 +900,25 @@ class PentoolApp(App):
|
|
|
877
900
|
self._proxy.stop(), self._proxy_loop
|
|
878
901
|
)
|
|
879
902
|
try:
|
|
880
|
-
|
|
903
|
+
# stop() now cancels all active tasks and waits up to ~4s
|
|
904
|
+
# internally, so 6s here is generous headroom
|
|
905
|
+
future.result(timeout=6)
|
|
881
906
|
except Exception as e:
|
|
882
907
|
logger.warning("APP: proxy.stop() error or timeout: %s", e)
|
|
908
|
+
# Force-cancel anything still running in the proxy loop so
|
|
909
|
+
# the thread can exit even if stop() itself timed out
|
|
910
|
+
if self._proxy_loop and not self._proxy_loop.is_closed():
|
|
911
|
+
try:
|
|
912
|
+
def _cancel_all():
|
|
913
|
+
for t in asyncio.all_tasks(self._proxy_loop):
|
|
914
|
+
t.cancel()
|
|
915
|
+
self._proxy_loop.call_soon_threadsafe(_cancel_all)
|
|
916
|
+
except Exception:
|
|
917
|
+
pass
|
|
883
918
|
if self._proxy_thread and self._proxy_thread.is_alive():
|
|
884
|
-
self._proxy_thread.join(timeout=
|
|
919
|
+
self._proxy_thread.join(timeout=5)
|
|
885
920
|
if self._proxy_thread.is_alive():
|
|
886
|
-
logger.warning("APP: proxy thread did not stop in
|
|
921
|
+
logger.warning("APP: proxy thread did not stop in 5s — port 8080 may still be in use")
|
|
887
922
|
self.call_after_refresh(self._update_status)
|
|
888
923
|
self.call_after_refresh(self._update_proxy_screen_labels)
|
|
889
924
|
|
|
@@ -974,7 +1009,20 @@ class PentoolApp(App):
|
|
|
974
1009
|
target = self.query_one(SCREEN_TARGET, TargetScreen)
|
|
975
1010
|
api = target._get_api()
|
|
976
1011
|
api.sitemap.set_in_scope(msg.host, msg.in_scope)
|
|
977
|
-
|
|
1012
|
+
# Rebuild the tree from the in-memory sitemap only — NOT
|
|
1013
|
+
# target._load_sitemap(), which does a full api.load() from
|
|
1014
|
+
# the DB. The sitemap is persisted in batches of 20 rows
|
|
1015
|
+
# (see target/screen.py _do_save_sitemap), so any host/path
|
|
1016
|
+
# added in-memory since the last batch save would be wiped
|
|
1017
|
+
# out by that reload, making a simple "Add to Scope" click
|
|
1018
|
+
# from Proxy appear to clear the whole Target tree.
|
|
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)
|
|
978
1026
|
except Exception as e:
|
|
979
1027
|
logger.debug("on_sync_scope_to_target: %s", e)
|
|
980
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
|
+
}
|
|
@@ -13,7 +13,6 @@ from textual.screen import ModalScreen
|
|
|
13
13
|
_CSS = (Path(__file__).parent / "match_replace_dialog.tcss").read_text(encoding="utf-8")
|
|
14
14
|
from textual.widgets import (
|
|
15
15
|
Button,
|
|
16
|
-
Checkbox,
|
|
17
16
|
DataTable,
|
|
18
17
|
Input,
|
|
19
18
|
Label,
|
|
@@ -21,6 +20,7 @@ from textual.widgets import (
|
|
|
21
20
|
)
|
|
22
21
|
|
|
23
22
|
from pentool.api.proxy_api import MatchReplaceRule
|
|
23
|
+
from pentool.tui.widgets.nice_checkbox import NiceCheckbox as Checkbox
|
|
24
24
|
from pentool.tui.widgets.toolbar_button import ToolbarButton
|
|
25
25
|
|
|
26
26
|
_TARGET_OPTIONS = [("request", "Request"), ("response", "Response"), ("both", "Both")]
|