Skip to content

Commit e566530

Browse files
UPinarclaude
andcommitted
fix(recon): catch inner crt.sh timeout in full_domain_report closures (v1.33.20)
The two helper closures that share the crt.sh task wrapped their inner asyncio.wait_for(asyncio.shield(...)) without a guard, so a slow upstream made the inner timeout raise inside the closure; the task ended with an unretrieved exception and the outer await failed the whole domain report. Catch the inner TimeoutError in both closures and fall through to the partial-success path (empty CT data + crt_sh_timeout sentinel). The certificates branch already accepted that input via check_ct_logs; extend enumerate_subdomains / _crtsh_subdomains with an optional crtsh_error so the subdomains branch can honestly surface crtsh_status='timeout' instead of masquerading as 'ok' with an empty result. Defaults preserve existing call sites — no schema delta, no breaking change. Add a regression test that hangs the crt.sh fetch and asserts both branches report the timeout via the documented status fields. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 54f4071 commit e566530

3 files changed

Lines changed: 81 additions & 9 deletions

File tree

app/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from pydantic import Field
1313
from pydantic_settings import BaseSettings, SettingsConfigDict
1414

15-
VERSION = "1.33.19"
15+
VERSION = "1.33.20"
1616
MCP_TOOL_COUNT = 53 # v1.33.0: +tech_stack_cve_audit (MCP-only composite)
1717
MCP_RESOURCE_COUNT = 7 # v1.23.0: atlas+d3fend+cwe (4 templates + 3 catalogs)
1818
MCP_PROMPT_COUNT = 3 # v1.23.0: security_audit, vulnerability_check, contrast_triage

app/domain/recon.py

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -334,8 +334,14 @@ def _parse_whois(text: str) -> dict:
334334
# === Subdomains ===
335335

336336

337-
async def enumerate_subdomains(domain: str, crtsh_data: list | None = None) -> dict:
338-
"""Enumerate subdomains via DNS brute force + crt.sh CT logs."""
337+
async def enumerate_subdomains(domain: str, crtsh_data: list | None = None, crtsh_error: str | None = None) -> dict:
338+
"""Enumerate subdomains via DNS brute force + crt.sh CT logs.
339+
340+
When the caller pre-fetched crt.sh and surfaced an upstream failure (e.g.,
341+
`crt_sh_timeout` from full_domain_report's shared f_crtsh task), pass that
342+
string as `crtsh_error` so the response honestly reports `crtsh_status`
343+
instead of masquerading as "ok" with an empty result.
344+
"""
339345
found_wordlist: set[str] = set()
340346
warnings: list[str] = []
341347

@@ -354,7 +360,7 @@ def _resolve_sub(sub):
354360
if result:
355361
found_wordlist.add(result)
356362

357-
found_crtsh, crtsh_warnings, crtsh_status = await _crtsh_subdomains(domain, crtsh_data)
363+
found_crtsh, crtsh_warnings, crtsh_status = await _crtsh_subdomains(domain, crtsh_data, fetch_error=crtsh_error)
358364
warnings.extend(crtsh_warnings)
359365

360366
all_found = sorted(found_wordlist | set(found_crtsh))
@@ -445,7 +451,9 @@ async def _fetch_crtsh(query: str) -> tuple[list, str | None]:
445451
}
446452

447453

448-
async def _crtsh_subdomains(domain: str, data: list | None = None) -> tuple[list, list, str]:
454+
async def _crtsh_subdomains(
455+
domain: str, data: list | None = None, fetch_error: str | None = None
456+
) -> tuple[list, list, str]:
449457
"""Extract subdomain names from crt.sh data.
450458
451459
Returns:
@@ -454,10 +462,17 @@ async def _crtsh_subdomains(domain: str, data: list | None = None) -> tuple[list
454462
callers distinguish "CT lookup confirmed empty" from "CT lookup failed";
455463
the legacy (subs, warnings) shape conflated the two and downstream tools
456464
could not tell the difference between an actually-tiny domain and a
457-
crt.sh outage.
465+
crt.sh outage. The optional `fetch_error` argument lets the caller
466+
propagate an upstream failure (e.g., crt_sh_timeout) when it pre-fetched
467+
the data itself — mirrors check_ct_logs's `crtsh_error` parameter so
468+
both halves of the recon report agree on whether crt.sh delivered.
458469
"""
459470
warnings: list[str] = []
460471
status = "ok"
472+
if fetch_error:
473+
warnings.append(fetch_error)
474+
status = _CRTSH_STATUS_BY_ERROR.get(fetch_error, "error")
475+
return ([], warnings, status)
461476
if data is None:
462477
data, fetch_error = await _fetch_crtsh(f"%.{domain}")
463478
if fetch_error:
@@ -1446,11 +1461,21 @@ async def full_domain_report(
14461461
async def _subs_with_crtsh():
14471462
# asyncio.shield prevents cancellation of f_crtsh from propagating
14481463
# back into the shared crtsh task — both _subs and _ct depend on it.
1449-
data, _ = await asyncio.wait_for(asyncio.shield(f_crtsh), timeout=CRTSH_TIMEOUT + 2)
1450-
return await enumerate_subdomains(domain, crtsh_data=data)
1464+
# Inner TimeoutError must be caught here so this closure's task
1465+
# completes cleanly with partial result; otherwise the unretrieved
1466+
# exception triggers 'Task exception was never retrieved' (S253 #1).
1467+
crtsh_err: str | None = None
1468+
try:
1469+
data, crtsh_err = await asyncio.wait_for(asyncio.shield(f_crtsh), timeout=CRTSH_TIMEOUT + 2)
1470+
except asyncio.TimeoutError:
1471+
data, crtsh_err = [], "crt_sh_timeout"
1472+
return await enumerate_subdomains(domain, crtsh_data=data, crtsh_error=crtsh_err)
14511473

14521474
async def _ct_with_crtsh():
1453-
data, fetch_error = await asyncio.wait_for(asyncio.shield(f_crtsh), timeout=CRTSH_TIMEOUT + 2)
1475+
try:
1476+
data, fetch_error = await asyncio.wait_for(asyncio.shield(f_crtsh), timeout=CRTSH_TIMEOUT + 2)
1477+
except asyncio.TimeoutError:
1478+
data, fetch_error = [], "crt_sh_timeout"
14541479
return await check_ct_logs(domain, data, crtsh_error=fetch_error)
14551480

14561481
f_subs = asyncio.create_task(_subs_with_crtsh())

app/tests/test_domain.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3445,6 +3445,53 @@ async def _run():
34453445
)
34463446

34473447

3448+
class TestCtCrtshInnerTimeoutOrphan:
3449+
"""Regression for S253 #1 (113/24h prod orphan logs). When _fetch_crtsh
3450+
is slow enough that the INNER asyncio.wait_for(asyncio.shield(f_crtsh),
3451+
timeout=CRTSH_TIMEOUT+2) times out FIRST, the TimeoutError must be
3452+
caught INSIDE _ct_with_crtsh() and _subs_with_crtsh() so f_certs/f_subs
3453+
complete cleanly with partial results. Pre-fix: TimeoutError propagates
3454+
to f_certs task, outer wait_for at line ~1488 fails the whole report,
3455+
and asyncio logs 'Task exception was never retrieved'."""
3456+
3457+
def test_inner_crtsh_timeout_returns_partial_certificates(self, monkeypatch):
3458+
async def _hang_crtsh(_q):
3459+
await asyncio.Event().wait()
3460+
return ([], None)
3461+
3462+
async def _ok_threat(*_a, **_kw):
3463+
return {
3464+
"urlhaus_status": "ok",
3465+
"url_count": 0,
3466+
"urls_online": 0,
3467+
"threat_types": [],
3468+
"tags": [],
3469+
"urls": [],
3470+
}
3471+
3472+
async def _ok_headers(*_a, **_kw):
3473+
return {"headers": {}, "status": 200}
3474+
3475+
monkeypatch.setattr("domain.recon.CRTSH_TIMEOUT", 0)
3476+
monkeypatch.setattr("domain.recon.dns_lookup", lambda d: {"a": [], "txt": [], "mx": [], "ns": []})
3477+
monkeypatch.setattr("domain.recon.reverse_dns", lambda d: {"ip": None})
3478+
monkeypatch.setattr("domain.recon.ssl_info", lambda d, ip: {})
3479+
monkeypatch.setattr("domain.recon.whois_lookup", lambda d: {})
3480+
monkeypatch.setattr("domain.recon.email_security", lambda d, txt: {"grade": "F"})
3481+
monkeypatch.setattr("domain.recon._fetch_crtsh", _hang_crtsh)
3482+
monkeypatch.setattr("domain.recon.fetch_live_headers", _ok_headers)
3483+
monkeypatch.setattr("domain.threat.check_urlhaus", _ok_threat)
3484+
3485+
from domain.recon import full_domain_report
3486+
3487+
result = asyncio.run(full_domain_report("example.com"))
3488+
3489+
assert result["certificates"]["error"] == "crt_sh_timeout"
3490+
assert result["certificates"]["crtsh_status"] == "timeout"
3491+
assert result["certificates"]["total_certificates"] == 0
3492+
assert result["subdomains"]["crtsh_status"] == "timeout"
3493+
3494+
34483495
# =========== fetch_live_page connection-release (cancel-without-await leak) ===========
34493496

34503497

0 commit comments

Comments
 (0)