From 237d3af76f8daf890f9f3b36d1ac15f0fe00c7f1 Mon Sep 17 00:00:00 2001 From: ogt Date: Mon, 27 Apr 2026 21:11:52 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20Phase=202=20P0=20=E5=85=A8=E6=B8=85?= =?UTF-8?q?=E9=9B=B6=20=E2=80=94=2014=20=E9=A0=85=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E8=88=87=E5=8A=9F=E8=83=BD=E4=BF=AE=E5=BE=A9=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0-06: google_drive_service.py — pickle.load() 改 JSON token(消除 RCE 風險) P0-07: bot_api_routes.py:30 — BOT_API_TOKEN 移除硬編碼預設值 clawdbot_momo_2026 P0-08: auto_import_index.html — showAlert innerHTML 改 createTextNode(XSS 修復) P0-09: abc_analysis_detail.html + dashboard.html + daily_sales.html — Jinja2 | e 轉義 P0-10: openclaw_bot_routes.py:2634 — vendor PPT 補 return ppt_path(廠商報告恢復) P0-11: telegram_bot_service.py:177-214 — cmd_start/cmd_help 補 try/except P0-12: app.py:689-712 — 10 個 Blueprint 補齊 register(消滅 404 路由) P0-13: auto_heal_service.py — 實作 _write_heal_log(),AIOps 稽核閉環補完 P0-14: monitoring/prometheus.yml — 取消 alert_rules comment;新增 alert_rules.yml Co-Authored-By: Claude Sonnet 4.6 --- abc_analysis_detail.html | 4 +-- app.py | 22 ++++++++++++++ auto_import_index.html | 20 +++++++++---- daily_sales.html | 2 +- dashboard.html | 27 +++++++++++++---- monitoring/alert_rules.yml | 26 +++++++++++++++++ monitoring/prometheus.yml | 10 +++---- routes/bot_api_routes.py | 7 +++-- routes/openclaw_bot_routes.py | 3 +- services/auto_heal_service.py | 50 ++++++++++++++++++++++++++++++-- services/google_drive_service.py | 23 ++++++++++----- services/telegram_bot_service.py | 26 ++++++++++------- 12 files changed, 178 insertions(+), 42 deletions(-) create mode 100644 monitoring/alert_rules.yml diff --git a/abc_analysis_detail.html b/abc_analysis_detail.html index 5a901af..971ce1d 100644 --- a/abc_analysis_detail.html +++ b/abc_analysis_detail.html @@ -110,8 +110,8 @@ {{ item[cols.pid] }} {% endif %} -
- {{ item[cols.name] }} +
+ {{ item[cols.name] | e }}
{% if cols.brand %} diff --git a/app.py b/app.py index 73a9c4f..e686403 100644 --- a/app.py +++ b/app.py @@ -687,6 +687,28 @@ try: except Exception as _e: sys_log.error(f"[Blueprint] ❌ OpenClaw Bot Blueprint 註冊失敗: {_e}") +# P0-12 修復:補齊缺少的 Blueprint 註冊 +for _bp_module, _bp_name in [ + ('routes.api_routes', 'api_bp'), + ('routes.edm_routes', 'edm_bp'), + ('routes.sales_routes', 'sales_bp'), + ('routes.monthly_routes', 'monthly_bp'), + ('routes.price_comparison_routes', 'price_comparison_bp'), + ('routes.export_routes', 'export_bp'), + ('routes.daily_sales_routes', 'daily_sales_bp'), + ('routes.dashboard_routes', 'dashboard_bp'), + ('routes.import_routes', 'import_bp'), + ('routes.pchome_routes', 'pchome_bp'), +]: + try: + import importlib as _il + _mod = _il.import_module(_bp_module) + _bp = getattr(_mod, _bp_name) + app.register_blueprint(_bp) + sys_log.info(f"[Blueprint] ✅ {_bp_name} 已註冊") + except Exception as _e: + sys_log.error(f"[Blueprint] ❌ {_bp_name} 註冊失敗: {_e}") + # V-Fix: 註冊 slugify 函數供模板使用,解決 'slugify is undefined' 錯誤 def slugify(text): if not text: return "" diff --git a/auto_import_index.html b/auto_import_index.html index df69986..7b8c80b 100644 --- a/auto_import_index.html +++ b/auto_import_index.html @@ -613,12 +613,20 @@ // 顯示提示 function showAlert(elementId, type, message) { const alertDiv = document.getElementById(elementId); - alertDiv.innerHTML = ` - - `; + // 使用 DOM API 建構元素,避免 XSS(禁止直接 innerHTML 插入 message) + alertDiv.innerHTML = ''; + const wrapper = document.createElement('div'); + wrapper.className = `alert alert-${type} alert-dismissible fade show`; + wrapper.setAttribute('role', 'alert'); + const msgNode = document.createTextNode(message); + wrapper.appendChild(msgNode); + const closeBtn = document.createElement('button'); + closeBtn.type = 'button'; + closeBtn.className = 'btn-close'; + closeBtn.setAttribute('data-bs-dismiss', 'alert'); + closeBtn.setAttribute('aria-label', 'Close'); + wrapper.appendChild(closeBtn); + alertDiv.appendChild(wrapper); if (type === 'success' || type === 'danger') { setTimeout(() => { diff --git a/daily_sales.html b/daily_sales.html index 2a97ec5..e6a5f06 100644 --- a/daily_sales.html +++ b/daily_sales.html @@ -942,7 +942,7 @@ data-date="{{ day.date }}" data-has-data="{{ 'true' if day.has_data and day.is_current_month else 'false' }}" onclick="{% if day.has_data and day.is_current_month %}toggleDateSelection('{{ day.date }}', '{{ selected_date }}'){% endif %}" - title="{% if day.is_holiday %}🎊 {{ day.holiday_name }} | {% endif %}{{ day.weekday }}{% if day.has_data %} | 業績: ${{ '{:,.0f}'.format(day.revenue) }} | 毛利: ${{ '{:,.0f}'.format(day.profit) }} | SKU: {{ day.sku_count }} | 客單價: ${{ '{:,.0f}'.format(day.avg_price) }} | 銷量: {{ '{:,.0f}'.format(day.qty) }} | DoD: {{ day.dod_percent }}%{% else %} | 無資料{% endif %}"> + title="{% if day.is_holiday %}🎊 {{ day.holiday_name | e }} | {% endif %}{{ day.weekday | e }}{% if day.has_data %} | 業績: ${{ '{:,.0f}'.format(day.revenue) }} | 毛利: ${{ '{:,.0f}'.format(day.profit) }} | SKU: {{ day.sku_count }} | 客單價: ${{ '{:,.0f}'.format(day.avg_price) }} | 銷量: {{ '{:,.0f}'.format(day.qty) }} | DoD: {{ day.dod_percent | e }}%{% else %} | 無資料{% endif %}">
{{ day.day }}
diff --git a/dashboard.html b/dashboard.html index ad8be5d..0081a62 100644 --- a/dashboard.html +++ b/dashboard.html @@ -1343,22 +1343,39 @@ if (data.products && data.products.length > 0) { document.getElementById('modalProductCount').innerText = `${data.products.length} 件商品`; + // XSS 防護:對 API 回傳的字串欄位進行 HTML 轉義 + function escapeHtml(str) { + if (str == null) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + let html = ''; data.products.forEach(p => { const changeClass = p.change > 0 ? 'text-danger' : (p.change < 0 ? 'text-success' : 'text-muted'); const changeIcon = p.change > 0 ? '↑' : (p.change < 0 ? '↓' : ''); const changeText = p.change > 0 ? `+$${Math.abs(p.change).toLocaleString()}` : (p.change < 0 ? `-$${Math.abs(p.change).toLocaleString()}` : '$0'); + const safeImageUrl = escapeHtml(p.image_url); + const safeUrl = escapeHtml(p.url); + const safeProductId = escapeHtml(p.product_id); + const safeName = escapeHtml(p.name); + const safeCategory = escapeHtml(p.category); + const safeUpdateTime = escapeHtml(p.update_time); html += ` - - ${p.product_id} - ${p.name} - ${p.category || '未分類'} + + ${safeProductId} + ${safeName} + ${safeCategory || '未分類'} $${(p.old_price || 0).toLocaleString()} $${(p.current_price || 0).toLocaleString()} ${changeIcon} ${changeText} - ${p.update_time} + ${safeUpdateTime} `; }); diff --git a/monitoring/alert_rules.yml b/monitoring/alert_rules.yml new file mode 100644 index 0000000..e173312 --- /dev/null +++ b/monitoring/alert_rules.yml @@ -0,0 +1,26 @@ +groups: + - name: momo_pro_alerts + rules: + - alert: ContainerDown + expr: up == 0 + for: 1m + labels: + severity: critical + annotations: + summary: "容器 {{ $labels.instance }} 已停止回應" + + - alert: HighMemoryUsage + expr: (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) < 0.1 + for: 2m + labels: + severity: warning + annotations: + summary: "記憶體使用率超過 90%" + + - alert: SchedulerTaskFailed + expr: increase(scheduler_task_failures_total[5m]) > 0 + for: 0m + labels: + severity: warning + annotations: + summary: "排程任務失敗:{{ $labels.task_name }}" diff --git a/monitoring/prometheus.yml b/monitoring/prometheus.yml index f95c8b1..07a7ca1 100644 --- a/monitoring/prometheus.yml +++ b/monitoring/prometheus.yml @@ -6,14 +6,14 @@ global: environment: 'uat' # Alertmanager configuration (可選) -# alerting: -# alertmanagers: -# - static_configs: -# - targets: [] +alerting: + alertmanagers: + - static_configs: + - targets: [] # 規則檔案 rule_files: - # - "alerts.yml" + - "alert_rules.yml" # 監控目標配置 scrape_configs: diff --git a/routes/bot_api_routes.py b/routes/bot_api_routes.py index 11d0a74..f03b72e 100644 --- a/routes/bot_api_routes.py +++ b/routes/bot_api_routes.py @@ -26,8 +26,11 @@ sys_log = SystemLogger("BotAPI").get_logger() # Blueprint 定義 bot_api_bp = Blueprint('bot_api', __name__) -# API Token (從環境變數讀取,預設值僅用於開發) -BOT_API_TOKEN = os.getenv('BOT_API_TOKEN', 'clawdbot_momo_2026') +# API Token (從環境變數讀取,無預設值) +BOT_API_TOKEN = os.getenv('BOT_API_TOKEN') +if not BOT_API_TOKEN: + import logging as _log + _log.warning("[BotAPI] BOT_API_TOKEN 未設定,Bot API 端點將拒絕所有請求") def require_api_token(f): diff --git a/routes/openclaw_bot_routes.py b/routes/openclaw_bot_routes.py index 4951526..2edae5d 100644 --- a/routes/openclaw_bot_routes.py +++ b/routes/openclaw_bot_routes.py @@ -2627,10 +2627,11 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str) - session.add(report_record) session.commit() sys_log.info(f"[OpenClawBot] 廠商 PPT 已快取: {ppt_path}") - + except Exception as e: sys_log.error(f"[OpenClawBot] 儲存廠商 PPT 快取失敗: {e}") session.rollback() + return ppt_path elif sub_type in ('bcg', 'BCG', '品牌矩陣', '矩陣'): # 檢查是否有快取的 PPT 報告 from database.ppt_reports import PPTReport diff --git a/services/auto_heal_service.py b/services/auto_heal_service.py index 9e1b490..1d8cf8a 100644 --- a/services/auto_heal_service.py +++ b/services/auto_heal_service.py @@ -235,6 +235,7 @@ class AutoHealService: action_type = playbook["action_type"] params = playbook.get("action_params", {}) + started_at = datetime.now() if action_type == "DOCKER_RESTART": result = self._docker_restart(params) elif action_type == "WAIT_RETRY": @@ -246,7 +247,8 @@ class AutoHealService: else: return AutoHealResult(success=False, action=action_type, message="Unhandled action_type") - self._alert_and_store(playbook, context) + duration_ms = (datetime.now() - started_at).total_seconds() * 1000 + self._alert_and_store(playbook, context, result, duration_ms) return result def _docker_restart(self, params: Dict[str, Any]) -> AutoHealResult: @@ -304,7 +306,49 @@ class AutoHealService: out = result["stdout"] or result["stderr"] return AutoHealResult(success=result["success"], action="SSH_CMD", message=out) - def _alert_and_store(self, playbook: Dict[str, Any], context: Dict[str, Any]) -> None: + def _alert_and_store( + self, + playbook: Dict[str, Any], + context: Dict[str, Any], + result: "AutoHealResult", + duration_ms: float = 0.0, + ) -> None: _store_escalation(playbook["action_type"]) - # TODO: integrate triaged_alert if needed self._log.info("[AutoHeal] Alert stored for: %s", playbook["action_type"]) + self._write_heal_log(playbook, context, result, duration_ms) + + def _write_heal_log( + self, + playbook: Dict[str, Any], + context: Dict[str, Any], + result: "AutoHealResult", + duration_ms: float, + ) -> None: + """Write heal execution record to heal_logs table (ADR-013 Step 6/7).""" + from database.autoheal_models import HealLog + incident_id = context.get("incident_id") + if not incident_id: + self._log.warning("[AutoHeal] _write_heal_log: incident_id missing in context, skipping DB write") + return + session = get_session() + try: + log_entry = HealLog( + incident_id=int(incident_id), + playbook_id=int(playbook["id"]), + action_type=playbook["action_type"], + action_detail=json.dumps(playbook.get("action_params", {}), ensure_ascii=False), + result="success" if result.success else "failed", + result_output=result.message[:2000] if result.message else None, + duration_ms=duration_ms, + ) + session.add(log_entry) + session.commit() + self._log.info( + "[AutoHeal] heal_log written: incident_id=%s playbook=%s result=%s duration_ms=%.1f", + incident_id, playbook["id"], log_entry.result, duration_ms, + ) + except Exception as exc: + self._log.error("[AutoHeal] _write_heal_log DB error: %s", exc) + session.rollback() + finally: + session.close() diff --git a/services/google_drive_service.py b/services/google_drive_service.py index b3526c5..9c6a928 100644 --- a/services/google_drive_service.py +++ b/services/google_drive_service.py @@ -7,10 +7,10 @@ Google Drive 服務模組 import os import io +import json import logging from typing import List, Optional, Dict, Any from datetime import datetime -import pickle from google.auth.transport.requests import Request from google.oauth2.credentials import Credentials @@ -27,7 +27,8 @@ SCOPES = ['https://www.googleapis.com/auth/drive'] # 認證檔案路徑 CREDENTIALS_FILE = 'config/google_credentials.json' -TOKEN_FILE = 'config/google_token.pickle' +TOKEN_FILE = 'config/google_token.json' +_LEGACY_PICKLE_FILE = 'config/google_token.pickle' class GoogleDriveService: @@ -46,10 +47,18 @@ class GoogleDriveService: bool: 認證是否成功 """ try: + # 舊版 pickle token 遷移提示(不自動刪除舊檔) + if os.path.exists(_LEGACY_PICKLE_FILE) and not os.path.exists(TOKEN_FILE): + logger.warning( + "[GoogleDrive] 偵測到舊版 token.pickle,已改用 JSON 格式。" + "請重新執行認證流程以產生新 token,舊 pickle 檔案不會被自動刪除。" + ) + # 檢查是否已有 token if os.path.exists(TOKEN_FILE): - with open(TOKEN_FILE, 'rb') as token: - self.credentials = pickle.load(token) + with open(TOKEN_FILE, 'r') as token: + token_data = json.load(token) + self.credentials = Credentials.from_authorized_user_info(token_data, SCOPES) # 如果沒有有效憑證,進行認證流程 if not self.credentials or not self.credentials.valid: @@ -70,9 +79,9 @@ class GoogleDriveService: # 對於「電腦版應用程式」類型,使用預設行為讓 Google 自動選擇埠號 self.credentials = flow.run_local_server() - # 儲存憑證供下次使用 - with open(TOKEN_FILE, 'wb') as token: - pickle.dump(self.credentials, token) + # 儲存憑證供下次使用(JSON 格式,安全無 RCE 風險) + with open(TOKEN_FILE, 'w') as token: + token.write(self.credentials.to_json()) logger.info(f"憑證已儲存到: {TOKEN_FILE}") # 建立 Drive API 服務 diff --git a/services/telegram_bot_service.py b/services/telegram_bot_service.py index 28f0e17..e3c95a4 100644 --- a/services/telegram_bot_service.py +++ b/services/telegram_bot_service.py @@ -176,17 +176,21 @@ class TrendTelegramBot: async def cmd_start(self, update: Update, context): """開始指令 - 顯示主選單""" - user = update.effective_user - await update.message.reply_text( - f"👋 嗨,{user.first_name}!\n\n" - f"我是 *MOMO 趨勢助手*,您的智能商業分析夥伴。\n" - f"請選擇下方功能,或直接輸入問題:", - reply_markup=self._get_main_menu_keyboard() - ) + try: + user = update.effective_user + await update.message.reply_text( + f"👋 嗨,{user.first_name}!\n\n" + f"我是 *MOMO 趨勢助手*,您的智能商業分析夥伴。\n" + f"請選擇下方功能,或直接輸入問題:", + reply_markup=self._get_main_menu_keyboard() + ) + except Exception as e: + logger.error(f"[Bot] cmd_start 失敗: {e}", exc_info=True) async def cmd_help(self, update: Update, context): """說明指令""" - help_text = """ + try: + help_text = """ 📖 *指令說明* *趨勢查詢* @@ -204,8 +208,10 @@ class TrendTelegramBot: *每日摘要* /daily - 查看今日趨勢摘要 - """ - await update.message.reply_text(help_text, parse_mode='Markdown') + """ + await update.message.reply_text(help_text, parse_mode='Markdown') + except Exception as e: + logger.error(f"[Bot] cmd_help 失敗: {e}", exc_info=True) async def cmd_trend(self, update: Update, context): """趨勢查詢指令"""