bbot 2.5.0__py3-none-any.whl → 2.7.2.7424rc0__py3-none-any.whl

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 (144) hide show
  1. bbot/__init__.py +1 -1
  2. bbot/cli.py +22 -8
  3. bbot/core/engine.py +1 -1
  4. bbot/core/event/__init__.py +2 -2
  5. bbot/core/event/base.py +138 -110
  6. bbot/core/flags.py +1 -0
  7. bbot/core/helpers/bloom.py +6 -7
  8. bbot/core/helpers/command.py +5 -2
  9. bbot/core/helpers/depsinstaller/installer.py +78 -7
  10. bbot/core/helpers/dns/dns.py +0 -1
  11. bbot/core/helpers/dns/engine.py +0 -2
  12. bbot/core/helpers/files.py +2 -2
  13. bbot/core/helpers/git.py +17 -0
  14. bbot/core/helpers/helper.py +6 -5
  15. bbot/core/helpers/misc.py +15 -28
  16. bbot/core/helpers/names_generator.py +5 -0
  17. bbot/core/helpers/ntlm.py +0 -2
  18. bbot/core/helpers/regex.py +1 -1
  19. bbot/core/helpers/regexes.py +25 -8
  20. bbot/core/helpers/web/engine.py +1 -1
  21. bbot/core/helpers/web/web.py +2 -1
  22. bbot/core/modules.py +22 -60
  23. bbot/core/shared_deps.py +38 -0
  24. bbot/defaults.yml +4 -2
  25. bbot/modules/apkpure.py +2 -2
  26. bbot/modules/aspnet_bin_exposure.py +80 -0
  27. bbot/modules/baddns.py +1 -1
  28. bbot/modules/baddns_direct.py +1 -1
  29. bbot/modules/baddns_zone.py +1 -1
  30. bbot/modules/badsecrets.py +1 -1
  31. bbot/modules/base.py +129 -40
  32. bbot/modules/bucket_amazon.py +1 -1
  33. bbot/modules/bucket_digitalocean.py +1 -1
  34. bbot/modules/bucket_firebase.py +1 -1
  35. bbot/modules/bucket_google.py +1 -1
  36. bbot/modules/{bucket_azure.py → bucket_microsoft.py} +2 -2
  37. bbot/modules/builtwith.py +4 -2
  38. bbot/modules/c99.py +1 -1
  39. bbot/modules/dnsbimi.py +1 -4
  40. bbot/modules/dnsbrute.py +6 -1
  41. bbot/modules/dnscommonsrv.py +1 -0
  42. bbot/modules/dnsdumpster.py +35 -52
  43. bbot/modules/dnstlsrpt.py +0 -6
  44. bbot/modules/docker_pull.py +2 -2
  45. bbot/modules/emailformat.py +17 -1
  46. bbot/modules/ffuf.py +4 -1
  47. bbot/modules/ffuf_shortnames.py +6 -3
  48. bbot/modules/filedownload.py +8 -5
  49. bbot/modules/fullhunt.py +1 -1
  50. bbot/modules/git_clone.py +47 -22
  51. bbot/modules/gitdumper.py +5 -15
  52. bbot/modules/github_workflows.py +6 -5
  53. bbot/modules/gitlab_com.py +31 -0
  54. bbot/modules/gitlab_onprem.py +84 -0
  55. bbot/modules/gowitness.py +60 -30
  56. bbot/modules/graphql_introspection.py +145 -0
  57. bbot/modules/httpx.py +2 -0
  58. bbot/modules/hunt.py +10 -3
  59. bbot/modules/iis_shortnames.py +16 -7
  60. bbot/modules/internal/cloudcheck.py +65 -72
  61. bbot/modules/internal/unarchive.py +9 -3
  62. bbot/modules/lightfuzz/lightfuzz.py +6 -2
  63. bbot/modules/lightfuzz/submodules/esi.py +42 -0
  64. bbot/modules/{deadly/medusa.py → medusa.py} +4 -7
  65. bbot/modules/nuclei.py +2 -2
  66. bbot/modules/otx.py +9 -2
  67. bbot/modules/output/base.py +3 -11
  68. bbot/modules/paramminer_headers.py +10 -7
  69. bbot/modules/passivetotal.py +1 -1
  70. bbot/modules/portfilter.py +2 -0
  71. bbot/modules/portscan.py +1 -1
  72. bbot/modules/postman_download.py +2 -2
  73. bbot/modules/retirejs.py +232 -0
  74. bbot/modules/securitytxt.py +0 -3
  75. bbot/modules/sslcert.py +2 -2
  76. bbot/modules/subdomaincenter.py +1 -16
  77. bbot/modules/telerik.py +7 -2
  78. bbot/modules/templates/bucket.py +24 -4
  79. bbot/modules/templates/gitlab.py +98 -0
  80. bbot/modules/trufflehog.py +7 -4
  81. bbot/modules/wafw00f.py +2 -2
  82. bbot/presets/web/dotnet-audit.yml +1 -0
  83. bbot/presets/web/lightfuzz-heavy.yml +1 -1
  84. bbot/presets/web/lightfuzz-medium.yml +1 -1
  85. bbot/presets/web/lightfuzz-superheavy.yml +1 -1
  86. bbot/scanner/manager.py +44 -37
  87. bbot/scanner/scanner.py +17 -4
  88. bbot/scripts/benchmark_report.py +433 -0
  89. bbot/test/benchmarks/__init__.py +2 -0
  90. bbot/test/benchmarks/test_bloom_filter_benchmarks.py +105 -0
  91. bbot/test/benchmarks/test_closest_match_benchmarks.py +76 -0
  92. bbot/test/benchmarks/test_event_validation_benchmarks.py +438 -0
  93. bbot/test/benchmarks/test_excavate_benchmarks.py +291 -0
  94. bbot/test/benchmarks/test_ipaddress_benchmarks.py +143 -0
  95. bbot/test/benchmarks/test_weighted_shuffle_benchmarks.py +70 -0
  96. bbot/test/conftest.py +1 -1
  97. bbot/test/test_step_1/test_bbot_fastapi.py +2 -2
  98. bbot/test/test_step_1/test_events.py +22 -21
  99. bbot/test/test_step_1/test_helpers.py +20 -0
  100. bbot/test/test_step_1/test_manager_scope_accuracy.py +45 -0
  101. bbot/test/test_step_1/test_modules_basic.py +40 -15
  102. bbot/test/test_step_1/test_python_api.py +2 -2
  103. bbot/test/test_step_1/test_regexes.py +21 -4
  104. bbot/test/test_step_1/test_scan.py +7 -8
  105. bbot/test/test_step_1/test_web.py +46 -0
  106. bbot/test/test_step_2/module_tests/base.py +6 -1
  107. bbot/test/test_step_2/module_tests/test_module_aspnet_bin_exposure.py +73 -0
  108. bbot/test/test_step_2/module_tests/test_module_bucket_amazon.py +52 -18
  109. bbot/test/test_step_2/module_tests/test_module_bucket_google.py +1 -1
  110. bbot/test/test_step_2/module_tests/{test_module_bucket_azure.py → test_module_bucket_microsoft.py} +7 -5
  111. bbot/test/test_step_2/module_tests/test_module_cloudcheck.py +19 -31
  112. bbot/test/test_step_2/module_tests/test_module_dnsbimi.py +2 -1
  113. bbot/test/test_step_2/module_tests/test_module_dnsdumpster.py +3 -5
  114. bbot/test/test_step_2/module_tests/test_module_emailformat.py +1 -1
  115. bbot/test/test_step_2/module_tests/test_module_emails.py +2 -2
  116. bbot/test/test_step_2/module_tests/test_module_excavate.py +64 -5
  117. bbot/test/test_step_2/module_tests/test_module_extractous.py +13 -1
  118. bbot/test/test_step_2/module_tests/test_module_github_workflows.py +10 -1
  119. bbot/test/test_step_2/module_tests/test_module_gitlab_com.py +66 -0
  120. bbot/test/test_step_2/module_tests/{test_module_gitlab.py → test_module_gitlab_onprem.py} +4 -69
  121. bbot/test/test_step_2/module_tests/test_module_gowitness.py +5 -5
  122. bbot/test/test_step_2/module_tests/test_module_graphql_introspection.py +34 -0
  123. bbot/test/test_step_2/module_tests/test_module_iis_shortnames.py +46 -1
  124. bbot/test/test_step_2/module_tests/test_module_jadx.py +9 -0
  125. bbot/test/test_step_2/module_tests/test_module_lightfuzz.py +71 -3
  126. bbot/test/test_step_2/module_tests/test_module_nuclei.py +8 -6
  127. bbot/test/test_step_2/module_tests/test_module_otx.py +3 -0
  128. bbot/test/test_step_2/module_tests/test_module_portfilter.py +2 -0
  129. bbot/test/test_step_2/module_tests/test_module_retirejs.py +161 -0
  130. bbot/test/test_step_2/module_tests/test_module_telerik.py +1 -1
  131. bbot/test/test_step_2/module_tests/test_module_trufflehog.py +10 -1
  132. bbot/test/test_step_2/module_tests/test_module_unarchive.py +9 -0
  133. {bbot-2.5.0.dist-info → bbot-2.7.2.7424rc0.dist-info}/METADATA +12 -9
  134. {bbot-2.5.0.dist-info → bbot-2.7.2.7424rc0.dist-info}/RECORD +137 -124
  135. {bbot-2.5.0.dist-info → bbot-2.7.2.7424rc0.dist-info}/WHEEL +1 -1
  136. {bbot-2.5.0.dist-info → bbot-2.7.2.7424rc0.dist-info/licenses}/LICENSE +98 -58
  137. bbot/modules/binaryedge.py +0 -42
  138. bbot/modules/censys.py +0 -98
  139. bbot/modules/gitlab.py +0 -141
  140. bbot/modules/zoomeye.py +0 -77
  141. bbot/test/test_step_2/module_tests/test_module_binaryedge.py +0 -33
  142. bbot/test/test_step_2/module_tests/test_module_censys.py +0 -83
  143. bbot/test/test_step_2/module_tests/test_module_zoomeye.py +0 -35
  144. {bbot-2.5.0.dist-info → bbot-2.7.2.7424rc0.dist-info}/entry_points.txt +0 -0
bbot/scanner/manager.py CHANGED
@@ -94,10 +94,6 @@ class ScanIngress(BaseInterceptModule):
94
94
  # special handling of URL extensions
95
95
  url_extension = getattr(event, "url_extension", None)
96
96
  if url_extension is not None:
97
- if url_extension in self.scan.url_extension_httpx_only:
98
- event.add_tag("httpx-only")
99
- event._omit = True
100
-
101
97
  # blacklist by extension
102
98
  if url_extension in self.scan.url_extension_blacklist:
103
99
  self.debug(
@@ -192,58 +188,69 @@ class ScanEgress(BaseInterceptModule):
192
188
  abort_if = kwargs.pop("abort_if", None)
193
189
  on_success_callback = kwargs.pop("on_success_callback", None)
194
190
 
195
- # omit certain event types
196
- if event.type in self.scan.omitted_event_types:
197
- if "target" in event.tags:
198
- self.debug(f"Allowing omitted event: {event} because it's a target")
199
- else:
200
- event._omit = True
191
+ # mark omitted event types
192
+ # we could do this all in the output module's filter_event(), but we mark it here permanently so the events' .get_parent() can factor in the omission, and skip over omitted parents
193
+ omitted_event_type = event.type in self.scan.omitted_event_types
194
+ is_target = "target" in event.tags
195
+ if omitted_event_type and not is_target:
196
+ self.debug(f"Making {event} omitted because its type is omitted in the config")
197
+ event._omit = True
201
198
 
202
199
  # make event internal if it's above our configured report distance
203
200
  event_in_report_distance = event.scope_distance <= self.scan.scope_report_distance
204
201
  event_will_be_output = event.always_emit or event_in_report_distance
205
202
 
206
- if not event_will_be_output:
207
- self.debug(
208
- f"Making {event} internal because its scope_distance ({event.scope_distance}) > scope_report_distance ({self.scan.scope_report_distance})"
209
- )
210
- event.internal = True
203
+ # if an event isn't being re-emitted for output, we may want to make it internal
204
+ if not event._graph_important:
205
+ if not event_will_be_output and not event.internal:
206
+ self.debug(
207
+ f"Making {event} internal because its scope_distance ({event.scope_distance}) > scope_report_distance ({self.scan.scope_report_distance})"
208
+ )
209
+ event.internal = True
210
+
211
+ # mark special URLs (e.g. Javascript) as internal so they don't get output except when they're critical to the graph
212
+ if event.type.startswith("URL") and not event.internal:
213
+ extension = getattr(event, "url_extension", "")
214
+ if extension in self.scan.url_extension_special:
215
+ self.debug(f"Making {event} internal because it is a special URL (extension {extension})")
216
+ event.internal = True
211
217
 
212
- if event.type in self.scan.omitted_event_types:
213
- self.debug(f"Omitting {event} because its type is omitted in the config")
214
- event._omit = True
218
+ # custom callback - abort event emission if it returns true
219
+ abort_result = False
220
+ if callable(abort_if):
221
+ async with self.scan._acatch(context=abort_if):
222
+ abort_result = await self.scan.helpers.execute_sync_or_async(abort_if, event)
223
+ msg = f"{event.module}: not raising event {event} due to custom criteria in abort_if()"
224
+ with suppress(ValueError, TypeError):
225
+ abort_result, reason = abort_result
226
+ msg += f": {reason}"
227
+ if abort_result:
228
+ return False, msg
229
+
230
+ if event._suppress_chain_dupes:
231
+ for parent in event.get_parents():
232
+ if parent == event:
233
+ return False, f"an identical parent {event} was found, and _suppress_chain_dupes=True"
215
234
 
216
235
  # if we discovered something interesting from an internal event,
217
236
  # make sure we preserve its chain of parents
218
- parent = event.parent
237
+ # here we retroactively resurrect any interesting internal events that led to this discovery
238
+ # "interesting" meaning any event types that aren't omitted in the config
239
+ # (by using .get_parent() instead of .parent, we're intentionally skipping over omitted events)
240
+ parent = event.get_parent()
219
241
  event_is_graph_worthy = (not event.internal) or event._graph_important
220
242
  parent_is_graph_worthy = (not parent.internal) or parent._graph_important
221
243
  if event_is_graph_worthy and not parent_is_graph_worthy:
222
244
  parent_in_report_distance = parent.scope_distance <= self.scan.scope_report_distance
245
+ self.debug(f"parent {parent} in report distance: {parent_in_report_distance}")
223
246
  if parent_in_report_distance:
247
+ self.debug(f"setting parent {parent} internal to False")
224
248
  parent.internal = False
225
249
  if not parent._graph_important:
226
- parent._graph_important = True
227
250
  self.debug(f"Re-queuing internal event {parent} with parent {event} to prevent graph orphan")
251
+ parent._graph_important = True
228
252
  await self.emit_event(parent)
229
253
 
230
- if event._suppress_chain_dupes:
231
- for parent in event.get_parents():
232
- if parent == event:
233
- return False, f"an identical parent {event} was found, and _suppress_chain_dupes=True"
234
-
235
- # custom callback - abort event emission it returns true
236
- abort_result = False
237
- if callable(abort_if):
238
- async with self.scan._acatch(context=abort_if):
239
- abort_result = await self.scan.helpers.execute_sync_or_async(abort_if, event)
240
- msg = f"{event.module}: not raising event {event} due to custom criteria in abort_if()"
241
- with suppress(ValueError, TypeError):
242
- abort_result, reason = abort_result
243
- msg += f": {reason}"
244
- if abort_result:
245
- return False, msg
246
-
247
254
  # run success callback before distributing event (so it can add tags, etc.)
248
255
  if callable(on_success_callback):
249
256
  async with self.scan._acatch(context=on_success_callback):
bbot/scanner/scanner.py CHANGED
@@ -10,7 +10,7 @@ from datetime import datetime
10
10
  from collections import OrderedDict
11
11
 
12
12
  from bbot import __version__
13
- from bbot.core.event import make_event
13
+ from bbot.core.event import make_event, update_event
14
14
  from .manager import ScanIngress, ScanEgress
15
15
  from bbot.core.helpers.misc import sha1, rand_string
16
16
  from bbot.core.helpers.names_generator import random_name
@@ -99,6 +99,7 @@ class Scanner:
99
99
  def __init__(
100
100
  self,
101
101
  *targets,
102
+ name=None,
102
103
  scan_id=None,
103
104
  dispatcher=None,
104
105
  **kwargs,
@@ -137,6 +138,9 @@ class Scanner:
137
138
 
138
139
  from .preset import Preset
139
140
 
141
+ if name is not None:
142
+ kwargs["scan_name"] = name
143
+
140
144
  base_preset = Preset(*targets, **kwargs)
141
145
 
142
146
  if custom_preset is not None:
@@ -175,6 +179,10 @@ class Scanner:
175
179
  else:
176
180
  self.home = self.preset.bbot_home / "scans" / self.name
177
181
 
182
+ # scan temp dir
183
+ self.temp_dir = self.home / "temp"
184
+ self.helpers.mkdir(self.temp_dir)
185
+
178
186
  self._status = "NOT_STARTED"
179
187
  self._status_code = 0
180
188
 
@@ -222,8 +230,8 @@ class Scanner:
222
230
  )
223
231
 
224
232
  # url file extensions
233
+ self.url_extension_special = {e.lower() for e in self.config.get("url_extension_special", [])}
225
234
  self.url_extension_blacklist = {e.lower() for e in self.config.get("url_extension_blacklist", [])}
226
- self.url_extension_httpx_only = {e.lower() for e in self.config.get("url_extension_httpx_only", [])}
227
235
 
228
236
  # url querystring behavior
229
237
  self.url_querystring_remove = self.config.get("url_querystring_remove", True)
@@ -476,7 +484,7 @@ class Scanner:
476
484
  for module in self.modules.values():
477
485
  module.start()
478
486
 
479
- async def setup_modules(self, remove_failed=True):
487
+ async def setup_modules(self, remove_failed=True, deps_only=False):
480
488
  """Asynchronously initializes all loaded modules by invoking their `setup()` methods.
481
489
 
482
490
  Args:
@@ -501,7 +509,7 @@ class Scanner:
501
509
  hard_failed = []
502
510
  soft_failed = []
503
511
 
504
- async for task in self.helpers.as_completed([m._setup() for m in self.modules.values()]):
512
+ async for task in self.helpers.as_completed([m._setup(deps_only=deps_only) for m in self.modules.values()]):
505
513
  module, status, msg = await task
506
514
  if status is True:
507
515
  self.debug(f"Setup succeeded for {module.name} ({msg})")
@@ -884,6 +892,7 @@ class Scanner:
884
892
  await mod._cleanup()
885
893
  with contextlib.suppress(Exception):
886
894
  self.home.rmdir()
895
+ self.helpers.rm_rf(self.temp_dir, ignore_errors=True)
887
896
  self.helpers.clean_old_scans()
888
897
 
889
898
  def in_scope(self, *args, **kwargs):
@@ -986,6 +995,10 @@ class Scanner:
986
995
  event = make_event(*args, **kwargs)
987
996
  return event
988
997
 
998
+ def update_event(self, event, **kwargs):
999
+ kwargs["scan"] = self
1000
+ return update_event(event, **kwargs)
1001
+
989
1002
  @property
990
1003
  def root_event(self):
991
1004
  """
@@ -0,0 +1,433 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Branch-based benchmark comparison tool for BBOT performance tests.
4
+
5
+ This script takes two git branches, runs benchmarks on each, and generates
6
+ a comparison report showing performance differences between them.
7
+ """
8
+
9
+ import json
10
+ import argparse
11
+ import subprocess
12
+ import tempfile
13
+ from pathlib import Path
14
+ from typing import Dict, List, Any, Tuple
15
+
16
+
17
+ def run_command(cmd: List[str], cwd: Path = None, capture_output: bool = True) -> subprocess.CompletedProcess:
18
+ """Run a shell command and return the result."""
19
+ try:
20
+ result = subprocess.run(cmd, cwd=cwd, capture_output=capture_output, text=True, check=True)
21
+ return result
22
+ except subprocess.CalledProcessError as e:
23
+ print(f"Command failed: {' '.join(cmd)}")
24
+ print(f"Exit code: {e.returncode}")
25
+ print(f"Error output: {e.stderr}")
26
+ raise
27
+
28
+
29
+ def get_current_branch() -> str:
30
+ """Get the current git branch name."""
31
+ result = run_command(["git", "branch", "--show-current"])
32
+ return result.stdout.strip()
33
+
34
+
35
+ def checkout_branch(branch: str, repo_path: Path = None):
36
+ """Checkout a git branch."""
37
+ print(f"Checking out branch: {branch}")
38
+ run_command(["git", "checkout", branch], cwd=repo_path)
39
+
40
+
41
+ def run_benchmarks(output_file: Path, repo_path: Path = None) -> bool:
42
+ """Run benchmarks and save results to JSON file."""
43
+ print(f"Running benchmarks, saving to {output_file}")
44
+
45
+ # Check if benchmarks directory exists
46
+ benchmarks_dir = repo_path / "bbot/test/benchmarks" if repo_path else Path("bbot/test/benchmarks")
47
+ if not benchmarks_dir.exists():
48
+ print(f"Benchmarks directory not found: {benchmarks_dir}")
49
+ print("This branch likely doesn't have benchmark tests yet.")
50
+ return False
51
+
52
+ try:
53
+ cmd = [
54
+ "poetry",
55
+ "run",
56
+ "python",
57
+ "-m",
58
+ "pytest",
59
+ "bbot/test/benchmarks/",
60
+ "--benchmark-only",
61
+ f"--benchmark-json={output_file}",
62
+ "-q",
63
+ ]
64
+ run_command(cmd, cwd=repo_path, capture_output=False)
65
+ return True
66
+ except subprocess.CalledProcessError:
67
+ print("Benchmarks failed for current state")
68
+ return False
69
+
70
+
71
+ def load_benchmark_data(filepath: Path) -> Dict[str, Any]:
72
+ """Load benchmark data from JSON file."""
73
+ try:
74
+ with open(filepath, "r") as f:
75
+ return json.load(f)
76
+ except FileNotFoundError:
77
+ print(f"Warning: Benchmark file not found: {filepath}")
78
+ return {}
79
+ except json.JSONDecodeError:
80
+ print(f"Warning: Could not parse JSON from {filepath}")
81
+ return {}
82
+
83
+
84
+ def format_time(seconds: float) -> str:
85
+ """Format time in human-readable format."""
86
+ if seconds < 0.000001: # Less than 1 microsecond
87
+ return f"{seconds * 1000000000:.0f}ns" # Show as nanoseconds with no decimal
88
+ elif seconds < 0.001: # Less than 1 millisecond
89
+ return f"{seconds * 1000000:.2f}µs" # Show as microseconds with 2 decimal places
90
+ elif seconds < 1: # Less than 1 second
91
+ return f"{seconds * 1000:.2f}ms" # Show as milliseconds with 2 decimal places
92
+ else:
93
+ return f"{seconds:.3f}s" # Show as seconds with 3 decimal places
94
+
95
+
96
+ def format_ops(ops: float) -> str:
97
+ """Format operations per second."""
98
+ if ops > 1000:
99
+ return f"{ops / 1000:.1f}K ops/sec"
100
+ else:
101
+ return f"{ops:.1f} ops/sec"
102
+
103
+
104
+ def calculate_change_percentage(old_value: float, new_value: float) -> Tuple[float, str]:
105
+ """Calculate percentage change and return emoji indicator."""
106
+ if old_value == 0:
107
+ return 0, "🆕"
108
+
109
+ change = ((new_value - old_value) / old_value) * 100
110
+
111
+ if change > 10:
112
+ return change, "⚠️" # Regression (slower)
113
+ elif change < -10:
114
+ return change, "🚀" # Improvement (faster)
115
+ else:
116
+ return change, "✅" # No significant change
117
+
118
+
119
+ def generate_benchmark_table(benchmarks: List[Dict[str, Any]], title: str = "Results") -> str:
120
+ """Generate markdown table for benchmark results."""
121
+ if not benchmarks:
122
+ return f"### {title}\nNo benchmark data available.\n"
123
+
124
+ table = f"""### {title}
125
+
126
+ | Test Name | Mean Time | Ops/sec | Min | Max |
127
+ |-----------|-----------|---------|-----|-----|
128
+ """
129
+
130
+ for bench in benchmarks:
131
+ stats = bench.get("stats", {})
132
+ name = bench.get("name", "Unknown")
133
+ # Generic test name cleanup - just remove 'test_' prefix and format nicely
134
+ test_name = name.replace("test_", "").replace("_", " ").title()
135
+
136
+ mean = format_time(stats.get("mean", 0))
137
+ ops = format_ops(stats.get("ops", 0))
138
+ min_time = format_time(stats.get("min", 0))
139
+ max_time = format_time(stats.get("max", 0))
140
+
141
+ table += f"| {test_name} | {mean} | {ops} | {min_time} | {max_time} |\n"
142
+
143
+ return table + "\n"
144
+
145
+
146
+ def generate_comparison_table(current_data: Dict, base_data: Dict, current_branch: str, base_branch: str) -> str:
147
+ """Generate comparison table between current and base benchmark results."""
148
+ if not current_data or not base_data:
149
+ return ""
150
+
151
+ current_benchmarks = current_data.get("benchmarks", [])
152
+ base_benchmarks = base_data.get("benchmarks", [])
153
+
154
+ # Create lookup for base benchmarks
155
+ base_lookup = {bench["name"]: bench for bench in base_benchmarks}
156
+
157
+ if not current_benchmarks:
158
+ return ""
159
+
160
+ # Count changes for summary
161
+ improvements = 0
162
+ regressions = 0
163
+ no_change = 0
164
+
165
+ table = f"""## 📊 Performance Benchmark Report
166
+
167
+ > Comparing **`{base_branch}`** (baseline) vs **`{current_branch}`** (current)
168
+
169
+ <details>
170
+ <summary>📈 <strong>Detailed Results</strong> (All Benchmarks)</summary>
171
+
172
+ > 📋 **Complete results for all benchmarks** - includes both significant and insignificant changes
173
+
174
+ | 🧪 Test Name | 📏 Base | 📏 Current | 📈 Change | 🎯 Status |
175
+ |--------------|---------|------------|-----------|-----------|"""
176
+
177
+ significant_changes = []
178
+ performance_summary = []
179
+
180
+ for current_bench in current_benchmarks:
181
+ name = current_bench.get("name", "Unknown")
182
+ # Generic test name cleanup - just remove 'test_' prefix and format nicely
183
+ test_name = name.replace("test_", "").replace("_", " ").title()
184
+
185
+ current_stats = current_bench.get("stats", {})
186
+ current_mean = current_stats.get("mean", 0)
187
+ # For multi-item benchmarks, calculate correct ops/sec
188
+ if "excavate" in name:
189
+ current_ops = 100 / current_mean # 100 segments per test
190
+ elif "event_validation" in name and "small" in name:
191
+ current_ops = 100 / current_mean # 100 targets per test
192
+ elif "event_validation" in name and "large" in name:
193
+ current_ops = 1000 / current_mean # 1000 targets per test
194
+ elif "make_event" in name and "small" in name:
195
+ current_ops = 100 / current_mean # 100 items per test
196
+ elif "make_event" in name and "large" in name:
197
+ current_ops = 1000 / current_mean # 1000 items per test
198
+ elif "ip" in name:
199
+ current_ops = 1000 / current_mean # 1000 IPs per test
200
+ elif "bloom_filter" in name:
201
+ if "dns_mutation" in name:
202
+ current_ops = 2500 / current_mean # 2500 operations per test
203
+ else:
204
+ current_ops = 13000 / current_mean # 13000 operations per test
205
+ else:
206
+ current_ops = 1 / current_mean # Default: single operation
207
+
208
+ base_bench = base_lookup.get(name)
209
+ if base_bench:
210
+ base_stats = base_bench.get("stats", {})
211
+ base_mean = base_stats.get("mean", 0)
212
+ # For multi-item benchmarks, calculate correct ops/sec
213
+ if "excavate" in name:
214
+ base_ops = 100 / base_mean # 100 segments per test
215
+ elif "event_validation" in name and "small" in name:
216
+ base_ops = 100 / base_mean # 100 targets per test
217
+ elif "event_validation" in name and "large" in name:
218
+ base_ops = 1000 / base_mean # 1000 targets per test
219
+ elif "make_event" in name and "small" in name:
220
+ base_ops = 100 / base_mean # 100 items per test
221
+ elif "make_event" in name and "large" in name:
222
+ base_ops = 1000 / base_mean # 1000 items per test
223
+ elif "ip" in name:
224
+ base_ops = 1000 / base_mean # 1000 IPs per test
225
+ elif "bloom_filter" in name:
226
+ if "dns_mutation" in name:
227
+ base_ops = 2500 / base_mean # 2500 operations per test
228
+ else:
229
+ base_ops = 13000 / base_mean # 13000 operations per test
230
+ else:
231
+ base_ops = 1 / base_mean # Default: single operation
232
+
233
+ change_percent, emoji = calculate_change_percentage(base_mean, current_mean)
234
+
235
+ # Create visual change indicator
236
+ if abs(change_percent) > 20:
237
+ change_bar = "🔴🔴🔴" if change_percent > 0 else "🟢🟢🟢"
238
+ elif abs(change_percent) > 10:
239
+ change_bar = "🟡🟡" if change_percent > 0 else "🟢🟢"
240
+ else:
241
+ change_bar = "⚪"
242
+
243
+ table += f"\n| **{test_name}** | `{format_time(base_mean)}` | `{format_time(current_mean)}` | **{change_percent:+.1f}%** {change_bar} | {emoji} |"
244
+
245
+ # Track significant changes
246
+ if abs(change_percent) > 10:
247
+ direction = "🐌 slower" if change_percent > 0 else "🚀 faster"
248
+ significant_changes.append(f"- **{test_name}**: {abs(change_percent):.1f}% {direction}")
249
+ if change_percent > 0:
250
+ regressions += 1
251
+ else:
252
+ improvements += 1
253
+ else:
254
+ no_change += 1
255
+
256
+ # Add to performance summary
257
+ ops_change = ((current_ops - base_ops) / base_ops) * 100 if base_ops > 0 else 0
258
+ performance_summary.append(
259
+ {
260
+ "name": test_name,
261
+ "time_change": change_percent,
262
+ "ops_change": ops_change,
263
+ "current_ops": current_ops,
264
+ }
265
+ )
266
+ else:
267
+ table += f"\n| **{test_name}** | `-` | `{format_time(current_mean)}` | **New** 🆕 | 🆕 |"
268
+ significant_changes.append(
269
+ f"- **{test_name}**: New test 🆕 ({format_time(current_mean)}, {format_ops(current_ops)})"
270
+ )
271
+
272
+ table += "\n\n</details>\n\n"
273
+
274
+ # Add performance summary
275
+ table += "## 🎯 Performance Summary\n\n"
276
+
277
+ if improvements > 0 or regressions > 0:
278
+ table += "```diff\n"
279
+ if improvements > 0:
280
+ table += f"+ {improvements} improvement{'s' if improvements != 1 else ''} 🚀\n"
281
+ if regressions > 0:
282
+ table += f"! {regressions} regression{'s' if regressions != 1 else ''} ⚠️\n"
283
+ if no_change > 0:
284
+ table += f" {no_change} unchanged ✅\n"
285
+ table += "```\n\n"
286
+ else:
287
+ table += "✅ **No significant performance changes detected** (all changes <10%)\n\n"
288
+
289
+ # Add significant changes section
290
+ if significant_changes:
291
+ table += "### 🔍 Significant Changes (>10%)\n\n"
292
+ for change in significant_changes:
293
+ table += f"{change}\n"
294
+ table += "\n"
295
+
296
+ return table
297
+
298
+
299
+ def generate_report(current_data: Dict, base_data: Dict, current_branch: str, base_branch: str) -> str:
300
+ """Generate complete benchmark comparison report."""
301
+
302
+ if not current_data:
303
+ report = """## 🚀 Performance Benchmark Report
304
+
305
+ > ⚠️ **No current benchmark data available**
306
+ >
307
+ > This might be because:
308
+ > - Benchmarks failed to run
309
+ > - No benchmark tests found
310
+ > - Dependencies missing
311
+
312
+ """
313
+ return report
314
+
315
+ if not base_data:
316
+ report = f"""## 🚀 Performance Benchmark Report
317
+
318
+ > ℹ️ **No baseline benchmark data available**
319
+ >
320
+ > Showing current results for **{current_branch}** only.
321
+
322
+ """
323
+ current_benchmarks = current_data.get("benchmarks", [])
324
+ if current_benchmarks:
325
+ report += f"""<details>
326
+ <summary>📊 Current Results ({current_branch}) - Click to expand</summary>
327
+
328
+ {generate_benchmark_table(current_benchmarks, "Results")}
329
+ </details>"""
330
+ else:
331
+ # Add comparison
332
+ comparison = generate_comparison_table(current_data, base_data, current_branch, base_branch)
333
+ if comparison:
334
+ report = comparison
335
+ else:
336
+ # Fallback if no comparison data
337
+ report = f"""## 🚀 Performance Benchmark Report
338
+
339
+ > ℹ️ **No baseline benchmark data available**
340
+ >
341
+ > Showing current results for **{current_branch}** only.
342
+
343
+ """
344
+
345
+ # Get Python version info
346
+ machine_info = current_data.get("machine_info", {})
347
+ python_version = machine_info.get("python_version", "Unknown")
348
+
349
+ report += f"\n\n---\n\n🐍 Python Version {python_version}"
350
+
351
+ return report
352
+
353
+
354
+ def main():
355
+ parser = argparse.ArgumentParser(description="Compare benchmark performance between git branches")
356
+ parser.add_argument("--base", required=True, help="Base branch name (e.g., 'main', 'dev')")
357
+ parser.add_argument("--current", required=True, help="Current branch name (e.g., 'feature-branch', 'HEAD')")
358
+ parser.add_argument("--output", type=Path, help="Output markdown file (default: stdout)")
359
+ parser.add_argument("--keep-results", action="store_true", help="Keep intermediate JSON files")
360
+
361
+ args = parser.parse_args()
362
+
363
+ # Get current working directory
364
+ repo_path = Path.cwd()
365
+
366
+ # Save original branch to restore later
367
+ try:
368
+ original_branch = get_current_branch()
369
+ print(f"Current branch: {original_branch}")
370
+ except subprocess.CalledProcessError:
371
+ print("Warning: Could not determine current branch")
372
+ original_branch = None
373
+
374
+ # Create temporary files for benchmark results
375
+ with tempfile.TemporaryDirectory() as temp_dir:
376
+ temp_path = Path(temp_dir)
377
+ base_results_file = temp_path / "base_results.json"
378
+ current_results_file = temp_path / "current_results.json"
379
+
380
+ base_data = {}
381
+ current_data = {}
382
+
383
+ base_data = {}
384
+ current_data = {}
385
+
386
+ try:
387
+ # Run benchmarks on base branch
388
+ print(f"\n=== Running benchmarks on base branch: {args.base} ===")
389
+ checkout_branch(args.base, repo_path)
390
+ if run_benchmarks(base_results_file, repo_path):
391
+ base_data = load_benchmark_data(base_results_file)
392
+
393
+ # Run benchmarks on current branch
394
+ print(f"\n=== Running benchmarks on current branch: {args.current} ===")
395
+ checkout_branch(args.current, repo_path)
396
+ if run_benchmarks(current_results_file, repo_path):
397
+ current_data = load_benchmark_data(current_results_file)
398
+
399
+ # Generate report
400
+ print("\n=== Generating comparison report ===")
401
+ report = generate_report(current_data, base_data, args.current, args.base)
402
+
403
+ # Output report
404
+ if args.output:
405
+ with open(args.output, "w") as f:
406
+ f.write(report)
407
+ print(f"Report written to {args.output}")
408
+ else:
409
+ print("\n" + "=" * 80)
410
+ print(report)
411
+
412
+ # Keep results if requested
413
+ if args.keep_results:
414
+ if base_data:
415
+ with open("base_benchmark_results.json", "w") as f:
416
+ json.dump(base_data, f, indent=2)
417
+ if current_data:
418
+ with open("current_benchmark_results.json", "w") as f:
419
+ json.dump(current_data, f, indent=2)
420
+ print("Benchmark result files saved.")
421
+
422
+ finally:
423
+ # Restore original branch
424
+ if original_branch:
425
+ print(f"\nRestoring original branch: {original_branch}")
426
+ try:
427
+ checkout_branch(original_branch, repo_path)
428
+ except subprocess.CalledProcessError:
429
+ print(f"Warning: Could not restore original branch {original_branch}")
430
+
431
+
432
+ if __name__ == "__main__":
433
+ main()
@@ -0,0 +1,2 @@
1
+ # Benchmark tests for BBOT performance monitoring
2
+ # These tests measure performance of critical code paths