diff --git a/docs/SECURITY_TOKENS.md b/docs/SECURITY_TOKENS.md index f2fbeb24c..b8931d481 100644 --- a/docs/SECURITY_TOKENS.md +++ b/docs/SECURITY_TOKENS.md @@ -14,11 +14,15 @@ - `database.py`: - `save_github_token(user_id, token)` – קורא ל-`encrypt_secret(...)` לפני השמירה. - `get_github_token(user_id)` – קורא ל-`decrypt_secret(...)` בעת שליפה. -- טשטוש בלוגים (Redaction): `utils.py` - - `SensitiveDataFilter` – מסנן שמחליף בלוגים: +- טשטוש בלוגים ו-tracebacks (Redaction): `utils.py` + - `SensitiveDataFilter` – מסנן שמחליף בלוגים והודעות חריגה: - `ghp_********` → `ghp_***REDACTED***` - `github_pat_********` → `github_pat_***REDACTED***` - `Bearer ` → `Bearer ***REDACTED***` + - טוקני בוט טלגרם (בפורמט `\d{5,16}:[A-Za-z0-9_-]{30,}`) → `` + - המסנן משתמש ברשימת דפוסים מרכזית (`_PATTERNS`) שמחילה את אותם כללי ניקוי על הודעות הלוג ועל ה-traceback, כך שטוקנים לא דולפים דרך הודעות שגיאה. + - המסנן מנקה גם את ה-traceback של חריגות (דרך `_redact_exception`) כדי למנוע דליפת טוקנים שמופיעים בכתובות API (כמו `https://api.telegram.org/bot/method`). + - דפוס הטוקן של טלגרם מיובא מ-`telegram_api.py` עם fallback מקומי, כדי להבטיח שהניקוי לא ייכשל בגלל שגיאת import (fail-closed). - המסנן מותקן בתחילת הריצה ב-`main.py`. ## איך מפעילים הצפנה @@ -27,4 +31,4 @@ ## הערות - ההצפנה נעשית מקומית בלבד; הטוקן נשלח רק ל-GitHub API לפי פעולה שבחרת. -- ניתן למחוק את הטוקן בכל רגע מתפריט GitHub. \ No newline at end of file +- ניתן למחוק את הטוקן בכל רגע מתפריט GitHub. diff --git a/observability.py b/observability.py index fa23d66ea..b26f5ebf2 100644 --- a/observability.py +++ b/observability.py @@ -855,6 +855,15 @@ def _before_send(event, hint): # type: ignore[no-redef] event["tags"] = tags except Exception: pass + # ניקוי טוקנים מכל האירוע — לא רק מ-extra לפי שם שדה. כתובות ה-API של + # טלגרם מכילות את הטוקן, והן מגיעות לתוך גוף החריגה ולתוך breadcrumbs, + # מקומות שסינון לפי שם מפתח לא מגיע אליהם. + try: + from telegram_api import redact_bot_token_deep # type: ignore + + event = redact_bot_token_deep(event) + except Exception: + pass return event # Resolve environment consistently with fallback to config if ENV/ENVIRONMENT not set diff --git a/telegram_api.py b/telegram_api.py index a66490349..b3bda1c17 100644 --- a/telegram_api.py +++ b/telegram_api.py @@ -1,7 +1,49 @@ from __future__ import annotations +import re from typing import Any, Dict, Optional +# מבנה טוקן של בוט טלגרם: מזהה מספרי, נקודתיים, ואז מחרוזת ארוכה. +# כתובות ה-API נבנות כ-https://api.telegram.org/bot/method — ולכן כל טקסט +# שנגזר מכתובת כזו (הודעת שגיאה, לוג, אירוע Sentry) עלול לשאת את הטוקן במלואו. +_BOT_TOKEN_RE = re.compile(r"\d{5,}:[A-Za-z0-9_-]{20,}") + +TOKEN_PLACEHOLDER = "" + + +def redact_bot_token(value: Any) -> Any: + """מחליף כל טוקן בוט שמופיע בטקסט בסימון ````. + + מחזיר ``None`` כפי שהוא, וכל ערך אחר מומר למחרוזת מנוקה. זו נקודת הניקוי + היחידה בקוד — גם ``TelegramAPIError`` וגם מסנני ה-Sentry נשענים עליה. + """ + if value is None: + return None + try: + text = value if isinstance(value, str) else str(value) + except Exception: + return value + return _BOT_TOKEN_RE.sub(TOKEN_PLACEHOLDER, text) + + +def redact_bot_token_deep(obj: Any, _depth: int = 0) -> Any: + """מנקה טוקנים מכל המחרוזות בתוך מבנה נתונים מקונן (dict/list/tuple). + + נועד למסנני Sentry: אירוע שגיאה פורש את הטוקן על פני כמה שדות (גוף החריגה, + הודעת הלוג, breadcrumbs), ורשימת שדות קבועה תמיד תפספס אחד. במקום זה עוברים + על כל המבנה. העומק מוגבל כדי לא להיתקע על מבנים מעגליים. + """ + if _depth > 12: + return obj + if isinstance(obj, str): + return redact_bot_token(obj) + if isinstance(obj, dict): + return {k: redact_bot_token_deep(v, _depth + 1) for k, v in obj.items()} + if isinstance(obj, (list, tuple)): + cleaned = [redact_bot_token_deep(v, _depth + 1) for v in obj] + return type(obj)(cleaned) if isinstance(obj, tuple) else cleaned + return obj + def _truncate(text: Any, limit: int = 800) -> str: try: @@ -27,10 +69,12 @@ def __init__( payload: Any = None, ) -> None: self.error_code = error_code - self.description = str(description or "").strip() - self.url = url + # ניקוי הטוקן כבר כאן, לפני ההשמה: כך גם ``self.url``/``self.description`` + # וגם טקסט החריגה נקיים, ולא משנה מי יקרא אותם או ירשום אותם ללוג. + self.description = redact_bot_token(str(description or "").strip()) + self.url = redact_bot_token(url) self.http_status = http_status - self.payload = payload + self.payload = redact_bot_token(payload) if isinstance(payload, str) else payload msg = f"Telegram API error" if error_code is not None: msg += f" error_code={error_code}" @@ -38,8 +82,8 @@ def __init__( msg += f" description={self.description}" if http_status is not None: msg += f" http_status={http_status}" - if url: - msg += f" url={url}" + if self.url: + msg += f" url={self.url}" super().__init__(msg) diff --git a/tests/test_telegram_token_redaction.py b/tests/test_telegram_token_redaction.py new file mode 100644 index 000000000..5ffe05e31 --- /dev/null +++ b/tests/test_telegram_token_redaction.py @@ -0,0 +1,142 @@ +"""בדיקות שהטוקן של הבוט לא דולף לטקסטים שנשמרים או נשלחים החוצה. + +כתובות ה-API של טלגרם נבנות כ-``https://api.telegram.org/bot/method``, +ולכן כל טקסט שנגזר מהן — הודעת חריגה, שורת לוג או אירוע Sentry — עלול לשאת +את הטוקן במלואו. הבדיקות כאן נועלות את נקודות הניקוי. +""" + +import logging + +import pytest + +from telegram_api import ( + TelegramAPIError, + parse_telegram_json_from_response, + redact_bot_token, + redact_bot_token_deep, + require_telegram_ok, +) + +# טוקן בדוי במבנה אמיתי — משמש רק לבדיקה שהוא לא שורד בפלט +FAKE_TOKEN = "7628556044:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw" +API_URL = f"https://api.telegram.org/bot{FAKE_TOKEN}/sendMessage" + + +class _FakeResponse: + """תגובת HTTP מינימלית, כמו זו ש-requests/http_sync מחזירים.""" + + def __init__(self, *, payload=None, text="", status_code=200, url=API_URL): + self._payload = payload + self.text = text + self.status_code = status_code + self.url = url + + def json(self): + if self._payload is None: + raise ValueError("not json") + return self._payload + + +def test_redact_bot_token_replaces_token_in_url(): + assert FAKE_TOKEN not in redact_bot_token(API_URL) + assert "" in redact_bot_token(API_URL) + + +def test_redact_bot_token_keeps_surrounding_text(): + cleaned = redact_bot_token(f"POST {API_URL} failed") + assert cleaned.startswith("POST https://api.telegram.org/bot") + assert cleaned.endswith("/sendMessage failed") + + +def test_redact_bot_token_passes_through_none_and_clean_text(): + assert redact_bot_token(None) is None + assert redact_bot_token("nothing secret here") == "nothing secret here" + + +def test_error_message_has_no_token(): + err = TelegramAPIError( + error_code=403, + description="Forbidden: bot was blocked by the user", + url=API_URL, + http_status=403, + ) + assert FAKE_TOKEN not in str(err) + + +def test_error_attributes_have_no_token(): + """גם מי שקורא ``e.url`` ישירות ורושם אותו ללוג לא אמור לקבל את הטוקן.""" + err = TelegramAPIError(error_code=None, description="boom", url=API_URL) + assert FAKE_TOKEN not in str(err.url) + + +def test_error_description_carrying_token_is_cleaned(): + err = TelegramAPIError(error_code=None, description=f"failed calling {API_URL}", url=None) + assert FAKE_TOKEN not in str(err) + + +def test_require_telegram_ok_raises_without_token(): + payload = {"ok": False, "error_code": 400, "description": "Bad Request: chat not found"} + with pytest.raises(TelegramAPIError) as excinfo: + require_telegram_ok(payload, url=API_URL) + assert FAKE_TOKEN not in str(excinfo.value) + + +def test_parse_invalid_json_raises_without_token(): + resp = _FakeResponse(payload=None, text="502", status_code=502) + with pytest.raises(TelegramAPIError) as excinfo: + parse_telegram_json_from_response(resp, url=API_URL) + assert FAKE_TOKEN not in str(excinfo.value) + + +def test_parse_falls_back_to_response_url_without_token(): + """כש-url לא מועבר במפורש הוא נשלף מהתגובה — וגם אז חייב להיות נקי.""" + resp = _FakeResponse(payload=None, text="oops", status_code=500) + with pytest.raises(TelegramAPIError) as excinfo: + parse_telegram_json_from_response(resp) + assert FAKE_TOKEN not in str(excinfo.value) + + +def test_parse_non_dict_json_raises_without_token(): + resp = _FakeResponse(payload=["not", "a", "dict"], status_code=200) + with pytest.raises(TelegramAPIError) as excinfo: + parse_telegram_json_from_response(resp, url=API_URL) + assert FAKE_TOKEN not in str(excinfo.value) + + +def test_redact_deep_cleans_nested_sentry_shaped_event(): + event = { + "exception": {"values": [{"type": "TelegramAPIError", "value": f"error url={API_URL}"}]}, + "logentry": {"message": f"calling {API_URL}"}, + "breadcrumbs": [{"data": {"url": API_URL}}], + "extra": {"safe": 1, "nested": ("tuple", API_URL)}, + } + cleaned = redact_bot_token_deep(event) + assert FAKE_TOKEN not in repr(cleaned) + # מבנה הנתונים נשמר — רק המחרוזות נוקו + assert cleaned["extra"]["safe"] == 1 + assert isinstance(cleaned["extra"]["nested"], tuple) + assert cleaned["exception"]["values"][0]["type"] == "TelegramAPIError" + + +def test_redact_deep_survives_self_referencing_structure(): + """מבנה מעגלי לא אמור להפיל את המסנן — הוא רץ לפני שליחה לכל אירוע.""" + node: dict = {"url": API_URL} + node["self"] = node + cleaned = redact_bot_token_deep(node) + assert FAKE_TOKEN not in str(cleaned["url"]) + + +def test_logging_filter_redacts_bot_token(): + from utils import SensitiveDataFilter + + record = logging.LogRecord( + name="test", + level=logging.ERROR, + pathname=__file__, + lineno=1, + msg=f"request failed: {API_URL}", + args=(), + exc_info=None, + ) + SensitiveDataFilter().filter(record) + assert FAKE_TOKEN not in record.getMessage() diff --git a/utils.py b/utils.py index b570768ab..3d90057f2 100644 --- a/utils.py +++ b/utils.py @@ -1433,6 +1433,13 @@ def filter(self, record: logging.LogRecord) -> bool: import re as _re for pat, repl in patterns: redacted = _re.sub(pat, repl, redacted) + # טוקן של בוט טלגרם — מגיע ללוגים דרך כתובות ה-API (‎/bot/method) + try: + from telegram_api import redact_bot_token as _redact_bot_token + + redacted = _redact_bot_token(redacted) + except Exception: + pass # עדכן רק את message הפורמטי record.msg = redacted # חשוב: נקה ארגומנטים כדי למנוע ניסיון פורמט חוזר (%s) שיוביל ל-TypeError diff --git a/webapp/app.py b/webapp/app.py index e8a284bea..01001264e 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -1534,11 +1534,25 @@ def _ensure_metric(name: str, create_fn): install_sensitive_filter() except Exception: pass + def _sentry_before_send(event, hint): + """מנקה טוקני בוט מכל אירוע לפני שהוא נשלח ל-Sentry. + + ה-filter על ה-logging handlers לא מכסה חריגות שנתפסות ישירות + על ידי FlaskIntegration, ושם בדיוק יושבות כתובות ה-API של טלגרם. + """ + try: + from telegram_api import redact_bot_token_deep # type: ignore + + return redact_bot_token_deep(event) + except Exception: + return event + sentry_sdk.init( dsn=getattr(__import__('config'), 'config').SENTRY_DSN, integrations=[FlaskIntegration()], traces_sample_rate=0.05, environment=getattr(__import__('config'), 'config').ENVIRONMENT, + before_send=_sentry_before_send, ) except Exception: pass