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.
- bbot/__init__.py +1 -1
- bbot/cli.py +22 -8
- bbot/core/engine.py +1 -1
- bbot/core/event/__init__.py +2 -2
- bbot/core/event/base.py +138 -110
- bbot/core/flags.py +1 -0
- bbot/core/helpers/bloom.py +6 -7
- bbot/core/helpers/command.py +5 -2
- bbot/core/helpers/depsinstaller/installer.py +78 -7
- bbot/core/helpers/dns/dns.py +0 -1
- bbot/core/helpers/dns/engine.py +0 -2
- bbot/core/helpers/files.py +2 -2
- bbot/core/helpers/git.py +17 -0
- bbot/core/helpers/helper.py +6 -5
- bbot/core/helpers/misc.py +15 -28
- bbot/core/helpers/names_generator.py +5 -0
- bbot/core/helpers/ntlm.py +0 -2
- bbot/core/helpers/regex.py +1 -1
- bbot/core/helpers/regexes.py +25 -8
- bbot/core/helpers/web/engine.py +1 -1
- bbot/core/helpers/web/web.py +2 -1
- bbot/core/modules.py +22 -60
- bbot/core/shared_deps.py +38 -0
- bbot/defaults.yml +4 -2
- bbot/modules/apkpure.py +2 -2
- bbot/modules/aspnet_bin_exposure.py +80 -0
- bbot/modules/baddns.py +1 -1
- bbot/modules/baddns_direct.py +1 -1
- bbot/modules/baddns_zone.py +1 -1
- bbot/modules/badsecrets.py +1 -1
- bbot/modules/base.py +129 -40
- bbot/modules/bucket_amazon.py +1 -1
- bbot/modules/bucket_digitalocean.py +1 -1
- bbot/modules/bucket_firebase.py +1 -1
- bbot/modules/bucket_google.py +1 -1
- bbot/modules/{bucket_azure.py → bucket_microsoft.py} +2 -2
- bbot/modules/builtwith.py +4 -2
- bbot/modules/c99.py +1 -1
- bbot/modules/dnsbimi.py +1 -4
- bbot/modules/dnsbrute.py +6 -1
- bbot/modules/dnscommonsrv.py +1 -0
- bbot/modules/dnsdumpster.py +35 -52
- bbot/modules/dnstlsrpt.py +0 -6
- bbot/modules/docker_pull.py +2 -2
- bbot/modules/emailformat.py +17 -1
- bbot/modules/ffuf.py +4 -1
- bbot/modules/ffuf_shortnames.py +6 -3
- bbot/modules/filedownload.py +8 -5
- bbot/modules/fullhunt.py +1 -1
- bbot/modules/git_clone.py +47 -22
- bbot/modules/gitdumper.py +5 -15
- bbot/modules/github_workflows.py +6 -5
- bbot/modules/gitlab_com.py +31 -0
- bbot/modules/gitlab_onprem.py +84 -0
- bbot/modules/gowitness.py +60 -30
- bbot/modules/graphql_introspection.py +145 -0
- bbot/modules/httpx.py +2 -0
- bbot/modules/hunt.py +10 -3
- bbot/modules/iis_shortnames.py +16 -7
- bbot/modules/internal/cloudcheck.py +65 -72
- bbot/modules/internal/unarchive.py +9 -3
- bbot/modules/lightfuzz/lightfuzz.py +6 -2
- bbot/modules/lightfuzz/submodules/esi.py +42 -0
- bbot/modules/{deadly/medusa.py → medusa.py} +4 -7
- bbot/modules/nuclei.py +2 -2
- bbot/modules/otx.py +9 -2
- bbot/modules/output/base.py +3 -11
- bbot/modules/paramminer_headers.py +10 -7
- bbot/modules/passivetotal.py +1 -1
- bbot/modules/portfilter.py +2 -0
- bbot/modules/portscan.py +1 -1
- bbot/modules/postman_download.py +2 -2
- bbot/modules/retirejs.py +232 -0
- bbot/modules/securitytxt.py +0 -3
- bbot/modules/sslcert.py +2 -2
- bbot/modules/subdomaincenter.py +1 -16
- bbot/modules/telerik.py +7 -2
- bbot/modules/templates/bucket.py +24 -4
- bbot/modules/templates/gitlab.py +98 -0
- bbot/modules/trufflehog.py +7 -4
- bbot/modules/wafw00f.py +2 -2
- bbot/presets/web/dotnet-audit.yml +1 -0
- bbot/presets/web/lightfuzz-heavy.yml +1 -1
- bbot/presets/web/lightfuzz-medium.yml +1 -1
- bbot/presets/web/lightfuzz-superheavy.yml +1 -1
- bbot/scanner/manager.py +44 -37
- bbot/scanner/scanner.py +17 -4
- bbot/scripts/benchmark_report.py +433 -0
- bbot/test/benchmarks/__init__.py +2 -0
- bbot/test/benchmarks/test_bloom_filter_benchmarks.py +105 -0
- bbot/test/benchmarks/test_closest_match_benchmarks.py +76 -0
- bbot/test/benchmarks/test_event_validation_benchmarks.py +438 -0
- bbot/test/benchmarks/test_excavate_benchmarks.py +291 -0
- bbot/test/benchmarks/test_ipaddress_benchmarks.py +143 -0
- bbot/test/benchmarks/test_weighted_shuffle_benchmarks.py +70 -0
- bbot/test/conftest.py +1 -1
- bbot/test/test_step_1/test_bbot_fastapi.py +2 -2
- bbot/test/test_step_1/test_events.py +22 -21
- bbot/test/test_step_1/test_helpers.py +20 -0
- bbot/test/test_step_1/test_manager_scope_accuracy.py +45 -0
- bbot/test/test_step_1/test_modules_basic.py +40 -15
- bbot/test/test_step_1/test_python_api.py +2 -2
- bbot/test/test_step_1/test_regexes.py +21 -4
- bbot/test/test_step_1/test_scan.py +7 -8
- bbot/test/test_step_1/test_web.py +46 -0
- bbot/test/test_step_2/module_tests/base.py +6 -1
- bbot/test/test_step_2/module_tests/test_module_aspnet_bin_exposure.py +73 -0
- bbot/test/test_step_2/module_tests/test_module_bucket_amazon.py +52 -18
- bbot/test/test_step_2/module_tests/test_module_bucket_google.py +1 -1
- bbot/test/test_step_2/module_tests/{test_module_bucket_azure.py → test_module_bucket_microsoft.py} +7 -5
- bbot/test/test_step_2/module_tests/test_module_cloudcheck.py +19 -31
- bbot/test/test_step_2/module_tests/test_module_dnsbimi.py +2 -1
- bbot/test/test_step_2/module_tests/test_module_dnsdumpster.py +3 -5
- bbot/test/test_step_2/module_tests/test_module_emailformat.py +1 -1
- bbot/test/test_step_2/module_tests/test_module_emails.py +2 -2
- bbot/test/test_step_2/module_tests/test_module_excavate.py +64 -5
- bbot/test/test_step_2/module_tests/test_module_extractous.py +13 -1
- bbot/test/test_step_2/module_tests/test_module_github_workflows.py +10 -1
- bbot/test/test_step_2/module_tests/test_module_gitlab_com.py +66 -0
- bbot/test/test_step_2/module_tests/{test_module_gitlab.py → test_module_gitlab_onprem.py} +4 -69
- bbot/test/test_step_2/module_tests/test_module_gowitness.py +5 -5
- bbot/test/test_step_2/module_tests/test_module_graphql_introspection.py +34 -0
- bbot/test/test_step_2/module_tests/test_module_iis_shortnames.py +46 -1
- bbot/test/test_step_2/module_tests/test_module_jadx.py +9 -0
- bbot/test/test_step_2/module_tests/test_module_lightfuzz.py +71 -3
- bbot/test/test_step_2/module_tests/test_module_nuclei.py +8 -6
- bbot/test/test_step_2/module_tests/test_module_otx.py +3 -0
- bbot/test/test_step_2/module_tests/test_module_portfilter.py +2 -0
- bbot/test/test_step_2/module_tests/test_module_retirejs.py +161 -0
- bbot/test/test_step_2/module_tests/test_module_telerik.py +1 -1
- bbot/test/test_step_2/module_tests/test_module_trufflehog.py +10 -1
- bbot/test/test_step_2/module_tests/test_module_unarchive.py +9 -0
- {bbot-2.5.0.dist-info → bbot-2.7.2.7424rc0.dist-info}/METADATA +12 -9
- {bbot-2.5.0.dist-info → bbot-2.7.2.7424rc0.dist-info}/RECORD +137 -124
- {bbot-2.5.0.dist-info → bbot-2.7.2.7424rc0.dist-info}/WHEEL +1 -1
- {bbot-2.5.0.dist-info → bbot-2.7.2.7424rc0.dist-info/licenses}/LICENSE +98 -58
- bbot/modules/binaryedge.py +0 -42
- bbot/modules/censys.py +0 -98
- bbot/modules/gitlab.py +0 -141
- bbot/modules/zoomeye.py +0 -77
- bbot/test/test_step_2/module_tests/test_module_binaryedge.py +0 -33
- bbot/test/test_step_2/module_tests/test_module_censys.py +0 -83
- bbot/test/test_step_2/module_tests/test_module_zoomeye.py +0 -35
- {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
|
-
#
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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()
|