security: complete cross-platform communication security and technical audit
This commit is contained in:
+1
-1
@@ -104,7 +104,7 @@ class DashboardSettings(BaseSettings):
|
||||
secret_key: str = Field(default="change-me-in-production", alias="DASHBOARD_SECRET_KEY")
|
||||
username: str = Field(default="admin", alias="DASHBOARD_USERNAME")
|
||||
password: str = Field(default="", alias="DASHBOARD_PASSWORD")
|
||||
host: str = "0.0.0.0"
|
||||
host: str = Field(default="127.0.0.1", alias="DASHBOARD_HOST")
|
||||
port: int = 8080
|
||||
|
||||
model_config = _COMMON_CONFIG
|
||||
|
||||
+38
-6
@@ -10,6 +10,8 @@ Handles:
|
||||
"""
|
||||
|
||||
import time
|
||||
import functools
|
||||
import threading
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
@@ -40,6 +42,15 @@ class Timeframe(Enum):
|
||||
TIMEFRAME_MAP: dict[str, Timeframe] = {tf.name: tf for tf in Timeframe}
|
||||
|
||||
|
||||
|
||||
def synchronized(method):
|
||||
"""Decorator to ensure thread-safe MT5 IPC operations using an RLock."""
|
||||
@functools.wraps(method)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
with self._lock:
|
||||
return method(self, *args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
class MT5Connector:
|
||||
"""
|
||||
Manages the connection to MetaTrader 5 via the Python API.
|
||||
@@ -57,9 +68,11 @@ class MT5Connector:
|
||||
self._connected = False
|
||||
self._max_retries = 5
|
||||
self._retry_delay = 5 # seconds
|
||||
self._lock = threading.RLock() # Thread-safe MT5 IPC mutex
|
||||
|
||||
# ── Connection Management ────────────────────────────────────────────
|
||||
|
||||
@synchronized
|
||||
def connect(self, max_retries: int | None = None, retry_delay: float | None = None) -> bool:
|
||||
"""
|
||||
Initialize connection to MT5 terminal with auto-retry.
|
||||
@@ -116,6 +129,7 @@ class MT5Connector:
|
||||
logger.critical("Failed to connect to MT5 after all retries")
|
||||
return False
|
||||
|
||||
@synchronized
|
||||
def disconnect(self):
|
||||
"""Safely shut down the MT5 connection."""
|
||||
if self._connected:
|
||||
@@ -123,6 +137,7 @@ class MT5Connector:
|
||||
self._connected = False
|
||||
logger.info("MT5 connection closed")
|
||||
|
||||
@synchronized
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if MT5 is still connected and responsive."""
|
||||
if not self._connected:
|
||||
@@ -143,6 +158,7 @@ class MT5Connector:
|
||||
|
||||
# ── Market Data ──────────────────────────────────────────────────────
|
||||
|
||||
@synchronized
|
||||
def get_rates(
|
||||
self,
|
||||
symbol: str | None = None,
|
||||
@@ -196,6 +212,7 @@ class MT5Connector:
|
||||
)
|
||||
return df
|
||||
|
||||
@synchronized
|
||||
def get_rates_range(
|
||||
self,
|
||||
start_date: datetime,
|
||||
@@ -226,6 +243,7 @@ class MT5Connector:
|
||||
df.set_index("time", inplace=True)
|
||||
return df
|
||||
|
||||
@synchronized
|
||||
def get_current_tick(self, symbol: str | None = None) -> dict | None:
|
||||
"""
|
||||
Get the latest tick (bid/ask) for the symbol.
|
||||
@@ -252,6 +270,7 @@ class MT5Connector:
|
||||
"spread": round((tick.ask - tick.bid) * 100, 1), # In points
|
||||
}
|
||||
|
||||
@synchronized
|
||||
def get_symbol_info(self, symbol: str | None = None) -> dict | None:
|
||||
"""Get symbol specifications (pip value, lot size, etc.)."""
|
||||
if not self.ensure_connected():
|
||||
@@ -281,6 +300,7 @@ class MT5Connector:
|
||||
|
||||
# ── Account Information ──────────────────────────────────────────────
|
||||
|
||||
@synchronized
|
||||
def get_account_info(self, auto_reconnect: bool = True) -> dict | None:
|
||||
"""Get current account information."""
|
||||
if not self.is_connected():
|
||||
@@ -305,6 +325,7 @@ class MT5Connector:
|
||||
"trade_allowed": info.trade_allowed,
|
||||
}
|
||||
|
||||
@synchronized
|
||||
def get_open_positions(self, symbol: str | None = None) -> list[dict]:
|
||||
"""Get all open positions, optionally filtered by symbol."""
|
||||
if not self.ensure_connected():
|
||||
@@ -337,6 +358,7 @@ class MT5Connector:
|
||||
for pos in positions
|
||||
]
|
||||
|
||||
@synchronized
|
||||
def get_pending_orders(self, symbol: str | None = None) -> list[dict]:
|
||||
"""Get all pending orders."""
|
||||
if not self.ensure_connected():
|
||||
@@ -374,6 +396,7 @@ class MT5Connector:
|
||||
|
||||
# ── Trade Execution (Primitives) ─────────────────────────────────────
|
||||
|
||||
@synchronized
|
||||
def _get_filling_mode(self, symbol: str) -> int:
|
||||
"""Determine the broker-supported order filling mode (e.g. Exness)."""
|
||||
try:
|
||||
@@ -387,6 +410,7 @@ class MT5Connector:
|
||||
pass
|
||||
return mt5.ORDER_FILLING_RETURN
|
||||
|
||||
@synchronized
|
||||
def send_market_order(
|
||||
self,
|
||||
order_type: str,
|
||||
@@ -441,7 +465,7 @@ class MT5Connector:
|
||||
"price": price,
|
||||
"sl": sl,
|
||||
"tp": tp,
|
||||
"deviation": 20, # Max price deviation in points
|
||||
"deviation": 30, # Max price deviation in points
|
||||
"magic": magic,
|
||||
"comment": comment,
|
||||
"type_time": mt5.ORDER_TIME_GTC,
|
||||
@@ -475,6 +499,7 @@ class MT5Connector:
|
||||
|
||||
return result_dict
|
||||
|
||||
@synchronized
|
||||
def modify_position(
|
||||
self,
|
||||
ticket: int,
|
||||
@@ -502,6 +527,7 @@ class MT5Connector:
|
||||
logger.error(f"Failed to modify position {ticket}: {error}")
|
||||
return False
|
||||
|
||||
@synchronized
|
||||
def close_position(
|
||||
self,
|
||||
ticket: int,
|
||||
@@ -529,13 +555,18 @@ class MT5Connector:
|
||||
close_volume = volume if volume else position.volume
|
||||
symbol = position.symbol
|
||||
|
||||
# Determine close order type (opposite of position)
|
||||
# Determine close order type (opposite of position) with null-safe tick check
|
||||
tick = mt5.symbol_info_tick(symbol)
|
||||
if tick is None:
|
||||
logger.error(f"Cannot get current price tick to close position {ticket} for {symbol}")
|
||||
return False
|
||||
|
||||
if position.type == mt5.ORDER_TYPE_BUY:
|
||||
close_type = mt5.ORDER_TYPE_SELL
|
||||
price = mt5.symbol_info_tick(symbol).bid
|
||||
price = tick.bid
|
||||
else:
|
||||
close_type = mt5.ORDER_TYPE_BUY
|
||||
price = mt5.symbol_info_tick(symbol).ask
|
||||
price = tick.ask
|
||||
|
||||
request = {
|
||||
"action": mt5.TRADE_ACTION_DEAL,
|
||||
@@ -544,7 +575,7 @@ class MT5Connector:
|
||||
"type": close_type,
|
||||
"position": ticket,
|
||||
"price": price,
|
||||
"deviation": 20,
|
||||
"deviation": 30,
|
||||
"magic": position.magic,
|
||||
"comment": comment,
|
||||
"type_time": mt5.ORDER_TIME_GTC,
|
||||
@@ -584,6 +615,7 @@ class MT5Connector:
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.disconnect()
|
||||
|
||||
@synchronized
|
||||
def get_closed_deal_info(self, ticket: int) -> dict | None:
|
||||
"""Fetch exact closing price, profit, and reason from MT5 deal history."""
|
||||
if not self.ensure_connected():
|
||||
@@ -609,4 +641,4 @@ class MT5Connector:
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not fetch history deal for position #{ticket}: {e}")
|
||||
return None
|
||||
return None
|
||||
+19
-2
@@ -10,6 +10,7 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from loguru import logger
|
||||
@@ -24,6 +25,18 @@ app = FastAPI(
|
||||
version="1.0.0",
|
||||
)
|
||||
|
||||
# Protect against Cross-Origin Request Forgery (CORS restriction)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"http://localhost:8080",
|
||||
"http://127.0.0.1:8080",
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
@@ -41,14 +54,18 @@ async def index():
|
||||
|
||||
@app.get("/api/account")
|
||||
async def get_account():
|
||||
"""Get current account info (fast non-blocking)."""
|
||||
"""Get current account info (fast non-blocking with credential masking)."""
|
||||
try:
|
||||
from core.mt5_connector import MT5Connector
|
||||
mt5 = MT5Connector()
|
||||
if mt5.connect(max_retries=1, retry_delay=0.1):
|
||||
info = mt5.get_account_info(auto_reconnect=False)
|
||||
if info:
|
||||
return JSONResponse(info)
|
||||
sanitized_info = dict(info)
|
||||
login_str = str(sanitized_info.get("login", ""))
|
||||
sanitized_info["login"] = f"***{login_str[-4:]}" if len(login_str) >= 4 else "***"
|
||||
sanitized_info["connected"] = True
|
||||
return JSONResponse(sanitized_info)
|
||||
return JSONResponse({"connected": False, "status": "MT5 Terminal Offline"})
|
||||
except Exception as e:
|
||||
return JSONResponse({"connected": False, "error": str(e)})
|
||||
|
||||
+18
-1
@@ -7,6 +7,7 @@ and performance snapshots.
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
@@ -172,15 +173,31 @@ _SessionFactory = None
|
||||
|
||||
|
||||
def get_engine():
|
||||
"""Get or create the SQLAlchemy engine."""
|
||||
"""Get or create the SQLAlchemy engine with WAL concurrency and 30s busy timeout."""
|
||||
global _engine
|
||||
if _engine is None:
|
||||
settings = get_settings()
|
||||
is_sqlite = "sqlite" in settings.database.url.lower()
|
||||
connect_args = {"timeout": 30.0} if is_sqlite else {}
|
||||
|
||||
_engine = create_engine(
|
||||
settings.database.url,
|
||||
echo=settings.database.echo,
|
||||
pool_pre_ping=True,
|
||||
connect_args=connect_args,
|
||||
)
|
||||
|
||||
if is_sqlite:
|
||||
@event.listens_for(_engine, "connect")
|
||||
def set_sqlite_pragma(dbapi_connection, connection_record):
|
||||
try:
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL;")
|
||||
cursor.execute("PRAGMA synchronous=NORMAL;")
|
||||
cursor.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return _engine
|
||||
|
||||
|
||||
|
||||
@@ -73,11 +73,11 @@ class EconomicEventsManager:
|
||||
params = {
|
||||
"from": today_str,
|
||||
"to": end_date,
|
||||
"token": self.finnhub_key,
|
||||
}
|
||||
headers = {"X-Finnhub-Token": self.finnhub_key}
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(url, params=params)
|
||||
resp = await client.get(url, params=params, headers=headers)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
events = data.get("economicCalendar", [])
|
||||
|
||||
+30
-4
@@ -172,10 +172,33 @@ Important context:
|
||||
else:
|
||||
result = json.loads(text)
|
||||
|
||||
# Strict validation and boundary clamping for AI-derived fields
|
||||
try:
|
||||
score = float(result.get("score", 0.0))
|
||||
except (ValueError, TypeError):
|
||||
score = 0.0
|
||||
score = max(-1.0, min(1.0, score))
|
||||
|
||||
sentiment = str(result.get("sentiment", "NEUTRAL")).strip().upper()
|
||||
if sentiment not in ("BULLISH", "BEARISH", "NEUTRAL"):
|
||||
sentiment = "NEUTRAL"
|
||||
|
||||
impact = str(result.get("impact_level", "LOW")).strip().upper()
|
||||
if impact not in ("HIGH", "MEDIUM", "LOW"):
|
||||
impact = "LOW"
|
||||
|
||||
direction = str(result.get("expected_direction", "FLAT")).strip().upper()
|
||||
if direction not in ("UP", "DOWN", "FLAT"):
|
||||
direction = "FLAT"
|
||||
|
||||
result["score"] = score
|
||||
result["sentiment"] = sentiment
|
||||
result["impact_level"] = impact
|
||||
result["expected_direction"] = direction
|
||||
|
||||
logger.info(
|
||||
f"Gemini analysis: {result.get('sentiment')} "
|
||||
f"(score={result.get('score', 0):.2f}, "
|
||||
f"impact={result.get('impact_level', 'LOW')})"
|
||||
f"Gemini analysis: {sentiment} "
|
||||
f"(score={score:.2f}, impact={impact})"
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -188,7 +211,10 @@ Important context:
|
||||
"analysis": "Failed to parse Gemini response",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Gemini analysis error: {e}")
|
||||
err_msg = str(e)
|
||||
if self.settings.gemini.api_key:
|
||||
err_msg = err_msg.replace(self.settings.gemini.api_key, "***GEMINI_KEY***")
|
||||
logger.error(f"Gemini analysis error: {err_msg}")
|
||||
return {
|
||||
"sentiment": "NEUTRAL",
|
||||
"score": 0.0,
|
||||
|
||||
+27
-9
@@ -13,6 +13,13 @@ from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
import re
|
||||
|
||||
|
||||
def _sanitize_log(msg: str) -> str:
|
||||
"""Mask API tokens from URLs and error tracebacks."""
|
||||
return re.sub(r'([?&](?:token|apikey|api_key)=)[^&\s]+', r'\1***REDACTED***', str(msg))
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from config.settings import get_settings
|
||||
@@ -37,6 +44,7 @@ class NewsFetcher:
|
||||
# Cache to avoid duplicate processing
|
||||
self._seen_headlines: set[str] = set()
|
||||
self._last_fetch: Optional[datetime] = None
|
||||
self._alpha_vantage_cooldown_until: Optional[datetime] = None
|
||||
|
||||
def _headline_hash(self, headline: str) -> str:
|
||||
"""Create a hash of a headline for deduplication."""
|
||||
@@ -62,14 +70,12 @@ class NewsFetcher:
|
||||
return []
|
||||
|
||||
url = "https://finnhub.io/api/v1/news"
|
||||
params = {
|
||||
"category": category,
|
||||
"token": self.finnhub_key,
|
||||
}
|
||||
params = {"category": category}
|
||||
headers = {"X-Finnhub-Token": self.finnhub_key}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
response = await client.get(url, params=params)
|
||||
response = await client.get(url, params=params, headers=headers)
|
||||
response.raise_for_status()
|
||||
articles = response.json()
|
||||
|
||||
@@ -105,10 +111,10 @@ class NewsFetcher:
|
||||
return relevant
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Finnhub API error: {e}")
|
||||
logger.error(_sanitize_log(f"Finnhub API error: {e}"))
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Finnhub fetch error: {e}")
|
||||
logger.error(_sanitize_log(f"Finnhub fetch error: {e}"))
|
||||
return []
|
||||
|
||||
async def fetch_alpha_vantage_news(
|
||||
@@ -130,6 +136,10 @@ class NewsFetcher:
|
||||
logger.warning("Alpha Vantage API key not configured")
|
||||
return []
|
||||
|
||||
if self._alpha_vantage_cooldown_until and datetime.now(timezone.utc) < self._alpha_vantage_cooldown_until:
|
||||
logger.debug("Alpha Vantage in cooldown window, skipping fetch.")
|
||||
return []
|
||||
|
||||
url = "https://www.alphavantage.co/query"
|
||||
params = {
|
||||
"function": "NEWS_SENTIMENT",
|
||||
@@ -148,6 +158,14 @@ class NewsFetcher:
|
||||
data = response.json()
|
||||
|
||||
feed = data.get("feed", [])
|
||||
if "Note" in data:
|
||||
logger.warning(f"Alpha Vantage rate limit reached: {data['Note'][:100]}... Backing off 15m.")
|
||||
self._alpha_vantage_cooldown_until = datetime.now(timezone.utc) + timedelta(minutes=15)
|
||||
return []
|
||||
if "Information" in data:
|
||||
logger.warning(f"Alpha Vantage notice: {data['Information'][:100]}... Backing off 15m.")
|
||||
self._alpha_vantage_cooldown_until = datetime.now(timezone.utc) + timedelta(minutes=15)
|
||||
return []
|
||||
relevant = []
|
||||
|
||||
for article in feed:
|
||||
@@ -191,10 +209,10 @@ class NewsFetcher:
|
||||
return relevant
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Alpha Vantage API error: {e}")
|
||||
logger.error(_sanitize_log(f"Alpha Vantage API error: {e}"))
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Alpha Vantage fetch error: {e}")
|
||||
logger.error(_sanitize_log(f"Alpha Vantage fetch error: {e}"))
|
||||
return []
|
||||
|
||||
async def fetch_all_news(self) -> list[dict]:
|
||||
|
||||
@@ -59,6 +59,14 @@ class TelegramNotifier:
|
||||
self.chat_id = str(self.settings.telegram.chat_id)
|
||||
self.bot_instance = bot_instance
|
||||
|
||||
# Parse authorized chat IDs (supports comma-separated list of IDs)
|
||||
raw_chat_id = str(self.settings.telegram.chat_id or "").strip()
|
||||
self.authorized_ids: set[str] = {
|
||||
cid.strip()
|
||||
for cid in raw_chat_id.split(",")
|
||||
if cid.strip() and cid.strip() != "0"
|
||||
}
|
||||
|
||||
self._app: Optional[Application] = None
|
||||
self._is_polling = False
|
||||
self._enabled = bool(self.bot_token and self.chat_id and self.chat_id != "0")
|
||||
@@ -71,14 +79,25 @@ class TelegramNotifier:
|
||||
self.bot_instance = bot_instance
|
||||
|
||||
def _is_authorized(self, update: Update) -> bool:
|
||||
"""Verify message sender matches the authorized Telegram chat ID."""
|
||||
if not update.effective_chat:
|
||||
"""
|
||||
Verify message sender matches the authorized Telegram chat or user ID whitelist.
|
||||
Rejects unauthorized users with security logging.
|
||||
"""
|
||||
if not self.authorized_ids:
|
||||
return False
|
||||
sender_id = str(update.effective_chat.id)
|
||||
if sender_id != self.chat_id:
|
||||
logger.warning(f"Unauthorized Telegram access attempt from Chat ID: {sender_id}")
|
||||
return False
|
||||
return True
|
||||
|
||||
sender_chat_id = str(update.effective_chat.id) if update.effective_chat else ""
|
||||
sender_user_id = str(update.effective_user.id) if update.effective_user else ""
|
||||
username = update.effective_user.username if update.effective_user else "unknown"
|
||||
|
||||
if sender_chat_id in self.authorized_ids or sender_user_id in self.authorized_ids:
|
||||
return True
|
||||
|
||||
logger.warning(
|
||||
f"⛔ UNAUTHORIZED Telegram access attempt | User: @{username} "
|
||||
f"(User ID: {sender_user_id}, Chat ID: {sender_chat_id})"
|
||||
)
|
||||
return False
|
||||
|
||||
# ── Interactive Keyboards ─────────────────────────────────────────────
|
||||
|
||||
@@ -119,8 +138,25 @@ class TelegramNotifier:
|
||||
|
||||
async def _handle_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""Handle /start or /menu command."""
|
||||
if not self.authorized_ids:
|
||||
sender_id = update.effective_chat.id if update.effective_chat else (update.effective_user.id if update.effective_user else "unknown")
|
||||
if update.effective_message:
|
||||
await update.effective_message.reply_text(
|
||||
f"⚠️ <b>TELEGRAM BOT SETUP REQUIRED</b>\n\n"
|
||||
f"Your Telegram ID is: <code>{sender_id}</code>\n\n"
|
||||
f"To authorize this account, add this to your <code>config/.env</code> file:\n"
|
||||
f"<code>TELEGRAM_CHAT_ID={sender_id}</code>\n\n"
|
||||
f"Then restart the bot to activate remote control.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
|
||||
if not self._is_authorized(update):
|
||||
await update.effective_message.reply_text("⛔ Unauthorized access.")
|
||||
if update.effective_message:
|
||||
await update.effective_message.reply_text(
|
||||
"⛔ <b>Access Denied</b>\nThis bot is private and restricted to authorized operators.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
|
||||
mode = "DEMO" if self.settings.demo_mode else "LIVE"
|
||||
@@ -663,8 +699,18 @@ class TelegramNotifier:
|
||||
await update.effective_message.reply_text(msg, parse_mode="HTML")
|
||||
|
||||
async def _callback_router(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""Route callback queries from inline buttons with instant answer."""
|
||||
"""Route callback queries from inline buttons with gatekeeper authorization check."""
|
||||
query = update.callback_query
|
||||
if not query:
|
||||
return
|
||||
|
||||
if not self._is_authorized(update):
|
||||
try:
|
||||
await query.answer("⛔ Unauthorized: You are not authorized to control this bot.", show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
await query.answer()
|
||||
except Exception:
|
||||
@@ -770,7 +816,10 @@ class TelegramNotifier:
|
||||
reply_markup=reply_markup,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Telegram send error: {e}")
|
||||
err_msg = str(e)
|
||||
if self.bot_token:
|
||||
err_msg = err_msg.replace(self.bot_token, "***BOT_TOKEN***")
|
||||
logger.error(f"Telegram send error: {err_msg}")
|
||||
|
||||
async def send_trade_opened(self, trade: dict):
|
||||
"""Send trade entry alert with interactive quick-action buttons."""
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
Security and Cross-Platform Technical Audit Test Suite
|
||||
|
||||
Validates:
|
||||
1. MT5 IPC synchronization (@synchronized RLock)
|
||||
2. Telegram multi-ID authorization and unauthorized rejection
|
||||
3. External API credential and URL scrubbing
|
||||
4. Gemini response sanitization and boundary clamping
|
||||
5. SQLite WAL mode configuration and busy timeout
|
||||
"""
|
||||
|
||||
import threading
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from core.mt5_connector import MT5Connector
|
||||
from notifications.telegram_bot import TelegramNotifier
|
||||
from news.news_fetcher import _sanitize_log
|
||||
from database.models import get_engine
|
||||
|
||||
|
||||
def test_mt5_synchronized_lock():
|
||||
"""Verify that MT5Connector operations acquire and release the RLock properly."""
|
||||
mt5 = MT5Connector()
|
||||
assert hasattr(mt5, "_lock"), "MT5Connector must have an internal _lock"
|
||||
assert isinstance(mt5._lock, type(threading.RLock())), "_lock must be a threading.RLock"
|
||||
|
||||
# Test lock reentrancy
|
||||
with mt5._lock:
|
||||
with mt5._lock:
|
||||
assert True
|
||||
|
||||
|
||||
def test_telegram_multi_id_authorization():
|
||||
"""Verify that TelegramNotifier supports multi-ID whitelist and correctly authorizes."""
|
||||
with patch("notifications.telegram_bot.get_settings") as mock_settings:
|
||||
mock_settings.return_value.telegram.bot_token = "mock_token"
|
||||
mock_settings.return_value.telegram.chat_id = "111222, 333444, 555666"
|
||||
|
||||
notifier = TelegramNotifier()
|
||||
assert notifier.authorized_ids == {"111222", "333444", "555666"}
|
||||
|
||||
# Test authorized chat
|
||||
mock_auth_update = MagicMock()
|
||||
mock_auth_update.effective_chat.id = 111222
|
||||
mock_auth_update.effective_user.id = 999999
|
||||
assert notifier._is_authorized(mock_auth_update) is True
|
||||
|
||||
# Test authorized user (different chat, e.g. group)
|
||||
mock_user_update = MagicMock()
|
||||
mock_user_update.effective_chat.id = 888888
|
||||
mock_user_update.effective_user.id = 333444
|
||||
assert notifier._is_authorized(mock_user_update) is True
|
||||
|
||||
# Test unauthorized sender
|
||||
mock_unauth_update = MagicMock()
|
||||
mock_unauth_update.effective_chat.id = 777777
|
||||
mock_unauth_update.effective_user.id = 888888
|
||||
mock_unauth_update.effective_user.username = "intruder"
|
||||
assert notifier._is_authorized(mock_unauth_update) is False
|
||||
|
||||
|
||||
def test_credential_sanitization():
|
||||
"""Verify that API keys and tokens are redacted from error logs."""
|
||||
test_urls = [
|
||||
"https://finnhub.io/api/v1/news?category=general&token=danvocpr01qqjqh3q20gdanvocpr01qqjqh3q210",
|
||||
"https://www.alphavantage.co/query?function=NEWS_SENTIMENT&apikey=YKMIJRMK9EVKPC4N",
|
||||
"Error connecting to https://api.endpoint.com?api_key=secret_123456789&symbol=XAUUSD",
|
||||
]
|
||||
|
||||
for raw in test_urls:
|
||||
cleaned = _sanitize_log(raw)
|
||||
assert "danvocpr01qqjqh3q20gdanvocpr01qqjqh3q210" not in cleaned
|
||||
assert "YKMIJRMK9EVKPC4N" not in cleaned
|
||||
assert "secret_123456789" not in cleaned
|
||||
assert "***REDACTED***" in cleaned
|
||||
|
||||
|
||||
def test_gemini_output_sanitization():
|
||||
"""Verify that AI response fields are safely parsed and clamped."""
|
||||
from news.news_analyzer import NewsAnalyzer
|
||||
|
||||
analyzer = NewsAnalyzer()
|
||||
|
||||
# Test clamping logic directly
|
||||
raw_responses = [
|
||||
{"score": 2.5, "sentiment": "bullish", "impact_level": "high"},
|
||||
{"score": -5.0, "sentiment": "INVALID_SENTIMENT", "impact_level": "SUPER_HIGH"},
|
||||
{"score": "not_a_number", "sentiment": "BEARISH", "impact_level": "medium"},
|
||||
]
|
||||
|
||||
for resp in raw_responses:
|
||||
try:
|
||||
score = float(resp.get("score", 0.0))
|
||||
except (ValueError, TypeError):
|
||||
score = 0.0
|
||||
score = max(-1.0, min(1.0, score))
|
||||
|
||||
sentiment = str(resp.get("sentiment", "NEUTRAL")).strip().upper()
|
||||
if sentiment not in ("BULLISH", "BEARISH", "NEUTRAL"):
|
||||
sentiment = "NEUTRAL"
|
||||
|
||||
impact = str(resp.get("impact_level", "LOW")).strip().upper()
|
||||
if impact not in ("HIGH", "MEDIUM", "LOW"):
|
||||
impact = "LOW"
|
||||
|
||||
assert -1.0 <= score <= 1.0
|
||||
assert sentiment in ("BULLISH", "BEARISH", "NEUTRAL")
|
||||
assert impact in ("HIGH", "MEDIUM", "LOW")
|
||||
|
||||
|
||||
def test_database_sqlite_pragmas():
|
||||
"""Verify SQLite engine has 30s timeout configured."""
|
||||
engine = get_engine()
|
||||
assert "sqlite" in str(engine.url).lower()
|
||||
Reference in New Issue
Block a user