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.
Files changed (169) hide show
  1. {pentool-0.2.2 → pentool-0.2.4}/PKG-INFO +6 -1
  2. {pentool-0.2.2 → pentool-0.2.4}/README.md +5 -0
  3. {pentool-0.2.2 → pentool-0.2.4}/pentool/__init__.py +1 -1
  4. {pentool-0.2.2 → pentool-0.2.4}/pentool/api/target_api.py +2 -0
  5. {pentool-0.2.2 → pentool-0.2.4}/pentool/core/database.py +36 -0
  6. {pentool-0.2.2 → pentool-0.2.4}/pentool/modules/proxy.py +48 -3
  7. {pentool-0.2.2 → pentool-0.2.4}/pentool/modules/target.py +44 -5
  8. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/app.py +52 -4
  9. pentool-0.2.4/pentool/tui/dialogs/comment_dialog.py +61 -0
  10. pentool-0.2.4/pentool/tui/dialogs/comment_dialog.tcss +25 -0
  11. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/dialogs/match_replace_dialog.py +1 -1
  12. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/project_manager.py +121 -64
  13. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/dashboard/live_dashboard.py +18 -0
  14. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/intruder/screen.py +1 -1
  15. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/proxy/screen.py +215 -64
  16. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/proxy/screen.tcss +0 -8
  17. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/repeater/screen.py +2 -21
  18. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/settings/screen.py +7 -4
  19. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/spider/screen.py +1 -1
  20. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/target/screen.py +8 -8
  21. pentool-0.2.4/pentool/tui/widgets/nice_checkbox.py +35 -0
  22. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/request_editor.py +44 -15
  23. {pentool-0.2.2 → pentool-0.2.4}/pentool.egg-info/PKG-INFO +6 -1
  24. {pentool-0.2.2 → pentool-0.2.4}/pentool.egg-info/SOURCES.txt +3 -0
  25. {pentool-0.2.2 → pentool-0.2.4}/pyproject.toml +1 -1
  26. {pentool-0.2.2 → pentool-0.2.4}/LICENSE +0 -0
  27. {pentool-0.2.2 → pentool-0.2.4}/pentool/__main__.py +0 -0
  28. {pentool-0.2.2 → pentool-0.2.4}/pentool/api/__init__.py +0 -0
  29. {pentool-0.2.2 → pentool-0.2.4}/pentool/api/base_api.py +0 -0
  30. {pentool-0.2.2 → pentool-0.2.4}/pentool/api/comparer_api.py +0 -0
  31. {pentool-0.2.2 → pentool-0.2.4}/pentool/api/decoder_api.py +0 -0
  32. {pentool-0.2.2 → pentool-0.2.4}/pentool/api/intruder_api.py +0 -0
  33. {pentool-0.2.2 → pentool-0.2.4}/pentool/api/proxy_api.py +0 -0
  34. {pentool-0.2.2 → pentool-0.2.4}/pentool/api/repeater_api.py +0 -0
  35. {pentool-0.2.2 → pentool-0.2.4}/pentool/api/sequencer_api.py +0 -0
  36. {pentool-0.2.2 → pentool-0.2.4}/pentool/api/spider_api.py +0 -0
  37. {pentool-0.2.2 → pentool-0.2.4}/pentool/cli/__init__.py +0 -0
  38. {pentool-0.2.2 → pentool-0.2.4}/pentool/cli/main.py +0 -0
  39. {pentool-0.2.2 → pentool-0.2.4}/pentool/cli/project.py +0 -0
  40. {pentool-0.2.2 → pentool-0.2.4}/pentool/cli/proxy.py +0 -0
  41. {pentool-0.2.2 → pentool-0.2.4}/pentool/cli/scan.py +0 -0
  42. {pentool-0.2.2 → pentool-0.2.4}/pentool/core/__init__.py +0 -0
  43. {pentool-0.2.2 → pentool-0.2.4}/pentool/core/config.py +0 -0
  44. {pentool-0.2.2 → pentool-0.2.4}/pentool/core/crash_reporter.py +0 -0
  45. {pentool-0.2.2 → pentool-0.2.4}/pentool/core/event_bus.py +0 -0
  46. {pentool-0.2.2 → pentool-0.2.4}/pentool/core/events.py +0 -0
  47. {pentool-0.2.2 → pentool-0.2.4}/pentool/core/features.py +0 -0
  48. {pentool-0.2.2 → pentool-0.2.4}/pentool/core/license.py +0 -0
  49. {pentool-0.2.2 → pentool-0.2.4}/pentool/core/logging.py +0 -0
  50. {pentool-0.2.2 → pentool-0.2.4}/pentool/core/plugin_manager.py +0 -0
  51. {pentool-0.2.2 → pentool-0.2.4}/pentool/core/project.py +0 -0
  52. {pentool-0.2.2 → pentool-0.2.4}/pentool/core/storage_interface.py +0 -0
  53. {pentool-0.2.2 → pentool-0.2.4}/pentool/core/updater.py +0 -0
  54. {pentool-0.2.2 → pentool-0.2.4}/pentool/core/utils.py +0 -0
  55. {pentool-0.2.2 → pentool-0.2.4}/pentool/modules/__init__.py +0 -0
  56. {pentool-0.2.2 → pentool-0.2.4}/pentool/modules/comparer.py +0 -0
  57. {pentool-0.2.2 → pentool-0.2.4}/pentool/modules/decoder.py +0 -0
  58. {pentool-0.2.2 → pentool-0.2.4}/pentool/modules/intruder.py +0 -0
  59. {pentool-0.2.2 → pentool-0.2.4}/pentool/modules/match_replace.py +0 -0
  60. {pentool-0.2.2 → pentool-0.2.4}/pentool/modules/repeater.py +0 -0
  61. {pentool-0.2.2 → pentool-0.2.4}/pentool/modules/sequencer.py +0 -0
  62. {pentool-0.2.2 → pentool-0.2.4}/pentool/modules/spider.py +0 -0
  63. {pentool-0.2.2 → pentool-0.2.4}/pentool/modules/websocket_handler.py +0 -0
  64. {pentool-0.2.2 → pentool-0.2.4}/pentool/plugins/__init__.py +0 -0
  65. {pentool-0.2.2 → pentool-0.2.4}/pentool/plugins/builtin/__init__.py +0 -0
  66. {pentool-0.2.2 → pentool-0.2.4}/pentool/plugins/example_plugin.py +0 -0
  67. {pentool-0.2.2 → pentool-0.2.4}/pentool/plugins/example_plugin.tcss +0 -0
  68. {pentool-0.2.2 → pentool-0.2.4}/pentool/services/__init__.py +0 -0
  69. {pentool-0.2.2 → pentool-0.2.4}/pentool/services/base_service.py +0 -0
  70. {pentool-0.2.2 → pentool-0.2.4}/pentool/services/intruder_service.py +0 -0
  71. {pentool-0.2.2 → pentool-0.2.4}/pentool/services/proxy_service.py +0 -0
  72. {pentool-0.2.2 → pentool-0.2.4}/pentool/services/repeater_service.py +0 -0
  73. {pentool-0.2.2 → pentool-0.2.4}/pentool/services/scan_service.py +0 -0
  74. {pentool-0.2.2 → pentool-0.2.4}/pentool/storage/__init__.py +0 -0
  75. {pentool-0.2.2 → pentool-0.2.4}/pentool/storage/http_storage.py +0 -0
  76. {pentool-0.2.2 → pentool-0.2.4}/pentool/storage/large_body_handler.py +0 -0
  77. {pentool-0.2.2 → pentool-0.2.4}/pentool/storage/lru_cache.py +0 -0
  78. {pentool-0.2.2 → pentool-0.2.4}/pentool/t.py +0 -0
  79. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/__init__.py +0 -0
  80. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/constants.py +0 -0
  81. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/dialogs/__init__.py +0 -0
  82. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/dialogs/cert_dialog.py +0 -0
  83. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/dialogs/cert_dialog.tcss +0 -0
  84. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/dialogs/file_selector.py +0 -0
  85. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/dialogs/file_selector.tcss +0 -0
  86. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/dialogs/load_from_proxy.py +0 -0
  87. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/dialogs/load_from_proxy.tcss +0 -0
  88. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/dialogs/match_replace_dialog.tcss +0 -0
  89. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/dialogs/project_dialog.py +0 -0
  90. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/dialogs/project_dialog.tcss +0 -0
  91. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/dialogs/scope_dialog.py +0 -0
  92. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/dialogs/scope_dialog.tcss +0 -0
  93. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/messages.py +0 -0
  94. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/mixins/__init__.py +0 -0
  95. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/mixins/app_mixin.py +0 -0
  96. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/mixins/dialog_cancel.py +0 -0
  97. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/mixins/request_context_menu.py +0 -0
  98. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/__init__.py +0 -0
  99. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/base.py +0 -0
  100. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/comparer/__init__.py +0 -0
  101. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/comparer/screen.py +0 -0
  102. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/comparer/screen.tcss +0 -0
  103. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/dashboard/__init__.py +0 -0
  104. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/dashboard/screen.py +0 -0
  105. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/dashboard/screen.tcss +0 -0
  106. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/decoder/__init__.py +0 -0
  107. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/decoder/screen.py +0 -0
  108. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/decoder/screen.tcss +0 -0
  109. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/extensions/__init__.py +0 -0
  110. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/extensions/screen.py +0 -0
  111. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/extensions/screen.tcss +0 -0
  112. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/intruder/__init__.py +0 -0
  113. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/intruder/screen.tcss +0 -0
  114. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/proxy/__init__.py +0 -0
  115. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/repeater/__init__.py +0 -0
  116. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/repeater/screen.tcss +0 -0
  117. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/scanner_unavailable.py +0 -0
  118. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/sequencer/__init__.py +0 -0
  119. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/sequencer/screen.py +0 -0
  120. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/sequencer/screen.tcss +0 -0
  121. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/settings/__init__.py +0 -0
  122. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/settings/hotkeys.py +0 -0
  123. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/settings/hotkeys.tcss +0 -0
  124. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/settings/screen.tcss +0 -0
  125. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/spider/__init__.py +0 -0
  126. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/spider/screen.tcss +0 -0
  127. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/target/__init__.py +0 -0
  128. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/target/screen.tcss +0 -0
  129. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/terminal/__init__.py +0 -0
  130. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/terminal/screen.py +0 -0
  131. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/screens/terminal/screen.tcss +0 -0
  132. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/__init__.py +0 -0
  133. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/activity_indicator.py +0 -0
  134. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/activity_indicator.tcss +0 -0
  135. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/context_menu.py +0 -0
  136. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/context_menu.tcss +0 -0
  137. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/filter_bar.py +0 -0
  138. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/filter_bar.tcss +0 -0
  139. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/inspector_panel.py +0 -0
  140. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/inspector_panel.tcss +0 -0
  141. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/menu.tcss +0 -0
  142. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/menu_bar.tcss +0 -0
  143. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/module_tabs.py +0 -0
  144. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/module_tabs.tcss +0 -0
  145. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/payload_drop_zone.py +0 -0
  146. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/payload_drop_zone.tcss +0 -0
  147. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/request_editor.tcss +0 -0
  148. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/resize_handle.py +0 -0
  149. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/resize_handle.tcss +0 -0
  150. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/search_bar.py +0 -0
  151. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/search_bar.tcss +0 -0
  152. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/statusbar.py +0 -0
  153. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/statusbar.tcss +0 -0
  154. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/toolbar_button.py +0 -0
  155. {pentool-0.2.2 → pentool-0.2.4}/pentool/tui/widgets/toolbar_button.tcss +0 -0
  156. {pentool-0.2.2 → pentool-0.2.4}/pentool/utils/__init__.py +0 -0
  157. {pentool-0.2.2 → pentool-0.2.4}/pentool/utils/cert.py +0 -0
  158. {pentool-0.2.2 → pentool-0.2.4}/pentool/utils/coder.py +0 -0
  159. {pentool-0.2.2 → pentool-0.2.4}/pentool/utils/copy_as.py +0 -0
  160. {pentool-0.2.2 → pentool-0.2.4}/pentool/utils/diff.py +0 -0
  161. {pentool-0.2.2 → pentool-0.2.4}/pentool/utils/http_client.py +0 -0
  162. {pentool-0.2.2 → pentool-0.2.4}/pentool/utils/parser.py +0 -0
  163. {pentool-0.2.2 → pentool-0.2.4}/pentool/utils/terminal_check.py +0 -0
  164. {pentool-0.2.2 → pentool-0.2.4}/pentool.egg-info/dependency_links.txt +0 -0
  165. {pentool-0.2.2 → pentool-0.2.4}/pentool.egg-info/entry_points.txt +0 -0
  166. {pentool-0.2.2 → pentool-0.2.4}/pentool.egg-info/requires.txt +0 -0
  167. {pentool-0.2.2 → pentool-0.2.4}/pentool.egg-info/top_level.txt +0 -0
  168. {pentool-0.2.2 → pentool-0.2.4}/setup.cfg +0 -0
  169. {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.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
 
@@ -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.4"
4
4
  __author__ = "pentool"
5
5
 
6
6
 
@@ -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
- 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
 
@@ -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
- return [h for h, paths in self._nodes.items() if any(n.in_scope for n in paths.values())]
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
- if host in self._nodes:
118
- for node in self._nodes[host].values():
119
- node.in_scope = in_scope
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
- 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,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
- 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()
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")]