Explain Bambu Cloud's CAPTCHA challenge instead of repeating it (#2790)

A reporter tried to connect to Bambu Cloud and got "We need you to confirm you
are not a robot" as an error toast, with no CAPTCHA anywhere to answer and
nothing to click. That sentence is Bambu's, not ours. Their anti-abuse layer had
flagged the network and was answering the sign-in with HTTP 418 and a challenge
body: {"captchaId": "...", "error": "We need you to confirm you are not a
robot"}.

Bambuddy had no idea what that was. The reply is well-formed JSON, so
_detect_cloudflare_challenge -- which triggers on an unparseable body, CF
markers, 403+cf-mitigated or 503+cf-ray -- never fired on it, and login_request
fell through to its generic error path, which lifts data["message"] or
data["error"] out and hands it to the UI verbatim. The user was left to conclude
their password was wrong or that Bambuddy was broken. Four sign-in attempts
inside eighteen seconds appear in their log, each one more evidence for the
thing that had flagged them.

is_captcha_challenge matches on the 418 status plus a challenge marker in the
body -- captchaId is the reliable one, the wording is matched too because Bambu
has shipped it under more than one phrasing. A bare 418 with no marker is
has shipped it under more than one phrasing. A bare 418 with no marker is
deliberately NOT reported as a CAPTCHA: telling someone to solve a challenge
that was never offered is the exact confusion this issue is about.

login_request, verify_code and verify_totp now return reason="captcha" with an
explanation covering the three things the reporter had no way to find out: the
credentials are not the problem, the block is keyed to the public IP address
rather than the account, and it clears by itself within a few hours.

Sign-in requests are then held back for 300s so Bambuddy stops deepening the
block. Keyed per origin, not per service: TOTP verification posts to
bambulab.com while everything else posts to api.bambulab.com, and a challenge
seen on one must not strand somebody halfway through a two-factor sign-in on the
other. Entries expire on read, so the map cannot grow past one per region. The
token endpoint is deliberately left ungated -- it is the way out.

The UI shows a persistent panel rather than a toast. A toast names a problem the
user cannot act on and then vanishes; this one stays put and carries a one-click
route to "Use access token instead", which is the only thing that works while
the challenge lasts, since that path does not touch the challenged endpoint.

MakerWorld meets the same challenge from the same edge and now shares the
detection. It used to require the literal word "robot" in the error text and
reported any other wording as an unexplained block.

The System Health scanner gets a bambu-cloud-captcha signature. The reporter's
bundle came back with zero findings while their log was full of the failure.

Its advice for a failed FTPS handshake was corrected at the same time: it still
blamed firewalls and outdated firmware, which the #2780 investigation ruled out
last release -- it is the printer's own file service wedging, and the fix is to
restart the printer. The wiki said so already; the health panel did not.
This commit is contained in:
maziggy
2026-08-08 10:04:55 +02:00
parent 14d0d14365
commit 604fa44593
27 changed files with 710 additions and 61 deletions
+2
View File
@@ -528,6 +528,7 @@ async def login(
message=result.get("message", "Unknown error"),
verification_type=result.get("verification_type"),
tfa_key=result.get("tfa_key"),
reason=result.get("reason"),
)
except BambuCloudAuthError as e:
raise HTTPException(status_code=401, detail=str(e))
@@ -573,6 +574,7 @@ async def verify_code(
success=result.get("success", False),
needs_verification=False,
message=result.get("message", "Unknown error"),
reason=result.get("reason"),
)
except BambuCloudAuthError as e:
raise HTTPException(status_code=401, detail=str(e))
+6
View File
@@ -30,6 +30,12 @@ class CloudLoginResponse(BaseModel):
message: str
verification_type: str | None = None # "email" or "totp"
tfa_key: str | None = None # Key needed for TOTP verification
# Machine-readable cause of a failure, when we know it. Currently only
# "captcha" — Bambu's anti-abuse layer is challenging this network and no
# credential will be accepted until it clears (#2790). The UI needs this to
# explain the situation in place, rather than flashing ``message`` as a
# toast that vanishes and leaves the user retrying a password that is fine.
reason: str | None = None
class CloudAuthStatus(BaseModel):
+168
View File
@@ -124,6 +124,110 @@ def _detect_cloudflare_challenge(response) -> str | None:
return None
# Bambu's own anti-abuse layer — distinct from the Cloudflare edge above —
# answers a request it has flagged with HTTP 418 and a challenge body:
#
# {"captchaId": "...", "error": "We need you to confirm you are not a robot"}
#
# The flag is keyed to the source IP and covers api.bambulab.com as a whole:
# the same 418 turns up on the login endpoint and on the design-service
# endpoints MakerWorld imports use. It clears on its own after a few hours of
# quiet traffic, and there is no server-side solve — a CAPTCHA is designed to be
# unanswerable without a real browser, and the challenge id is of no use to us
# because we have nowhere to render the widget.
#
# It reaches ``login_request`` as a perfectly well-formed JSON body, so
# ``_detect_cloudflare_challenge`` above never fires on it. Before #2790 the
# generic error path then lifted Bambu's sentence out of ``error`` and showed it
# as a bare toast: the reporter saw "We need you to confirm you are not a robot"
# with no challenge, no explanation and nothing to click, and filed it as a
# Bambuddy bug.
_CAPTCHA_HTTP_STATUS = 418
# Markers that identify a 418 as the CAPTCHA challenge rather than some other
# refusal. ``captchaId`` is the reliable one; the wording is matched too because
# Bambu has shipped the challenge under more than one phrasing.
_CAPTCHA_BODY_MARKERS = ("captchaid", "captcha", "robot")
CAPTCHA_USER_MESSAGE = (
"Bambu Cloud is challenging this network with a CAPTCHA before it will accept a sign-in, "
"and there is no way to answer it from Bambuddy. Your email and password are not the "
"problem. The block is tied to your public IP address and normally clears by itself within "
"a few hours — retrying repeatedly extends it. To sign in now, use 'Use access token "
"instead' and paste a token taken from a browser session."
)
# How long to stop sending sign-in requests to a Bambu region after it answered
# with a CAPTCHA challenge. The reporter's log shows four attempts in eighteen
# seconds, which is exactly the traffic pattern that deepens the block: every
# extra request is more evidence for the thing that flagged us. Five minutes is
# short against the hours the block itself lasts — the point is not to wait it
# out here, only to stop Bambuddy from making it worse while the user reads the
# explanation.
_CAPTCHA_COOLOFF_SECONDS = 300.0
# API base URL -> monotonic time its cool-off expires. Keyed by base URL because
# the block lives at the edge in front of one region: being challenged on
# api.bambulab.com says nothing about api.bambulab.cn.
_captcha_blocked_until: dict[str, float] = {}
def is_captcha_challenge(response) -> bool:
"""Whether Bambu answered with an anti-abuse CAPTCHA challenge.
Requires the 418 status *and* a challenge marker in the body, so an
unrelated 418 is not reported to the user as "solve a CAPTCHA" — that would
send them looking for a widget that was never there, which is the exact
confusion #2790 is about. Callers that want to say something about a bare
418 must handle it themselves.
Shared by the Bambu Cloud and MakerWorld services: same edge, same body.
"""
try:
status = int(getattr(response, "status_code", 0) or 0)
except (TypeError, ValueError):
return False
if status != _CAPTCHA_HTTP_STATUS:
return False
try:
data = response.json()
except Exception:
data = None
if isinstance(data, dict):
# Field *names* count as well as their text: the challenge is
# identified by carrying a ``captchaId`` at all, whatever it says.
parts = [str(key) for key in data]
parts += [str(data[key]) for key in ("captchaId", "error", "message", "detail") if data.get(key)]
haystack = " ".join(parts).lower()
else:
# Not JSON (or not an object) — fall back to the raw body so a
# challenge served as HTML is still recognised rather than reported as
# an unexplained failure.
try:
haystack = (response.text or "").lower()
except Exception:
return False
return any(marker in haystack for marker in _CAPTCHA_BODY_MARKERS)
def captcha_cooloff_active(base_url: str) -> bool:
"""Whether sign-in requests to ``base_url`` are still held back after a
CAPTCHA challenge. Expired entries are dropped on the way past, so the dict
cannot grow past one entry per region."""
deadline = _captcha_blocked_until.get(base_url)
if deadline is None:
return False
if time.monotonic() >= deadline:
del _captcha_blocked_until[base_url]
return False
return True
def note_captcha_challenge(base_url: str) -> None:
"""Start the cool-off for ``base_url`` after a challenge was seen."""
_captcha_blocked_until[base_url] = time.monotonic() + _CAPTCHA_COOLOFF_SECONDS
# The `/v1/iot-service/api/slicer/setting` endpoint subtree — the plural GET
# for the list, the singular GET/DELETE for a specific preset by setting_id, and
# the POST for create — requires a `version` query parameter in the XX.YY.ZZ.WW
@@ -317,12 +421,62 @@ class BambuCloudService:
headers["Authorization"] = f"Bearer {self.access_token}"
return headers
def _captcha_refusal(self) -> dict:
"""The result every sign-in call returns while Bambu is challenging us.
``reason`` is what lets the UI tell this apart from a wrong password and
render the explanation next to the access-token route, instead of
flashing Bambu's own one-liner as a toast that then disappears (#2790).
"""
return {
"success": False,
"needs_verification": False,
"reason": "captcha",
"message": CAPTCHA_USER_MESSAGE,
}
def _captcha_cooloff_holds(self, origin: str | None = None) -> bool:
"""Whether to refuse a sign-in locally because Bambu just challenged us.
Keyed by the origin the call actually goes to. The TOTP step talks to
``bambulab.com`` while everything else talks to ``api.bambulab.com``, and
a challenge seen on one must not strand a user halfway through a
two-factor sign-in on the other.
"""
origin = origin or self.base_url
if not captcha_cooloff_active(origin):
return False
logger.warning(
"Bambu Cloud is challenging this network with a CAPTCHA — not sending the sign-in to %s. "
"The challenge cannot be answered from Bambuddy and normally clears within a few hours.",
origin,
)
return True
def _note_captcha(self, response, origin: str | None = None) -> bool:
"""Record and log a CAPTCHA challenge. Returns whether it was one."""
if not is_captcha_challenge(response):
return False
origin = origin or self.base_url
logger.warning(
"Bambu Cloud is challenging this network with a CAPTCHA (HTTP %s from %s). Sign-in cannot "
"complete until the challenge clears; pausing sign-in requests for %.0fs so retries do not "
"extend the block.",
response.status_code,
origin,
_CAPTCHA_COOLOFF_SECONDS,
)
note_captcha_challenge(origin)
return True
async def login_request(self, email: str, password: str) -> dict:
"""
Initiate login - this will trigger either email verification or TOTP prompt.
Returns dict with login status, verification type, and tfaKey if needed.
"""
if self._captcha_cooloff_holds():
return self._captcha_refusal()
try:
response = await self._client.post(
f"{self.base_url}/v1/user-service/user/login",
@@ -333,6 +487,9 @@ class BambuCloudService:
},
)
if self._note_captcha(response):
return self._captcha_refusal()
try:
data = response.json()
except Exception as json_err:
@@ -388,6 +545,8 @@ class BambuCloudService:
"""
Complete login with email verification code.
"""
if self._captcha_cooloff_holds():
return self._captcha_refusal()
try:
response = await self._client.post(
f"{self.base_url}/v1/user-service/user/login",
@@ -398,6 +557,9 @@ class BambuCloudService:
},
)
if self._note_captcha(response):
return self._captcha_refusal()
try:
data = response.json()
except Exception as json_err:
@@ -472,6 +634,9 @@ class BambuCloudService:
web_origin = "https://bambulab.cn" if "bambulab.cn" in self.base_url else "https://bambulab.com"
tfa_url = f"{web_origin}/api/sign-in/tfa"
if self._captcha_cooloff_holds(web_origin):
return self._captcha_refusal()
# #2696: the web origin is CSRF-protected (double submit). Without
# both halves the endpoint 403s before it ever evaluates the code,
# which surfaced to users as a permanent, misleading "Invalid code".
@@ -509,6 +674,9 @@ class BambuCloudService:
f"TOTP verify response: status={response.status_code}, body={response.text[:200] if response.text else '(empty)'}"
)
if self._note_captcha(response, web_origin):
return self._captcha_refusal()
# Handle empty response
if not response.text or not response.text.strip():
logger.warning("TOTP verification returned empty response (status %s)", response.status_code)
+11
View File
@@ -123,6 +123,17 @@ SIGNATURES: tuple[LogSignature, ...] = (
logger_prefix="backend.app.services.camera",
min_count=3,
),
LogSignature(
# Bambu's anti-abuse layer is challenging this network with a CAPTCHA,
# so no Bambu Cloud sign-in can complete. Nothing in the install is
# broken and no credential will help — see bambu_cloud.is_captcha_challenge.
id="bambu-cloud-captcha",
patterns=_compile(r"challenging this network with a CAPTCHA"),
severity="warning",
category="environment",
wiki_anchor="bambu-cloud-captcha",
logger_prefix="backend.app.services.bambu_cloud",
),
LogSignature(
# SQLite write contention. Surfaces inside exception tracebacks; folded
# continuation lines are part of the entry message, so this still
+18 -12
View File
@@ -28,7 +28,7 @@ from urllib.parse import urlparse
import certifi
import httpx
from backend.app.services.bambu_cloud import is_expiry_401
from backend.app.services.bambu_cloud import is_captcha_challenge, is_expiry_401
logger = logging.getLogger(__name__)
@@ -331,18 +331,24 @@ class MakerWorldService:
if response.status_code == 404:
raise MakerWorldNotFoundError(f"MakerWorld resource not found: {path}")
if response.status_code == 418:
# MakerWorld's anti-abuse layer challenges the source IP with a
# CAPTCHA (``{"captchaId":"...","error":"We need to confirm..."}``).
# This is application-level, not Cloudflare-edge, and clears
# on its own within 1–4 hours of quiet traffic. There's no
# server-side solve — CAPTCHAs are intentionally unsolvable
# without a real browser. Surface the upstream message so the
# user can recognise it and reach for the "Open on MakerWorld"
# fallback instead of thinking the feature is broken.
upstream = _extract_upstream_error(response)
if upstream and "robot" in upstream.lower():
# Bambu's anti-abuse layer challenges the source IP with a CAPTCHA
# (``{"captchaId":"...","error":"We need to confirm..."}``). This is
# application-level, not Cloudflare-edge, and clears on its own
# within 1–4 hours of quiet traffic. There's no server-side solve —
# CAPTCHAs are intentionally unsolvable without a real browser.
# Surface the upstream message so the user can recognise it and
# reach for the "Open on MakerWorld" fallback instead of thinking
# the feature is broken.
#
# The same challenge also lands on the Bambu Cloud sign-in endpoint,
# so the shape test lives in ``bambu_cloud`` and is shared (#2790).
# It used to be a bare "robot" substring check on the error text,
# which missed a challenge worded any other way.
if is_captcha_challenge(response):
upstream = _extract_upstream_error(response)
detail = f" ({upstream})" if upstream else ""
raise MakerWorldUnavailableError(
f"MakerWorld is challenging this IP with a CAPTCHA ({upstream}). "
f"MakerWorld is challenging this IP with a CAPTCHA{detail}. "
"This usually clears within a few hours. In the meantime, use "
"'Open on MakerWorld' below to download the 3MF manually."
)
@@ -0,0 +1,242 @@
"""Tests for Bambu's anti-abuse CAPTCHA challenge on sign-in (#2790).
Bambu's own anti-abuse layer -- not the Cloudflare edge -- answers a request it
has flagged with ``HTTP 418`` and ``{"captchaId": ..., "error": "We need you to
confirm you are not a robot"}``. It is keyed to the source IP, no credential
will be accepted until it clears, and there is no server-side solve.
That body is well-formed JSON, so the Cloudflare detector never fired on it and
``login_request`` fell through to its generic error path, which lifted Bambu's
sentence out of ``error`` and returned it verbatim. The reporter got a bare
toast reading "We need you to confirm you are not a robot" -- no challenge to
answer, no explanation, nothing to click -- and filed it as a Bambuddy bug.
These tests pin: the challenge is recognised by shape rather than by wording,
all three sign-in calls report it as ``reason="captcha"`` with an explanation
instead of Bambu's raw string, retries are held back per-origin so Bambuddy
stops deepening the block, and the scanner names it in the next support bundle.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from backend.app.services import bambu_cloud as bc
from backend.app.services.bambu_cloud import BambuCloudService
# Bambu's actual challenge body, as seen on both the login endpoint and the
# design-service endpoints MakerWorld imports use.
_CAPTCHA_BODY = {
"captchaId": "3f2a9c1e64b04d7f",
"error": "We need you to confirm you are not a robot",
}
@pytest.fixture(autouse=True)
def _clear_captcha_cooloff():
"""The cool-off map is module-level; don't leak it across tests."""
bc._captcha_blocked_until.clear()
yield
bc._captcha_blocked_until.clear()
def _response(status_code: int, body: object | None = None, *, text: str | None = None):
resp = MagicMock()
resp.status_code = status_code
if body is None and text is not None:
resp.json = MagicMock(side_effect=ValueError("not json"))
else:
resp.json = MagicMock(return_value=body if body is not None else {})
resp.text = text if text is not None else "{}"
return resp
def _service(response) -> BambuCloudService:
svc = BambuCloudService(client=MagicMock(spec=httpx.AsyncClient))
svc._client.post = AsyncMock(return_value=response)
svc._client.get = AsyncMock(return_value=response)
return svc
class TestChallengeIsRecognisedByShape:
def test_captcha_id_marks_the_challenge(self):
assert bc.is_captcha_challenge(_response(418, _CAPTCHA_BODY)) is True
def test_wording_alone_is_enough(self):
"""No captchaId, but the text says what it is. Bambu has shipped the
challenge under more than one body shape."""
assert bc.is_captcha_challenge(_response(418, {"error": "please confirm you are not a robot"})) is True
def test_a_418_without_a_marker_is_not_reported_as_a_captcha(self):
"""Telling a user to solve a CAPTCHA that was never offered is the exact
confusion this issue is about -- don't invent one for any stray 418."""
assert bc.is_captcha_challenge(_response(418, {"error": "Too many requests"})) is False
def test_status_alone_does_not_decide_it(self):
"""A captchaId on a 200 is not a refusal -- only the 418 is."""
assert bc.is_captcha_challenge(_response(200, _CAPTCHA_BODY)) is False
def test_a_non_json_challenge_is_still_recognised(self):
resp = _response(418, None, text="<html><body>captcha required</body></html>")
assert bc.is_captcha_challenge(resp) is True
def test_a_non_json_body_without_markers_is_not(self):
resp = _response(418, None, text="<html><body>Service unavailable</body></html>")
assert bc.is_captcha_challenge(resp) is False
class TestSignInReportsTheChallenge:
@pytest.mark.asyncio
async def test_login_explains_instead_of_echoing_bambu(self):
svc = _service(_response(418, _CAPTCHA_BODY))
result = await svc.login_request("user@example.com", "pw")
assert result["success"] is False
assert result["needs_verification"] is False
assert result["reason"] == "captcha"
# The regression in one line: this used to BE Bambu's sentence.
assert result["message"] != _CAPTCHA_BODY["error"]
assert "CAPTCHA" in result["message"]
# The two things the reporter had no way to know.
assert "password" in result["message"].lower()
assert "access token" in result["message"].lower()
@pytest.mark.asyncio
async def test_email_code_verification_reports_it_too(self):
svc = _service(_response(418, _CAPTCHA_BODY))
result = await svc.verify_code("user@example.com", "123456")
assert result["reason"] == "captcha"
assert result["message"] != _CAPTCHA_BODY["error"]
@pytest.mark.asyncio
async def test_totp_verification_reports_it_too(self):
svc = _service(_response(418, _CAPTCHA_BODY))
with patch.object(svc, "_fetch_csrf_token", AsyncMock(return_value="csrf-token")):
result = await svc.verify_totp("tfa-key", "123456")
assert result["reason"] == "captcha"
assert result["message"] != _CAPTCHA_BODY["error"]
@pytest.mark.asyncio
async def test_an_ordinary_rejection_is_unchanged(self):
"""Wrong password still says what Bambu said, and carries no reason --
the UI must keep toasting those rather than showing the CAPTCHA panel."""
svc = _service(_response(400, {"error": "Login failed"}))
result = await svc.login_request("user@example.com", "wrong")
assert result["message"] == "Login failed"
assert result.get("reason") is None
assert not bc.captcha_cooloff_active(svc.base_url)
class TestRetriesAreHeldBack:
@pytest.mark.asyncio
async def test_a_second_attempt_is_not_sent_to_bambu(self):
"""The reporter's log shows four attempts in eighteen seconds. Every one
of them is more evidence for the thing that flagged us."""
svc = _service(_response(418, _CAPTCHA_BODY))
await svc.login_request("user@example.com", "pw")
assert svc._client.post.await_count == 1
result = await svc.login_request("user@example.com", "pw")
assert svc._client.post.await_count == 1
assert result["reason"] == "captcha"
@pytest.mark.asyncio
async def test_the_cooloff_covers_a_fresh_service_instance(self):
"""Services are built per request, so the cool-off has to outlive one."""
await _service(_response(418, _CAPTCHA_BODY)).login_request("user@example.com", "pw")
second = _service(_response(200, {"loginType": "verifyCode"}))
result = await second.login_request("user@example.com", "pw")
second._client.post.assert_not_awaited()
assert result["reason"] == "captcha"
@pytest.mark.asyncio
async def test_the_cooloff_expires(self):
svc = _service(_response(418, _CAPTCHA_BODY))
await svc.login_request("user@example.com", "pw")
bc._captcha_blocked_until[svc.base_url] = bc.time.monotonic() - 1
svc._client.post = AsyncMock(return_value=_response(200, {"loginType": "verifyCode"}))
result = await svc.login_request("user@example.com", "pw")
assert result["needs_verification"] is True
assert bc._captcha_blocked_until == {}, "the expired entry should be dropped on the way past"
@pytest.mark.asyncio
async def test_a_challenge_on_the_api_host_does_not_strand_a_totp_sign_in(self):
"""TOTP verification goes to bambulab.com, everything else to
api.bambulab.com. Blocking one on the other's behalf would leave a user
halfway through two-factor with no way forward."""
svc = _service(_response(418, _CAPTCHA_BODY))
await svc.login_request("user@example.com", "pw")
svc._client.post = AsyncMock(return_value=_response(200, {"accessToken": "tok"}))
with patch.object(svc, "_fetch_csrf_token", AsyncMock(return_value="csrf-token")):
result = await svc.verify_totp("tfa-key", "123456")
assert result["success"] is True
@pytest.mark.asyncio
async def test_the_china_region_is_tracked_separately(self):
"""The block lives at the edge in front of one region."""
await _service(_response(418, _CAPTCHA_BODY)).login_request("user@example.com", "pw")
cn = BambuCloudService(region="china", client=MagicMock(spec=httpx.AsyncClient))
cn._client.post = AsyncMock(return_value=_response(200, {"loginType": "verifyCode"}))
result = await cn.login_request("user@example.com", "pw")
cn._client.post.assert_awaited_once()
assert result["needs_verification"] is True
class TestMakerWorldSharesTheDetector:
@pytest.mark.asyncio
async def test_a_challenge_worded_differently_is_still_named(self):
"""MakerWorld used to require the literal word "robot" in the error text
and reported anything else as an unexplained block."""
from backend.app.services.makerworld import MakerWorldService, MakerWorldUnavailableError
svc = MakerWorldService(client=MagicMock(spec=httpx.AsyncClient), auth_token="tok")
svc._client.get = AsyncMock(return_value=_response(418, {"captchaId": "abc", "error": "verification required"}))
with pytest.raises(MakerWorldUnavailableError) as exc:
await svc._get_json("/design/1")
assert "CAPTCHA" in str(exc.value)
assert "Open on MakerWorld" in str(exc.value)
class TestTheSupportBundleNamesIt:
def test_the_warning_we_log_matches_the_signature(self, tmp_path, monkeypatch, caplog):
"""The reporter's bundle came back with zero log-health findings while
the log was full of the failure -- tie the two ends together."""
from backend.app.core.config import settings as app_settings
from backend.app.services.log_health import scan_logs
svc = _service(_response(418, _CAPTCHA_BODY))
with caplog.at_level("WARNING", logger="backend.app.services.bambu_cloud"):
svc._note_captcha(_response(418, _CAPTCHA_BODY))
logged = caplog.records[-1].getMessage()
log_file = tmp_path / "bambuddy.log"
log_file.write_text(
f"2026-08-08 05:15:37,068 WARNING [backend.app.services.bambu_cloud] {logged}\n",
encoding="utf-8",
)
monkeypatch.setattr(app_settings, "log_dir", tmp_path)
findings = scan_logs().findings
assert [f.signature_id for f in findings] == ["bambu-cloud-captcha"]
assert findings[0].wiki_anchor == "bambu-cloud-captcha"