This repository has been archived on 2026-08-07. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
openclaw/agents/haonan/workspace/dev-workflow/stats.py
T
starmen1265 3b5f728efe 安全修复(High): 文件创建时设置权限 0o600
- 锁文件创建时设置权限 0o600
- 临时文件创建时设置权限 0o600
- 数据文件写入后确保权限为 0o600
- 防止未授权访问敏感统计数据
2026-03-09 15:18:22 +08:00

371 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Cross 模式统计报告模块
提供事件记录和统计报告生成功能
"""
import json
import os
import tempfile
import fcntl
import logging
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Any, Optional
from collections import defaultdict
# 配置日志
logger = logging.getLogger(__name__)
# 统计数据存储路径
STATS_DIR = Path(__file__).parent / "data"
STATS_FILE = STATS_DIR / "stats_data.json"
# 锁文件路径
LOCK_FILE = STATS_DIR / "stats_data.lock"
# 最大事件数量限制
MAX_EVENTS = 10000
# 敏感字段列表(不记录这些字段)
SENSITIVE_FIELDS = {"password", "token", "secret", "key", "credential", "auth"}
def _ensure_stats_dir():
"""确保统计目录存在"""
STATS_DIR.mkdir(parents=True, exist_ok=True)
def _acquire_lock(lock_file_path: Path):
"""获取文件锁
Raises:
OSError: 无法创建或锁定文件时
"""
try:
lock_file_path.parent.mkdir(parents=True, exist_ok=True)
# 使用 os.open 创建文件并设置权限
fd = os.open(lock_file_path, os.O_WRONLY | os.O_CREAT, 0o600)
lock_fd = os.fdopen(fd, 'w')
fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX)
return lock_fd
except (OSError, IOError) as e:
logger.error(f"获取文件锁失败: {e}")
raise
def _release_lock(lock_fd):
"""释放文件锁"""
if lock_fd is None:
return
try:
fcntl.flock(lock_fd.fileno(), fcntl.LOCK_UN)
lock_fd.close()
except (OSError, IOError) as e:
logger.warning(f"释放文件锁时发生错误: {e}")
try:
lock_fd.close()
except Exception:
pass
def _load_stats_data() -> Dict[str, Any]:
"""加载统计数据(带锁保护)
Returns:
Dict: 统计数据,加载失败时返回空结构
"""
if not STATS_FILE.exists():
return {"events": [], "metadata": {"created_at": datetime.now().isoformat()}}
lock_fd = None
try:
lock_fd = _acquire_lock(LOCK_FILE)
with open(STATS_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
except json.JSONDecodeError as e:
logger.warning(f"JSON 解析失败,将备份损坏文件: {e}")
# 备份损坏的文件
backup_path = STATS_FILE.with_suffix(f'.corrupted.{datetime.now().strftime("%Y%m%d_%H%M%S")}.bak')
try:
STATS_FILE.rename(backup_path)
logger.info(f"已备份损坏文件到: {backup_path}")
except OSError as backup_error:
logger.error(f"备份损坏文件失败: {backup_error}")
return {"events": [], "metadata": {"created_at": datetime.now().isoformat()}}
except (OSError, IOError) as e:
logger.error(f"读取统计文件失败: {e}")
return {"events": [], "metadata": {"created_at": datetime.now().isoformat()}}
except Exception as e:
logger.error(f"加载统计数据时发生未知错误: {e}")
return {"events": [], "metadata": {"created_at": datetime.now().isoformat()}}
finally:
_release_lock(lock_fd)
def _atomic_write(data: Dict[str, Any]):
"""原子写入统计数据(带锁保护)
Raises:
OSError: 文件操作失败时
TypeError: 数据无法序列化为 JSON 时
"""
_ensure_stats_dir()
lock_fd = None
temp_path = None
try:
lock_fd = _acquire_lock(LOCK_FILE)
# 写入临时文件(使用 os.open 设置权限)
fd = os.open(
tempfile.mktemp(suffix='.tmp', dir=STATS_DIR),
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
0o600
)
temp_path = f"/proc/self/fd/{fd}"
with os.fdopen(fd, 'w', encoding='utf-8') as temp_file:
json.dump(data, temp_file, ensure_ascii=False, indent=2)
temp_path = temp_file.name
# 原子重命名
os.replace(temp_path, STATS_FILE)
# 确保最终文件权限正确
os.chmod(STATS_FILE, 0o600)
temp_path = None # 防止在 finally 中再次删除
except (OSError, IOError) as e:
logger.error(f"写入统计文件失败: {e}")
raise
except (TypeError, ValueError) as e:
logger.error(f"JSON 序列化失败: {e}")
raise
finally:
_release_lock(lock_fd)
# 清理临时文件
if temp_path and os.path.exists(temp_path):
try:
os.unlink(temp_path)
except OSError as e:
logger.warning(f"清理临时文件失败: {e}")
def _filter_sensitive_data(data: Dict[str, Any]) -> Dict[str, Any]:
"""过滤敏感数据"""
filtered = {}
for key, value in data.items():
# 检查键名是否包含敏感字段
if any(sensitive in key.lower() for sensitive in SENSITIVE_FIELDS):
filtered[key] = "[REDACTED]"
elif isinstance(value, dict):
filtered[key] = _filter_sensitive_data(value)
else:
filtered[key] = value
return filtered
def record_event(
event_type: str,
mode: str = "cross",
metadata: Optional[Dict[str, Any]] = None
) -> bool:
"""
记录事件
Args:
event_type: 事件类型(如 'command_executed', 'task_completed' 等)
mode: 工作模式(默认 'cross')
metadata: 事件元数据
Returns:
bool: 记录是否成功
Raises:
TypeError: metadata 不是 dict 类型时
"""
try:
# metadata 类型校验
if metadata is not None and not isinstance(metadata, dict):
raise TypeError(f"metadata 必须是 dict 类型,当前为: {type(metadata).__name__}")
# 过滤敏感数据
safe_metadata = _filter_sensitive_data(metadata or {})
# 创建事件记录
event = {
"event_type": event_type,
"mode": mode,
"timestamp": datetime.now().isoformat(),
"metadata": safe_metadata
}
# 加载现有数据
data = _load_stats_data()
data["events"].append(event)
# 事件数量限制:超过 MAX_EVENTS 时移除最旧的事件
if len(data["events"]) > MAX_EVENTS:
data["events"] = data["events"][-MAX_EVENTS:]
data["metadata"]["last_updated"] = datetime.now().isoformat()
# 原子写入
_atomic_write(data)
return True
except TypeError:
raise # 重新抛出类型错误
except Exception as e:
logger.error(f"记录事件失败: {e}")
return False
def get_stats(mode: Optional[str] = None) -> Dict[str, Any]:
"""
生成统计报告
Args:
mode: 筛选特定模式,None 表示所有模式
Returns:
Dict: 统计报告数据
"""
data = _load_stats_data()
events = data.get("events", [])
# 筛选模式
if mode:
events = [e for e in events if e.get("mode") == mode]
# 统计数据
stats = {
"total_events": len(events),
"events_by_type": defaultdict(int),
"events_by_mode": defaultdict(int),
"events_by_date": defaultdict(int),
"first_event": None,
"last_event": None,
"metadata": data.get("metadata", {})
}
if events:
stats["first_event"] = events[0].get("timestamp")
stats["last_event"] = events[-1].get("timestamp")
for event in events:
event_type = event.get("event_type", "unknown")
event_mode = event.get("mode", "unknown")
stats["events_by_type"][event_type] += 1
stats["events_by_mode"][event_mode] += 1
# 按日期统计
timestamp = event.get("timestamp", "")
date = timestamp[:10] if timestamp else "unknown" # YYYY-MM-DD
stats["events_by_date"][date] += 1
# 转换 defaultdict 为普通 dict
stats["events_by_type"] = dict(stats["events_by_type"])
stats["events_by_mode"] = dict(stats["events_by_mode"])
stats["events_by_date"] = dict(stats["events_by_date"])
return stats
def format_stats_report(stats: Dict[str, Any]) -> str:
"""
格式化统计报告为可读文本
Args:
stats: 统计数据
Returns:
str: 格式化的报告文本
"""
lines = [
"=" * 60,
"Cross 模式统计报告",
"=" * 60,
"",
f"总事件数: {stats['total_events']}",
""
]
if stats["total_events"] == 0:
lines.append("暂无统计数据")
return "\n".join(lines)
# 时间范围
lines.extend([
f"首次事件: {stats['first_event']}",
f"最后事件: {stats['last_event']}",
""
])
# 按类型统计
if stats["events_by_type"]:
lines.append("按事件类型统计:")
for event_type, count in sorted(stats["events_by_type"].items()):
lines.append(f" - {event_type}: {count}")
lines.append("")
# 按模式统计
if stats["events_by_mode"]:
lines.append("按模式统计:")
for mode, count in sorted(stats["events_by_mode"].items()):
lines.append(f" - {mode}: {count}")
lines.append("")
# 按日期统计(最近7天)
if stats["events_by_date"]:
lines.append("按日期统计(最近7天):")
sorted_dates = sorted(stats["events_by_date"].items(), reverse=True)[:7]
for date, count in sorted_dates:
lines.append(f" - {date}: {count}")
lines.append("")
lines.append("=" * 60)
return "\n".join(lines)
def clear_stats(confirm: bool = False) -> bool:
"""
清空统计数据
Args:
confirm: 确认清空
Returns:
bool: 是否成功清空
"""
if not confirm:
return False
# 安全检查:验证管理员令牌
admin_token = os.getenv("ADMIN_TOKEN")
if not admin_token:
logger.error("清空统计数据需要设置 ADMIN_TOKEN 环境变量")
return False
try:
if STATS_FILE.exists():
STATS_FILE.unlink()
return True
except Exception as e:
logger.error(f"清空统计数据失败: {e}")
return False
if __name__ == "__main__":
# 测试代码
print("测试 stats 模块")
# 记录一些测试事件
record_event("test_event", mode="cross", metadata={"user": "test", "action": "demo"})
record_event("another_event", mode="normal", metadata={"data": "sample"})
# 生成报告
stats = get_stats()
print(format_stats_report(stats))