pentool 0.2.2__tar.gz → 0.2.3__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.
Files changed (167) hide show
  1. {pentool-0.2.2 → pentool-0.2.3}/PKG-INFO +1 -1
  2. {pentool-0.2.2 → pentool-0.2.3}/pentool/__init__.py +1 -1
  3. {pentool-0.2.2 → pentool-0.2.3}/pentool/core/database.py +36 -0
  4. {pentool-0.2.2 → pentool-0.2.3}/pentool/modules/proxy.py +48 -3
  5. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/app.py +46 -4
  6. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/dialogs/match_replace_dialog.py +1 -1
  7. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/project_manager.py +121 -64
  8. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/intruder/screen.py +1 -1
  9. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/proxy/screen.py +184 -18
  10. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/proxy/screen.tcss +0 -8
  11. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/repeater/screen.py +2 -21
  12. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/settings/screen.py +7 -4
  13. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/spider/screen.py +1 -1
  14. pentool-0.2.3/pentool/tui/widgets/nice_checkbox.py +35 -0
  15. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/widgets/request_editor.py +44 -15
  16. {pentool-0.2.2 → pentool-0.2.3}/pentool.egg-info/PKG-INFO +1 -1
  17. {pentool-0.2.2 → pentool-0.2.3}/pentool.egg-info/SOURCES.txt +1 -0
  18. {pentool-0.2.2 → pentool-0.2.3}/pyproject.toml +1 -1
  19. {pentool-0.2.2 → pentool-0.2.3}/LICENSE +0 -0
  20. {pentool-0.2.2 → pentool-0.2.3}/README.md +0 -0
  21. {pentool-0.2.2 → pentool-0.2.3}/pentool/__main__.py +0 -0
  22. {pentool-0.2.2 → pentool-0.2.3}/pentool/api/__init__.py +0 -0
  23. {pentool-0.2.2 → pentool-0.2.3}/pentool/api/base_api.py +0 -0
  24. {pentool-0.2.2 → pentool-0.2.3}/pentool/api/comparer_api.py +0 -0
  25. {pentool-0.2.2 → pentool-0.2.3}/pentool/api/decoder_api.py +0 -0
  26. {pentool-0.2.2 → pentool-0.2.3}/pentool/api/intruder_api.py +0 -0
  27. {pentool-0.2.2 → pentool-0.2.3}/pentool/api/proxy_api.py +0 -0
  28. {pentool-0.2.2 → pentool-0.2.3}/pentool/api/repeater_api.py +0 -0
  29. {pentool-0.2.2 → pentool-0.2.3}/pentool/api/sequencer_api.py +0 -0
  30. {pentool-0.2.2 → pentool-0.2.3}/pentool/api/spider_api.py +0 -0
  31. {pentool-0.2.2 → pentool-0.2.3}/pentool/api/target_api.py +0 -0
  32. {pentool-0.2.2 → pentool-0.2.3}/pentool/cli/__init__.py +0 -0
  33. {pentool-0.2.2 → pentool-0.2.3}/pentool/cli/main.py +0 -0
  34. {pentool-0.2.2 → pentool-0.2.3}/pentool/cli/project.py +0 -0
  35. {pentool-0.2.2 → pentool-0.2.3}/pentool/cli/proxy.py +0 -0
  36. {pentool-0.2.2 → pentool-0.2.3}/pentool/cli/scan.py +0 -0
  37. {pentool-0.2.2 → pentool-0.2.3}/pentool/core/__init__.py +0 -0
  38. {pentool-0.2.2 → pentool-0.2.3}/pentool/core/config.py +0 -0
  39. {pentool-0.2.2 → pentool-0.2.3}/pentool/core/crash_reporter.py +0 -0
  40. {pentool-0.2.2 → pentool-0.2.3}/pentool/core/event_bus.py +0 -0
  41. {pentool-0.2.2 → pentool-0.2.3}/pentool/core/events.py +0 -0
  42. {pentool-0.2.2 → pentool-0.2.3}/pentool/core/features.py +0 -0
  43. {pentool-0.2.2 → pentool-0.2.3}/pentool/core/license.py +0 -0
  44. {pentool-0.2.2 → pentool-0.2.3}/pentool/core/logging.py +0 -0
  45. {pentool-0.2.2 → pentool-0.2.3}/pentool/core/plugin_manager.py +0 -0
  46. {pentool-0.2.2 → pentool-0.2.3}/pentool/core/project.py +0 -0
  47. {pentool-0.2.2 → pentool-0.2.3}/pentool/core/storage_interface.py +0 -0
  48. {pentool-0.2.2 → pentool-0.2.3}/pentool/core/updater.py +0 -0
  49. {pentool-0.2.2 → pentool-0.2.3}/pentool/core/utils.py +0 -0
  50. {pentool-0.2.2 → pentool-0.2.3}/pentool/modules/__init__.py +0 -0
  51. {pentool-0.2.2 → pentool-0.2.3}/pentool/modules/comparer.py +0 -0
  52. {pentool-0.2.2 → pentool-0.2.3}/pentool/modules/decoder.py +0 -0
  53. {pentool-0.2.2 → pentool-0.2.3}/pentool/modules/intruder.py +0 -0
  54. {pentool-0.2.2 → pentool-0.2.3}/pentool/modules/match_replace.py +0 -0
  55. {pentool-0.2.2 → pentool-0.2.3}/pentool/modules/repeater.py +0 -0
  56. {pentool-0.2.2 → pentool-0.2.3}/pentool/modules/sequencer.py +0 -0
  57. {pentool-0.2.2 → pentool-0.2.3}/pentool/modules/spider.py +0 -0
  58. {pentool-0.2.2 → pentool-0.2.3}/pentool/modules/target.py +0 -0
  59. {pentool-0.2.2 → pentool-0.2.3}/pentool/modules/websocket_handler.py +0 -0
  60. {pentool-0.2.2 → pentool-0.2.3}/pentool/plugins/__init__.py +0 -0
  61. {pentool-0.2.2 → pentool-0.2.3}/pentool/plugins/builtin/__init__.py +0 -0
  62. {pentool-0.2.2 → pentool-0.2.3}/pentool/plugins/example_plugin.py +0 -0
  63. {pentool-0.2.2 → pentool-0.2.3}/pentool/plugins/example_plugin.tcss +0 -0
  64. {pentool-0.2.2 → pentool-0.2.3}/pentool/services/__init__.py +0 -0
  65. {pentool-0.2.2 → pentool-0.2.3}/pentool/services/base_service.py +0 -0
  66. {pentool-0.2.2 → pentool-0.2.3}/pentool/services/intruder_service.py +0 -0
  67. {pentool-0.2.2 → pentool-0.2.3}/pentool/services/proxy_service.py +0 -0
  68. {pentool-0.2.2 → pentool-0.2.3}/pentool/services/repeater_service.py +0 -0
  69. {pentool-0.2.2 → pentool-0.2.3}/pentool/services/scan_service.py +0 -0
  70. {pentool-0.2.2 → pentool-0.2.3}/pentool/storage/__init__.py +0 -0
  71. {pentool-0.2.2 → pentool-0.2.3}/pentool/storage/http_storage.py +0 -0
  72. {pentool-0.2.2 → pentool-0.2.3}/pentool/storage/large_body_handler.py +0 -0
  73. {pentool-0.2.2 → pentool-0.2.3}/pentool/storage/lru_cache.py +0 -0
  74. {pentool-0.2.2 → pentool-0.2.3}/pentool/t.py +0 -0
  75. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/__init__.py +0 -0
  76. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/constants.py +0 -0
  77. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/dialogs/__init__.py +0 -0
  78. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/dialogs/cert_dialog.py +0 -0
  79. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/dialogs/cert_dialog.tcss +0 -0
  80. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/dialogs/file_selector.py +0 -0
  81. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/dialogs/file_selector.tcss +0 -0
  82. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/dialogs/load_from_proxy.py +0 -0
  83. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/dialogs/load_from_proxy.tcss +0 -0
  84. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/dialogs/match_replace_dialog.tcss +0 -0
  85. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/dialogs/project_dialog.py +0 -0
  86. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/dialogs/project_dialog.tcss +0 -0
  87. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/dialogs/scope_dialog.py +0 -0
  88. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/dialogs/scope_dialog.tcss +0 -0
  89. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/messages.py +0 -0
  90. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/mixins/__init__.py +0 -0
  91. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/mixins/app_mixin.py +0 -0
  92. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/mixins/dialog_cancel.py +0 -0
  93. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/mixins/request_context_menu.py +0 -0
  94. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/__init__.py +0 -0
  95. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/base.py +0 -0
  96. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/comparer/__init__.py +0 -0
  97. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/comparer/screen.py +0 -0
  98. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/comparer/screen.tcss +0 -0
  99. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/dashboard/__init__.py +0 -0
  100. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/dashboard/live_dashboard.py +0 -0
  101. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/dashboard/screen.py +0 -0
  102. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/dashboard/screen.tcss +0 -0
  103. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/decoder/__init__.py +0 -0
  104. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/decoder/screen.py +0 -0
  105. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/decoder/screen.tcss +0 -0
  106. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/extensions/__init__.py +0 -0
  107. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/extensions/screen.py +0 -0
  108. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/extensions/screen.tcss +0 -0
  109. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/intruder/__init__.py +0 -0
  110. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/intruder/screen.tcss +0 -0
  111. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/proxy/__init__.py +0 -0
  112. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/repeater/__init__.py +0 -0
  113. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/repeater/screen.tcss +0 -0
  114. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/scanner_unavailable.py +0 -0
  115. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/sequencer/__init__.py +0 -0
  116. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/sequencer/screen.py +0 -0
  117. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/sequencer/screen.tcss +0 -0
  118. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/settings/__init__.py +0 -0
  119. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/settings/hotkeys.py +0 -0
  120. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/settings/hotkeys.tcss +0 -0
  121. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/settings/screen.tcss +0 -0
  122. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/spider/__init__.py +0 -0
  123. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/spider/screen.tcss +0 -0
  124. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/target/__init__.py +0 -0
  125. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/target/screen.py +0 -0
  126. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/target/screen.tcss +0 -0
  127. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/terminal/__init__.py +0 -0
  128. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/terminal/screen.py +0 -0
  129. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/screens/terminal/screen.tcss +0 -0
  130. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/widgets/__init__.py +0 -0
  131. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/widgets/activity_indicator.py +0 -0
  132. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/widgets/activity_indicator.tcss +0 -0
  133. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/widgets/context_menu.py +0 -0
  134. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/widgets/context_menu.tcss +0 -0
  135. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/widgets/filter_bar.py +0 -0
  136. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/widgets/filter_bar.tcss +0 -0
  137. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/widgets/inspector_panel.py +0 -0
  138. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/widgets/inspector_panel.tcss +0 -0
  139. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/widgets/menu.tcss +0 -0
  140. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/widgets/menu_bar.tcss +0 -0
  141. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/widgets/module_tabs.py +0 -0
  142. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/widgets/module_tabs.tcss +0 -0
  143. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/widgets/payload_drop_zone.py +0 -0
  144. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/widgets/payload_drop_zone.tcss +0 -0
  145. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/widgets/request_editor.tcss +0 -0
  146. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/widgets/resize_handle.py +0 -0
  147. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/widgets/resize_handle.tcss +0 -0
  148. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/widgets/search_bar.py +0 -0
  149. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/widgets/search_bar.tcss +0 -0
  150. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/widgets/statusbar.py +0 -0
  151. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/widgets/statusbar.tcss +0 -0
  152. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/widgets/toolbar_button.py +0 -0
  153. {pentool-0.2.2 → pentool-0.2.3}/pentool/tui/widgets/toolbar_button.tcss +0 -0
  154. {pentool-0.2.2 → pentool-0.2.3}/pentool/utils/__init__.py +0 -0
  155. {pentool-0.2.2 → pentool-0.2.3}/pentool/utils/cert.py +0 -0
  156. {pentool-0.2.2 → pentool-0.2.3}/pentool/utils/coder.py +0 -0
  157. {pentool-0.2.2 → pentool-0.2.3}/pentool/utils/copy_as.py +0 -0
  158. {pentool-0.2.2 → pentool-0.2.3}/pentool/utils/diff.py +0 -0
  159. {pentool-0.2.2 → pentool-0.2.3}/pentool/utils/http_client.py +0 -0
  160. {pentool-0.2.2 → pentool-0.2.3}/pentool/utils/parser.py +0 -0
  161. {pentool-0.2.2 → pentool-0.2.3}/pentool/utils/terminal_check.py +0 -0
  162. {pentool-0.2.2 → pentool-0.2.3}/pentool.egg-info/dependency_links.txt +0 -0
  163. {pentool-0.2.2 → pentool-0.2.3}/pentool.egg-info/entry_points.txt +0 -0
  164. {pentool-0.2.2 → pentool-0.2.3}/pentool.egg-info/requires.txt +0 -0
  165. {pentool-0.2.2 → pentool-0.2.3}/pentool.egg-info/top_level.txt +0 -0
  166. {pentool-0.2.2 → pentool-0.2.3}/setup.cfg +0 -0
  167. {pentool-0.2.2 → pentool-0.2.3}/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.2
3
+ Version: 0.2.3
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
@@ -1,6 +1,6 @@
1
1
  """Pentool — professional web security testing toolkit with Textual TUI."""
2
2
 
3
- __version__ = "0.2.2"
3
+ __version__ = "0.2.3"
4
4
  __author__ = "pentool"
5
5
 
6
6
 
@@ -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
- await self._server.wait_closed()
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
- if not self.is_in_scope(domain):
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
- if not is_https and not self.is_in_scope(host):
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
 
@@ -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
- future.result(timeout=5)
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=3)
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 3s")
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,14 @@ 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
- target._load_sitemap()
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()
978
1020
  except Exception as e:
979
1021
  logger.debug("on_sync_scope_to_target: %s", e)
980
1022
 
@@ -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")]
@@ -325,9 +325,14 @@ class ProjectManager:
325
325
  )
326
326
  self._app._stop_proxy()
327
327
 
328
+ # Gate Start Proxy (action_toggle_proxy) until _do_switch flips this
329
+ # back to True once HttpStorage.switch_db() has actually completed —
330
+ # otherwise a Start Proxy click during the switch could race the
331
+ # storage connection being closed/reopened underneath it.
332
+ self._app._project_loaded = False
333
+
328
334
  self._cfg.db_path = path
329
335
  self._app._project_path = path
330
- # Note: _project_loaded will be set AFTER _reload_project_screens in _do_switch
331
336
 
332
337
  if self._proxy:
333
338
  self._proxy.db_path = path
@@ -337,6 +342,10 @@ class ProjectManager:
337
342
  self._proxy.clear_requests()
338
343
  self._proxy.scope = []
339
344
  self._proxy.match_replace_rules = []
345
+ # New project's DB has no project_settings row yet — reset to
346
+ # the documented default (OFF) rather than carrying over
347
+ # whatever the previous project had in memory.
348
+ self._proxy.set_enforce_scope(False)
340
349
  except Exception:
341
350
  pass
342
351
 
@@ -371,12 +380,44 @@ class ProjectManager:
371
380
  """Single entry point for all project switches.
372
381
 
373
382
  Runs in an exclusive worker — no two switches can overlap.
374
- Order is strict:
383
+ Order:
384
+ 0. Wait for the proxy thread to fully exit (if _stop_proxy was
385
+ called but the thread didn't die within join(timeout) — the
386
+ old loop may still hold the SQLite WAL lock or the TCP port).
375
387
  1. init_db (CREATE TABLE IF NOT EXISTS, migrations)
376
388
  2. switch_db on ProxyService (close old connection, open new one)
377
389
  3. clear in-memory proxy state if new project
378
- 4. reload all screens sequentially (no nested run_worker)
390
+ 4. set _project_loaded — DB is ready, Start Proxy is now safe,
391
+ even though the other screens (Repeater/Scanner/Target/Dashboard)
392
+ may still be reloading in the background (step 5). Proxy only
393
+ depends on the HttpStorage connection from step 2, not on any
394
+ of those screens, so there is no reason to make the user wait
395
+ for all of them before allowing Start Proxy.
396
+ 5. reload the remaining screens — run concurrently (they read
397
+ independent tables/state), not sequentially.
379
398
  """
399
+ # 0. Wait for the proxy thread to die before touching the DB.
400
+ # _stop_proxy() already called join(timeout=5), but if the thread
401
+ # is still alive (e.g. a 30-second _READ_TIMEOUT blocked task didn't
402
+ # get cancelled in time) we must not open a new SQLite connection
403
+ # while the old proxy event loop might still be writing to it.
404
+ proxy_thread = self._app._proxy_thread
405
+ if proxy_thread is not None and proxy_thread.is_alive():
406
+ logger.info("_do_switch: waiting for proxy thread to exit before DB switch…")
407
+ loop = asyncio.get_running_loop()
408
+ # Poll in the async event loop so we don't block the TUI thread
409
+ for _ in range(50): # up to 5 seconds in 100ms steps
410
+ alive = await loop.run_in_executor(
411
+ None, lambda: proxy_thread.is_alive()
412
+ )
413
+ if not alive:
414
+ break
415
+ await asyncio.sleep(0.1)
416
+ if proxy_thread.is_alive():
417
+ logger.warning("_do_switch: proxy thread still alive after extra 5s — proceeding anyway")
418
+ else:
419
+ logger.info("_do_switch: proxy thread exited, proceeding with DB switch")
420
+
380
421
  # 1. Ensure schema exists (safe for both new and existing DBs)
381
422
  await self._init_new_db(path)
382
423
 
@@ -405,75 +446,91 @@ class ProjectManager:
405
446
  except Exception as exc:
406
447
  logger.debug("_do_switch: clear for new project: %s", exc)
407
448
 
408
- # 4. Reload all screens — all awaited, never spawning sub-workers
409
- await self._reload_project_screens(path, is_new=is_new)
410
-
411
- # 5. Set flag AFTER all screens are reloaded (fixes race condition)
449
+ # 4. Set flag as soon as the DB itself is ready — Start Proxy only
450
+ # needs HttpStorage (step 2) done, not the other screens below.
412
451
  self._app._project_loaded = True
413
- logger.info("_do_switch: project loaded flag set")
452
+ logger.info("_do_switch: project loaded flag set (DB ready)")
453
+
454
+ # 5. Reload the remaining screens concurrently — independent of
455
+ # each other and of the flag above.
456
+ await self._reload_project_screens(path, is_new=is_new)
457
+ logger.info("_do_switch: all screens reloaded")
414
458
 
415
459
  async def _reload_project_screens(self, path: str, is_new: bool = False) -> None:
416
- """Reload data from DB into all screens. Called from _do_switch only."""
460
+ """Reload data from DB into all screens. Called from _do_switch only.
417
461
 
418
- # 0. RepeaterScreen — сброс или загрузка вкладок
419
- try:
420
- from pentool.tui.constants import SCREEN_REPEATER
421
- from pentool.tui.screens.repeater.screen import RepeaterScreen
422
- repeater = self._app.query_one(SCREEN_REPEATER, RepeaterScreen)
423
- if is_new:
424
- await repeater.reset_for_new_project()
425
- else:
426
- await repeater.reload_from_project(path)
427
- logger.info("_reload_project_screens: repeater %s",
428
- "reset" if is_new else "reloaded")
429
- except Exception as exc:
430
- logger.debug("_reload_project_screens repeater: %s", exc)
462
+ Each screen's reload is independent (different tables/state), so they
463
+ run concurrently via asyncio.gather instead of one after another —
464
+ total time is bounded by the slowest single reload, not the sum.
465
+ """
431
466
 
432
- # 1. ProxyScreen — must come after switch_db is fully done
433
- try:
434
- screen = self._app.query_one(SCREEN_PROXY, ProxyScreen)
435
- await screen._reload_from_storage()
436
- logger.info("_reload_project_screens: proxy reloaded from %s", path)
437
- except Exception as exc:
438
- logger.debug("_reload_project_screens proxy: %s", exc)
467
+ async def _reload_repeater() -> None:
468
+ try:
469
+ from pentool.tui.constants import SCREEN_REPEATER
470
+ from pentool.tui.screens.repeater.screen import RepeaterScreen
471
+ repeater = self._app.query_one(SCREEN_REPEATER, RepeaterScreen)
472
+ if is_new:
473
+ await repeater.reset_for_new_project()
474
+ else:
475
+ await repeater.reload_from_project(path)
476
+ logger.info("_reload_project_screens: repeater %s",
477
+ "reset" if is_new else "reloaded")
478
+ except Exception as exc:
479
+ logger.debug("_reload_project_screens repeater: %s", exc)
439
480
 
440
- # 2. ScannerScreen
441
- try:
442
- from pentool.tui.screens.scanner.screen import ScannerScreen
443
- scanner_screen = self._app.query_one(SCREEN_SCANNER, ScannerScreen)
444
- if scanner_screen._scanner_api is not None:
445
- scanner_screen._scanner_api._db_path = path
446
- scanner_screen._scanner_api = None
447
- scanner_screen._scanner_api = scanner_screen._get_or_create_api(path)
448
- scanner_screen._populate_from_db([])
449
- scanner_screen._load_findings_worker()
450
- logger.info("_reload_project_screens: scanner reloaded")
451
- except Exception as exc:
452
- logger.debug("_reload_project_screens scanner: %s", exc)
481
+ async def _reload_proxy() -> None:
482
+ try:
483
+ screen = self._app.query_one(SCREEN_PROXY, ProxyScreen)
484
+ await screen._reload_from_storage()
485
+ logger.info("_reload_project_screens: proxy reloaded from %s", path)
486
+ except Exception as exc:
487
+ logger.debug("_reload_project_screens proxy: %s", exc)
453
488
 
454
- # 3. TargetScreen
455
- try:
456
- from pentool.tui.screens.target.screen import TargetScreen
457
- target_screen = self._app.query_one(SCREEN_TARGET, TargetScreen)
458
- if target_screen._target_api is not None:
459
- try:
460
- await target_screen._target_api.save()
461
- except Exception:
462
- pass
463
- target_screen._target_api = None
464
- target_screen._get_api()
465
- target_screen._load_sitemap()
466
- logger.info("_reload_project_screens: target reloaded from %s", path)
467
- except Exception as exc:
468
- logger.debug("_reload_project_screens target: %s", exc)
489
+ async def _reload_scanner() -> None:
490
+ try:
491
+ from pentool.tui.screens.scanner.screen import ScannerScreen
492
+ scanner_screen = self._app.query_one(SCREEN_SCANNER, ScannerScreen)
493
+ if scanner_screen._scanner_api is not None:
494
+ scanner_screen._scanner_api._db_path = path
495
+ scanner_screen._scanner_api = None
496
+ scanner_screen._scanner_api = scanner_screen._get_or_create_api(path)
497
+ scanner_screen._populate_from_db([])
498
+ scanner_screen._load_findings_worker()
499
+ logger.info("_reload_project_screens: scanner reloaded")
500
+ except Exception as exc:
501
+ logger.debug("_reload_project_screens scanner: %s", exc)
469
502
 
470
- # 4. Dashboard
471
- try:
472
- from pentool.tui.screens.dashboard.screen import DashboardScreen
473
- dash = self._app.query_one(SCREEN_DASHBOARD, DashboardScreen)
474
- dash.refresh_stats()
475
- except Exception as exc:
476
- logger.debug("_reload_project_screens dashboard: %s", exc)
503
+ async def _reload_target() -> None:
504
+ try:
505
+ from pentool.tui.screens.target.screen import TargetScreen
506
+ target_screen = self._app.query_one(SCREEN_TARGET, TargetScreen)
507
+ if target_screen._target_api is not None:
508
+ try:
509
+ await target_screen._target_api.save()
510
+ except Exception:
511
+ pass
512
+ target_screen._target_api = None
513
+ target_screen._get_api()
514
+ target_screen._load_sitemap()
515
+ logger.info("_reload_project_screens: target reloaded from %s", path)
516
+ except Exception as exc:
517
+ logger.debug("_reload_project_screens target: %s", exc)
518
+
519
+ async def _reload_dashboard() -> None:
520
+ try:
521
+ from pentool.tui.screens.dashboard.screen import DashboardScreen
522
+ dash = self._app.query_one(SCREEN_DASHBOARD, DashboardScreen)
523
+ dash.refresh_stats()
524
+ except Exception as exc:
525
+ logger.debug("_reload_project_screens dashboard: %s", exc)
526
+
527
+ await asyncio.gather(
528
+ _reload_repeater(),
529
+ _reload_proxy(),
530
+ _reload_scanner(),
531
+ _reload_target(),
532
+ _reload_dashboard(),
533
+ )
477
534
 
478
535
  async def _init_new_db(self, path: str) -> None:
479
536
  try:
@@ -20,7 +20,6 @@ _CSS = (Path(__file__).parent / "screen.tcss").read_text(encoding="utf-8")
20
20
  from textual.message import Message as _Message
21
21
  from textual.widgets import (
22
22
  Button,
23
- Checkbox,
24
23
  DataTable,
25
24
  Input,
26
25
  Label,
@@ -45,6 +44,7 @@ from pentool.core.logging import get_logger
45
44
  from pentool.tui.messages import SendToRepeater
46
45
  from pentool.tui.mixins.app_mixin import AppMixin
47
46
  from pentool.tui.mixins.request_context_menu import RequestContextMenuMixin
47
+ from pentool.tui.widgets.nice_checkbox import NiceCheckbox as Checkbox
48
48
  from pentool.tui.widgets.request_editor import HttpView, _load_into_textarea
49
49
  from pentool.tui.widgets.resize_handle import ResizeHandle
50
50
  from pentool.tui.widgets.toolbar_button import ToolbarButton