This commit is contained in:
Andrew Pimenov 2026-08-26 17:09:58 +03:00 • committed by GitHub
commit 9910888db5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -5,6 +5,7 @@ import os
import re import re
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from netsleuth.interpret import bufferbloat_consequence
from netsleuth.config import FORMAT_EXTENSIONS from netsleuth.config import FORMAT_EXTENSIONS
from netsleuth.interpret import overall_verdict from netsleuth.interpret import overall_verdict
@ -32,7 +33,9 @@ SECTION_ORDER = (
) )
_UNSAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+") _UNSAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
_ISO_RE = re.compile(r"^(?P<y>\d{4})-(?P<mo>\d{2})-(?P<d>\d{2})T(?P<h>\d{2}):(?P<mi>\d{2}):(?P<s>\d{2})") _ISO_RE = re.compile(
r"^(?P<y>\d{4})-(?P<mo>\d{2})-(?P<d>\d{2})T(?P<h>\d{2}):(?P<mi>\d{2}):(?P<s>\d{2})"
)
def sanitize_name(value: str | None, fallback: str = "unknown") -> str: def sanitize_name(value: str | None, fallback: str = "unknown") -> str:
@ -77,7 +80,10 @@ def build_report(
raw: dict[str, Any], raw: dict[str, Any],
) -> dict[str, Any]: ) -> dict[str, Any]:
status, score, summary = overall_verdict(findings) status, score, summary = overall_verdict(findings)
report: dict[str, Any] = {"schema_version": SCHEMA_VERSION, "meta": to_jsonable(meta)} report: dict[str, Any] = {
"schema_version": SCHEMA_VERSION,
"meta": to_jsonable(meta),
}
for section in SECTION_ORDER: for section in SECTION_ORDER:
result = modules.get(section) or ModuleResult(name=section, status="skipped") result = modules.get(section) or ModuleResult(name=section, status="skipped")
report[section] = to_jsonable(result) report[section] = to_jsonable(result)
@ -93,7 +99,9 @@ def build_report(
def dump_json(report: dict[str, Any]) -> str: def dump_json(report: dict[str, Any]) -> str:
return json.dumps(to_jsonable(report), allow_nan=False, ensure_ascii=False, indent=2) return json.dumps(
to_jsonable(report), allow_nan=False, ensure_ascii=False, indent=2
)
def atomic_write(path: Path, text: str) -> Path: def atomic_write(path: Path, text: str) -> Path:
@ -110,7 +118,9 @@ def egress_asn(report: dict[str, Any]) -> str | None:
return ((data.get("egress_v4") or {}) if isinstance(data, dict) else {}).get("asn") return ((data.get("egress_v4") or {}) if isinstance(data, dict) else {}).get("asn")
def write_report(report: dict[str, Any], artifacts: dict[str, str], logs_dir: Path) -> list[Path]: def write_report(
report: dict[str, Any], artifacts: dict[str, str], logs_dir: Path
) -> list[Path]:
meta = report.get("meta") or {} meta = report.get("meta") or {}
started = meta.get("started_at", "") started = meta.get("started_at", "")
asn = meta.get("target") or egress_asn(report) asn = meta.get("target") or egress_asn(report)
@ -121,12 +131,16 @@ def write_report(report: dict[str, Any], artifacts: dict[str, str], logs_dir: Pa
] ]
from netsleuth.interpret import bufferbloat_consequence
SPARK_CHARS = "▁▂▃▅▇" SPARK_CHARS = "▁▂▃▅▇"
_BADGES = {"ok": "🟢", "info": "🟢", "warn": "🟡", "crit": "🔴"} _BADGES = {"ok": "🟢", "info": "🟢", "warn": "🟡", "crit": "🔴"}
_SEVERITY_RANK = {"crit": 0, "warn": 1, "info": 2, "ok": 3} _SEVERITY_RANK = {"crit": 0, "warn": 1, "info": 2, "ok": 3}
_SEVERITY_WORDS_RU = {"ok": "норма", "info": "инфо", "warn": "предупреждение", "crit": "критично"} _SEVERITY_WORDS_RU = {
"ok": "норма",
"info": "инфо",
"warn": "предупреждение",
"crit": "критично",
}
_LOSS_JUMP_PCT = 20.0 _LOSS_JUMP_PCT = 20.0
@ -181,7 +195,10 @@ def badge(severity: str, emoji: bool, lang: str = "en") -> str:
def first_loss_jump(hops: list[dict]) -> int | None: def first_loss_jump(hops: list[dict]) -> int | None:
for hop, following in zip(hops, hops[1:]): for hop, following in zip(hops, hops[1:]):
if hop.get("loss_pct", 0.0) >= _LOSS_JUMP_PCT and following.get("loss_pct", 0.0) >= _LOSS_JUMP_PCT: if (
hop.get("loss_pct", 0.0) >= _LOSS_JUMP_PCT
and following.get("loss_pct", 0.0) >= _LOSS_JUMP_PCT
):
return hop.get("ttl") return hop.get("ttl")
return None return None
@ -195,16 +212,22 @@ def _data(report: dict, section: str) -> Any:
def _num(value: Any) -> str: def _num(value: Any) -> str:
return "—" if value is None else f"{value:g}" if isinstance(value, (int, float)) else str(value) return (
"—"
if value is None
else f"{value:g}"
if isinstance(value, (int, float))
else str(value)
)
def _table(headers: list[str], rows: list[list[str]], lang: str = "en") -> list[str]: def _table(headers: list[str], rows: list[list[str]], lang: str = "en") -> list[str]:
if not rows: if not rows:
return [_t(lang, "_No rows._", "_Нет строк._")] return [_t(lang, "_No rows._", "_Нет строк._")]
return ( return [
["| " + " | ".join(headers) + " |", "|" + "|".join(["---"] * len(headers)) + "|"] "| " + " | ".join(headers) + " |",
+ ["| " + " | ".join(row) + " |" for row in rows] "|" + "|".join(["---"] * len(headers)) + "|",
) ] + ["| " + " | ".join(row) + " |" for row in rows]
def _findings(report: dict) -> list[dict]: def _findings(report: dict) -> list[dict]:
@ -230,9 +253,15 @@ def _ru_summary(report: dict, status: str) -> str:
def _header(report: dict, emoji: bool, lang: str = "en") -> list[str]: def _header(report: dict, emoji: bool, lang: str = "en") -> list[str]:
meta = report.get("meta") or {} meta = report.get("meta") or {}
interp = report.get("interpretation") or {} interp = report.get("interpretation") or {}
target = f" · {_t(lang, 'target', 'цель')} `{meta.get('target')}`" if meta.get("target") else "" target = (
f" · {_t(lang, 'target', 'цель')} `{meta.get('target')}`"
if meta.get("target")
else ""
)
status = interp.get("overall_status", "ok") status = interp.get("overall_status", "ok")
summary = interp.get("summary_text", "") if lang == "en" else _ru_summary(report, status) summary = (
interp.get("summary_text", "") if lang == "en" else _ru_summary(report, status)
)
status_word = status if lang == "en" else _SEVERITY_WORDS_RU.get(status, status) status_word = status if lang == "en" else _SEVERITY_WORDS_RU.get(status, status)
return [ return [
"# netsleuth report", "# netsleuth report",
@ -252,16 +281,37 @@ def _tldr(report: dict, emoji: bool, lang: str = "en") -> list[str]:
found = _findings(report) found = _findings(report)
heading = _t(lang, "## TL;DR", "## Коротко") heading = _t(lang, "## TL;DR", "## Коротко")
banner = ( banner = (
[_t(lang, "> ⚠️ Every measurement below was taken from behind a captive portal.", "> ⚠️ Все измерения ниже сделаны из-за captive portal."), ""] [
_t(
lang,
"> ⚠️ Every measurement below was taken from behind a captive portal.",
"> ⚠️ Все измерения ниже сделаны из-за captive portal.",
),
"",
]
if (report.get("meta") or {}).get("captive_portal") if (report.get("meta") or {}).get("captive_portal")
else [] else []
) )
if not found: if not found:
return banner + [heading, "", _t(lang, "No problems found on this connection.", "Проблем на этом соединении не найдено."), ""] return banner + [
return banner + [heading, ""] + [ heading,
f"- {badge(f.get('severity', 'info'), emoji, lang)} **{_f(f, 'title', lang)}** — {_f(f, 'detail', lang)}" "",
for f in found[:5] _t(
] + [""] lang,
"No problems found on this connection.",
"Проблем на этом соединении не найдено.",
),
"",
]
return (
banner
+ [heading, ""]
+ [
f"- {badge(f.get('severity', 'info'), emoji, lang)} **{_f(f, 'title', lang)}** — {_f(f, 'detail', lang)}"
for f in found[:5]
]
+ [""]
)
_SECTION_TITLES = { _SECTION_TITLES = {
@ -309,7 +359,12 @@ def _section_title(section: str, lang: str = "en") -> str:
return _SECTION_TITLES_RU[section] if lang == "ru" else _SECTION_TITLES[section] return _SECTION_TITLES_RU[section] if lang == "ru" else _SECTION_TITLES[section]
_MODULE_STATUS_RU = {"ok": "ок", "partial": "частично", "failed": "ошибка", "skipped": "пропущено"} _MODULE_STATUS_RU = {
"ok": "ок",
"partial": "частично",
"failed": "ошибка",
"skipped": "пропущено",
}
def _module_status_word(status: str, lang: str) -> str: def _module_status_word(status: str, lang: str) -> str:
@ -320,11 +375,17 @@ def unavailable(report: dict[str, Any], section: str, lang: str = "en") -> str |
module = _module(report, section) module = _module(report, section)
if module.get("status") in ("ok", "partial") and module.get("data") is not None: if module.get("status") in ("ok", "partial") and module.get("data") is not None:
return None return None
detail = "; ".join(f"{e.get('source')}: {e.get('message')}" for e in module.get("errors") or []) detail = "; ".join(
f"{e.get('source')}: {e.get('message')}" for e in module.get("errors") or []
)
if not detail: if not detail:
detail = "; ".join(_warning(lang, w) for w in module.get("warnings") or []) detail = "; ".join(_warning(lang, w) for w in module.get("warnings") or [])
if not detail: if not detail:
detail = _t(lang, "no data was collected for this section", "для этого раздела не собраны данные") detail = _t(
lang,
"no data was collected for this section",
"для этого раздела не собраны данные",
)
status_word = _module_status_word(module.get("status", "skipped"), lang) status_word = _module_status_word(module.get("status", "skipped"), lang)
label = _t(lang, "Not available", "Недоступно") label = _t(lang, "Not available", "Недоступно")
return f"_{label} — {status_word}: {detail}._" return f"_{label} — {status_word}: {detail}._"
@ -341,12 +402,25 @@ def _forced_interface_rows(meta: dict, lang: str = "en") -> list[list[str]]:
] ]
] ]
os_iface, os_ipv4 = forced.get("os_default_iface"), forced.get("os_default_ipv4") os_iface, os_ipv4 = forced.get("os_default_iface"), forced.get("os_default_ipv4")
if os_iface and (os_iface, os_ipv4) != (forced.get("resolved_iface"), forced.get("bind_ipv4")): if os_iface and (os_iface, os_ipv4) != (
rows.append([_t(lang, "OS default interface", "Интерфейс ОС по умолчанию"), f"{os_iface} — {os_ipv4 or '?'}"]) forced.get("resolved_iface"),
forced.get("bind_ipv4"),
):
rows.append(
[
_t(lang, "OS default interface", "Интерфейс ОС по умолчанию"),
f"{os_iface} — {os_ipv4 or '?'}",
]
)
return rows return rows
_PORTAL_VERDICT_RU = {"clean": "чисто", "portal": "портал", "suspect": "под вопросом", "error": "ошибка"} _PORTAL_VERDICT_RU = {
"clean": "чисто",
"portal": "портал",
"suspect": "под вопросом",
"error": "ошибка",
}
def _captive_portal(report: dict, lang: str = "en") -> list[str]: def _captive_portal(report: dict, lang: str = "en") -> list[str]:
@ -357,21 +431,40 @@ def _captive_portal(report: dict, lang: str = "en") -> list[str]:
cp = _data(report, "captive_portal") or {} cp = _data(report, "captive_portal") or {}
checks = cp.get("checks") or [] checks = cp.get("checks") or []
lines = [title, ""] + _table( lines = [title, ""] + _table(
[_t(lang, "URL", "URL"), _t(lang, "Status", "Статус"), _t(lang, "Verdict", "Вердикт"), _t(lang, "Evidence", "Доказательство")], [
_t(lang, "URL", "URL"),
_t(lang, "Status", "Статус"),
_t(lang, "Verdict", "Вердикт"),
_t(lang, "Evidence", "Доказательство"),
],
[ [
[ [
c.get("url", ""), c.get("url", ""),
_num(c.get("status")), _num(c.get("status")),
c.get("verdict", "") if lang == "en" else _PORTAL_VERDICT_RU.get(c.get("verdict"), c.get("verdict", "")), c.get("verdict", "")
if lang == "en"
else _PORTAL_VERDICT_RU.get(c.get("verdict"), c.get("verdict", "")),
c.get("evidence") or "—", c.get("evidence") or "—",
] ]
for c in checks for c in checks
], ],
lang, lang,
) )
verdict_word = cp.get("verdict", "clean") if lang == "en" else _PORTAL_VERDICT_RU.get(cp.get("verdict"), cp.get("verdict")) verdict_word = (
note_text = (cp.get("note_ru") or cp.get("note") or "") if lang == "ru" else (cp.get("note") or "") cp.get("verdict", "clean")
lines += ["", f"{_t(lang, 'Verdict', 'Вердикт')}: **{verdict_word}**" + (f" — {note_text}" if note_text else "")] if lang == "en"
else _PORTAL_VERDICT_RU.get(cp.get("verdict"), cp.get("verdict"))
)
note_text = (
(cp.get("note_ru") or cp.get("note") or "")
if lang == "ru"
else (cp.get("note") or "")
)
lines += [
"",
f"{_t(lang, 'Verdict', 'Вердикт')}: **{verdict_word}**"
+ (f" — {note_text}" if note_text else ""),
]
return lines + [""] return lines + [""]
@ -389,32 +482,51 @@ def _connection(report: dict, lang: str = "en") -> list[str]:
cf = geo_bundle.get("cf_trace") or {} cf = geo_bundle.get("cf_trace") or {}
meta = report.get("meta") or {} meta = report.get("meta") or {}
rows = [ rows = [
[_t(lang, "Interface", "Интерфейс"), f"{local.get('iface_name') or '?'} (MTU {local.get('iface_mtu') or '?'})"], [
_t(lang, "Interface", "Интерфейс"),
f"{local.get('iface_name') or '?'} (MTU {local.get('iface_mtu') or '?'})",
],
*_forced_interface_rows(meta, lang), *_forced_interface_rows(meta, lang),
[ [
_t(lang, "Local address", "Локальный адрес"), _t(lang, "Local address", "Локальный адрес"),
f"{local.get('local_ipv4') or '?'} / {local.get('local_ipv6') or '—'}", f"{local.get('local_ipv4') or '?'} / {local.get('local_ipv6') or '—'}",
], ],
[_t(lang, "Default gateway", "Шлюз по умолчанию"), local.get("default_gateway_v4") or "?"], [
_t(lang, "Default gateway", "Шлюз по умолчанию"),
local.get("default_gateway_v4") or "?",
],
[ [
_t(lang, "Egress IPv4", "Внешний IPv4"), _t(lang, "Egress IPv4", "Внешний IPv4"),
f"{geo.get('ip') or '?'} ({geo.get('asn') or '?'} {geo.get('as_name') or geo.get('org') or ''})", f"{geo.get('ip') or '?'} ({geo.get('asn') or '?'} {geo.get('as_name') or geo.get('org') or ''})",
], ],
[_t(lang, "Egress IPv6", "Внешний IPv6"), f"{v6.get('ip') or '—'} ({v6.get('asn') or '—'})"], [
_t(lang, "Egress IPv6", "Внешний IPv6"),
f"{v6.get('ip') or '—'} ({v6.get('asn') or '—'})",
],
[_t(lang, "Reverse DNS", "Обратный DNS"), geo.get("reverse_dns") or "—"], [_t(lang, "Reverse DNS", "Обратный DNS"), geo.get("reverse_dns") or "—"],
[ [
_t(lang, "Location", "Местоположение"), _t(lang, "Location", "Местоположение"),
f"{geo.get('city') or '?'}, {geo.get('country') or geo.get('country_code') or '?'}", f"{geo.get('city') or '?'}, {geo.get('country') or geo.get('country_code') or '?'}",
], ],
[_t(lang, "Address type", "Тип адреса"), geo.get("ip_type") or _t(lang, "unknown", "неизвестно")], [
_t(lang, "Address type", "Тип адреса"),
geo.get("ip_type") or _t(lang, "unknown", "неизвестно"),
],
[_t(lang, "Cloudflare colo", "Дата-центр Cloudflare"), cf.get("colo") or "—"], [_t(lang, "Cloudflare colo", "Дата-центр Cloudflare"), cf.get("colo") or "—"],
[ [
_t(lang, "CGNAT", "CGNAT"), _t(lang, "CGNAT", "CGNAT"),
_t(lang, "yes", "да") if local.get("cgnat") else _t(lang, "no", "нет"), _t(lang, "yes", "да") if local.get("cgnat") else _t(lang, "no", "нет"),
], ],
[_t(lang, "NAT64 prefix", "Префикс NAT64"), geo_bundle.get("nat64_prefix") or "—"], [
_t(lang, "NAT64 prefix", "Префикс NAT64"),
geo_bundle.get("nat64_prefix") or "—",
],
] ]
lines = [title, ""] + _table([_t(lang, "Field", "Поле"), _t(lang, "Value", "Значение")], rows, lang) + [""] lines = (
[title, ""]
+ _table([_t(lang, "Field", "Поле"), _t(lang, "Value", "Значение")], rows, lang)
+ [""]
)
note = geo_bundle.get("dual_stack_note_ru" if lang == "ru" else "dual_stack_note") note = geo_bundle.get("dual_stack_note_ru" if lang == "ru" else "dual_stack_note")
if note: if note:
lines += [f"> {note}", ""] lines += [f"> {note}", ""]
@ -432,7 +544,72 @@ def _connection(report: dict, lang: str = "en") -> list[str]:
return lines return lines
_VPN_VERDICT_RU = {"none": "нет", "likely": "вероятно", "confirmed": "подтверждено", "unknown": "неизвестно"} _VPN_VERDICT_RU = {
"none": "нет",
"likely": "вероятно",
"confirmed": "подтверждено",
"unknown": "неизвестно",
}
def _vpn_signals(signals: list, lang: str, yes: str, no: str) -> list[str]:
return _table(
[
_t(lang, "Signal", "Сигнал"),
_t(lang, "Observed", "Замечен"),
_t(lang, "Weight", "Вес"),
_t(lang, "Direction", "Направление"),
_t(lang, "Note", "Примечание"),
],
[
[
s.get("name", ""),
yes if s.get("observed") else no,
_num(s.get("weight")),
s.get("direction", ""),
s.get("note") or "—",
]
for s in signals
],
lang,
)
def _vpn_dns_leak(leak: dict, lang: str, yes: str, no: str) -> list[str]:
lines = ["", _t(lang, "### DNS leak", "### Утечка DNS"), ""]
lines += _table(
[
_t(lang, "Adapter", "Адаптер"),
_t(lang, "Configured resolvers", "Настроенные резолверы"),
_t(lang, "Echoed IP", "Отражённый IP"),
_t(lang, "Echoed ASN", "Отражённый ASN"),
_t(lang, "Same ASN as egress", "Тот же ASN, что и внешний"),
],
[
[
a.get("adapter", ""),
", ".join(a.get("configured_resolvers") or []) or "—",
a.get("echoed_ip") or "—",
a.get("echoed_asn") or "—",
{True: yes, False: no, None: "?"}[a.get("matches_egress_asn")],
]
for a in leak.get("per_adapter") or []
],
lang,
)
ecs_word = yes if leak.get("ecs_leaked") else no
note_text = (
(leak.get("note_ru") or leak.get("note") or "")
if lang == "ru"
else (leak.get("note") or "")
)
lines += [
"",
f"{_t(lang, 'EDNS Client Subnet leaked', 'Утечка EDNS Client Subnet')}: {ecs_word}",
"",
note_text,
]
return lines
def _vpn(report: dict, lang: str = "en") -> list[str]: def _vpn(report: dict, lang: str = "en") -> list[str]:
@ -452,61 +629,18 @@ def _vpn(report: dict, lang: str = "en") -> list[str]:
f"{vpn.get('tunnel_iface') or '—'}", f"{vpn.get('tunnel_iface') or '—'}",
"", "",
] ]
lines += _table( lines += _vpn_signals(vpn.get("signals") or [], lang, yes, no)
[
_t(lang, "Signal", "Сигнал"),
_t(lang, "Observed", "Замечен"),
_t(lang, "Weight", "Вес"),
_t(lang, "Direction", "Направление"),
_t(lang, "Note", "Примечание"),
],
[
[
s.get("name", ""),
yes if s.get("observed") else no,
_num(s.get("weight")),
s.get("direction", ""),
s.get("note") or "—",
]
for s in vpn.get("signals") or []
],
lang,
)
leak = vpn.get("dns_leak") or {} leak = vpn.get("dns_leak") or {}
if leak: if leak:
lines += ["", _t(lang, "### DNS leak", "### Утечка DNS"), ""] lines += _vpn_dns_leak(leak, lang, yes, no)
lines += _table(
[
_t(lang, "Adapter", "Адаптер"),
_t(lang, "Configured resolvers", "Настроенные резолверы"),
_t(lang, "Echoed IP", "Отражённый IP"),
_t(lang, "Echoed ASN", "Отражённый ASN"),
_t(lang, "Same ASN as egress", "Тот же ASN, что и внешний"),
],
[
[
a.get("adapter", ""),
", ".join(a.get("configured_resolvers") or []) or "—",
a.get("echoed_ip") or "—",
a.get("echoed_asn") or "—",
{True: yes, False: no, None: "?"}[a.get("matches_egress_asn")],
]
for a in leak.get("per_adapter") or []
],
lang,
)
ecs_word = yes if leak.get("ecs_leaked") else no
note_text = (leak.get("note_ru") or leak.get("note") or "") if lang == "ru" else (leak.get("note") or "")
lines += [
"",
f"{_t(lang, 'EDNS Client Subnet leaked', 'Утечка EDNS Client Subnet')}: {ecs_word}",
"",
note_text,
]
return lines + [""] return lines + [""]
_ROUTE_STABILITY_RU = {"stable": "стабильный", "unstable": "нестабильный", "unknown": "неизвестно"} _ROUTE_STABILITY_RU = {
"stable": "стабильный",
"unstable": "нестабильный",
"unknown": "неизвестно",
}
def _bgp(report: dict, lang: str = "en") -> list[str]: def _bgp(report: dict, lang: str = "en") -> list[str]:
@ -516,16 +650,27 @@ def _bgp(report: dict, lang: str = "en") -> list[str]:
return [title, "", note, ""] return [title, "", note, ""]
bgp = _data(report, "bgp") or {} bgp = _data(report, "bgp") or {}
stability = bgp.get("stability", "unknown") stability = bgp.get("stability", "unknown")
stability_word = stability if lang == "en" else _ROUTE_STABILITY_RU.get(stability, stability) stability_word = (
stability if lang == "en" else _ROUTE_STABILITY_RU.get(stability, stability)
)
rows = [ rows = [
[_t(lang, "ASN", "ASN"), f"{bgp.get('asn') or '?'} — {bgp.get('holder') or '?'}"], [
_t(lang, "ASN", "ASN"),
f"{bgp.get('asn') or '?'} — {bgp.get('holder') or '?'}",
],
[ [
_t(lang, "Registry", "Реестр"), _t(lang, "Registry", "Реестр"),
f"{bgp.get('registry') or '?'} ({_t(lang, 'allocated', 'выделен')} {bgp.get('allocated_at') or '?'})", f"{bgp.get('registry') or '?'} ({_t(lang, 'allocated', 'выделен')} {bgp.get('allocated_at') or '?'})",
], ],
[_t(lang, "Upstreams", "Аплинки"), ", ".join(bgp.get("upstreams") or []) or "—"], [
_t(lang, "Upstreams", "Аплинки"),
", ".join(bgp.get("upstreams") or []) or "—",
],
[_t(lang, "Peers", "Пиры"), ", ".join(bgp.get("peers") or []) or "—"], [_t(lang, "Peers", "Пиры"), ", ".join(bgp.get("peers") or []) or "—"],
[_t(lang, "Downstreams", "Клиенты"), ", ".join((bgp.get("downstreams") or [])[:10]) or "—"], [
_t(lang, "Downstreams", "Клиенты"),
", ".join((bgp.get("downstreams") or [])[:10]) or "—",
],
[ [
_t(lang, "Announced prefixes", "Анонсируемые префиксы"), _t(lang, "Announced prefixes", "Анонсируемые префиксы"),
f"{bgp.get('prefix_count_v4', 0)} IPv4 / {bgp.get('prefix_count_v6', 0)} IPv6", f"{bgp.get('prefix_count_v4', 0)} IPv4 / {bgp.get('prefix_count_v6', 0)} IPv6",
@ -539,9 +684,14 @@ def _bgp(report: dict, lang: str = "en") -> list[str]:
f"#{_num(bgp.get('asrank'))} · {_t(lang, 'cone', 'конус')} {_num(bgp.get('cone_asns'))} " f"#{_num(bgp.get('asrank'))} · {_t(lang, 'cone', 'конус')} {_num(bgp.get('cone_asns'))} "
f"{_t(lang, 'ASNs', 'ASN')} / {_num(bgp.get('cone_prefixes'))} {_t(lang, 'prefixes', 'префиксов')}", f"{_t(lang, 'ASNs', 'ASN')} / {_num(bgp.get('cone_prefixes'))} {_t(lang, 'prefixes', 'префиксов')}",
], ],
[_t(lang, "PeeringDB", "PeeringDB"), f"{bgp.get('pdb_info_type') or '—'} · {bgp.get('pdb_traffic') or '—'}"], [
_t(lang, "PeeringDB", "PeeringDB"),
f"{bgp.get('pdb_info_type') or '—'} · {bgp.get('pdb_traffic') or '—'}",
],
] ]
lines = [title, ""] + _table([_t(lang, "Field", "Поле"), _t(lang, "Value", "Значение")], rows, lang) lines = [title, ""] + _table(
[_t(lang, "Field", "Поле"), _t(lang, "Value", "Значение")], rows, lang
)
ixps = bgp.get("ixps") or [] ixps = bgp.get("ixps") or []
if ixps: if ixps:
lines += ["", _t(lang, "### IXP presence", "### Присутствие на IX"), ""] lines += ["", _t(lang, "### IXP presence", "### Присутствие на IX"), ""]
@ -552,13 +702,26 @@ def _bgp(report: dict, lang: str = "en") -> list[str]:
_t(lang, "Country", "Страна"), _t(lang, "Country", "Страна"),
_t(lang, "Speed (Mbps)", "Скорость (Мбит/с)"), _t(lang, "Speed (Mbps)", "Скорость (Мбит/с)"),
], ],
[[i.get("name", ""), i.get("city") or "—", i.get("country") or "—", _num(i.get("speed_mbps"))] for i in ixps], [
[
i.get("name", ""),
i.get("city") or "—",
i.get("country") or "—",
_num(i.get("speed_mbps")),
]
for i in ixps
],
lang, lang,
) )
return lines + [""] return lines + [""]
_CAPTCHA_RISK_RU = {"unknown": "неизвестен", "low": "низкий", "medium": "средний", "high": "высокий"} _CAPTCHA_RISK_RU = {
"unknown": "неизвестен",
"low": "низкий",
"medium": "средний",
"high": "высокий",
}
def _reputation(report: dict, lang: str = "en") -> list[str]: def _reputation(report: dict, lang: str = "en") -> list[str]:
@ -570,23 +733,40 @@ def _reputation(report: dict, lang: str = "en") -> list[str]:
idb = rep.get("internetdb") or {} idb = rep.get("internetdb") or {}
none_word = _t(lang, "none", "нет") none_word = _t(lang, "none", "нет")
rows = [ rows = [
[_t(lang, "FireHOL hits", "Совпадения FireHOL"), ", ".join(rep.get("firehol_hits") or []) or none_word], [
_t(lang, "FireHOL hits", "Совпадения FireHOL"),
", ".join(rep.get("firehol_hits") or []) or none_word,
],
[ [
_t(lang, "Open ports (InternetDB)", "Открытые порты (InternetDB)"), _t(lang, "Open ports (InternetDB)", "Открытые порты (InternetDB)"),
", ".join(str(p) for p in idb.get("ports") or []) or none_word, ", ".join(str(p) for p in idb.get("ports") or []) or none_word,
], ],
[_t(lang, "Tags", "Теги"), ", ".join(idb.get("tags") or []) or none_word], [_t(lang, "Tags", "Теги"), ", ".join(idb.get("tags") or []) or none_word],
[_t(lang, "Known CVEs", "Известные CVE"), ", ".join(idb.get("vulns") or []) or none_word], [
_t(lang, "Known CVEs", "Известные CVE"),
", ".join(idb.get("vulns") or []) or none_word,
],
[ [
_t(lang, "AbuseIPDB", "AbuseIPDB"), _t(lang, "AbuseIPDB", "AbuseIPDB"),
_num(rep.get("abuseipdb_score")) if rep.get("abuseipdb_score") is not None else _t(lang, "not queried", "не запрошено"), _num(rep.get("abuseipdb_score"))
if rep.get("abuseipdb_score") is not None
else _t(lang, "not queried", "не запрошено"),
], ],
] ]
hits = rep.get("dnsbl_hits") hits = rep.get("dnsbl_hits")
if hits is None: if hits is None:
rows.append([_t(lang, "DNSBL", "DNSBL"), _t(lang, "not run (pass --dnsbl)", "не выполнено (используйте --dnsbl)")]) rows.append(
[
_t(lang, "DNSBL", "DNSBL"),
_t(
lang, "not run (pass --dnsbl)", "не выполнено (используйте --dnsbl)"
),
]
)
else: else:
listed = ", ".join(h.get("zone", "") for h in hits) or _t(lang, "no listing", "нет в списках") listed = ", ".join(h.get("zone", "") for h in hits) or _t(
lang, "no listing", "нет в списках"
)
if rep.get("dnsbl_query_blocked"): if rep.get("dnsbl_query_blocked"):
listed += _t( listed += _t(
lang, lang,
@ -596,9 +776,18 @@ def _reputation(report: dict, lang: str = "en") -> list[str]:
rows.append([_t(lang, "DNSBL", "DNSBL"), listed]) rows.append([_t(lang, "DNSBL", "DNSBL"), listed])
risk = rep.get("captcha_risk", "unknown") risk = rep.get("captcha_risk", "unknown")
risk_word = risk if lang == "en" else _CAPTCHA_RISK_RU.get(risk, risk) risk_word = risk if lang == "en" else _CAPTCHA_RISK_RU.get(risk, risk)
rationale = (rep.get("rationale_ru") or rep.get("rationale") or "") if lang == "ru" else (rep.get("rationale") or "") rationale = (
(rep.get("rationale_ru") or rep.get("rationale") or "")
if lang == "ru"
else (rep.get("rationale") or "")
)
return ( return (
[title, "", f"{_t(lang, 'Captcha/blocklist risk', 'Риск капчи/блок-листов')}: **{risk_word}** — {rationale}", ""] [
title,
"",
f"{_t(lang, 'Captcha/blocklist risk', 'Риск капчи/блок-листов')}: **{risk_word}** — {rationale}",
"",
]
+ _table([_t(lang, "Field", "Поле"), _t(lang, "Value", "Значение")], rows, lang) + _table([_t(lang, "Field", "Поле"), _t(lang, "Value", "Значение")], rows, lang)
+ [""] + [""]
) )
@ -638,7 +827,10 @@ def _latency(report: dict, lang: str = "en") -> list[str]:
) )
if pings: if pings:
lines += ["", "```"] lines += ["", "```"]
lines += [f"{p.get('label', ''):<18}{sparkline(p.get('samples') or [])}" for p in pings] lines += [
f"{p.get('label', ''):<18}{sparkline(p.get('samples') or [])}"
for p in pings
]
lines += ["```"] lines += ["```"]
if any(p.get("method") == "tcp" for p in pings): if any(p.get("method") == "tcp" for p in pings):
lines += [ lines += [
@ -663,7 +855,9 @@ def _path(report: dict, lang: str = "en") -> list[str]:
hops = trace.get("hops") or [] hops = trace.get("hops") or []
jump = first_loss_jump(hops) jump = first_loss_jump(hops)
completed_word = ( completed_word = (
_t(lang, "complete", "завершено") if trace.get("completed") else _t(lang, "incomplete", "не завершено") _t(lang, "complete", "завершено")
if trace.get("completed")
else _t(lang, "incomplete", "не завершено")
) )
lines += [ lines += [
f"### {trace.get('target') or '?'} — {_t(lang, 'backend', 'бэкенд')} `{trace.get('backend', 'none')}`, " f"### {trace.get('target') or '?'} — {_t(lang, 'backend', 'бэкенд')} `{trace.get('backend', 'none')}`, "
@ -694,14 +888,28 @@ def _ecmp(report: dict, lang: str = "en") -> list[str]:
for r in reports: for r in reports:
target = r.get("target") or "?" target = r.get("target") or "?"
if not r.get("divergent_ttls"): if not r.get("divergent_ttls"):
lines += [f"- {target}: {_t(lang, 'single path across', 'единый маршрут во всех')} {r.get('runs', 0)} {_t(lang, 'runs', 'прогонах')}"] lines += [
f"- {target}: {_t(lang, 'single path across', 'единый маршрут во всех')} {r.get('runs', 0)} {_t(lang, 'runs', 'прогонах')}"
]
continue continue
note_text = (r.get("note_ru") or r.get("note") or "") if lang == "ru" else (r.get("note") or "") note_text = (
(r.get("note_ru") or r.get("note") or "")
if lang == "ru"
else (r.get("note") or "")
)
lines += [f"### {target}", "", note_text, ""] lines += [f"### {target}", "", note_text, ""]
lines += _table( lines += _table(
[_t(lang, "TTL", "TTL"), _t(lang, "Next hops", "Следующие хопы"), _t(lang, "RTT spread (ms)", "Разброс RTT (мс)")],
[ [
[str(h.get("ttl", "")), ", ".join(h.get("ips") or []), _num(h.get("rtt_spread_ms"))] _t(lang, "TTL", "TTL"),
_t(lang, "Next hops", "Следующие хопы"),
_t(lang, "RTT spread (ms)", "Разброс RTT (мс)"),
],
[
[
str(h.get("ttl", "")),
", ".join(h.get("ips") or []),
_num(h.get("rtt_spread_ms")),
]
for h in r.get("hops") or [] for h in r.get("hops") or []
if len(h.get("ips") or []) > 1 if len(h.get("ips") or []) > 1
], ],
@ -711,7 +919,12 @@ def _ecmp(report: dict, lang: str = "en") -> list[str]:
return lines + [""] return lines + [""]
_PMTU_VERDICT_RU = {"ok": "ок", "reduced": "снижен", "blackhole": "чёрная дыра", "unknown": "неизвестно"} _PMTU_VERDICT_RU = {
"ok": "ок",
"reduced": "снижен",
"blackhole": "чёрная дыра",
"unknown": "неизвестно",
}
def _pmtud(report: dict, lang: str = "en") -> list[str]: def _pmtud(report: dict, lang: str = "en") -> list[str]:
@ -731,10 +944,14 @@ def _pmtud(report: dict, lang: str = "en") -> list[str]:
[ [
[ [
r.get("host", ""), r.get("host", ""),
r.get("verdict", "unknown") if lang == "en" else _PMTU_VERDICT_RU.get(r.get("verdict"), r.get("verdict")), r.get("verdict", "unknown")
if lang == "en"
else _PMTU_VERDICT_RU.get(r.get("verdict"), r.get("verdict")),
_num(r.get("discovered_mtu")), _num(r.get("discovered_mtu")),
_num(r.get("iface_mtu")), _num(r.get("iface_mtu")),
(r.get("note_ru") or r.get("note") or "") if lang == "ru" else (r.get("note") or ""), (r.get("note_ru") or r.get("note") or "")
if lang == "ru"
else (r.get("note") or ""),
] ]
for r in results for r in results
], ],
@ -760,10 +977,22 @@ def _speed(report: dict, lang: str = "en") -> list[str]:
rows = [ rows = [
[_t(lang, "Method", "Метод"), method_word], [_t(lang, "Method", "Метод"), method_word],
[_t(lang, "Server", "Сервер"), speed.get("server") or "—"], [_t(lang, "Server", "Сервер"), speed.get("server") or "—"],
[_t(lang, "Server country", "Страна сервера"), speed.get("server_country") or "—"], [
[_t(lang, "Download", "Скачивание"), f"{_num(speed.get('download_mbps'))} {_t(lang, 'Mbps', 'Мбит/с')}"], _t(lang, "Server country", "Страна сервера"),
[_t(lang, "Upload", "Отдача"), f"{_num(speed.get('upload_mbps'))} {_t(lang, 'Mbps', 'Мбит/с')}"], speed.get("server_country") or "—",
[_t(lang, "Idle RTT", "RTT в простое"), f"{_num(speed.get('idle_rtt_ms'))} {_t(lang, 'ms', 'мс')}"], ],
[
_t(lang, "Download", "Скачивание"),
f"{_num(speed.get('download_mbps'))} {_t(lang, 'Mbps', 'Мбит/с')}",
],
[
_t(lang, "Upload", "Отдача"),
f"{_num(speed.get('upload_mbps'))} {_t(lang, 'Mbps', 'Мбит/с')}",
],
[
_t(lang, "Idle RTT", "RTT в простое"),
f"{_num(speed.get('idle_rtt_ms'))} {_t(lang, 'ms', 'мс')}",
],
[ [
_t(lang, "Loaded RTT (down / up)", "RTT под нагрузкой (приём / передача)"), _t(lang, "Loaded RTT (down / up)", "RTT под нагрузкой (приём / передача)"),
f"{_num(speed.get('loaded_rtt_down_ms'))} / {_num(speed.get('loaded_rtt_up_ms'))} {_t(lang, 'ms', 'мс')}", f"{_num(speed.get('loaded_rtt_down_ms'))} / {_num(speed.get('loaded_rtt_up_ms'))} {_t(lang, 'ms', 'мс')}",
@ -774,7 +1003,10 @@ def _speed(report: dict, lang: str = "en") -> list[str]:
f"+{_num(speed.get('bufferbloat_down_ms'))} {_t(lang, 'ms', 'мс')}, " f"+{_num(speed.get('bufferbloat_down_ms'))} {_t(lang, 'ms', 'мс')}, "
f"{_t(lang, 'up', 'передача')} +{_num(speed.get('bufferbloat_up_ms'))} {_t(lang, 'ms', 'мс')}", f"{_t(lang, 'up', 'передача')} +{_num(speed.get('bufferbloat_up_ms'))} {_t(lang, 'ms', 'мс')}",
], ],
[_t(lang, "Netflix OCA on-net", "Netflix OCA в сети"), yn(speed.get("netflix_oca_onnet"))], [
_t(lang, "Netflix OCA on-net", "Netflix OCA в сети"),
yn(speed.get("netflix_oca_onnet")),
],
] ]
lines = ( lines = (
[title, ""] [title, ""]
@ -792,7 +1024,12 @@ def _speed(report: dict, lang: str = "en") -> list[str]:
_t(lang, "ms", "мс"), _t(lang, "ms", "мс"),
], ],
[ [
[a.get("tier", ""), yn(a.get("ok")), a.get("reason") or "—", _num(a.get("duration_ms"))] [
a.get("tier", ""),
yn(a.get("ok")),
a.get("reason") or "—",
_num(a.get("duration_ms")),
]
for a in attempts for a in attempts
], ],
lang, lang,
@ -828,7 +1065,8 @@ def _tls(report: dict, lang: str = "en") -> list[str]:
_num(r.get("ttfb_ms")), _num(r.get("ttfb_ms")),
r.get("tls_version") or "—", r.get("tls_version") or "—",
r.get("cipher") or "—", r.get("cipher") or "—",
(r.get("cert_sha256") or "—")[:16] + ("…" if r.get("cert_sha256") else ""), (r.get("cert_sha256") or "—")[:16]
+ ("…" if r.get("cert_sha256") else ""),
r.get("cert_issuer") or "—", r.get("cert_issuer") or "—",
_num(r.get("cert_days_remaining")), _num(r.get("cert_days_remaining")),
] ]
@ -872,7 +1110,9 @@ def _quic(report: dict, lang: str = "en") -> list[str]:
r.get("label", ""), r.get("label", ""),
_num(r.get("handshake_ms")), _num(r.get("handshake_ms")),
r.get("alpn") or "—", r.get("alpn") or "—",
_t(lang, "yes", "да") if r.get("session_resumed") else _t(lang, "no", "нет"), _t(lang, "yes", "да")
if r.get("session_resumed")
else _t(lang, "no", "нет"),
_num(r.get("tcp_rtt_ms")), _num(r.get("tcp_rtt_ms")),
] ]
for r in results for r in results
@ -923,12 +1163,18 @@ def _dns_advanced(report: dict, lang: str = "en") -> list[str]:
lang, lang,
) )
proxy = adv.get("transparent_proxy") proxy = adv.get("transparent_proxy")
proxy_word = {True: _t(lang, "yes", "да"), False: _t(lang, "no", "нет"), None: "—"}[proxy] proxy_word = {True: _t(lang, "yes", "да"), False: _t(lang, "no", "нет"), None: "—"}[
proxy
]
lines += [ lines += [
"", "",
f"{_t(lang, 'Transparent DNS proxy', 'Прозрачный DNS-прокси')}: **{proxy_word}** — {adv.get('transparent_proxy_detail') or ''}", f"{_t(lang, 'Transparent DNS proxy', 'Прозрачный DNS-прокси')}: **{proxy_word}** — {adv.get('transparent_proxy_detail') or ''}",
] ]
note_text = (adv.get("note_ru") or adv.get("note") or "") if lang == "ru" else (adv.get("note") or "") note_text = (
(adv.get("note_ru") or adv.get("note") or "")
if lang == "ru"
else (adv.get("note") or "")
)
if note_text: if note_text:
lines += ["", note_text] lines += ["", note_text]
return lines + [""] return lines + [""]
@ -955,9 +1201,13 @@ def _path_diversity(report: dict, lang: str = "en") -> list[str]:
[ [
h.get("target", ""), h.get("target", ""),
h.get("resolved_ip") or "—", h.get("resolved_ip") or "—",
f"{h.get('ip_city') or '?'}, {h.get('ip_country')}" if h.get("ip_country") else "—", f"{h.get('ip_city') or '?'}, {h.get('ip_country')}"
if h.get("ip_country")
else "—",
h.get("edge_colo") or "—", h.get("edge_colo") or "—",
f"{h.get('edge_city') or '?'}, {h.get('edge_country')}" if h.get("edge_country") else "—", f"{h.get('edge_city') or '?'}, {h.get('edge_country')}"
if h.get("edge_country")
else "—",
_num(h.get("edge_rtt_ms")), _num(h.get("edge_rtt_ms")),
_num(h.get("client_rtt_ms")), _num(h.get("client_rtt_ms")),
] ]
@ -965,9 +1215,18 @@ def _path_diversity(report: dict, lang: str = "en") -> list[str]:
], ],
lang, lang,
) )
loop_word = _t(lang, "yes", "да") if pd.get("international_loop") else _t(lang, "no", "нет") loop_word = (
lines += ["", f"{_t(lang, 'International routing loop', 'Международная петля маршрута')}: **{loop_word}**"] _t(lang, "yes", "да") if pd.get("international_loop") else _t(lang, "no", "нет")
note_text = (pd.get("note_ru") or pd.get("note") or "") if lang == "ru" else (pd.get("note") or "") )
lines += [
"",
f"{_t(lang, 'International routing loop', 'Международная петля маршрута')}: **{loop_word}**",
]
note_text = (
(pd.get("note_ru") or pd.get("note") or "")
if lang == "ru"
else (pd.get("note") or "")
)
if note_text: if note_text:
lines += ["", note_text] lines += ["", note_text]
return lines + [""] return lines + [""]
@ -988,7 +1247,12 @@ def _prefix_benchmark(report: dict, lang: str = "en") -> list[str]:
_t(lang, "Loss", "Потери"), _t(lang, "Loss", "Потери"),
], ],
[ [
[r.get("prefix", ""), r.get("probe_ip") or "—", _num(r.get("avg_ms")), f"{r.get('loss_pct', 0)}%"] [
r.get("prefix", ""),
r.get("probe_ip") or "—",
_num(r.get("avg_ms")),
f"{r.get('loss_pct', 0)}%",
]
for r in results for r in results
], ],
lang, lang,
@ -1027,12 +1291,24 @@ def _dpi_check(report: dict, lang: str = "en") -> list[str]:
_t(lang, "RTT (ms)", "RTT (мс)"), _t(lang, "RTT (ms)", "RTT (мс)"),
_t(lang, "Detail", "Детали"), _t(lang, "Detail", "Детали"),
], ],
[[str(p.get("port", "")), p.get("state", ""), _num(p.get("rtt_ms")), p.get("detail") or "—"] for p in ports], [
[
str(p.get("port", "")),
p.get("state", ""),
_num(p.get("rtt_ms")),
p.get("detail") or "—",
]
for p in ports
],
lang, lang,
) )
verdict = dpi.get("verdict", "unknown") verdict = dpi.get("verdict", "unknown")
verdict_word = verdict if lang == "en" else _DPI_VERDICT_RU.get(verdict, verdict) verdict_word = verdict if lang == "en" else _DPI_VERDICT_RU.get(verdict, verdict)
rationale = (dpi.get("rationale_ru") or dpi.get("rationale") or "") if lang == "ru" else (dpi.get("rationale") or "") rationale = (
(dpi.get("rationale_ru") or dpi.get("rationale") or "")
if lang == "ru"
else (dpi.get("rationale") or "")
)
lines += ["", f"{_t(lang, 'Verdict', 'Вердикт')}: **{verdict_word}** — {rationale}"] lines += ["", f"{_t(lang, 'Verdict', 'Вердикт')}: **{verdict_word}** — {rationale}"]
return lines + [""] return lines + [""]
@ -1041,10 +1317,17 @@ def _problems(report: dict, emoji: bool, lang: str = "en") -> list[str]:
found = _findings(report) found = _findings(report)
heading = _t(lang, "## Problems & recommendations", "## Проблемы и рекомендации") heading = _t(lang, "## Problems & recommendations", "## Проблемы и рекомендации")
if not found: if not found:
return [heading, "", _t(lang, "Nothing to act on.", "Не на что реагировать."), ""] return [
heading,
"",
_t(lang, "Nothing to act on.", "Не на что реагировать."),
"",
]
lines = [heading, ""] lines = [heading, ""]
for f in found: for f in found:
lines.append(f"- {badge(f.get('severity', 'info'), emoji, lang)} **{_f(f, 'title', lang)}** — {_f(f, 'detail', lang)}") lines.append(
f"- {badge(f.get('severity', 'info'), emoji, lang)} **{_f(f, 'title', lang)}** — {_f(f, 'detail', lang)}"
)
advice = _f(f, "advice", lang) advice = _f(f, "advice", lang)
if advice: if advice:
lines.append(f" - {advice}") lines.append(f" - {advice}")
@ -1056,13 +1339,24 @@ def _diagnostics(report: dict, lang: str = "en") -> list[str]:
warnings: list[str] = [] warnings: list[str] = []
for section in SECTION_ORDER: for section in SECTION_ORDER:
module = _module(report, section) module = _module(report, section)
errors = "; ".join(f"{e.get('source')}: {e.get('kind')}" for e in module.get("errors") or []) or "—" errors = (
"; ".join(
f"{e.get('source')}: {e.get('kind')}"
for e in module.get("errors") or []
)
or "—"
)
status_word = _module_status_word(module.get("status", "skipped"), lang) status_word = _module_status_word(module.get("status", "skipped"), lang)
rows.append([section, status_word, _num(module.get("duration_ms")), errors]) rows.append([section, status_word, _num(module.get("duration_ms")), errors])
warnings += list(module.get("warnings") or []) warnings += list(module.get("warnings") or [])
heading = _t(lang, "## Run diagnostics", "## Диагностика запуска") heading = _t(lang, "## Run diagnostics", "## Диагностика запуска")
lines = [heading, ""] + _table( lines = [heading, ""] + _table(
[_t(lang, "Module", "Модуль"), _t(lang, "Status", "Статус"), _t(lang, "ms", "мс"), _t(lang, "Errors", "Ошибки")], [
_t(lang, "Module", "Модуль"),
_t(lang, "Status", "Статус"),
_t(lang, "ms", "мс"),
_t(lang, "Errors", "Ошибки"),
],
rows, rows,
lang, lang,
) )
@ -1072,7 +1366,10 @@ def _diagnostics(report: dict, lang: str = "en") -> list[str]:
def render_markdown( def render_markdown(
report: dict[str, Any], emoji: bool = True, lang: str = "en", sibling: str | None = None report: dict[str, Any],
emoji: bool = True,
lang: str = "en",
sibling: str | None = None,
) -> str: ) -> str:
lines = _header(report, emoji, lang) lines = _header(report, emoji, lang)
if sibling: if sibling: