pbl_compiler/scripts/audit_rbac_parity.py
2026-09-19 10:10:08 +08:00

223 lines
9.8 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_compiler 接线/RBAC 一致性机械审计QC 硬门禁自证工具)。
背景:连续多轮 QC 退回的根因都是「写了但没接线 / 接了线但没登记 RBAC」——
人肉声明不可信,故本脚本用机械比对把四张登记面钉死一致:
① pbl_compiler/init.py CONTRACTS 契约名集合
② pbl_compiler/__init__.py __all__ 导出集合(须 ⊇ CONTRACTS
③ wwwroot/api/*.dspy 实际端点文件集合(须 == CONTRACTS
④ scripts/load_path.py PATHS 登记集合(须 == CONTRACTS
并额外检查:
· load_path.py 无通配符(规范硬规定:禁 % / *
· 每个 .dspy 显式 return、除 sqlor.filter 外无顶层 import、无 ServerEnv() 取请求态
· 实现层:契约名必须能在 api.py / rules_export_api.py 中找到同名 async def防幽灵注册
· wwwroot/ 下若出现 CRUD 生成子目录,其 index.ui + get_/add_/update_/delete_*.dspy
必须在 PATHS 中逐条登记(防「生成目录未登记 → 403」复发
退出码0 全通过1 存在 FAIL。交付前必须实跑并在交付摘要引用输出。
"""
import ast
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE) # 模块仓库根
PKG = os.path.join(ROOT, 'pbl_compiler')
WWW = os.path.join(ROOT, 'wwwroot')
API = os.path.join(WWW, 'api')
failures = []
notes = []
def fail(msg):
failures.append(msg)
def ok(msg):
notes.append('PASS ' + msg)
def parse(path):
"""ast 解析:返回 (tree, 字面量常量, 顶层 def 名集合, 顶层赋值目标名)。"""
with open(path, 'r', encoding='utf-8') as fh:
tree = ast.parse(fh.read(), filename=path)
consts, defs, assigned = {}, set(), []
for node in tree.body:
if isinstance(node, ast.Assign):
for tgt in node.targets:
if isinstance(tgt, ast.Name):
assigned.append(tgt.id)
try:
consts[tgt.id] = ast.literal_eval(node.value)
except Exception: # noqa: BLE001 - 含函数引用的 dict 非字面量
consts[tgt.id] = node.value # 保留 AST 供专用提取
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
defs.add(node.name)
return tree, consts, defs, assigned
def dict_keys(node):
"""从 dict AST或已 literal_eval 的 dict取字符串键集合。
init.py 的 CONTRACTS 形如 {'name': fn_obj},值是实现函数引用,
ast.literal_eval 整体求值会失败——必须只提取键,不能因此判为空。
"""
if isinstance(node, dict):
return {k for k in node if isinstance(k, str)}
if isinstance(node, ast.Dict):
return {k.value for k in node.keys if isinstance(k, ast.Constant)
and isinstance(k.value, str)}
return set()
# ── ① init.py CONTRACTS ────────────────────────────────────────────────────
init_py = os.path.join(PKG, 'init.py')
CONTRACTS = set()
impl_defs = set()
if not os.path.isfile(init_py):
fail('缺 pbl_compiler/init.py')
else:
_, consts, _, _ = parse(init_py)
if 'CONTRACTS' not in consts:
fail('init.py 无 CONTRACTS 常量(三处同步的事实源丢失)')
else:
CONTRACTS = dict_keys(consts['CONTRACTS'])
if not CONTRACTS:
fail('init.py CONTRACTS 为空 dict')
else:
ok('init.py CONTRACTS = %d 个契约' % len(CONTRACTS))
# 实现层:契约必须在 api.py 或 rules_export_api.py 中有同名 async def
for impl in ('api.py', 'rules_export_api.py'):
p = os.path.join(PKG, impl)
if os.path.isfile(p):
impl_defs |= parse(p)[2]
ghost = sorted(CONTRACTS - impl_defs)
if ghost:
fail('CONTRACTS 注册了实现层不存在的函数(幽灵注册 → AttributeError: %s' % ghost)
else:
ok('%d 个契约在 api.py/rules_export_api.py 均有同名实现' % len(CONTRACTS))
# ── ② __init__.py __all__ ─────────────────────────────────────────────────
pkg_init = os.path.join(PKG, '__init__.py')
if not os.path.isfile(pkg_init):
fail('缺 pbl_compiler/__init__.py')
else:
_, consts, _, _ = parse(pkg_init)
all_names = set(consts.get('__all__') or [])
missing = sorted(CONTRACTS - all_names)
if missing:
fail('__init__.py __all__ 未导出 CONTRACTS 契约(漏 ② → .dspy NameError: %s' % missing)
else:
ok('__init__.py __all__ 覆盖全部 %d 个契约' % len(CONTRACTS))
# ── ③ wwwroot/api/*.dspy ──────────────────────────────────────────────────
dspy_files = sorted(f for f in os.listdir(API) if f.endswith('.dspy')) \
if os.path.isdir(API) else []
DSPY_NAMES = {f[:-len('.dspy')] for f in dspy_files}
if DSPY_NAMES != CONTRACTS:
fail('wwwroot/api/ 端点与 CONTRACTS 不一致:缺端点=%s 多余端点=%s'
% (sorted(CONTRACTS - DSPY_NAMES), sorted(DSPY_NAMES - CONTRACTS)))
else:
ok('wwwroot/api/*.dspy = %d 个,与 CONTRACTS 一一对应' % len(DSPY_NAMES))
# ── ④ scripts/load_path.py PATHS ──────────────────────────────────────────
lp_py = os.path.join(HERE, 'load_path.py')
PATH_NAMES = set()
entries = []
if not os.path.isfile(lp_py):
fail('缺 scripts/load_path.py')
else:
_, consts, _, _ = parse(lp_py)
raw = consts.get('PATHS')
if isinstance(raw, ast.List):
raw = [ast.literal_eval(e) for e in raw.elts]
if not raw:
fail('load_path.py PATHS 为空RBAC 零登记 → 全部 403')
for item in raw or []:
if not (isinstance(item, (list, tuple)) and len(item) == 2):
fail('load_path.py PATHS 元素必须是 (path, role) 二元组: %r' % (item,))
continue
entries.append((item[0], item[1]))
PATH_NAMES = {p.rsplit('/', 1)[-1][:-len('.dspy')]
for p, _ in entries if '/api/' in p}
if PATH_NAMES != CONTRACTS:
fail('load_path.py PATHS 与 CONTRACTS 不一致:未登记=%s 幽灵登记=%s'
% (sorted(CONTRACTS - PATH_NAMES), sorted(PATH_NAMES - CONTRACTS)))
else:
ok('load_path.py PATHS 登记 %d 条,与 CONTRACTS 一一对应' % len(PATH_NAMES))
for p, _role in entries:
if '%' in p or '*' in p:
fail('load_path.py 禁用通配符: %s' % p)
if not any('%' in p or '*' in p for p, _ in entries):
ok('load_path.py 无通配符(%d 条全显式)' % len(entries))
n_api = len([1 for p, _ in entries if '/api/' in p])
ok('grep 口径自证PATHS 中 /api/ 条目=%dwwwroot/api/*.dspy=%d'
% (n_api, len(dspy_files)))
if n_api != len(dspy_files):
fail('端点数与 RBAC 登记数不等:%d != %d' % (n_api, len(dspy_files)))
# ── .dspy 规范审计dspy-file-implementation-spec─────────────────────────
bad_import, no_return, env_req = [], [], []
for fname in dspy_files:
text = open(os.path.join(API, fname), 'r', encoding='utf-8').read()
for line in text.splitlines():
s = line.strip()
if s.startswith('import ') or s.startswith('from '):
if 'sqlor.filter' not in s:
bad_import.append('%s: %s' % (fname, s))
if not re.search(r'(?m)^\s*return\b', text):
no_return.append(fname)
if 'ServerEnv()' in text:
env_req.append(fname)
if bad_import:
fail('.dspy 存在非法顶层 import应由 load_*() 导出): %s' % bad_import)
else:
ok('%d 个 .dspy 无非法 import仅允许 sqlor.filter' % len(dspy_files))
if no_return:
fail('.dspy 缺显式 return隐式返回 None → 前端拿到 null: %s' % no_return)
else:
ok('%d 个 .dspy 均有显式 return' % len(dspy_files))
if env_req:
fail('.dspy 不得用 ServerEnv() 取请求态: %s' % env_req)
else:
ok('.dspy 无 ServerEnv() 取请求态')
# ── CRUD 生成目录登记面(防「生成目录未登记 → 403」───────────────────────
SKIP_DIRS = {'api', 'i18n', 'imgs', 'styles', 'scripts', 'bricks'}
crud_dirs = [d for d in sorted(os.listdir(WWW))
if os.path.isdir(os.path.join(WWW, d)) and d not in SKIP_DIRS] \
if os.path.isdir(WWW) else []
lp_paths = {p for p, _ in entries}
for alias in crud_dirs:
adir = os.path.join(WWW, alias)
for fn in sorted(os.listdir(adir)):
if fn == 'index.ui' or re.match(r'^(get_|add_|update_|delete_)', fn):
expect = '/pbl_compiler/%s/%s' % (alias, fn)
if expect not in lp_paths:
fail('CRUD 生成文件未登记 RBAC: %s' % expect)
if crud_dirs:
ok('发现 CRUD 生成目录 %d 个,已逐文件核对登记' % len(crud_dirs))
else:
ok('wwwroot/ 无 CRUD 生成子目录(只读列表走手写契约 api/pbl_script_rule_list.dspy'
'无需登记 CRUD 6 条路径')
# ── 输出 ───────────────────────────────────────────────────────────────────
print('=== pbl_compiler 接线/RBAC parity 审计 ===')
for line in notes:
print(' ' + line)
if failures:
print('--- FAIL (%d) ---' % len(failures))
for line in failures:
print(' ' + line)
print('RESULT: FAIL')
sys.exit(1)
print('RESULT: PASS (contracts=%d, dspy=%d, rbac_api_paths=%d)'
% (len(CONTRACTS), len(dspy_files), len(PATH_NAMES)))
sys.exit(0)