fix: Phase 2 P0 全清零 — 14 項安全與功能修復完成
Some checks failed
CD Pipeline / deploy (push) Failing after 2m59s

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 <noreply@anthropic.com>
This commit is contained in:
ogt
2026-04-27 21:11:52 +08:00
parent f59b23f969
commit 237d3af76f
12 changed files with 178 additions and 42 deletions

View File

@@ -110,8 +110,8 @@
<td class="small text-muted">{{ item[cols.pid] }}</td>
{% endif %}
<td>
<div class="text-truncate-2" title="{{ item[cols.name] }}">
{{ item[cols.name] }}
<div class="text-truncate-2" title="{{ item[cols.name] | e }}">
{{ item[cols.name] | e }}
</div>
</td>
{% if cols.brand %}

22
app.py
View File

@@ -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 ""

View File

@@ -613,12 +613,20 @@
// 顯示提示
function showAlert(elementId, type, message) {
const alertDiv = document.getElementById(elementId);
alertDiv.innerHTML = `
<div class="alert alert-${type} alert-dismissible fade show" role="alert">
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
// 使用 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(() => {

View File

@@ -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 %}">
<div class="calendar-day-header">
<div class="calendar-day-number">{{ day.day }}</div>

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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 += `
<tr>
<td><img src="${p.image_url}" class="product-thumb" onerror="this.src='/static/placeholder.png'" style="width: 60px; height: 60px; object-fit: cover; border-radius: 8px;"></td>
<td><a href="${p.url}" target="_blank" class="text-decoration-none">${p.product_id}</a></td>
<td><a href="${p.url}" target="_blank" class="text-decoration-none" title="${p.name}">${p.name}</a></td>
<td><span class="badge bg-secondary">${p.category || '未分類'}</span></td>
<td><img src="${safeImageUrl}" class="product-thumb" onerror="this.src='/static/placeholder.png'" style="width: 60px; height: 60px; object-fit: cover; border-radius: 8px;"></td>
<td><a href="${safeUrl}" target="_blank" class="text-decoration-none">${safeProductId}</a></td>
<td><a href="${safeUrl}" target="_blank" class="text-decoration-none" title="${safeName}">${safeName}</a></td>
<td><span class="badge bg-secondary">${safeCategory || '未分類'}</span></td>
<td>$${(p.old_price || 0).toLocaleString()}</td>
<td><strong>$${(p.current_price || 0).toLocaleString()}</strong></td>
<td><strong class="${changeClass}">${changeIcon} ${changeText}</strong></td>
<td class="small text-muted">${p.update_time}</td>
<td class="small text-muted">${safeUpdateTime}</td>
</tr>
`;
});

View File

@@ -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 }}"

View File

@@ -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:

View File

@@ -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):

View File

@@ -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

View File

@@ -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()

View File

@@ -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 服務

View File

@@ -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):
"""趨勢查詢指令"""