#!/usr/bin/env python3 # -*- coding: utf-8 -*- """PBL-RTX-0005 实库取证:修复后 DDL 在一次性 MariaDB 沙箱中的装载与守卫行为。 响应 QC 退回意见 #4:上一轮留存的是**修复前**的失败日志(ERROR 1064 at line 65), 本轮重新拉起沙箱库,用部署同款路径(mariadb CLI + DELIMITER)装载修复后的 full_script(),留存: 1) mariadb-install-db / mariadbd 启动与建库建号输出; 2) DDL 装载 rc 与 stderr(应为空); 3) SHOW PROCEDURE STATUS / SHOW TRIGGERS(对象真实存在); 4) 一次 append-only 拒绝(CALL pbl_assert_append_only -> SQLSTATE 45000 / 45001); 5) 一次 break-glass 放行(SET @pbl_guard_ctx='append_only_admin' 后 CALL 不报错); 6) UPDATE / DELETE 触发器拒绝 + INSERT 正常 + CALL ensure_partitions(9) 成功; 7) 结束后彻底销毁 datadir。 凭据只在进程内使用:从 projects/pbls/env/test.json 读 db 段,不 echo、不落盘、不 commit。 用法:python3 tests/evidence/rtx0005_live_evidence.py """ import json import os import shutil import subprocess import sys import time HERE = os.path.dirname(os.path.abspath(__file__)) MODULE_DIR = os.path.dirname(os.path.dirname(HERE)) WORKSPACE = os.path.dirname(os.path.dirname(MODULE_DIR)) GEN = os.path.join(MODULE_DIR, "scripts", "pbl_runtime_event_ddl.py") SANDBOX = os.environ.get("RTX0005_SANDBOX") or "/tmp/rtx0005_evidence_db" PORT = int(os.environ.get("RTX0005_DB_PORT") or 13317) def sh(cmd, **kw): r = subprocess.run(cmd, capture_output=True, text=True, **kw) return r def cli(dbname, sql, user="root", pw=None): cmd = ["mariadb", "--protocol=tcp", "-h", "127.0.0.1", "-P", str(PORT), "-u", user] if pw: cmd.append("-p" + pw) cmd += [dbname] return sh(cmd, input=sql) def main(): print("=" * 78) print("PBL-RTX-0005 实库取证(修复后 DDL 装载 + 守卫行为)") print("时间: %s" % time.strftime("%Y-%m-%d %H:%M:%S %z")) print("=" * 78) conf = json.load(open(os.path.join(WORKSPACE, "projects", "pbls", "env", "test.json"), encoding="utf-8"))["db"] dbname, user, pw = conf["dbname"], conf["user"], conf["password"] print("[1] 连接参数来源: projects/pbls/env/test.json db 段(dbname=%s user=%s,凭据不打印)" % (dbname, user)) shutil.rmtree(SANDBOX, ignore_errors=True) os.makedirs(SANDBOX) r = sh(["mariadb-install-db", "--user=%s" % os.environ.get("USER", "pipeline"), "--datadir=%s" % SANDBOX, "--auth-root-authentication-method=normal"]) print("[2] mariadb-install-db rc=%d" % r.returncode) if r.returncode: print(r.stdout[-800:] + r.stderr[-800:]) return 1 srv = subprocess.Popen(["mariadbd", "--user=%s" % os.environ.get("USER", "pipeline"), "--datadir=%s" % SANDBOX, "--socket=%s/my.sock" % SANDBOX, "--port=%d" % PORT, "--bind-address=127.0.0.1", "--pid-file=%s/my.pid" % SANDBOX, "--skip-name-resolve", "--log-error=%s/server.err" % SANDBOX], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) ready = False for _ in range(40): if cli("mysql", "SELECT 1").returncode == 0: ready = True break time.sleep(1) if not ready: print("[3] FAIL: mariadbd 未就绪") srv.terminate() shutil.rmtree(SANDBOX, ignore_errors=True) return 1 print("[3] mariadbd 就绪: %s" % cli("mysql", "SELECT VERSION()").stdout.strip().split("\n")[1]) print("[4] 建库建号 rc=%d" % cli("mysql", "CREATE DATABASE IF NOT EXISTS `%s` " "CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" "CREATE USER IF NOT EXISTS '%s'@'%%' IDENTIFIED BY '%s';" "GRANT ALL PRIVILEGES ON *.* TO '%s'@'%%' WITH GRANT OPTION;" "FLUSH PRIVILEGES;" % (dbname, user, pw.replace("'", "''"), user)).returncode) # 渲染修复后的完整 DDL(与部署路径一致:mariadb CLI + DELIMITER) sys.path.insert(0, MODULE_DIR) import importlib.util spec = importlib.util.spec_from_file_location("gen_rtx0005", GEN) gen = importlib.util.module_from_spec(spec) spec.loader.exec_module(gen) full = os.path.join(SANDBOX, "full_after_fix.sql") with open(full, "w", encoding="utf-8") as f: f.write(gen.full_script(dbname=dbname, app_user=user)) print("[5] full_script() 渲染 %d bytes(修复后,无过程体 RETURN)" % os.path.getsize(full)) load = cli(dbname, open(full, encoding="utf-8").read(), user=user, pw=pw) print("[6] DDL 装载 rc=%d" % load.returncode) print(" stdout: %s" % (load.stdout.strip().replace("\n", " | ") or "(空)")) print(" stderr: %s" % (load.stderr.strip().replace("\n", " | ") or "(空) <-- 修复前此处为 " "ERROR 1064 (42000) at line 65")) rc_all = 0 if load.returncode == 0 else 1 print("\n[7] SHOW PROCEDURE STATUS(过程真实存在)") print(cli(dbname, "SELECT DB, NAME, TYPE FROM information_schema.ROUTINES " "WHERE ROUTINE_SCHEMA='%s' ORDER BY NAME;" % dbname, user=user, pw=pw).stdout) print("[8] SHOW TRIGGERS(触发器真实存在)") print(cli(dbname, "SELECT TRIGGER_NAME, EVENT_MANIPULATION, ACTION_TIMING, EVENT_OBJECT_TABLE" " FROM information_schema.TRIGGERS WHERE EVENT_OBJECT_SCHEMA='%s' " "ORDER BY TRIGGER_NAME;" % dbname, user=user, pw=pw).stdout) print("[9] 分区清单") print(cli(dbname, "SELECT PARTITION_NAME, PARTITION_DESCRIPTION FROM information_schema.PARTITIONS" " WHERE TABLE_SCHEMA='%s' AND TABLE_NAME='pbl_runtime_event' " "ORDER BY PARTITION_ORDINAL_POSITION;" % dbname, user=user, pw=pw).stdout) print("[10] CALL pbl_runtime_event_ensure_partitions(9)(修复前报 1305 does not exist)") r10 = cli(dbname, "CALL pbl_runtime_event_ensure_partitions(9);", user=user, pw=pw) print(" rc=%d stderr=%s" % (r10.returncode, r10.stderr.strip() or "(空)")) print(" 调用后分区数=%s" % cli(dbname, "SELECT COUNT(*) FROM information_schema.PARTITIONS" " WHERE TABLE_SCHEMA='%s' AND TABLE_NAME='pbl_runtime_event';" % dbname, user=user, pw=pw).stdout.split("\n")[1]) rc_all |= r10.returncode print("[11] append-only 守卫:非 break-glass 会话 CALL pbl_assert_append_only 必须被拒(45001)") r11 = cli(dbname, "CALL pbl_assert_append_only('pbl_runtime_event','update','probe-deny');", user=user, pw=pw) print(" rc=%d stderr=%s" % (r11.returncode, r11.stderr.strip())) if r11.returncode == 0 or "PBL_APPEND_ONLY_DENIED" not in r11.stderr: print(" FAIL: 守卫未生效") rc_all = 1 else: print(" PASS: 拒绝生效(SQLSTATE 45000 / MYSQL_ERRNO 45001)") print("[12] break-glass 放行:SET @pbl_guard_ctx='append_only_admin' 后同一 CALL 不报错") r12 = cli(dbname, "SET @pbl_guard_ctx='append_only_admin';" " CALL pbl_assert_append_only('pbl_runtime_event','update','probe-allow');", user=user, pw=pw) print(" rc=%d stderr=%s" % (r12.returncode, r12.stderr.strip() or "(空)")) rc_all |= r12.returncode print("[13] 非守护表不受影响:CALL pbl_assert_append_only('other_table',...) 应放行") r13 = cli(dbname, "CALL pbl_assert_append_only('other_table','update','probe-other');", user=user, pw=pw) print(" rc=%d stderr=%s" % (r13.returncode, r13.stderr.strip() or "(空)")) rc_all |= r13.returncode print("[14] 触发器层:INSERT 允许 / UPDATE 拒绝 / DELETE 拒绝(同会话内逐条验证)") sql14 = ( "INSERT INTO pbl_runtime_event (tenant_id,event_uid,event_type,seq,created_at)" " VALUES ('t_rtx5','rtx5_probe_a','probe',1,'2026-09-22 00:00:00');\n" "SELECT CONCAT('inserted=', ROW_COUNT()) AS r;\n" "UPDATE pbl_runtime_event SET event_type='x' WHERE tenant_id='t_rtx5';\n") r14 = cli(dbname, sql14, user=user, pw=pw) print(" UPDATE 段 rc=%d stderr=%s" % (r14.returncode, r14.stderr.strip() or "(空)")) r14b = cli(dbname, "DELETE FROM pbl_runtime_event WHERE tenant_id='t_rtx5';", user=user, pw=pw) print(" DELETE 段 rc=%d stderr=%s" % (r14b.returncode, r14b.stderr.strip() or "(空)")) if r14.returncode == 0 or r14b.returncode == 0: print(" FAIL: 触发器未拦住 UPDATE/DELETE") rc_all = 1 else: print(" PASS: INSERT 成功、UPDATE/DELETE 均被触发器拒绝") r14c = cli(dbname, "SET @pbl_guard_ctx='append_only_admin';" "DELETE FROM pbl_runtime_event WHERE tenant_id='t_rtx5';" "SET @pbl_guard_ctx=NULL;", user=user, pw=pw) print(" break-glass 清理探针 rc=%d stderr=%s" % (r14c.returncode, r14c.stderr.strip() or "(空)")) rc_all |= r14c.returncode print("\n[15] 取证结论: %s" % ("PASS" if rc_all == 0 else "FAIL")) srv.terminate() for _ in range(20): if not os.path.exists(os.path.join(SANDBOX, "my.pid")): break time.sleep(0.5) shutil.rmtree(SANDBOX, ignore_errors=True) print("[16] 沙箱 datadir 已销毁: %s 存在=%s" % (SANDBOX, os.path.exists(SANDBOX))) return rc_all if __name__ == "__main__": sys.exit(main())