Skip to content
Closed
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
10 changes: 7 additions & 3 deletions docs/SECURITY_TOKENS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>` → `Bearer ***REDACTED***`
- טוקני בוט טלגרם (בפורמט `\d{5,16}:[A-Za-z0-9_-]{30,}`) → `<REDACTED>`
- המסנן משתמש ברשימת דפוסים מרכזית (`_PATTERNS`) שמחילה את אותם כללי ניקוי על הודעות הלוג ועל ה-traceback, כך שטוקנים לא דולפים דרך הודעות שגיאה.
- המסנן מנקה גם את ה-traceback של חריגות (דרך `_redact_exception`) כדי למנוע דליפת טוקנים שמופיעים בכתובות API (כמו `https://api.telegram.org/bot<TOKEN>/method`).
- דפוס הטוקן של טלגרם מיובא מ-`telegram_api.py` עם fallback מקומי, כדי להבטיח שהניקוי לא ייכשל בגלל שגיאת import (fail-closed).
- המסנן מותקן בתחילת הריצה ב-`main.py`.

## איך מפעילים הצפנה
Expand All @@ -27,4 +31,4 @@

## הערות
- ההצפנה נעשית מקומית בלבד; הטוקן נשלח רק ל-GitHub API לפי פעולה שבחרת.
- ניתן למחוק את הטוקן בכל רגע מתפריט GitHub.
- ניתן למחוק את הטוקן בכל רגע מתפריט GitHub.
9 changes: 9 additions & 0 deletions observability.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 49 additions & 5 deletions telegram_api.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,49 @@
from __future__ import annotations

import re
from typing import Any, Dict, Optional

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

TOKEN_PLACEHOLDER = "<REDACTED>"


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

מחזיר ``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:
Expand All @@ -27,19 +69,21 @@ 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}"
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
142 changes: 142 additions & 0 deletions tests/test_telegram_token_redaction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"""בדיקות שהטוקן של הבוט לא דולף לטקסטים שנשמרים או נשלחים החוצה.

כתובות ה-API של טלגרם נבנות כ-``https://api.telegram.org/bot<TOKEN>/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 "<REDACTED>" 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="<html>502</html>", 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()
7 changes: 7 additions & 0 deletions utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<TOKEN>/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
Expand Down
14 changes: 14 additions & 0 deletions webapp/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading