fix: Phase 2 P0 全清零 — 14 項安全與功能修復完成
Some checks failed
CD Pipeline / deploy (push) Failing after 2m59s
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:
@@ -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
22
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 ""
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, '"')
|
||||
.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 += `
|
||||
<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>
|
||||
`;
|
||||
});
|
||||
|
||||
26
monitoring/alert_rules.yml
Normal file
26
monitoring/alert_rules.yml
Normal 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 }}"
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 服務
|
||||
|
||||
@@ -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):
|
||||
"""趨勢查詢指令"""
|
||||
|
||||
Reference in New Issue
Block a user