pbl_evidence/tests/s3_sql_probe.py
agent.develop e0acb9f4af [S3-c] develop 自有 commit 真实收口探针文件(QC#16 落地)
task OqAv27u3w8DE9nirTwPp2(S3-c 子任务:git 收口取证,不改业务逻辑、
不改 gen_s3_log.py 的切片/统计逻辑、不重跑取证链)

- 6 个探针文件(s3_trigger_probe.py / s3_sql_probe.py / s3_replay_idempotency.py /
  s3_clock_skew_probe.py / s3_db_url.py / s3_clock_compare.py)文件头 docstring
  各加一行 task-key 收口标注 → 纳入 develop 自有 commit(真实变更,非 --allow-empty)
- tests/s3_evidence_chain.sh 头部注释块加一行同源标注
- tests/gen_s3_log.py 仅同步 §7「事实陈述」prose,使措辞与本 commit 的
  `git show --stat` 文件清单一致(原「探针未内嵌 task key / 非本轮新增」表述
  已与 git 事实矛盾,按 QC#16「纳入真实变更」路径改写);切片与统计代码零改动
- 选择性 git add(逐个列名 8 文件),未使用 git add -A,未纳入 __pycache__/logs
2026-09-23 03:47:15 +08:00

111 lines
4.3 KiB
Python
Executable File
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 -*-
"""S3 取证用只读 SQL 探针(可选 --drop-sandbox 收尾清理)。
task OqAv27u3w8DE9nirTwPp2(S3-c develop 自有 commit 收口标注):本探针随本轮 develop 自有提交纳入版本控制(非引擎代收口),与文档 §7 声明的文件清单一致。
在沙箱库上逐条执行 SELECT 并原样回显结果,供取证链记录"重放前后 count(*)、
主键长度回显、trigger 终态"等事实。凭据唯一事实源 =
<workspace>/projects/pbls/env/test.json 的 db.sandbox(scope=sandbox_only),
口令/账号在输出中一律脱敏为 ******。
用法::
python3 tests/s3_sql_probe.py "SELECT COUNT(*) AS c FROM pbl_evidence" "SHOW TABLES"
python3 tests/s3_sql_probe.py --schema pbl_m5a_u7rb --drop-sandbox
安全约束(硬):非 --drop-sandbox 模式下只允许 SELECT / SHOW / DESC(RIBE) /
information_schema 查询;出现 DML/DDL 关键字直接拒绝并非 0 退出,避免取证脚本
变成改数据的后门。--drop-sandbox 只允许 DROP 掉 env 里声明的 sandbox_schema。
"""
import argparse
import json
import pathlib
import re
import sys
import pymysql
TESTS_DIR = pathlib.Path(__file__).resolve().parent
REPO_ROOT = TESTS_DIR.parent
WORKSPACE_ROOT = REPO_ROOT.parent.parent
ENV_FILE = WORKSPACE_ROOT / "projects" / "pbls" / "env" / "test.json"
FORBIDDEN = re.compile(
r"\b(INSERT|UPDATE|DELETE|REPLACE|CREATE|ALTER|DROP|TRUNCATE|GRANT|REVOKE|MERGE)\b",
re.IGNORECASE)
ALLOWED_HEAD = re.compile(r"^\s*(SELECT|SHOW|DESC|DESCRIBE|EXPLAIN)\b", re.IGNORECASE)
def load_sandbox():
cfg = json.loads(ENV_FILE.read_text(encoding="utf-8"))
sb = cfg["db"]["sandbox"]
if sb.get("scope") != "sandbox_only":
raise SystemExit("refuse non-sandbox scope: %r" % sb.get("scope"))
return sb
def mask(sb, text):
out = str(text)
for secret in (sb.get("password"), sb.get("user")):
if secret:
out = out.replace(str(secret), "******")
return out
def main(argv=None):
ap = argparse.ArgumentParser(
description="S3 取证只读 SQL 探针(默认拒写;--drop-sandbox 仅清理沙箱 schema)")
ap.add_argument("sqls", nargs="*", help="要执行并原样回显的 SELECT/SHOW 语句(可多条)")
ap.add_argument("--schema", default=None,
help="沙箱 schema(缺省取 env/test.json db.sandbox.sandbox_schema)")
ap.add_argument("--drop-sandbox", action="store_true",
help="取证结束后 DROP DATABASE 沙箱 schema(不影响任何其他库)")
ns = ap.parse_args(argv)
sb = load_sandbox()
schema = ns.schema or sb["sandbox_schema"]
rc = 0
# 查询模式连到沙箱 schema(否则 SELECT 报 1046 No database selected);
# DROP DATABASE 需要不指定库的根连接,故分开建连。
def _connect(with_db):
kw = dict(host=sb["host"], port=int(sb["port"]), user=sb["user"],
password=sb["password"], charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor, autocommit=True)
if with_db:
kw["database"] = schema
return pymysql.connect(**kw)
conn = _connect(bool(ns.sqls))
try:
for sql in ns.sqls:
if not ALLOWED_HEAD.match(sql) or FORBIDDEN.search(sql):
print("REJECT(只读门禁): %s" % sql)
rc = 1
continue
print("SQL> %s" % sql)
with conn.cursor() as cur:
cur.execute(sql)
rows = list(cur.fetchall() or [])
print(" -> %d 行" % len(rows))
for r in rows:
print(" | " + json.dumps({k: mask(sb, v) for k, v in r.items()},
ensure_ascii=False))
if ns.drop_sandbox:
conn.close()
conn = _connect(False)
print("SQL> DROP DATABASE IF EXISTS `%s` (仅沙箱 schema,凭据不落盘)" % schema)
with conn.cursor() as cur:
cur.execute("DROP DATABASE IF EXISTS `%s`" % schema)
print("DROP DATABASE %s 完成" % schema)
except Exception as exc: # noqa: BLE001
print("ERROR: %s: %s" % (type(exc).__name__, mask(sb, exc)))
rc = 1
finally:
conn.close()
return rc
if __name__ == "__main__":
sys.exit(main())