security: 超管初始化改本地 CLI + 部署期安全检查脚本

- scripts/create_superuser.py: 替代已删的 /rbac/add_superuser.dspy 后门,
  部署期本地执行,强制强密码(>=10位/3类字符/拒弱口令),已存在同名用户则拒绝
- scripts/security_check.py: 部署期扫「any 授权 + 无鉴权 + 危险动作」组合防回归,
  另检 any 通配授权、指向已删文件的脏授权、终端端点匿名回退;A/B/D 类阻断部署
This commit is contained in:
yumoqing 2026-08-25 14:47:25 +08:00
parent 8a3eb50300
commit 5f18745ace
2 changed files with 299 additions and 0 deletions

114
scripts/create_superuser.py Normal file
View File

@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""创建超级管理员(部署期本地执行,不经 HTTP 暴露)。
替代已删除的 /rbac/add_superuser.dspy 那个端点被授权给 any 角色且无任何鉴权
任何人裸调 URL 即可创建 superuser/111111 超管并接管平台安全事故已移除
超管初始化属于一次性引导动作只应在部署期由运维在服务器本地执行
用法
py3/bin/python scripts/create_superuser.py --username superuser --password '强密码'
py3/bin/python scripts/create_superuser.py -u admin2 -p 'xxx' --orgid 0
安全约定
- 拒绝弱密码长度 < 10或在常见弱口令表里
- 已存在同名用户则拒绝不静默改密码避免误覆盖
- 只能本地跑依赖本机 conf/config.json DB 配置
"""
import argparse
import asyncio
import getpass
import os
import sys
APP_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, APP_DIR)
for _p in ('pkgs/apppublic', 'pkgs/sqlor', 'pkgs/ahserver', 'pkgs/rbac'):
sys.path.insert(0, os.path.join(APP_DIR, _p))
WEAK = {
'111111', '123456', '12345678', '123456789', 'password', 'admin', 'admin123',
'passw0rd', 'qwerty', 'abc123', '1qaz2wsx', 'aa123456', 'Aa123456',
}
MIN_LEN = 10
def _check_password(pwd):
if not pwd:
return "密码不能为空"
if pwd in WEAK or pwd.lower() in WEAK:
return "密码在常见弱口令表中,拒绝使用"
if len(pwd) < MIN_LEN:
return f"密码长度须 >= {MIN_LEN}(当前 {len(pwd)}"
kinds = sum([
any(c.islower() for c in pwd),
any(c.isupper() for c in pwd),
any(c.isdigit() for c in pwd),
any(not c.isalnum() for c in pwd),
])
if kinds < 3:
return "密码须至少包含大写/小写/数字/符号中的 3 类"
return ""
async def main(args):
from appPublic.jsonConfig import getConfig
from appPublic.uniqueID import getID
from appPublic.dictObject import DictObject
from sqlor.dbpools import DBPools
from ahserver.globalEnv import password_encode
from rbac.check_perm import create_user
config = getConfig(APP_DIR, {'workdir': APP_DIR})
if not config.databases:
print("ERROR: conf/config.json 里没有 databases 配置", file=sys.stderr)
return 2
DBPools(config.databases)
dbname = 'pipeline'
async with DBPools().sqlorContext(dbname) as sor:
exist = await sor.sqlExe(
"SELECT id FROM users WHERE username=${u}$", {"u": args.username})
await sor.sqlExe("COMMIT", {})
if exist:
print(f"ERROR: 用户 {args.username} 已存在(拒绝覆盖,如需改密码请走 rbac 重置密码流程)",
file=sys.stderr)
return 3
ns = DictObject(**{
"id": getID(),
"username": args.username,
"password": password_encode(args.password),
"orgid": args.orgid,
})
roles = [{"orgtypeid": "owner", "roles": ["superuser", "admin"]}]
await create_user(sor, ns, roles)
await sor.sqlExe("COMMIT", {})
print(f"OK: 超管 {args.username} 已创建orgid={args.orgid}, 角色 owner.superuser+owner.admin")
print("提示:请立即登录验证,并妥善保管密码;本脚本不回显密码。")
return 0
if __name__ == '__main__':
ap = argparse.ArgumentParser(description="创建超级管理员(部署期本地执行)")
ap.add_argument('-u', '--username', required=True, help="超管用户名")
ap.add_argument('-p', '--password', default=None,
help="超管密码(省略则交互式输入,不回显)")
ap.add_argument('--orgid', default='0', help="所属机构 id默认 0平台业主")
a = ap.parse_args()
if not a.password:
a.password = getpass.getpass("请输入超管密码(不回显): ")
again = getpass.getpass("再次输入确认: ")
if a.password != again:
print("ERROR: 两次输入不一致", file=sys.stderr)
sys.exit(1)
err = _check_password(a.password)
if err:
print(f"ERROR: {err}", file=sys.stderr)
sys.exit(1)
sys.exit(asyncio.run(main(a)) or 0)

185
scripts/security_check.py Normal file
View File

@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""部署期安全检查扫「any 授权 + 无鉴权」的危险组合,防回归。
背景2026-08-25 安全审计实锤两个未授权接管路径
1. /rbac/add_superuser.dspy 授权给 any 且文件内零鉴权 未登录创建超管 接管平台
2. /pipeline-sdlc/% 通配授权给 anyrbac % 是前缀通配 整模块对未登录开放
实测未登录可读 sd_deploy_envs部署主机/SSH 账号/路径全泄露
这类洞的成因是RBAC 授权端点内部鉴权两层各自都可能漏
组合起来才安全本脚本在部署期把危险组合拦下来
用法
py3/bin/python scripts/security_check.py # 检查,有问题退出码 1
py3/bin/python scripts/security_check.py --warn-only # 只告警,退出码恒 0
检查项
[A] any 授权指向的端点文件里没有鉴权调用get_user / check_*_owner / verify_*_token
[B] any 授权用了通配符** %覆盖整个模块目录
[C] any 授权指向已不存在的文件permission 表脏数据应清理
[D] .xterm 终端类端点存在匿名回退uid = 'user-01' 之类
"""
import argparse
import asyncio
import os
import re
import sys
APP_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, APP_DIR)
for _p in ('pkgs/apppublic', 'pkgs/sqlor', 'pkgs/ahserver'):
sys.path.insert(0, os.path.join(APP_DIR, _p))
WWWROOT = os.path.join(APP_DIR, 'wwwroot')
# 视为「已鉴权」的调用特征
AUTH_PATTERNS = [
r'get_user\(\)',
r'check_project_owner', r'check_task_owner', r'check_tenant_owner',
r'verify_llm_token', r'proxy_chat_completion', # token 鉴权端点
r'check_wechat_signature', r'verify_signature', # 回调签名校验
]
# 危险动作特征(写/删/执行)——只读端点漏鉴权风险较低,单独归类
DANGER_PATTERNS = [
(r'\bsor\.C\(|insert\s+into', '写库'),
(r'\bsor\.U\(|\bupdate\s+\w+\s+set', '改库'),
(r'\bsor\.D\(|delete\s+from', '删库'),
(r'run_shell|subprocess|create_subprocess|os\.system', '执行命令'),
(r'cmdargs', '终端进程'),
(r'create_user|password_encode', '账号操作'),
(r'shutil\.(copy|move|rmtree)|os\.remove|open\([^)]*[\'"]w', '写文件'),
]
# 白名单:设计上必须匿名可访问且已自带鉴权/无危险动作的端点
WHITELIST_PREFIXES = (
'/rbac/user/up_login.dspy', '/rbac/user/login', '/rbac/user/register',
'/rbac/userpassword_login', '/rbac/user/code_login.dspy',
'/rbac/user/sms_register.dspy', '/rbac/gen_sms_code.dspy',
'/rbac/phone_login.dspy', '/rbac/user/logout.dspy',
'/rbac/user/reset_password',
'/i18n', '/bricks/', '/assets/', '/imgs/', '/download/',
'/pipeline_core/api/llm_v1/', # token 鉴权的 LLM 代理
)
def _is_whitelisted(path):
return any(path.startswith(p) for p in WHITELIST_PREFIXES)
def _resolve_file(url_path):
"""URL 路径 → wwwroot 下的实际文件(含无扩展名回退)。找不到返回 None。"""
rel = url_path.lstrip('/')
if not rel or '*' in rel or rel.endswith('%'):
return None
cand = os.path.join(WWWROOT, rel)
if os.path.isfile(cand):
return cand
for ext in ('.dspy', '.ui', '.xterm', '.html'):
if os.path.isfile(cand + ext):
return cand + ext
if os.path.isdir(cand):
return None
return None
def _scan_file(fp):
try:
src = open(fp, encoding='utf-8', errors='ignore').read()
except OSError:
return True, []
has_auth = any(re.search(p, src) for p in AUTH_PATTERNS)
dangers = [label for pat, label in DANGER_PATTERNS if re.search(pat, src, re.I)]
return has_auth, dangers
async def main(warn_only):
from appPublic.jsonConfig import getConfig
from sqlor.dbpools import DBPools
config = getConfig(APP_DIR, {'workdir': APP_DIR})
if not config.databases:
print("SKIP: 无 databases 配置,跳过 RBAC 检查")
return 0
DBPools(config.databases)
findings = {'A': [], 'B': [], 'C': [], 'D': []}
async with DBPools().sqlorContext('pipeline') as sor:
recs = await sor.sqlExe(
"SELECT p.path FROM permission p JOIN rolepermission rp ON rp.permid=p.id "
"WHERE rp.roleid='any'", {})
await sor.sqlExe("COMMIT", {})
any_paths = sorted({(getattr(r, 'path', '') or '').strip() for r in (recs or [])})
for path in any_paths:
if not path or _is_whitelisted(path):
continue
# [B] 通配授权
if path.endswith('**') or path.endswith('%') or path.endswith('/*'):
findings['B'].append(path)
continue
fp = _resolve_file(path)
if fp is None:
# [C] 指向不存在的文件(脏数据);静态资源目录/根路径跳过
if path not in ('/', '') and not path.endswith(('.js', '.css', '.png', '.svg',
'.ico', '.woff', '.woff2', '.ttf')):
findings['C'].append(path)
continue
if fp.endswith(('.js', '.css', '.png', '.svg', '.ico', '.md')):
continue
has_auth, dangers = _scan_file(fp)
# [A] any 授权 + 无鉴权 + 有危险动作
if not has_auth and dangers:
findings['A'].append(f"{path} [{'+'.join(dangers)}]")
# [D] 终端类端点匿名回退
for root, _dirs, files in os.walk(WWWROOT):
for fn in files:
if not fn.endswith('.xterm'):
continue
fp = os.path.join(root, fn)
try:
src = open(fp, encoding='utf-8', errors='ignore').read()
except OSError:
continue
if re.search(r"if\s+not\s+uid\s*:\s*\n\s+uid\s*=\s*['\"]", src):
findings['D'].append(os.path.relpath(fp, APP_DIR))
titles = {
'A': 'any 授权 + 端点无鉴权 + 含危险动作(未授权写/删/执行)',
'B': 'any 通配授权(覆盖整个模块目录,安全全靠各端点自觉)',
'C': 'any 授权指向不存在的文件permission 表脏数据,应清理)',
'D': '终端类端点存在匿名回退(未登录被当成某个用户)',
}
total = sum(len(v) for v in findings.values())
print("=" * 60)
print("部署期安全检查any 授权 + 鉴权组合")
print("=" * 60)
for k in ('A', 'B', 'C', 'D'):
items = findings[k]
mark = '🔴' if k in ('A', 'B', 'D') else '🟠'
print(f"\n[{k}] {titles[k]}: {len(items)}")
for it in items:
print(f" {mark} {it}")
print(f"\n合计 {total}")
blocking = len(findings['A']) + len(findings['B']) + len(findings['D'])
if blocking and not warn_only:
print(f"\n❌ 存在 {blocking} 项阻断级问题A/B/D部署应中止。"
f"\n 修法:删除多余 any 授权(改 logined 或具体角色)+ 端点内补鉴权;"
f"\n 改完执行 redis-cli DEL sc:rbac:role_perms 并重启。")
return 1
if total:
print("\n⚠️ 存在非阻断项C 类脏数据建议清理)")
else:
print("\n✅ 未发现危险组合")
return 0
if __name__ == '__main__':
ap = argparse.ArgumentParser(description="部署期 RBAC 安全检查")
ap.add_argument('--warn-only', action='store_true', help="只告警不阻断(退出码恒 0")
a = ap.parse_args()
sys.exit(asyncio.run(main(a.warn_only)) or 0)