Files
codex-app-server/cloud/chv/audit_trail.py
T
host 414e245752 round-12: ruff hygiene on migration code (import sort, narrow store except)
- test_chv_muse_round12.py: drop unused socket/time imports, drop
  unnecessary sys.path.insert (P3 pattern needs none), sort method-local
  imports (activity/capabilities, approval_card/channel/sentinel)
- audit_trail.py: single-line method-local imports (I001)
- swarm.py / presentation.py / browser_tasks.py: store-path fail-safe
  except narrowed from bare Exception to (sqlite3.Error, OSError)
  (DB/file failures only); transport spawn/ping/close handlers in
  browser_tasks.py deliberately stay bare Exception (arbitrary
  transport errors must map to failed/False/close_error)
- NOT fixed (pre-existing style, left as-is): UP031 % formatting
  (used throughout the codebase incl. the copied lines), BLE001/
  TRY004 in untouched code, F401 atomic_write + RUF059 swarm
  (P7/P8 pre-existing)
2026-11-07 00:57:05 +00:00

508 lines
20 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.
"""统一审计轨迹(feat-chv-muse-p12,叶子模块,纯标准库)。
接 Muse 官方要求(报告 §2.2 第 7 步、§2.6):完整审计轨迹展示
已做与计划做的事;审批走确定性 UI;用户可检查、编辑、下载
VM 内所有文件。
现状收敛:基线里审计是散的——``AuthStore.audit()`` 只追加
``(user_id, project_id, action, detail, created_at)`` 到 SQLite
``audit_log``,``Worker._audit()`` 是 best-effort 包装,
``worker_adapter.make_chv_audit_callback`` 把 CHV 事件转成
``"chv_vm_" + event``。本模块给它们一个统一记录模型与一条
追加写 JSONL 轨迹,不改现有调用方。
记录模型(每条都是纯数据 dict,可直接 JSON 序列化)::
{"seq": int, "ts": float, "project_id": str, "actor": str,
"category": str, "action": str, "object_type": str,
"object_id": str, "result": str, "reason": str,
"task_id": str, "approval_id": str, "detail": dict,
"prev_hash": str, "hash": str}
- ``category`` 只认 ``AUDIT_CATEGORIES``:任务派发、VM 生命周期、
同步、审批、支付(P10)、配额(P11)、记忆操作(P7 的忘记)、
后台任务(P8)、推理/遥测(P9)、安全事件。
- ``detail`` 只含描述性字段:``sanitize_detail()`` 按字段级白名
单清洗——密钥、token 值、对话原文绝不进审计。清洗是字段名
黑名单 + ``key=value`` 模式脱敏,不是内容理解:密钥若藏在
自由文本里且不带标记,清洗器看不出来,调用方仍须只传描述
性字段(与 P3/P5 的口径一致)。
- 哈希链:每条记录的 ``hash`` = SHA-256(prev_hash + 规范 JSON),
首条 prev_hash 为 ``GENESIS_HASH``。``verify()`` 能发现改写/
删除/调序。诚实边界:这是**篡改可发现**,不是防篡改——能写
文件的人重算整条链就能掩盖改动;真防篡改要外部锚定(远端
日志/签名时间戳),不在本轨。
- project_id 强制:``list_records()``/``export_json()`` 必须传 project_id,
只返回该项目记录;跨项目读取返回空,不抛错(与现有
``list_audit`` 行为一致,不泄露“存在别的项目记录”)。
- 持久化:round-12 已迁移到 P7 状态库(``StoreAuditTrail``,
宿主级 AppStateStore 的 ``audit_trail`` 表,同一记录形状与
哈希链);``AuditTrail``(JSONL 0600)保留,用于读旧文件与
迁移源,``migrate_jsonl_to_store()`` 原样搬链(坏链拒绝、
分叉拒绝、重跑幂等);``from_legacy_row()`` 把现有
``audit_log`` 行转成统一记录(标 ``legacy: true``,本身无
哈希链,append 时重新入链)。
"""
import hashlib
import json
import os
import re
import time
# 审计事件分类:P1~P11 各轨的事件都落进这里,不另起表。
AUDIT_CATEGORIES = frozenset((
"task_dispatch", "vm_lifecycle", "gitea_sync", "approval",
"payment", "quota", "memory", "background", "inference",
"telemetry", "security",
))
# 创世前哈希:首条记录的 prev_hash。
GENESIS_HASH = "0" * 64
# 字段名里出现这些子串(小写比对)的键一律丢弃:值疑似密钥。
_SECRET_KEY_HINTS = (
"token", "secret", "password", "passwd", "api_key", "apikey",
"credential", "private_key", "bearer", "cookie", "auth",
"session",
)
# 自由文本里的 ``key=value`` / ``key: value`` 模式:值脱敏。
_SECRET_VALUE_RE = re.compile(
r"(?i)(token|secret|password|passwd|api[-_]?key|bearer"
r"|authorization)\s*[:=]\s*[^,\s;\"']+")
_MAX_STRING_LEN = 4096
def _is_secret_key(key: str) -> bool:
"""字段名是否疑似密钥(小写子串比对)。"""
lowered = key.lower()
return any(hint in lowered for hint in _SECRET_KEY_HINTS)
def _sanitize_string(value: str) -> str:
"""自由文本脱敏:密钥模式的值打码,超长截断。"""
redacted = _SECRET_VALUE_RE.sub(r"\1=***", value)
if len(redacted) > _MAX_STRING_LEN:
return redacted[:_MAX_STRING_LEN] + "...(truncated)"
return redacted
def sanitize_detail(detail: dict | None) -> dict:
"""递归清洗 detail:密钥字段丢弃,自由文本脱敏。
dict 键疑似密钥即整键丢弃;list 逐项清洗;str 脱敏;
其它标量原样保留。返回新 dict,不改入参。
"""
if not detail:
return {}
cleaned: dict = {}
for key, value in detail.items():
if _is_secret_key(str(key)):
continue
cleaned[str(key)] = _sanitize_value(value)
return cleaned
def _sanitize_value(value: object) -> object:
"""清洗单个值:dict/list/str 递归,其它原样。"""
if isinstance(value, dict):
return {str(k): _sanitize_value(v)
for k, v in value.items() if not _is_secret_key(str(k))}
if isinstance(value, list):
return [_sanitize_value(item) for item in value]
if isinstance(value, str):
return _sanitize_string(value)
return value
def _canonical(record: dict) -> str:
"""记录的规范 JSON(排序键、紧凑分隔、原文编码)。"""
return json.dumps(record, sort_keys=True, separators=(",", ":"),
ensure_ascii=False)
def compute_hash(prev_hash: str, record: dict) -> str:
"""一条记录的链哈希:SHA-256(prev_hash + 规范 JSON)。"""
body = prev_hash + "\n" + _canonical(record)
return hashlib.sha256(body.encode("utf-8")).hexdigest()
def from_legacy_row(row: dict) -> dict:
"""现有 audit_log 行转统一记录(标 legacy,无哈希链)。
入参是 ``AuthStore.list_audit()`` 的行 dict:
id/user_id/project_id/action/detail/created_at。
detail 若是 str 则原样包进 ``{"detail": ...}`` 再清洗;
若是 dict 直接清洗。返回的记录由调用方经 ``append()``
重新入链,seq/hash 由 ``append()`` 补。
"""
detail = row.get("detail")
if isinstance(detail, str):
detail = {"detail": detail}
if not isinstance(detail, dict):
detail = {}
return {
"project_id": str(row.get("project_id", "")),
"actor": str(row.get("user_id", "")),
"category": "security",
"action": str(row.get("action", "")),
"object_type": "",
"object_id": str(row.get("id", "")),
"result": "ok",
"reason": "legacy audit_log row",
"task_id": "",
"approval_id": "",
"detail": sanitize_detail(detail),
"ts": float(row.get("created_at", 0.0) or 0.0),
"legacy": True,
}
class AuditTrail:
"""追加写 JSONL 审计轨迹:哈希链 + 项目隔离 + 导出。
文件不存在即创建(0600);每条 append 都 O_APPEND 写 +
fsync,写失败原样抛错(审计失败不静默丢,由调用方决定
是否 best-effort)。读路径全部只读,不改文件。
"""
def __init__(self, path: str) -> None:
self.path = path
self._last_hash = GENESIS_HASH
self._seq = 0
self._load_tail()
def _load_tail(self) -> None:
"""启动时扫一遍文件,恢复末尾 seq 与链哈希。"""
if not os.path.exists(self.path):
return
with open(self.path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
record = json.loads(line)
except ValueError:
continue
seq = record.get("seq")
if isinstance(seq, int):
self._seq = seq
chain_hash = record.get("hash")
if isinstance(chain_hash, str):
self._last_hash = chain_hash
def append(self, project_id: str, actor: str, category: str,
action: str, object_type: str = "",
object_id: str = "", result: str = "ok",
reason: str = "", task_id: str = "",
approval_id: str = "",
detail: dict | None = None,
ts: float | None = None) -> dict:
"""追加一条审计记录,返回该记录(含 seq 与链哈希)。"""
if not project_id:
raise ValueError("project_id 不能为空")
if category not in AUDIT_CATEGORIES:
raise ValueError("未知审计分类:%s" % category)
seq = self._seq + 1
record = {
"seq": seq,
"ts": ts if ts is not None else time.time(),
"project_id": project_id,
"actor": actor,
"category": category,
"action": action,
"object_type": object_type,
"object_id": object_id,
"result": result,
"reason": reason,
"task_id": task_id,
"approval_id": approval_id,
"detail": sanitize_detail(detail),
"prev_hash": self._last_hash,
}
chain_hash = compute_hash(self._last_hash, record)
record["hash"] = chain_hash
line = _canonical(record) + "\n"
fd = os.open(self.path, os.O_WRONLY | os.O_CREAT | os.O_APPEND,
0o600)
try:
os.write(fd, line.encode("utf-8"))
os.fsync(fd)
finally:
os.close(fd)
self._seq = seq
self._last_hash = chain_hash
return dict(record)
def list_records(self, project_id: str, category: str = "",
action: str = "", task_id: str = "",
since: float = 0.0, until: float = 0.0,
limit: int = 100, offset: int = 0) -> list:
"""按时间倒序返回该项目的审计记录(副本,可分页过滤)。
方法名不用 ``list``:类内同名方法会遮住内置 ``list``
类型,导致返回注解被 mypy 认成方法本身。
project_id 为空或不匹配一律返回空列表。limit <= 0
表示不限制条数。
"""
if not project_id:
return []
rows = self._read_all()
kept = [r for r in rows if r.get("project_id") == project_id]
kept = _filter_rows(kept, category, action, task_id,
since, until)
kept.sort(key=lambda r: (r.get("ts", 0.0), r.get("seq", 0)),
reverse=True)
if offset:
kept = kept[offset:]
if limit > 0:
kept = kept[:limit]
return [dict(r) for r in kept]
def verify(self) -> tuple[bool, int]:
"""校验整条哈希链:返回 (是否通过, 首个坏行下标)。
通过时下标为 -1。坏行包括:解析失败、seq 不连续、
prev_hash 对不上、hash 重算不符。
"""
rows = self._read_raw()
expected_prev = GENESIS_HASH
expected_seq = 1
for index, raw in enumerate(rows):
try:
record = json.loads(raw)
except ValueError:
return False, index
if not _chain_ok(record, expected_prev, expected_seq):
return False, index
expected_prev = record["hash"]
expected_seq += 1
return True, -1
def export_json(self, project_id: str, dest: str) -> int:
"""导出该项目全部审计记录为 JSON 数组,返回导出条数。
导出内容与 ``list_records()`` 同款清洗后记录(含哈希链
字段,不含任何原始 detail 密钥——清洗在 append 时已做)。
"""
rows = self.list_records(project_id, limit=0)
rows.sort(key=lambda r: (r.get("ts", 0.0), r.get("seq", 0)))
with open(dest, "w", encoding="utf-8") as f:
json.dump(rows, f, ensure_ascii=False, indent=2)
os.chmod(dest, 0o600)
return len(rows)
def _read_all(self) -> list:
"""读全部可解析记录(跳过坏行,坏行由 verify 发现)。"""
rows = []
for raw in self._read_raw():
try:
record = json.loads(raw)
except ValueError:
continue
if isinstance(record, dict):
rows.append(record)
return rows
def _read_raw(self) -> list:
"""读全部非空行原文(verify 用)。"""
if not os.path.exists(self.path):
return []
with open(self.path, "r", encoding="utf-8") as f:
return [line.strip() for line in f if line.strip()]
class StoreAuditTrail:
"""AppStateStore 后端的统一审计轨迹(round-12 迁移)。
与 ``AuditTrail``(JSONL)同一记录形状、同一哈希链算法、
同一过滤/导出语义;持久化改走宿主级 AppStateStore 的
``audit_trail`` 表(``state_dir/app-state.sqlite``)。
JSONL 版保留,用于读旧文件与迁移源;新写一律走本类。
写失败不静默丢(与 JSONL 版同款:append 抛错由调用方
决定是否 best-effort);库打不开时 __init__ 即抛错,不
回空链——审计链丢了不能假装没事(与 P8 登记的 fail-safe
不同,见差距表)。
"""
def __init__(self, state_dir: str) -> None:
if not state_dir or not isinstance(state_dir, str):
raise ValueError("state_dir required")
self.state_dir = state_dir
self._last_hash = GENESIS_HASH
self._seq = 0
self._load_tail()
def _open_store(self):
"""打开宿主级状态库(每次调用新建连接,用完即关)。"""
from cloud.chv.app_state import AppStateStore, host_state_db_path
store = AppStateStore(host_state_db_path(self.state_dir))
store.open()
return store
def _load_tail(self) -> None:
"""启动时读库尾,恢复末尾 seq 与链哈希。"""
with self._open_store() as store:
rows = store.list_audit_trail_rows()
if not rows:
return
last = rows[-1]
self._seq = int(last["seq"])
self._last_hash = str(last["hash"])
def append(self, project_id: str, actor: str, category: str,
action: str, object_type: str = "",
object_id: str = "", result: str = "ok",
reason: str = "", task_id: str = "",
approval_id: str = "",
detail: dict | None = None,
ts: float | None = None) -> dict:
"""追加一条审计记录,返回该记录(含 seq 与链哈希)。"""
if not project_id:
raise ValueError("project_id 不能为空")
if category not in AUDIT_CATEGORIES:
raise ValueError("未知审计分类:%s" % category)
seq = self._seq + 1
record = {
"seq": seq,
"ts": ts if ts is not None else time.time(),
"project_id": project_id,
"actor": actor,
"category": category,
"action": action,
"object_type": object_type,
"object_id": object_id,
"result": result,
"reason": reason,
"task_id": task_id,
"approval_id": approval_id,
"detail": sanitize_detail(detail),
"prev_hash": self._last_hash,
}
chain_hash = compute_hash(self._last_hash, record)
record["hash"] = chain_hash
with self._open_store() as store:
store.insert_audit_trail_row(record)
self._seq = seq
self._last_hash = chain_hash
return dict(record)
def list_records(self, project_id: str, category: str = "",
action: str = "", task_id: str = "",
since: float = 0.0, until: float = 0.0,
limit: int = 100, offset: int = 0) -> list:
"""按时间倒序返回该项目的审计记录(副本,可分页过滤)。
project_id 为空或不匹配一律返回空列表。limit <= 0
表示不限制条数。"""
if not project_id:
return []
with self._open_store() as store:
rows = store.list_audit_trail_rows()
kept = [r for r in rows if r.get("project_id") == project_id]
kept = _filter_rows(kept, category, action, task_id,
since, until)
kept.sort(key=lambda r: (r.get("ts", 0.0), r.get("seq", 0)),
reverse=True)
if offset:
kept = kept[offset:]
if limit > 0:
kept = kept[:limit]
return [dict(r) for r in kept]
def verify(self) -> tuple[bool, int]:
"""校验整条哈希链:返回 (是否通过, 首个坏行下标)。
通过时下标为 -1。"""
with self._open_store() as store:
rows = store.list_audit_trail_rows()
expected_prev = GENESIS_HASH
expected_seq = 1
for index, record in enumerate(rows):
if not _chain_ok(record, expected_prev, expected_seq):
return False, index
expected_prev = record["hash"]
expected_seq += 1
return True, -1
def export_json(self, project_id: str, dest: str) -> int:
"""导出该项目全部审计记录为 JSON 数组,返回导出条数。"""
rows = self.list_records(project_id, limit=0)
rows.sort(key=lambda r: (r.get("ts", 0.0), r.get("seq", 0)))
with open(dest, "w", encoding="utf-8") as f:
json.dump(rows, f, ensure_ascii=False, indent=2)
os.chmod(dest, 0o600)
return len(rows)
def migrate_jsonl_to_store(path: str, state_dir: str) -> int:
"""把 JSONL 轨迹原样搬进 StoreAuditTrail,返回新插入条数。
先校验 JSONL 整条链(坏链即 ValueError,拒绝迁移,不静
默丢行);行按原文 seq/hash/prev_hash 插入,不重算链;
库里已有且内容相同的 seq 跳过(幂等,可重跑);已有但
内容不同的 seq 即 ValueError(分叉,fail-closed)。
文件不存在回 0。"""
if not os.path.exists(path):
return 0
trail = AuditTrail(path)
ok, bad = trail.verify()
if not ok:
raise ValueError("JSONL 审计链校验失败,拒绝迁移:行 %d"
% bad)
rows = trail._read_all()
from cloud.chv.app_state import AppStateStore, host_state_db_path
store = AppStateStore(host_state_db_path(state_dir))
store.open()
try:
existing = {int(r["seq"]): r
for r in store.list_audit_trail_rows()}
inserted = 0
for row in sorted(rows, key=lambda r: int(r.get("seq", 0))):
seq = int(row["seq"])
if seq in existing:
if existing[seq] != row:
raise ValueError(
"审计 seq %d 在库里已有不同内容,拒绝迁移"
% seq)
continue
store.insert_audit_trail_row(row)
inserted += 1
return inserted
finally:
store.close()
def _filter_rows(rows: list, category: str, action: str,
task_id: str, since: float, until: float) -> list:
"""list() 的过滤:分类/动作/任务/时间窗口,全空即不过滤。"""
kept = rows
if category:
kept = [r for r in kept if r.get("category") == category]
if action:
kept = [r for r in kept if r.get("action") == action]
if task_id:
kept = [r for r in kept if r.get("task_id") == task_id]
if since:
kept = [r for r in kept if r.get("ts", 0.0) >= since]
if until:
kept = [r for r in kept if r.get("ts", 0.0) <= until]
return kept
def _chain_ok(record: dict, expected_prev: str,
expected_seq: int) -> bool:
"""单条记录的链校验:seq 连续 + prev 对上 + hash 重算符。"""
if not isinstance(record, dict):
return False
if record.get("seq") != expected_seq:
return False
if record.get("prev_hash") != expected_prev:
return False
claimed = record.get("hash")
if not isinstance(claimed, str):
return False
body = {k: v for k, v in record.items() if k != "hash"}
return compute_hash(expected_prev, body) == claimed