247 lines
11 KiB
Python
247 lines
11 KiB
Python
#!/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())
|