pbl_runtime_ext/tests/evidence/rtx0005_live_evidence.py

247 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
# -*- 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。
响应 QC 退回意见 #11:全部取证输出经 _Tee **同步落盘**到
tests/evidence/rtx0005_live_evidence.txt(终端仍原样可见),取证不再只存在于
一次性 stdout / 临时目录。
凭据只在进程内使用:从 projects/pbls/env/test.json 读 db 段,不 echo、不落盘、不 commit。
用法:python3 tests/evidence/rtx0005_live_evidence.py
退出码:0=取证全部 PASS,1=任一项 FAIL(含沙箱无法拉起)。
"""
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)
# QC #11:取证正文落盘位置(归档目录 tests/evidence/,非临时目录)
EVIDENCE_TXT = os.path.join(HERE, "rtx0005_live_evidence.txt")
class _Tee:
"""把 stdout 同步复制一份写入文件,保证终端输出与取证 .txt 逐字一致。"""
def __init__(self, stream, path):
self._stream = stream
self._fh = open(path, "w", encoding="utf-8", buffering=1)
def write(self, data):
self._stream.write(data)
self._fh.write(data)
return len(data)
def writelines(self, lines):
for one in lines:
self.write(one)
def flush(self):
try:
self._stream.flush()
except Exception:
pass
self._fh.flush()
def isatty(self):
return False
def close(self):
self._fh.close()
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 git_head():
r = sh(["git", "-C", MODULE_DIR, "rev-parse", "--short", "HEAD"])
return r.stdout.strip() if r.returncode == 0 else "(not a git repo)"
def run():
print("=" * 78)
print("PBL-RTX-0005 实库取证(修复后 DDL 装载 + 守卫行为)")
print("时间: %s" % time.strftime("%Y-%m-%d %H:%M:%S %z"))
print("git HEAD: %s 模块目录: %s" % (git_head(), MODULE_DIR))
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)))
print("[17] 本取证正文已同步落盘: %s" % os.path.relpath(EVIDENCE_TXT, MODULE_DIR))
return rc_all
def main():
original = sys.stdout
tee = _Tee(original, EVIDENCE_TXT)
sys.stdout = tee
try:
rc = run()
except Exception as exc: # 取证脚本自身异常也要留痕,不允许静默
print("EXC: %r" % (exc,))
rc = 1
finally:
sys.stdout = original
tee.flush()
tee.close()
return rc
if __name__ == "__main__":
sys.exit(main())