pentool 0.2.3__tar.gz → 0.2.4__tar.gz

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