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:
parent
8a3eb50300
commit
5f18745ace
114
scripts/create_superuser.py
Normal file
114
scripts/create_superuser.py
Normal 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
185
scripts/security_check.py
Normal file
@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python3
|
||||
"""部署期安全检查:扫「any 授权 + 无鉴权」的危险组合,防回归。
|
||||
|
||||
背景:2026-08-25 安全审计实锤两个未授权接管路径——
|
||||
1. /rbac/add_superuser.dspy 授权给 any 且文件内零鉴权 → 未登录创建超管 → 接管平台
|
||||
2. /pipeline-sdlc/% 通配授权给 any(rbac 的 % 是前缀通配)→ 整模块对未登录开放,
|
||||
实测未登录可读 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)
|
||||
Loading…
x
Reference in New Issue
Block a user