Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,7 @@ clone) או לקרוא ממנו ישירות. אין צורך בהרשאות מ
| PyGithub / קריאות SDK חיצוני | `BY-STACK/external-sdk.md` |
| קבצי `docs/**/*.rst` | `bugbot-rules/line-number-coupling.md` |
| טסטים עם סטאבים ידניים | `TESTING-PATTERNS.md` + `bugbot-rules/widened-exception-scope.md` |
| הרכבת URL/מחרוזת שמכילה סוד, הודעות חריגה, ניקוי לוגים/Sentry | `CRITICAL-PATTERNS.md` K13 + `bugbot-rules/secret-in-derived-text.md` |

### תמיד, בלי קשר לטבלה

Expand Down
34 changes: 34 additions & 0 deletions docs/security.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,40 @@ Security Guide

אל תרשום סודות/PII בלוגים, השתמש ב‑ENV בלבד.

ניקוי טוקן הבוט מלוגים, חריגות ו‑Sentry
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

כתובות ה‑API של טלגרם נבנות כ‑``https://api.telegram.org/bot<TOKEN>/method`` —
כלומר **כל טקסט שנגזר מכתובת כזו נושא את הטוקן במלואו**: הודעת חריגה, שורת לוג,
traceback או אירוע Sentry. בעקבות אירוע אבטחה (אוגוסט 2026) קיימת נקודת ניקוי
מרכזית ב‑``telegram_api``:

- ``redact_bot_token(text)`` — מנקה טוקן ממחרוזת בודדת.
- ``redact_bot_token_deep(obj)`` — מנקה מבנה מקונן שלם (dict כולל מפתחות, list,
tuple/namedtuple, set, bytes, ואובייקטים זרים שהייצוג שלהם נושא טוקן). עמיד
למבנים מעגליים, וכשל ניקוי מחזיר placeholder — לעולם לא את הערך הגולמי.

השכבות שנשענות על הנקודה הזו:

- ``TelegramAPIError`` מנקה את ``url``/``description``/``payload`` **בהשמה**, כך
שכל מקומות הקריאה מכוסים בלי לגעת בהם — כולל כאלה שיתווספו בעתיד.
- ``SensitiveDataFilter`` (ב‑``utils``) מנקה את ההודעה **ואת ה‑traceback** דרך
רשימת דפוסים אחת (טלגרם, GitHub, Bearer). ``exc_info`` נשמר — ה‑Formatter
משתמש בקאש ``exc_text`` המנוקה, ו‑Sentry מנקה את החריגה המובנית בעצמו.
- ``before_send`` בבוט (``observability``) ובוובאפ הוא **fail-closed**: אם
הניקוי נכשל, האירוע נזרק ונרשמת אזהרת ``sentry_redaction_failed`` — עדיף
לאבד אירוע מאשר להדליף טוקן.

כללים לקוד חדש:

- אין לשרשר URL של Bot API לתוך הודעת שגיאה או לוג כמות שהוא — להעביר דרך
``redact_bot_token`` קודם.
- סוג סוד חדש דורש עדכון בשתי נקודות: ``SensitiveDataFilter._PATTERNS`` (מכסה
הודעות לוג ו‑traceback) **וגם** נקודת הניקוי ב‑``telegram_api`` (מכסה חריגות
ו‑before_send של Sentry). עדכון של אחת בלבד משאיר את המסלול השני חשוף.
- הטסטים ב‑``tests/test_telegram_token_redaction.py`` נועלים את ההתנהגות; שינוי
במנגנון חייב לעבור אותם.

הרצת קוד (Code Execution Playground)
------------------------------------
ה‑WebApp כולל Playground בכתובת ``/tools/code`` (פתוח לכל משתמש מחובר), כאשר **הרצת קוד בפועל** זמינה רק ל‑Premium/Admin.
Expand Down
14 changes: 14 additions & 0 deletions observability.py
Original file line number Diff line number Diff line change
Expand Up @@ -855,6 +855,20 @@
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:
Comment thread
amirbiron marked this conversation as resolved.
# fail-closed: עדיף לאבד אירוע אחד מאשר לשלוח אירוע שלא נוקה
try:
LOGGER.warning("sentry event dropped: token redaction failed", extra={"event": "sentry_redaction_failed"})
except Exception:
pass
return None
return event

# Resolve environment consistently with fallback to config if ENV/ENVIRONMENT not set
Expand Down Expand Up @@ -1037,8 +1051,8 @@
# Emit the internal alert as an error to reflect the source severity.
# Re-entry is prevented by the _IN_SINGLE_ERROR_ALERT guard above.
emit_internal_alert(name=name, severity="error", summary=summary)
except Exception:
# As a fallback, log directly without re-entering emit_event to avoid recursion

Check notice on line 1055 in observability.py

View check run for this annotation

codefactor.io / CodeFactor

observability.py#L1054-L1055

Try, Except, Pass detected. (B110)
try:
# Log fallback as anomaly to reflect auto-handled path
structlog.get_logger().warning(event="single_error_alert_fallback", level="ANOMALY", name=name, summary=summary)
Expand Down
150 changes: 145 additions & 5 deletions telegram_api.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,144 @@
from __future__ import annotations

import re
from typing import Any, Dict, Optional

# מבנה טוקן של בוט טלגרם: מזהה מספרי, נקודתיים, ואז סוד באורך ~35 תווים.
# כתובות ה-API נבנות כ-https://api.telegram.org/bot<TOKEN>/method — ולכן כל טקסט
# שנגזר מכתובת כזו (הודעת שגיאה, לוג, אירוע Sentry) עלול לשאת את הטוקן במלואו.
# דרישת 30+ תווים בסוד מצמצמת פגיעה בטקסטים לגיטימיים (hash/מזהה עם נקודתיים),
# ועדיין תופסת כל טוקן אמיתי.
_BOT_TOKEN_RE = re.compile(r"\d{5,16}:[A-Za-z0-9_-]{30,}")

TOKEN_PLACEHOLDER = "<REDACTED>"

# מוחזר כשאי אפשר לנקות ערך (str() נכשל) — עדיף לאבד את הערך מאשר להדליף טוקן
UNREDACTABLE_PLACEHOLDER = "<UNREDACTABLE>"


def redact_bot_token(value: Any) -> Any:
"""מחליף כל טוקן בוט שמופיע בטקסט בסימון ``<REDACTED>``.

מחזיר ``None`` כפי שהוא, וכל ערך אחר מומר למחרוזת מנוקה. אם ההמרה למחרוזת
נכשלת מוחזר ``<UNREDACTABLE>`` — כישלון ניקוי לעולם לא מחזיר את הערך הגולמי.
זו נקודת הניקוי היחידה בקוד — ``TelegramAPIError``, מסנני ה-Sentry ומסנן
הלוגים נשענים עליה.
"""
if value is None:
return None
try:
text = value if isinstance(value, str) else str(value)
return _BOT_TOKEN_RE.sub(TOKEN_PLACEHOLDER, text)
except Exception:
return UNREDACTABLE_PLACEHOLDER


def _redact_bytes(obj: Any) -> Any:
"""מנקה טוקן מ-bytes/bytearray תוך שמירה על הטיפוס המקורי."""
try:
cleaned_text = _BOT_TOKEN_RE.sub(TOKEN_PLACEHOLDER, obj.decode("utf-8", errors="replace"))
encoded = cleaned_text.encode("utf-8")
return bytearray(encoded) if isinstance(obj, bytearray) else encoded
except Exception:
return UNREDACTABLE_PLACEHOLDER


def _dedupe_key(ck: Any, cleaned_dict: Dict[Any, Any]) -> Any:
"""ממספר מפתח שמתנגש עם מפתח קיים אחרי ניקוי (‎#2 למחרוזת, ‎(key, 2)‎ לאחרים).

שני מפתחות שונים יכולים להתנקות לאותו ערך (שתי כתובות עם טוקנים שונים);
דריסה שקטה מאבדת שדה אבחוני — במקום זה המאוחר מקבל סיומת מספור.
"""
if ck not in cleaned_dict:
return ck
n = 2
candidate = f"{ck}#{n}" if isinstance(ck, str) else (ck, n)
while candidate in cleaned_dict:
n += 1
candidate = f"{ck}#{n}" if isinstance(ck, str) else (ck, n)
return candidate


def _redact_dict(obj: Dict[Any, Any], _memo: Dict[int, Any], _depth: int) -> Dict[Any, Any]:
cleaned_dict: Dict[Any, Any] = {}
# רישום לפני המילוי — כך הפניה מעגלית חוזרת לעותק המנוקה ולא למקור
_memo[id(obj)] = cleaned_dict
for k, v in obj.items():
# גם מפתח יכול לשאת טוקן — כמחרוזת או בתוך tuple/frozenset (שנשארים hashable)
ck = _dedupe_key(redact_bot_token_deep(k, _memo, _depth + 1), cleaned_dict)
cleaned_dict[ck] = redact_bot_token_deep(v, _memo, _depth + 1)
return cleaned_dict


def _redact_list(obj: list, _memo: Dict[int, Any], _depth: int) -> list:
cleaned_list: list = []
_memo[id(obj)] = cleaned_list
for v in obj:
cleaned_list.append(redact_bot_token_deep(v, _memo, _depth + 1))
return cleaned_list


def _redact_tuple(obj: tuple, _memo: Dict[int, Any], _depth: int) -> tuple:
cleaned_items = [redact_bot_token_deep(v, _memo, _depth + 1) for v in obj]
try:
if hasattr(obj, "_fields"): # namedtuple — בנייה מאיברים בודדים
return type(obj)(*cleaned_items)
return type(obj)(cleaned_items)
except Exception:
# תת-מחלקה עם בנאי לא סטנדרטי — tuple רגיל עדיף על אובייקט לא מנוקה
return tuple(cleaned_items)


def redact_bot_token_deep(obj: Any, _memo: Optional[Dict[int, Any]] = None, _depth: int = 0) -> Any:
"""מנקה טוקנים מכל המחרוזות בתוך מבנה נתונים מקונן.

נועד למסנני Sentry: אירוע שגיאה פורש את הטוקן על פני כמה שדות (גוף החריגה,
הודעת הלוג, breadcrumbs), ורשימת שדות קבועה תמיד תפספס אחד. במקום זה עוברים
על כל המבנה — dict (כולל מפתחות), list, tuple (כולל namedtuple), set ו-frozenset.

מבנים מעגליים מטופלים ב-memo לפי ‎id()‎ כך שכל צומת מנוקה בדיוק פעם אחת;
מגבלת העומק היא רשת ביטחון בלבד, וחצייה שלה מחזירה placeholder — לעולם לא
את הערך המקורי.
"""
if _memo is None:
_memo = {}
if _depth > 100:
# עומק כזה לא קיים באירועי Sentry אמיתיים; מחזירים placeholder ולא את המקור
return UNREDACTABLE_PLACEHOLDER
if isinstance(obj, str):
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
return redact_bot_token(obj)
if isinstance(obj, (bytes, bytearray)):
return _redact_bytes(obj)
if isinstance(obj, (dict, list)):
existing = _memo.get(id(obj))
if existing is not None:
return existing
if isinstance(obj, dict):
return _redact_dict(obj, _memo, _depth)
return _redact_list(obj, _memo, _depth)
if isinstance(obj, tuple):
return _redact_tuple(obj, _memo, _depth)
if isinstance(obj, (set, frozenset)):
cleaned_set = {redact_bot_token_deep(v, _memo, _depth + 1) for v in obj}
return frozenset(cleaned_set) if isinstance(obj, frozenset) else cleaned_set
# סקלרים חסרי טקסט — אין מה לנקות בהם
if obj is None or isinstance(obj, (int, float, bool)):
return obj
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
# אובייקט זר (למשל מופע חריגה בתוך hint/extra): Sentry ימיר אותו למחרוזת
# אחרי שהניקוי כבר עבר — ב-repr() עבור אובייקטים שאינם JSON — ולכן בודקים
# את שני הייצוגים. אם אחד מהם נושא טוקן, מחזירים את הייצוג המנוקה במקום
# האובייקט. אובייקט נקי בשניהם נשמר כמות שהוא.
try:
text = str(obj)
rep = repr(obj)
except Exception:
return UNREDACTABLE_PLACEHOLDER
if _BOT_TOKEN_RE.search(rep):
return _BOT_TOKEN_RE.sub(TOKEN_PLACEHOLDER, rep)
if _BOT_TOKEN_RE.search(text):
return _BOT_TOKEN_RE.sub(TOKEN_PLACEHOLDER, text)
return obj
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def _truncate(text: Any, limit: int = 800) -> str:
try:
Expand All @@ -27,19 +164,22 @@ 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
# ניקוי עמוק — גם dict/list (למשל payload מלא של תגובת טלגרם) חייבים לצאת נקיים
self.payload = redact_bot_token_deep(payload) if payload is not None else None
msg = f"Telegram API error"
if error_code is not None:
msg += f" error_code={error_code}"
if self.description:
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)


Expand Down
Loading
Loading