Skip to content

Commit ce8a832

Browse files
authored
Merge pull request #3081 from blacklanternsecurity/blasthttp-mock-migration
Blasthttp mock migration
2 parents d45f5fa + 52dee5f commit ce8a832

18 files changed

Lines changed: 236 additions & 655 deletions

bbot/core/helpers/web/blast_response.py

Lines changed: 0 additions & 170 deletions
This file was deleted.

bbot/core/helpers/web/web.py

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@
1010
from bs4 import MarkupResemblesLocatorWarning
1111
from bs4.builder import XMLParsedAsHTMLWarning
1212

13+
from blasthttp import HTTPStatusError
14+
1315
from bbot.core.helpers.misc import truncate_filename, bytes_to_human, get_exception_chain
1416
from bbot.errors import WordlistError, WebError
15-
from .blast_response import BlasthttpResponse, BlasthttpHTTPError
1617

1718
warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning)
1819
warnings.filterwarnings("ignore", category=MarkupResemblesLocatorWarning)
@@ -284,9 +285,7 @@ async def request(self, *args, **kwargs):
284285
log.trace(f"blasthttp request: {method} {url}")
285286

286287
# blasthttp returns a native coroutine via pyo3-async-runtimes
287-
blast_response = await self.client.request(url, **blast_kwargs)
288-
289-
response = BlasthttpResponse(blast_response, request_url=url, method=method)
288+
response = await self.client.request(url, **blast_kwargs)
290289

291290
if self.http_debug:
292291
log.trace(
@@ -389,10 +388,7 @@ async def request_batch_stream(self, urls, threads=10, **kwargs):
389388
trackers_by_url.setdefault(config.url, deque()).append(tracker)
390389

391390
async for br in iter_batch_results(self.client.request_batch_stream(configs, concurrency=threads)):
392-
if br.response is not None:
393-
response = BlasthttpResponse(br.response, request_url=br.url, method="GET")
394-
else:
395-
response = None
391+
response = br.response # blasthttp.Response or None
396392
if has_tracker:
397393
queue = trackers_by_url.get(br.url)
398394
tracker = queue.popleft() if queue else None
@@ -455,7 +451,7 @@ async def download(self, url, **kwargs):
455451
response = await self.request(url, **kwargs)
456452

457453
if response is None:
458-
raise BlasthttpHTTPError(f"No response from {url}")
454+
raise HTTPStatusError(f"No response from {url}")
459455

460456
log.debug(f"Download result: HTTP {response.status_code}")
461457
response.raise_for_status()
@@ -473,7 +469,7 @@ async def download(self, url, **kwargs):
473469
f.write(content)
474470
success = True
475471

476-
except (BlasthttpHTTPError, WebError, RuntimeError) as e:
472+
except (HTTPStatusError, WebError, RuntimeError) as e:
477473
log_fn = log.verbose
478474
if warn:
479475
log_fn = log.warning

bbot/modules/http.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,14 +109,15 @@ def _response_to_json(self, url_input, response):
109109
parsed = urlparse(response.url)
110110
path = parsed.path or "/"
111111

112-
# Build raw_header string (required by HTTP_RESPONSE validation)
112+
# Build raw_header string (required by HTTP_RESPONSE validation).
113+
# blasthttp already builds the canonical "Name: Value\r\n..." form
114+
# — reuse it instead of rebuilding.
113115
status_line = f"HTTP/1.1 {response.status} \r\n"
114-
header_lines = "\r\n".join(f"{k}: {v}" for k, v in response.headers)
115-
raw_header = f"{status_line}{header_lines}\r\n\r\n"
116+
raw_header = f"{status_line}{response.raw_headers}\r\n\r\n"
116117

117118
# Build header dict (lowercase keys, comma-joined for dupes)
118119
header_dict = {}
119-
for k, v in response.headers:
120+
for k, v in response.headers.items():
120121
key = k.lower().replace("-", "_")
121122
if key in header_dict:
122123
header_dict[key] += f", {v}"

bbot/modules/telerik.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ class telerik(BaseModule):
156156
options = {"exploit_RAU_crypto": False, "include_subdirs": False}
157157
options_desc = {
158158
"exploit_RAU_crypto": "Attempt to confirm any RAU AXD detections are vulnerable",
159-
"include_subdirs": "Include subdirectories in the scan (off by default)", # will create many finding events if used in conjunction with web spider or web_brute
159+
"include_subdirs": "Include subdirectories in the scan (off by default)", # will create many finding events if used in conjunction with web spider or webbrute
160160
}
161161

162162
in_scope_only = True

bbot/modules/url_manipulation.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ async def handle_event(self, event):
6868
self.debug(f"Encountered HttpCompareError: [{e}] for URL [{event.url}]")
6969

7070
if subject_response:
71-
subject_content = "".join([str(x) for x in subject_response.headers])
71+
subject_content = subject_response.raw_headers
7272
if subject_response.text is not None:
7373
subject_content += subject_response.text
7474

Lines changed: 15 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from bbot.modules.base import BaseModule
88

99

10-
class web_brute(BaseModule):
10+
class webbrute(BaseModule):
1111
watched_events = ["URL"]
1212
produced_events = ["URL_UNVERIFIED"]
1313
flags = ["active", "loud"]
@@ -124,16 +124,6 @@ def _response_metrics(self, response):
124124
"lines": text.count("\n") + 1,
125125
}
126126

127-
def _batch_response_metrics(self, response):
128-
"""Extract metrics from a raw blasthttp batch response."""
129-
body = response.body or ""
130-
return {
131-
"status": response.status,
132-
"length": len(response.body_bytes),
133-
"words": len(body.split()),
134-
"lines": body.count("\n") + 1,
135-
}
136-
137127
def _is_baseline_match(self, metrics, baseline_filter):
138128
"""Return True if the response matches the baseline (i.e. should be filtered OUT)."""
139129
if baseline_filter.get("abort"):
@@ -187,7 +177,7 @@ async def baseline_fuzz(self, url, exts=None, prefix="", suffix=""):
187177
self.blast_client.request_batch_stream(canary_configs, 4, rate_limit=self.rate)
188178
):
189179
if result.success:
190-
canary_results.append(self._batch_response_metrics(result.response))
180+
canary_results.append(self._response_metrics(result.response))
191181
if await self.helpers.yara.match(self.waf_yara_rules, result.response.body):
192182
canary_waf_count += 1
193183

@@ -331,7 +321,7 @@ async def execute_fuzz(
331321
continue
332322

333323
response = result.response
334-
metrics = self._batch_response_metrics(response)
324+
metrics = self._response_metrics(response)
335325

336326
# Check if this matches the baseline (should be filtered out)
337327
if ext_filter and self._is_baseline_match(metrics, ext_filter):
@@ -353,7 +343,7 @@ async def execute_fuzz(
353343
# not real findings (e.g. mod_userdir sending ~user to /)
354344
if 300 <= response.status < 400:
355345
location = ""
356-
for hdr_name, hdr_val in response.headers:
346+
for hdr_name, hdr_val in response.headers.items():
357347
if hdr_name.lower() == "location":
358348
location = hdr_val
359349
break
@@ -373,12 +363,15 @@ async def execute_fuzz(
373363
self.debug("Found canary in results, all hits are likely false positives — aborting")
374364
return
375365

376-
# Mid-scan validation: one canary check per extension
366+
# Mid-scan validation: one canary check per extension.
367+
# Single request — use client.request() directly instead of a
368+
# 1-config request_batch_stream loop (the streaming API only
369+
# earns its keep with multiple in-flight requests).
377370
if hits and not baseline and ext_filter:
378371
canary_word = "".join(random.choice(string.ascii_lowercase) for _ in range(4))
379372
canary_url = f"{url}{prefix}{canary_word}{suffix}{ext}"
380-
canary_configs = [
381-
blasthttp.BatchConfig(
373+
try:
374+
canary_response = await self.blast_client.request(
382375
canary_url,
383376
headers=headers,
384377
timeout=self.scan.http_timeout,
@@ -387,14 +380,11 @@ async def execute_fuzz(
387380
follow_redirects=False,
388381
proxy=proxy,
389382
)
390-
]
391-
canary_result = None
392-
async for r in iter_batch_results(
393-
self.blast_client.request_batch_stream(canary_configs, 1, rate_limit=self.rate)
394-
):
395-
canary_result = r
396-
if canary_result is not None and canary_result.success:
397-
canary_metrics = self._batch_response_metrics(canary_result.response)
383+
except Exception as e:
384+
self.debug(f"Mid-scan canary request failed: {e}")
385+
canary_response = None
386+
if canary_response is not None:
387+
canary_metrics = self._response_metrics(canary_response)
398388
if not self._is_baseline_match(canary_metrics, ext_filter):
399389
self.verbose(
400390
f"Would have reported {len(hits)} hit(s), but mid-scan baseline check failed. "
Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
import random
44
import string
55

6-
from bbot.modules.web_brute import web_brute
6+
from bbot.modules.webbrute import webbrute
77

88

9-
class web_brute_shortnames(web_brute):
9+
class webbrute_shortnames(webbrute):
1010
watched_events = ["URL_HINT"]
1111
produced_events = ["URL_UNVERIFIED"]
1212
flags = ["loud", "active", "iis-shortnames", "web-heavy"]
@@ -183,7 +183,7 @@ def find_delimiter(self, hint):
183183

184184
async def filter_event(self, event):
185185
if "iis-magic-url" in event.tags:
186-
return False, "iis-magic-url URL_HINTs are not solvable by web_brute_shortnames"
186+
return False, "iis-magic-url URL_HINTs are not solvable by webbrute_shortnames"
187187
if event.parent.type != "URL":
188188
return False, "its parent event is not of type URL"
189189
return True
@@ -204,7 +204,7 @@ async def handle_event(self, event):
204204
elif "shortname-directory" in event.tags:
205205
shortname_type = "directory"
206206
else:
207-
self.error("web_brute_shortnames received URL_HINT without proper 'shortname-' tag")
207+
self.error("webbrute_shortnames received URL_HINT without proper 'shortname-' tag")
208208
return
209209

210210
host = f"{event.parent.parsed_url.scheme}://{event.parent.parsed_url.netloc}/"
@@ -329,7 +329,7 @@ async def finish(self):
329329
elif "shortname-directory" in self.shortname_to_event[hint].tags:
330330
shortname_type = "directory"
331331
else:
332-
self.error("web_brute_shortnames received URL_HINT without proper 'shortname-' tag")
332+
self.error("webbrute_shortnames received URL_HINT without proper 'shortname-' tag")
333333
continue
334334

335335
partial_hint = hint[len(prefix) :]

0 commit comments

Comments
 (0)