"""统一审计轨迹(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