Add static security/quality audit reports for all 18 Go service modules under module/, plus a consolidated index (docs/audit/README.md) with per-module statistics, top risks, cross-module systemic defects and a phased TODO list (T1-T19). No production code is modified.
62 KiB
审计报告:module/base/mgt
1. 模块概览
module/base/mgt 是后管基础模块(服务名 mgt),承载后台管理的登录、用户、应用、角色、权限、部门六大子域,对外暴露 Gin REST 接口,前缀 /rest/mgt(cmd/main/main.go:25 + internal/routers/register.go:21)。
- 技术栈:Gin
v1.12.0+ GORMv1.31.2(MySQL/PostgreSQL)+ Redis +gin-contrib/sessionscookie store + etcd(go.mod:5-14)。 - 鉴权模型:JWT(HS256,
Authorization头)+ SDKmiddleware.JwtAuth(true)(校验过期)+ 本模块自定义的mgtmw.RequireAdmin()(仅要求「root 账号或名为『超级管理员』的角色」)。没有基于权限点(permission code)的接口级鉴权。 - 分层:
cmd/main(进程入口)→internal/routers(路由)→internal/logic/*(业务)→internal/models(GORM 模型 + 自动迁移 + root 种子数据)→internal/impl(Redis/DB/etcd/go-cache 全局单例)。 - 3 个
etc/*.yaml配置(dev/test/prod),prod 的 DB/Redis/SecretKey 均为CHANGE_ME占位符。 - 规模:78 个非 pb Go 文件、约 5269 行(与任务描述一致);
internal/logic下 7 个子包,internal/models下 12 个模型 + 8 张关联表。
关键风险画像:默认口令 + 默认 JWT 密钥构成「开箱即被接管」的完整链路;写接口只做「超级管理员」粗粒度校验,任何拥有该角色的人可对任意用户/角色/部门全量赋权且无审计;三处 Updates+Select 会把请求中省略的字段写成零值(含 account/phone/code)。
2. 审计范围与方法
2.1 覆盖方式
先侦察全部非 pb Go 文件、etc 配置、cmd/main 与 internal/routers/register.go 的完整路由表,再以「路由 → 中间件 → logic 写操作」为主线逐条核对,最后核对 SDK 侧依赖(D:\work\bsm-sdk\core,go.mod:84 的 replace 指向该本地目录)、internal/models/init_db.go 种子数据、GORM v1.31.2 更新语义(读源码 + 隔离环境 DryRun 实测)。
2.2 命令执行结果
| 命令 | 结果 |
|---|---|
gofmt -l .(在 module/base/mgt) |
无输出,exit 0(格式干净) |
go vet ./...(在 module/base/mgt) |
无输出,exit 0(无 vet 告警) |
| GORM 零值更新语义实测 | 在仓库外临时目录 D:\work\bsm-infra\full\.builds\_tmp_gorm_probe(已删除,未触碰任何被审计文件)对 gorm v1.31.2 打桩 DryRun 实测,用于问题 3 / 问题 4(TODO 编号 P1-3 / P1-4)的判定 |
| GORM 删除钩子语义核对 | 直接阅读本地 module cache 的 gorm.io/gorm@v1.31.2\callbacks\{delete.go,callmethod.go,update.go} 与 statement.go 源码,用于 P2-17 与 P1-3/P1-4 的判定(含 SelectAndOmitColumns 与 ConvertToAssignments 的零值分支) |
| 网关/gRPC 适用性核对 | `Select-String "internal/server |
命名返回值/裸 return 核对 |
逐函数核对本模块命名返回值与裸 return(唯一命中 internal/models/impl.go:37,47,已确认不会吞错),见 2.5 附注 |
未执行 go build/go test(会写 go.sum/构建缓存,且非本任务要求)。
2.3 已覆盖子域
- pub(登录/刷新/重置密码):
login.go、refresh.go、forget.go全文。 - middleware:
rbac.go全文(RequireAdmin/IsSuperAdmin/EnsureSelfOrAdmin)。 - models:
impl.go、init_db.go、user.go、role.go、permission.go、application.go、department.go、全部 8 张link_*.go、query.go(空文件)。 - user:
create.go、del.go、detail.go、fetch.go、list.go、modify.go、set_role.go、set_pmn.go、fetch_app.go、fetch_pmn.go、fetch_role.go全文。 - role:
create.go、del.go、detail.go、fetch.go、fetch_other.go、modify.go、set.go全文。 - permission:
create.go、del.go、detail.go、fetch.go、fetch_other.go、modify.go、sort.go全文。 - department:
create.go、del.go、detail.go、fetch.go、fetch_pmn.go、fetch_pmn_tree.go、fetch_tree.go、list.go、modify.go、set_pmn.go、user.go全文。 - application:
create.go、del.go、detail.go、fetch.go、fetch_other.go、modify.go全文。 - 其他:
cmd/main/main.go、internal/config/config.go、internal/impl/{impl,with}.go、internal/libs/validator.go、internal/types/{req,resp,types,vars}.go、internal/logic/{hello,tool}、service/{expose,dependencies}.go、etc/mgt_{dev,test,prod}.yaml、doc/*.md(7 篇)、test/*.http(54 个)、internal/routers/register_test.go。
2.4 未覆盖 / 无法验证
- 未做动态验证:无可用 DB/Redis 实例,所有越权、爆破、数据损坏结论均为静态代码推演;GORM 零值语义除外(已 DryRun 实测)。
- 未审计 SDK 全量:仅按需审计了
core/middleware/{jwt,cors}.go、core/crypto/token/jwt.go、core/env/env.go、core/service/meta.go、core/types/db.go、core/vars/{jwt,status}.go,未覆盖 SDK 的 DB/Redis 连接、infra.Response序列化、printer日志落盘等实现细节。 - 未审计跨模块调用方:
module/all如何挂载service.Expose(是否叠加网关级限流/鉴权)、module/base/sender如何写入短信验证码键(仅确认共用前缀/SMS/Code/)。 - 未逐条核对 54 个
test/*.http:仅确认其为请求样例、无断言。 - 前端/网关:
bsm-infra/gateway是否对/rest/mgt做额外保护、是否强依赖BSM_JwtSecretKey环境变量,未验证(无法从本仓库确认部署态环境变量)。
2.5 关于「internal/server 只注册 gRPC 导致 HTTP 网关不可用」这一跨模块模板缺陷:本模块不适用
该缺陷不适用于 mgt,无需为本模块计入风险。依据:
- mgt 是纯 Gin HTTP 模块,没有任何 gRPC/网关/protobuf 代码——全模块
Select-String "internal/server|gwRuntime|NewServeMux|RegisterHandlerServer|Gateway"零命中,且module/base/mgt下不存在任何.pb.go与.proto文件。 - 独立部署入口自建
*gin.Engine并直接注册路由:cmd/main/main.go:32-42(app := gin.Default()→routers.Register(ServiceKey, app)),全程不经过 gRPC Mux。 service/expose.go:17-23的func Expose(options ExposeOptions) error在本仓库内无任何调用方(全模块仅此定义处命中),属于供外部宿主使用的可选入口;它同样只是把options.Engine交给routers.Register,不涉及 gRPC 网关。因此该全局模板缺陷对本模块既不影响部署可用性,也不构成独立风险。
附:本模块未发现「命名返回值未赋值 → 恒返回 nil」类缺陷。唯一使用命名返回值 + 裸 return 的位置是 internal/models/impl.go:37(func New(...) (err error))的 :47,其 err 在 switch 各分支已被 :42/:44 赋值,且 :46 的 log.Fatalln 不会返回,故裸 return 不会吞错;其余函数均为显式返回值(gofmt -l/go vet ./... 亦无告警)。
3. 问题清单
P0
1. root 默认口令 123456 硬编码并在启动时自动播种(README 审计记录已证实仍存在)
- 位置:
module/base/mgt/internal/models/init_db.go:15-17、:60;配置开关module/base/mgt/etc/mgt_dev.yaml(InitRootUser: true);触发点module/base/mgt/internal/models/impl.go:69-71;文档module/base/mgt/README.md:168-172、module/base/mgt/test/login.http:5-6 - 证据:
// internal/models/init_db.go:14-17 salt := utils.UUID() account := "root" password := "123456" hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password+salt), bcrypt.DefaultCost)// internal/models/impl.go:69-71 —— AutoMigrate 后紧跟 root 播种 if config.InitRootUserEnabled() { InitRootUserData()# etc/mgt_dev.yaml InitRootUser: true<!-- README.md:169-172 --> - **账号**: `root` - **密码**: `123456` > ⚠️ **安全提示**: 生产环境请务必修改默认密码! - 影响:任何能访问
/rest/mgt/login的人知道口令即可登录 root;而IsSuperAdmin对account == "root"直接返回 true(internal/middleware/rbac.go:24-26),因此默认口令一条请求即等于全系统最高权限(可改任意用户密码、赋任意权限、删除任意应用/部门)。etc/mgt_dev.yaml已将开关打开,若该文件被用于任何可达网络的环境(README 的快速开始正是让开发者照此配置),即为可被直接利用的接管漏洞。 - 建议:删除硬编码口令,改为「首次启动从环境变量/secret 读取初始口令,缺失则随机生成 24 位并一次性打印到 stdout 且标记强制改密」;
InitRootUserData增加「已初始化则跳过」之外的二次确认;把README.md/test/login.http中的明文口令改为占位符。
2. JWT 密钥存在硬编码默认值 Cblocksmesh2022C,可离线伪造任意身份(含 root)
- 位置:SDK
D:\work\bsm-sdk\core\env\env.go:19;本模块签发与校验均取该值(module/base/mgt/internal/logic/pub/login.go:90、internal/logic/pub/refresh.go:28);go.mod:84将 SDK 指向本地目录../../../../../bsm-sdk/core - 证据:
// D:\work\bsm-sdk\core\env\env.go:17-22 Runtime = &types.RuntimeEnv{ Workspace: GetEnvDefault("BSM_Workspace", "default"), JwtSecretKey: GetEnvDefault("BSM_JwtSecretKey", "Cblocksmesh2022C"),默认值长度恰为 16 字节,通过// internal/logic/pub/login.go:90 resp.Token, err = token.New(env.Runtime.JwtSecretKey).GenerateJwt(uint(user.ID), user.Identity, c.ClientIP(), "", nil, extend)core/crypto/token/jwt.go:34-37的keyLen == 16 || 24 || 32校验,因此在未设置BSM_JwtSecretKey的部署里签发与验签都会静默使用这个公开字符串。 - 影响:攻击者用公开默认密钥自行 HS256 签名一个
{"id":1,...}的 token 即可通过JwtAuth(true)的过期校验与验签,被RequireAdmin认定为 root(ID=1)。etc/*.yaml里的SecretKey: CHANGE_ME与 JWT 无关(仅用于 session key 拼接),运维极易忽略该环境变量。此外 SDK 解析器不校验alg白名单,一旦密钥泄露也无法通过轮换密钥快速止损(无 kid/版本)。 - 建议:SDK 侧改为「未显式配置即
log.Fatal退出」;mgt在config.New中显式校验env.Runtime.JwtSecretKey长度 ≥32 且不等于内置默认值,否则拒绝启动;签发时使用jwt.WithValidMethods([]string{"HS256"})。
P1
3. user/modify 用 Select+结构体更新,请求中省略的字段被写成零值(禁用账号 + 擦除 account/phone/email/avatar)
- 位置:
module/base/mgt/internal/logic/user/modify.go:57-72(路由POST /rest/mgt/user/modify,internal/routers/register.go:60) - 证据:
隔离环境对 gorm v1.31.2 的 DryRun 实测(同构结构体 + 同
// user/modify.go:62-69 userModel := models.MgtUser{ Name: request.Name, Phone: request.Phone, Email: request.Email, Avatar: request.Avatar, Std_IICUDS: coreTypes.Std_IICUDS{Status: request.Status} } updateFields := []string{"name", "phone", "email", "avatar", "status"} ... impl.DBService.Model(&models.MgtUser{}).Where("id = ?", request.ID).Select(updateFields).Updates(&userModel)Select列表):即B with-select SQL: UPDATE t SET status=?,name=?,title=?,desc=? WHERE id = ? B with-select VARS: [0 0 t3 t3 1 1] # 未提供的列仍被写入零值 A no-select SQL: UPDATE t SET title=? WHERE id = ? # 对照组:仅更新非零字段Select显式列名会强制写入零值。internal/types/req.go:9的Status int8无默认值,请求体不带status时写入 0(core/types/db.go:34:0=未定义,-1 禁止,1 正常)。 - 影响:任何超管调用一次「只改姓名」的
POST /user/modify,就会把该用户的account、phone、email、avatar清空、status置 0;由于login只在stat == vars.DisabledStatus(-1)时拒绝(pub/login.go:118-123),账号不会被锁死但已无法用 account 登录、无法按手机号找回密码,且account是uniqueIndex,第二个被清空的用户会触发唯一冲突导致更新失败。属于确定性数据损坏。 - 建议:改用
map[string]interface{}只装请求中显式提供的字段(配合*int8/指针类型区分「未提供」与「零值」),或先First取原记录再局部赋值;同时为modify增加「不允许修改 root 账号」的保护。
4. permission/modify 同样以零值覆盖 code / parent_id / title / menu_path / component 等字段
- 位置:
module/base/mgt/internal/logic/permission/modify.go:123-144(路由POST /rest/mgt/pmn/modify,register.go:109);重复分支:67-89 - 证据:
// permission/modify.go:140-141 updateFields := []string{"status", "code", "title", "parent_id", "menu_path", "menu_icon", "type", "component", "description", "is_web_page", "is_new_tab", "is_full", "hide_menu", "web_url"} impl.DBService.Model(&models.MgtPermission{}).Where("id = ?", request.ID).Select(updateFields).Updates(&dataModel)request.Code等来自types.PermissionRequest(internal/types/req.go:57-76),全部omitempty,省略即为零值。 - 影响:只改标题的一次调用会把该权限的
code清空、parent_id归 0(菜单从二级掉到顶级)、menu_path/component清空,导致前端菜单整体错乱;code是uniqueIndex:idx_mgt_pmn_app_code的联合唯一键(internal/models/permission.go:10-12),清空后同应用第二条修改即唯一冲突失败。与 P1-3 同类,合并修复。 - 建议:同 P1-3,或至少对
Code/Title做「空则沿用原值」的显式回填。注意role/modify.go:62、application/modify.go:66、department/modify.go:107未使用Select,实测不会写零值(对照组 A),无需改动——这一点已用 DryRun 验证,勿被同类代码误导。
5. 赋权接口不校验权限与应用的归属,且放任权限跨应用/自引用成环(越权与树污染)
- 位置:
module/base/mgt/internal/logic/user/set_pmn.go:33-63、:90-125;internal/logic/role/set.go:33-66、:92-126;反例(做对了的写法)internal/logic/department/set_pmn.go:42-64 - 证据:
部门侧有归属校验(可对照),用户/角色侧缺失:
// user/set_pmn.go:40-47 —— 只校验 v != 0,未校验权限是否属于该 appId for _, v := range request.List { if v == 0 { ... } pmnData = append(pmnData, models.MgtLinkUserPmn{AppId: appId, PmnId: v, UserId: request.Id})另一个环/跨应用来源:// department/set_pmn.go:51-55 if dpt.AppID != appId { printer.Error("部门不属于当前应用: dpt_id=%d", request.Id) infra.Response.Error(c, errcode.ErrInvalidArgument)permission/create.go:71与permission/modify.go:72,127直接落库ParentID: request.ParentID,既不校验父权限是否属于同一appId,也不做自引用/后代检查。 - 影响:写入
mgt_link_user_pmn{app_id=A, pmn_id=<B 应用的权限>}这类跨应用脏关联后,user/fetch_pmn.go:210-217的权限树按app_id = apps[i].ID AND id IN ?查询会查不到该权限却仍把它算作「已授权」,前端展示与后端判定长期不一致;父级成环会让department/fetch_pmn_tree.go:109-130的buildPermTreeFilter递归不收敛(栈溢出 DoS),与 P1-11 联动。 - 建议:所有
set_pmn/modify_pmn在写关联前用一条SELECT id FROM mgt_permission WHERE id IN ? AND app_id = ?校验集合完整性,数量不符即拒绝;权限创建/修改时校验parent_id属于同应用且不等于自身、且不在自身后代集合内(复用department/del.go:17的递归工具)。
6. 重置密码接口匿名可调,无验证码尝试次数限制 / 无限流 / 无验证码,且账号手机号可枚举
- 位置:
module/base/mgt/internal/routers/register.go:36(anonymous.POST("/reset", pub.ForgetPwdBySms));实现internal/logic/pub/forget.go:22-99 - 证据:
// register.go:27-38 —— 整个 anonymous 组的 JwtAuth 被注释掉 func registerAnonymous(base string, engine *gin.Engine) { anonymous := engine.Group(base) { // anonymous.Use(middleware.JwtAuth()) anonymous.GET("/ping", hello.Ping) anonymous.GET("/session", hello.SessionDemo) anonymous.POST("/login", pub.Login) anonymous.POST("/refresh", pub.Refresh) anonymous.POST("/reset", pub.ForgetPwdBySms) // 重置密码账号/手机号比对失败与验证码错误的响应码不同(// pub/forget.go:57-73 —— 比对通过即改密;全程无尝试计数、无失败锁定、无图形验证码 smsKey := types.KeyPrefix + request.Phone storedCode, err := impl.RedisService.Client.Get(impl.RedisService.Ctx, smsKey).Result() ... if storedCode != request.Code { ... infra.Response.Error(c, errcode.ErrInvalidArgument); return }forget.go:51-55与:69-73),可用于枚举。// pub/login.go:64-71 账号不存在返回 ErrRecordNotFound,密码错返回 ErrPassword —— 可直接枚举账号 - 影响:短信验证码为 6 位数字(
doc/pub.md:123-127),本模块对同一手机号不限制尝试次数,验证码 TTL 内可爆破验证码 + 已知/枚举出的account+phone组合即可匿名重置任意用户(含 root)的密码并接管账号;/login与/reset的错误码差异使攻击者可以先枚举出合法账号与绑定手机号。注意test/reset.http:7使用"code": "123456"暗示测试环境验证码固定。边界说明:验证码的生成、TTL 与下发不在本模块内(本模块只读取types.KeyPrefix = "/SMS/Code/",与module/base/sender/internal/logic/sms/const.go:5同前缀,写入方为 sender),因此 TTL 长度与「同一验证码可尝试次数」无法从本模块源码确证,属推测;可确证的是本模块侧没有任何尝试计数与限流代码。 - 建议:
/reset与/login加 IP+账号维度限流(Redis 计数 + 指数退避)、验证码错误计数(如 5 次即作废该验证码)、统一「账号/验证码错误」响应码与响应耗时;为/reset加图形/滑块验证码;对 root 等高权限账号强制二次校验(邮箱/OTP)。
7. 全部管理端接口仅以「root 或角色名为『超级管理员』」做粗粒度授权,无接口级权限点校验
- 位置:
module/base/mgt/internal/middleware/rbac.go:19-54;路由挂载internal/routers/register.go:42-43,56,72,86,104,118 - 证据:
// rbac.go:14-16, 30-36 const ( superAdminRoleName = "超级管理员"; rootAccount = "root" ) ... for _, role := range user.Roles { if role.Name == superAdminRoleName { return true } }// rbac.go:47-51 —— 唯一判定;路由表里没有任何 per-route 权限点 if !IsSuperAdmin(auth.ID) { infra.Response.Error(c, errcode.ErrPermissionDenied)internal/types/vars.go:5-6定义了Admin = "SYSADMIN"、Staff = "STAFF",但全模块无任何引用(仅此一处定义)。 - 影响:权限体系(
mgt_permission.code、mgt_link_role_pmn、mgt_link_user_pmn、部门权限)只用于「界面展示哪些菜单」,不参与任何接口鉴权。任何被授予「超级管理员」角色的账号可越权访问全部 50 个管理端接口,包括给任意用户/角色赋任意权限(P1-5)、改任意用户密码。同时系统只支持「超管」一档权限,无法实现「管理员只能管本应用/本部门」的垂直隔离。RequireAdmin判定依赖角色名称字符串,重命名角色即失效(role/modify.go允许改名)。 - 建议:建立路由→权限点(
permission.Code)映射中间件(如RequirePmn("mgt:user:modify")),逐个接口挂载;将「超管」判定改为不可变标识(role.Identity或专用 flag)而非name;补齐Admin/Staff或删除死常量。
8. role/del 按 (user_id, pmn_id) 笛卡尔积删除用户直接权限 → 误撤销经其他角色获得的权限
- 位置:
module/base/mgt/internal/logic/role/del.go:40-61(路由POST /rest/mgt/role/del) - 证据:
对比
// role/del.go:55-61 if len(pmnId) > 0 && len(userIds) > 0 { if err := tx.Where("user_id in ? and pmn_id in ?", userIds, pmnId). Delete(&models.MgtLinkUserPmn{}).Error; err != nil {internal/logic/user/set_role.go:120-181(DelRole)——那里做了「该权限是否由用户其他角色提供」的差集判断(otherPmnSet),可证明此处是遗漏而非设计。 - 影响:删除角色 R 时,只要某用户同时拥有另一角色 R2(R2 也含权限 P),该用户的直接授权
(user, P)会被一并删除,导致「不应失去的权限」丢失;同时该操作不清理部门维度(mgt_link_dpt_role)之外的应用关联,而mgt_link_user_app本应由MgtLinkUserPmn.AfterDelete钩子在「该应用下再无任何权限」时清理——该钩子在批量删除场景下以全零接收者执行、恒为空操作(详见 P2-17,internal/models/link_user_pmn.go:26、link_role_pmn.go:26),因此用户/角色与应用的关联会残留成孤儿,前端「我的应用」出现无权限的空应用。 - 建议:
role/del复刻user/set_role.go的差集逻辑,只删除「不再由任何剩余角色提供的」直接授权;把应用关联清理从AfterDelete钩子改为事务内显式 SQL(或在批量删除后统一重算)。
9. /refresh 匿名可调且不校验过期时间与账号状态,配合超长有效期形成可无限续期会话
- 位置:
module/base/mgt/internal/routers/register.go:35;实现internal/logic/pub/refresh.go:21-34;SDK 解析器D:\work\bsm-sdk\core\service\meta.go:19-49、core/crypto/token/jwt.go:76-98 - 证据:
// pub/refresh.go:22-28 claims, err := service.ParseMetaCtx(c, nil) // opts==nil → 不做角色校验、不做 mustPrivate 校验 ... token, err := token.New(env.Runtime.JwtSecretKey).GenerateJwt(claims.ID, claims.Identity, claims.Client, claims.Role, nil, claims.Extend)ParseMetaCtx只调用ParseJwt(签名校验),不调用IsExpired;JwtAuth里的过期校验(SDKmiddleware/jwt.go:32-47)在/refresh上因未挂载而完全不生效。 - 影响:持有一个已过期但签名合法的 token 即可换取新的 24 小时 token,会话实际上永不过期;token 被盗后无法通过「等它过期」止损,也无法通过改密失效(无
SaveToken/黑名单调用,pub/login.go:108-116的SaveToken是 dead code)。token 内还携带手机号之外的extend(id/Identity/status/name)。边界说明:ParseMetaCtx仍会调用ParseJwt做 HS256 验签(core/service/meta.go:31、core/crypto/token/jwt.go:64-73),因此该缺陷只放大到「过期 token 可续期」,并不等于可伪造 token(伪造能力来自 P0-2 的默认密钥)。 - 建议:
/refresh挂JwtAuth(true)(强制过期校验),并在 handler 内回落数据库复核账号状态与是否存在,再加pwd_version/token_version声明实现改密即失效;补齐登出接口与 Redis 黑名单。
10. 登录与重置无失败计数/锁定/限流(可无限次猜口令)
- 位置:
module/base/mgt/internal/logic/pub/login.go:35-106(checkPwdAndStatus:118-145);internal/logic/pub/forget.go:22-99;cmd/main/main.go:32-42未挂任何限流中间件 - 证据:
// login.go:125-141 —— 失败仅返回错误,无失败计数与延迟 err := bcrypt.CompareHashAndPassword([]byte(userPwd), []byte(inPwd+salt)) if err == nil { return nil } ... if userPwd == md5Hash { return nil } return errcode.ErrPassword// main.go:38-42 —— 全局中间件只有 Mode 与 Cors middleware.Mode(app) app.Use(middleware.Cors()) - 影响:结合 P0-1 的弱默认口令(
123456、min=6无复杂度)与 P1-6 的账号枚举,攻击者可对 root 账号做在线字典爆破;bcrypt 的 CPU 成本也构成单请求 CPU 放大,可用于资源耗尽。 - 建议:引入 Redis 计数限流(同 IP/账号 5 次失败锁定 15 分钟 + 递增延迟),并为弱口令建立拒绝清单。
11. 部门树/权限树构建无环检测,脏数据可致无限递归(进程崩溃)
- 位置:
module/base/mgt/internal/logic/department/del.go:17-37、internal/logic/department/list.go:71-83、internal/logic/department/fetch_pmn_tree.go:109-130 - 证据:
// department/del.go:29-36 —— 无 visited 集合,环即无限递归 var collectChildren func(uint) collectChildren = func(deptId uint) { result = append(result, deptId) for _, childId := range childrenMap[deptId] { collectChildren(childId) } }环数据的来源:// department/list.go:71-83 func buildDeptTree(list []models.MgtDepartment, parentId uint) []models.MgtDepartment { var node []models.MgtDepartment for _, d := range list { if d.ParentID != parentId { continue }; children := buildDeptTree(list, d.ID) ...department/modify.go:51-99虽校验了自引用与「新父节点不是自身后代」,但该检查是先读后写、非原子(并发两次互为父级的修改可绕过),且permission.modify.go:72,127完全没有环/同应用校验。 - 影响:一旦
parent_id成环(并发写入或直接改库/历史数据),POST /rest/mgt/dpt/del、/dpt/list?tree=true、/dpt/pmn_tree会无限递归导致 goroutine 栈溢出,进程崩溃且每次调用都必然崩溃(持久性 DoS)。 - 建议:所有递归遍历加
visited map[uint]struct{}并在检测到环时返回错误(同时记录脏数据告警);把树操作收敛到一个带环检测的公共工具包。
P2
12. 创建用户/角色/权限/应用时物理删除同名的软删除记录,留下多表孤儿数据
- 位置:
internal/logic/user/create.go:57-72、internal/logic/role/create.go:49-56、internal/logic/permission/create.go:52-58、internal/logic/application/create.go:46-69 - 证据:
// user/create.go:57-64 var deletedData models.MgtUser if err = impl.DBService.Unscoped().Where("phone = ?", request.Phone).First(&deletedData).Error; err == nil { if err = impl.DBService.Unscoped().Delete(&deletedData).Error; err != nil { ... } printer.Info("user deleted: id=%d, phone=%s", deletedData.ID, deletedData.Phone) - 影响:为绕开
uniqueIndex而物理删除历史用户,但mgt_link_user_role/mgt_link_user_app/mgt_link_user_pmn/mgt_link_user_dpt中该 user_id 的关联行仍在(无外键约束,模型里没有constraint标签)。当新用户复用到同一自增 ID 时,会继承前一个用户的角色/权限/部门/应用关联,造成越权与数据错乱;应用被物理删除同理会让mgt_permission/mgt_link_*大面积悬空。 - 建议:不要物理删除;改为「复用并覆盖」或「先事务内清理全部关联再物理删除」;为关联表补外键或定期孤儿清理任务。
13. 部门/角色/权限/应用列表查询在循环内访问数据库(N+1)
- 位置:
internal/logic/department/fetch_pmn.go:74-94与internal/logic/department/fetch_pmn_tree.go:82-89(buildPermTreeFilter每层递归重扫全量list)、internal/logic/user/fetch_pmn.go:88-99(4 层嵌套Preload) - 证据:
// department/fetch_pmn_tree.go:82-89 —— 每个部门都对同一份 perms 全量重扫并建树 for i := range data { ids := dptToPmn[data[i].ID] idSet := make(map[uint]struct{}) for _, id := range ids { idSet[id] = struct{}{} } data[i].Permissions = buildPermTreeFilter(perms, 0, idSet) }// user/fetch_pmn.go:88-99 —— 用户→应用/直接权限/角色权限/部门权限/部门角色权限 Preload("Permissions", ...).Preload("Roles", func(db){ ...Preload("Permissions") }). Preload("Departments", func(db){ ...Preload("Permissions")...Preload("Roles", ...Preload("Permissions")) }) - 影响:
O(部门数 × 权限数^深度)的 CPU 开销;Preload链一次请求触发约 7-8 条 SQL。数据量上千后这两个接口(前端菜单渲染必调)成为明显瓶颈。 - 建议:一次取全部权限后按
parent_id建索引字典,各层 O(1) 查找并共享children缓存;Preload改为一次性 join/IN 查询后在内存组装。
14. 列表分页排序不确定、size 无上限、Order 缺省
- 位置:
internal/logic/user/fetch.go:44-48、internal/logic/application/fetch.go:44-48、internal/logic/role/fetch.go:46-50、internal/logic/department/fetch.go:63-67(均无Order);internal/logic/tool/tool.go:5-14(仅默认值,无上限) - 证据:
// tool/tool.go:5-14 func GetSizeAndPage(page, size int) (int, int) { if page <= 0 { page = types.DefaultPage } if size <= 0 { size = types.DefaultSize } return page, size // 没有最大值截断 }// user/fetch.go:45 —— 无 Order,跨页结果不稳定 if err := db.Count(&count).Limit(size).Offset((page - 1) * size).Find(&data).Error; err != nil { - 影响:
size=100000可被匿名/超管用于拉全表(内存与带宽放大);无ORDER BY时 PostgreSQL/MySQL 不保证行序,翻页会重复/漏数据;role/fetch_other.go的UserFetch/ApplicationsFetch/PermissionsFetch与pmn/user、pmn/role、dpt/user完全无分页。 - 建议:
size上限(如 200)并在超限时 400;所有列表补Order("id asc")之类确定性排序;无分页的关联查询补Limit/Offset。
15. 无结构化日志与审计日志:高危操作均只写标准库 log/printer,不含操作者与变更前后值
- 位置:全模块
printer.Info/Error与log.Printf(示例:internal/logic/user/set_pmn.go:70、internal/logic/role/del.go:94、internal/logic/user/del.go:76、internal/logic/pub/forget.go:98) - 证据:
// user/set_pmn.go:70 printer.Info("用户权限设置成功: userId=%d", request.Id)// pub/forget.go:98 printer.Info("密码重置成功: account=%s, phone=%s", request.Account, request.Phone) - 影响:赋权、改密、删除用户/角色/应用这些高危动作无法追溯操作者、来源 IP、变更内容,
RequireAdmin放行后也没有留下「谁给谁授权」的记录。发生权限滥用时无法定责,也无法比对变更。相反pub/login.go:58,80,121,133,143会打印失败原因(含账号),日志中还有 PII。 - 建议:新增
mgt_audit_log(操作者 ID/IP/路由/请求摘要/前后值/结果)并在所有写 handler 落库;日志改为结构化(zap)并脱敏账号/手机号。
16. 三处查询引用了不存在的列名(code like ?)或 ? 与参数不匹配,接口直接报错
- 位置:
internal/logic/application/fetch.go:36-39、internal/logic/permission/fetch.go:37-40 - 证据:
// application/fetch.go:36-39 —— mgt_application 没有 code 列;两个 ? 只传一个参数 if request.Keyword != "" { likeStr := "%" + request.Keyword + "%" db = db.Where("title like ? or code like ? ", likeStr) }// permission/fetch.go:37-40(同样的 2 个占位符 + 1 个参数) db = db.Where("title like ? or code like ? ", likeStr) - 影响:
mgt_application表结构只有title/workspace/description(internal/models/application.go:6-17),引用code列会直接 SQL 报错 →POST /rest/mgt/app/fetch带keyword必然 500;permission/fetch同样带keyword必然失败。属于「接口文档宣传可用、实际不可用」的功能性缺陷。 - 建议:
application/fetch改为title like ? or workspace like ?(或删除code),permission/fetch补第二个参数。
17. MgtLinkUserPmn / MgtLinkRolePmn 的 AfterDelete 钩子在批量删除场景下以全零字段执行,清理逻辑实际失效
- 位置:
internal/models/link_user_pmn.go:25-39、internal/models/link_role_pmn.go:25-39;调用方均为以零值空结构体为 Dest 的批量Where(...).Delete(&models.MgtLinkUserPmn{})(user/set_pmn.go:108,165、role/set.go:110,114,164、role/del.go:56,68、user/del.go:59、department/del.go:85、application/del.go:67,73) - 证据:
// link_user_pmn.go:25-38 注释明确该钩子用于清理用户-应用关联 // AfterDelete钩子:仅当该用户在该应用下不再拥有任何权限时,移除用户-应用关联 func (l *MgtLinkUserPmn) AfterDelete(tx *gorm.DB) error { var count int64 if err := tx.Model(&MgtLinkUserPmn{}).Where("app_id = ? AND user_id = ?", l.AppId, l.UserId).Count(&count).Error; ...GORM v1.31.2 源码(// user/set_pmn.go:108 —— Dest 是零值空结构体,钩子接收者 l 的 AppId/UserId 全为 0 if err := tx.Where("app_id = ? and user_id = ?", appId, request.Id).Delete(&models.MgtLinkUserPmn{}).Error; err != nil {callbacks/delete.go:190-199→callbacks/callmethod.go:9-31):AfterDelete会以db.Statement.ReflectValue.Interface()调用钩子,批量删除时该值是零值MgtLinkUserPmn{},因此l.AppId == 0 && l.UserId == 0,Count与随后的Delete都作用在app_id=0 AND user_id=0上——钩子确实被调用,但恒为空操作(与「批删不触发钩子」的常见说法不同,此处是传参全零)。 - 影响:
mgt_link_user_app/mgt_link_role_app中的「0 权限应用关联」长期残留,前端出现无权限的空应用;mgt_link_dpt_pmn侧则完全没有等价清理机制。即模型层注释所声明的级联维护语义从未真正生效。 - 建议:删除关联后在同一事务内显式执行「清理 0 权限的应用关联」SQL(不要依赖钩子);或在服务层统一封装;同时修正模型注释避免误导。
18. 部门/角色/权限/应用的「先查后建」无唯一索引兜底,并发下可产生重复数据
- 位置:
internal/logic/department/create.go:60-70、internal/logic/role/create.go:44-60、internal/logic/permission/create.go:47-62 - 证据:
// department/create.go:60-70 —— 只靠 SELECT 判重,DB 层无 (app_id,name) 唯一约束 if err = impl.DBService.Model(&models.MgtDepartment{}). Where("app_id = ? and name = ?", appId, request.Name).First(&data).Error; err != nil { ... } else { infra.Response.Error(c, errcode.ErrAlreadyExists); return }internal/models/department.go:6-18中Name无uniqueIndex;role.Name有全局唯一索引(role.go:8),但role/create.go:49-56会在冲突时物理删除重建(见 P2-12),并发下仍可删掉他人正在使用的角色。 - 影响:同一应用下可存在多个同名部门;角色创建在并发/软删除场景下会「删旧建新」,使既有
mgt_link_user_role/mgt_link_dpt_role指向已删除角色 ID。 - 建议:为
mgt_department(app_id, name)加复合唯一索引并捕获唯一冲突错误;角色创建改为「已存在则直接报冲突」,不做物理删除。
19. 自定义分页/校验/状态语义不一致,魔法数字散落
- 位置:
internal/types/vars.go:3-9(DefaultPage/DefaultSize、死常量Admin/Staff)、internal/types/req.go:9(Status int8无oneof)、core/types/db.go:34(0/1/-1 语义)、internal/types/req.go:16(min=6) - 证据:
// internal/types/vars.go:4-9 KeyPrefix = "/SMS/Code/" Admin = "SYSADMIN" // 全模块无引用 Staff = "STAFF" // 全模块无引用 DefaultPage = 1 DefaultSize = 10// internal/types/req.go:16 Password string `json:"password,omitempty" validate:"required,min=6"` // 密码,最少6位 - 影响:状态值 0/1/-1 在代码中以字面量出现(
Std_IICUDS{Status: 1}、if request.Status != 0),无统一常量;Admin/Staff从未使用;DefaultSize=10与文档中展示的示例不一致;密码策略仅 6 位且无复杂度/常见弱口令校验。 - 建议:集中定义状态常量与权限点常量;删除或启用死常量;密码策略提升到 8-12 位 + 复杂度 + 弱口令清单(与 P1-10 一起做)。
20. 登录 MD5 向后兼容分支缺迁移路径,旧哈希不升级、无标记可观测
- 位置:
internal/logic/pub/login.go:130-141(写于登录路径);无任何「登录成功后重哈希为 bcrypt」的代码 - 证据:
// login.go:130-141 // 如果bcrypt失败,尝试MD5验证(向后兼容旧用户) isBcrypt := len(userPwd) > 4 && (userPwd[0:4] == "$2a$" || ...) if isBcrypt { ...; return errcode.ErrPassword } hash := md5.Sum([]byte(inPwd + salt)) md5Hash := hex.EncodeToString(hash[:]) if userPwd == md5Hash { return nil } - 影响:MD5+salt 的历史口令可永久继续登录(无迁移强制),一旦数据库泄露,MD5 哈希可被彩虹表/GPU 秒破;代码里也没有「旧哈希计数」指标来评估迁移进度。
test/reset.http、test/user_create.http使用123456佐证弱口令长期存在。 - 建议:MD5 校验成功后立即在事务内把该用户口令重写为 bcrypt(透明升级);统计并告警剩余 MD5 账号数;设定下线时间表后移除分支。
21. HTTP 服务与 Session cookie 安全配置缺失
- 位置:
cmd/main/main.go:34-45 - 证据:
// main.go:34-45 sessionKey := config.Spec.SecretKey + "-session" store := cookie.NewStore([]byte(sessionKey)) app.Use(sessions.Sessions("mysession", store)) ... srv := &http.Server{Addr: addr, Handler: app} // 无 Read/Write/Idle/Header 超时 - 影响:
http.Server无任何超时(慢速攻击/连接耗尽风险);cookie.NewStore未设置HttpOnly/Secure/SameSite/MaxAge,sessionKey直接等于配置文件里的SecretKey(etc/mgt_prod.yaml:16为占位符CHANGE_ME,弱且可预测),而/rest/mgt/session是匿名可写接口(register.go:32、internal/logic/hello/ping.go:25-35),任何人均可让服务端下发并签名 cookie。目前 session 未被用于鉴权(JWT 才是),因此风险为「不必要的攻击面」而非直接绕过。 - 建议:删除
SessionDemo与 session 中间件(或至少改为仅内网);http.Server补ReadHeaderTimeout/ReadTimeout/WriteTimeout/IdleTimeout;如保留 cookie,设置HttpOnly+Secure+SameSite=Lax并使用独立强随机 key。
22. CORS 允许所有来源,且允许携带 Authorization
- 位置:SDK
D:\work\bsm-sdk\core\middleware\cors.go:9-17;挂载点cmd/main/main.go:39 - 证据:
// D:\work\bsm-sdk\core\middleware\cors.go:9-17 return cors.New(cors.Config{ AllowAllOrigins: true, AllowHeaders: []string{"Origin", "Content-Length", "Content-Type", "Workspace", "Request-Id", "Authorization", "Token"}, - 影响:任意站点可对
/rest/mgt/**发起跨域请求并读取响应;若前端把 token 存在localStorage并由 JS 读取(常见做法),恶意页面可借用户浏览器直接调用全部管理接口。超管在浏览器登录后访问任意恶意页面即可被借用权限。 - 建议:改为白名单
AllowOrigins(按环境配置),并明确AllowCredentials策略;管理端接口建议叠加 CSRF/自定义头校验。
23. /app/user 返回手机号、邮箱等 PII,且分页参数放在 Preload 内不生效
- 位置:
internal/logic/application/fetch_other.go:46-53 - 证据:
// application/fetch_other.go:47-49 if err := db.Where("workspace = ?", request.Workspace).Preload("Users", func(db *gorm.DB) *gorm.DB { return db.Select("id", "identity", "name", "account", "phone", "email", "avatar").Count(&count). Limit(size).Offset((page - 1) * size) }).Find(&data).Error; err != nil { - 影响:
POST /rest/mgt/app/user一次返回整个应用的全体用户手机号/邮箱,无有效分页(Limit/Offset/Count作用在 preload 关联查询上,Count结果被写入外层count变量但外层查询本身不受限),total语义也不正确。属于 PII 批量泄露 + 分页失效。 - 建议:拆成两次查询(先分页取 user_id 再取明细),或对手机号/邮箱做脱敏(仅返回掩码),并限制单次返回量。
24. 全局可变基础设施单例 + 配置缺校验导致不安全默认值
- 位置:
internal/impl/impl.go:13-26、internal/impl/with.go:13-16、internal/config/config.go:31-49 - 证据:
// impl.go:13-18 —— 包级可变单例,被 logic 层直接读取 var ( RedisService *redis.RedisClient EtcdService *clientv3.Client MemorySerice *cache.Cache DBService *gorm.DB )// config.go:49 —— 只校验 Service/Cache 非空,未校验 DB/JWT/端口冲突 conf.NotNil(Spec.Service, Spec.Cache) - 影响:
DBService/RedisService可在运行期被service.applyDependencies覆盖(service/dependencies.go:19-32),logic 层直接引用全局变量(分层违规、无法注入 mock、单测困难);config.New不校验Databases,缺失时由with.go:14直接panic(启动期崩溃而非友好报错),也不校验 JWT 密钥(见 P0-2);MemorySerice/EtcdService在本模块从未被使用(仅赋值),属无效基础设施成本。 - 建议:改为显式依赖注入(构造函数传
*gorm.DB/Redis),或至少提供只读访问器;config.New增加必填项与非默认密钥校验;移除未使用的 go-cache/etcd 依赖。
25. 端口与时间/时区相关配置隐患
- 位置:
internal/config/config.go:36、etc/mgt_prod.yaml:2,7 - 证据:
// config.go:35-36 // 配置校验 服务端口如果不合规,则随机分配端口 Spec.Port = conf.CheckPort(Spec.Port)# etc/mgt_prod.yaml:1-7 Service: mgt Port: 18001 Databases: Driver: postgres Source: - host=127.0.0.1 user=ec_dev password=CHANGE_ME dbname=factor_prod port=5432 sslmode=disable TimeZone=Asia/Shanghai - 影响:非法端口被静默改为随机端口,容器/网关按固定端口探活会失败(启动「成功」但不可达);DSN 用
TimeZone=Asia/Shanghai而模型用time.Time+ DBTIMESTAMP(core/types/db.go:31-33),跨时区部署时created_at/last_login_at语义易错;sslmode=disable使 DB 连接明文(内网可接受,生产需确认)。 - 建议:非法端口直接 Fatal 并输出明确原因;统一使用 UTC 存储、展示层转换;生产 DSN 启用 TLS 或明确豁免记录。
26. 大量写操作缺少 context 传递与超时控制
- 位置:全模块 logic 层(示例
internal/logic/user/create.go:108、internal/logic/role/del.go:54、internal/logic/department/del.go:78) - 证据:
// user/create.go:108 —— 全部使用 impl.DBService 全局句柄,无 WithContext(c.Request.Context()) if err = impl.DBService.Transaction(func(tx *gorm.DB) error {impl.RedisService.Client.Get/Set(pub/forget.go:58、pub/login.go:110)同样不传 context。 - 影响:客户端断开或请求超时后,DB/Redis 调用仍会继续执行到结束(长事务、慢查询无法被取消),故障时连接池被长时间占用;也无法做链路追踪。
- 建议:统一改为
impl.DBService.WithContext(c.Request.Context()),Redis 调用传ctx,并为关键写操作设置语句级超时。
27. 无健康检查的依赖探测、启动与迁移/播种强耦合
- 位置:
cmd/main/main.go:41(app.HEAD("/", infra.Health))、internal/models/impl.go:57-74 - 证据:
// models/impl.go:57-74 —— 进程启动路径内串行执行 setupRegister + AutoMigrate + 播种 setupRegister() log.Println("自定义连接表注册成功") err = DBService.AutoMigrate(migrateTables...) ... if config.InitRootUserEnabled() { InitRootUserData() } - 影响:
AutoMigrate与 root 播种在启动路径上同步执行,DB 不可用/迁移变慢会让进程迟迟不监听端口,且log.Fatalln(impl.go:46,52,64)在迁移失败时直接退出——配合无健康检查探活语义,滚动发布易出现「启动即崩」的雪崩;infra.Health只回固定内容,不探测 DB/Redis。 - 建议:迁移拆为独立命令/initContainer;健康检查区分 liveness/readiness 并探测 DB+Redis;启动失败给出可读错误并做有限重试。
P3
28. 生成的校验错误信息被丢弃,接口只返回通用 ErrInvalidArgument
- 位置:
internal/libs/validator.go:78-92、:95-122 - 证据:
// validator.go:79-91 func formatValidationError(err error) error { if validationErrors, ok := err.(validator.ValidationErrors); ok { var errMsg strings.Builder errMsg.WriteString("参数验证失败: ") for i, validationError := range validationErrors { ... errMsg.WriteString(getFieldErrorMsg(validationError)) } return errcode.ErrInvalidArgument // 精心拼装的 errMsg 从未被使用 } return errcode.ErrInvalidArgument } - 影响:
getFieldErrorMsg(11 个分支)构造的字段级提示全部丢弃,前端只能看到「参数错误」,排障与体验受损,同时产生一段 dead code。 - 建议:返回
errcode.String(errcode.ErrInvalidArgument, errMsg.String()),或至少把字段错误写入结构化日志。
29. 无 Go 单元测试,仅有 1 个路由注册测试与 54 个无断言的 .http 样例
- 位置:
internal/routers/register_test.go:10-35(唯一_test.go);test/*.http(54 个,纯请求样例) - 证据:
// register_test.go:15-20 —— 只断言 4 条路由存在 expected := map[string]bool{ "GET /rest/mgt/ping": false, "POST /rest/mgt/login": false, "POST /rest/mgt/user/create": false, "POST /rest/mgt/app/fetch": false, } - 影响:登录(含 MD5 兼容分支)、
RequireAdmin越权、EnsureSelfOrAdmin水平越权、init_db播种幂等、赋权差集(DelRole)、部门树环检测这些高风险逻辑全部零覆盖,本次审计发现的问题(P1-3 零值覆盖、P1-5 越权赋权、P1-8 误删权限)都没有任何自动化防线。 - 建议:补齐关键缺失用例(见第 5 节 TODO)。
30. 文档与实现多处不一致
- 位置:
doc/README.md:3-4、doc/pub.md:3,81-83、doc/user.md:3、doc/pmn.md、doc/dpt.md、README.md:217 - 证据:
实际路由前缀为
<!-- doc/README.md:3-4 --> 接口基础地址:`http(s)://{host}:{port}/mgt/` 除公开接口外,需在请求头携带:`Authorization: Bearer {token}`。/rest/mgt(register.go:21),register_test.go:26-28还专门断言不存在/mgt/*旧前缀——文档与测试相互矛盾。实际匿名可调且不校验过期(P1-9)。全部文档都未说明管理端接口需要「超级管理员」角色。<!-- doc/pub.md:83 --> **说明**:基于当前请求中的 JWT 签发新 token。通常需在 Header 中带有效 Authorization。 - 影响:接入方按文档用
/mgt/login会 404;调用方无法预知哪些接口需要超管,容易误判 403 为 bug;权限点体系与文档完全脱节。 - 建议:统一为
/rest/mgt/**,逐接口补「所需角色/权限点」,并在 CI 中用register_test.go的方式校验文档路由集合与实际路由一致。
31. README/测试样例硬编码真实默认口令,死代码残留
- 位置:
README.md:168-172、test/login.http:5-6、test/reset.http:7、test/user_create.http:8;internal/models/query.go(仅 1 行package models);pub/login.go:108-116(SaveToken无调用方);internal/types/types.go:1-93(10 个结构体全无引用);internal/types/vars.go:5-6(Admin/Staff);config.go:18+impl.go:15,25(SecretKey与 etcd 实际未用于 JWT) - 证据:
// pub/login.go:108-116 —— 零调用方(全模块仅此定义处出现) func SaveToken(id int64, role, token string) error { tokenKey := fmt.Sprintf("%d-%s-%s", uint(id), role, "token") status := impl.RedisService.Client.Set(impl.RedisService.Ctx, tokenKey, token, 0) - 影响:文档与样例即攻击字典;至少 6 处 dead code 增加维护成本,并让读者误以为存在 token 存储/登出机制、类型别名约定、Admin/Staff 角色体系。
- 建议:样例口令改占位符;删除无引用代码或补齐实现(
SaveToken若要用于登出需同时接JwtAuth校验)。
4. 推荐优化方案
按「先止血、再补结构、后做工程化」三层推进:
第一层:止血(1-3 天内可完成,对应 P0/P1-6/P1-9/P1-10)
- 鉴权基础设施硬化:SDK 侧要求显式配置 JWT 密钥(缺失即 Fatal),
mgt启动时校验密钥强度;/refresh挂JwtAuth(true)并复核账号状态;/login、/reset加 Redis 限流(IP+账号)与验证码错误计数。 - 口令治理:移除
init_db.go的123456,改为「环境变量初始口令 / 随机生成 + 强制首登改密」;保留 MD5 兼容分支但在登录成功后透明升级为 bcrypt,并加迁移进度指标。 - 数据损坏修复:把
user/modify.go、permission/modify.go的Select+结构体改成map[string]interface{}(只装显式字段);为/user/modify增加 root 保护。
第二层:补结构(1-2 周,对应 P1-5/P1-7/P1-8 与 P2 主体)
- 真正的接口级授权:建立
路由 → permission.Code映射中间件,逐个接口挂载权限点;把超管判定从「角色名」改为不可变标识;清理或启用Admin/Staff。 - 赋权链路收敛:抽出
grantPermissions(scope, id, appId, pmnIds)统一入口,强制「权限必须属于 appId」校验 + 事务内「先删后插」+ 变更审计;role/del复用user/set_role.go的差集算法;应用关联清理由钩子改为事务内显式 SQL。 - 树与递归安全:所有父子遍历加
visited环检测,统一到internal/logic/tree工具包;权限/部门的parent_id写入前校验同应用 + 非自身 + 非后代。 - 审计日志:新增
mgt_audit_log表,所有写 handler 记录操作者/路由/关键参数/前后值/IP;日志脱敏并结构化(zap)。 - 查询与索引:为
mgt_department(app_id,name)加复合唯一索引;补Order确定性与size上限;把department/fetch_pmn*的 O(n²) 建树改为字典;application/fetch_other.go的分页重写。
第三层:工程化(持续,对应 P2 剩余与 P3)
- 依赖注入替换全局单例;
config.New增加必填项/非默认密钥/端口校验;移除未使用的 go-cache/etcd。 http.Server补超时;CORS 改白名单;删除SessionDemo或迁到内网。- context 贯通(DB/Redis 全量
WithContext)+ 关键写操作语句超时。 - 测试与文档:补齐登录/越权/初始化/赋权差集/树环检测的单元与集成测试;CI 中校验文档路由集合与实际路由一致;密码策略与错误信息改进。
5. TODO 清单
- P0-1 移除 root 硬编码默认口令,改为环境变量或随机生成 + 强制首登改密|验收:
grep -rn "123456" module/base/mgt无源码命中,首次启动日志不含可复用明文口令,InitRootUserData输出的仅是一次性随机口令|涉及:internal/models/init_db.go:16、README.md:170、test/login.http:6 - P0-2 JWT 密钥禁止默认值,启动即校验长度与黑名单|验收:未设
BSM_JwtSecretKey时进程启动失败并输出可读错误;密钥长度 <32 或等于Cblocksmesh2022C时拒绝启动|涉及:D:\work\bsm-sdk\core\env\env.go:19、internal/config/config.go:39 - P1-3
user/modify改为按显式字段更新,禁止零值覆盖|验收:只传{"id":1,"name":"x"}后account/phone/email/avatar/status均保持不变(集成测试断言)|涉及:internal/logic/user/modify.go:57-72 - P1-4
permission/modify改为按显式字段更新|验收:只传{"id":1,"title":"x"}后code/parent_id/menu_path/component不变|涉及:internal/logic/permission/modify.go:67-89,123-144 - P1-5 赋权前校验权限集合全部属于目标
app_id;权限parent_id校验同应用、非自身、非后代|验收:跨应用权限 ID 或自引用parent_id请求返回 400,库中不留脏关联|涉及:internal/logic/user/set_pmn.go:40-47、internal/logic/role/set.go:40-48、internal/logic/permission/create.go:71、internal/logic/permission/modify.go:72,127 - P1-6
/reset与/login加限流、验证码错误计数、统一错误码|验收:同手机号 5 次验证码错误后验证码失效;同 IP 高频请求返回 429;账号不存在与密码错误响应一致|涉及:internal/routers/register.go:34-36、internal/logic/pub/forget.go:57-73、internal/logic/pub/login.go:64-71 - P1-7 建立路由→权限点鉴权中间件并逐接口挂载,超管判定改用不可变标识|验收:非超管账号调用
/rest/mgt/user/create返回 403;重命名「超级管理员」角色后 root 仍为超管|涉及:internal/middleware/rbac.go:19-54、internal/routers/register.go:45-135 - P1-8
role/del按用户其他角色做权限差集,应用关联清理改为事务内显式 SQL|验收:删除角色后,用户经其他角色仍持有的权限不被删除;mgt_link_user_app无 0 权限残留行|涉及:internal/logic/role/del.go:40-88、internal/models/link_user_pmn.go:26、internal/models/link_role_pmn.go:26 - P1-9
/refresh挂JwtAuth(true)并复核账号状态,引入 token 版本/黑名单与登出接口|验收:过期 token 调用/refresh返回 401;改密后旧 token 失效|涉及:internal/routers/register.go:35、internal/logic/pub/refresh.go:21-34 - P1-10 登录失败限流与弱口令清单,密码策略提升至 ≥8 位含复杂度|验收:连续 5 次失败锁定 15 分钟;
123456/password等被拒|涉及:internal/logic/pub/login.go:118-145、internal/types/req.go:16 - P1-11 部门/权限树遍历加环检测,脏数据返回错误而非崩溃|验收:构造
parent_id环后/dpt/del、/dpt/list?tree=true返回错误且进程存活|涉及:internal/logic/department/del.go:17-37、internal/logic/department/list.go:71-83、internal/logic/department/fetch_pmn_tree.go:109-130 - P2-12 创建接口不再物理删除软删除记录,或事务内先清理全部关联|验收:重建同名账号后不继承任何历史角色/权限/部门关联|涉及:
internal/logic/user/create.go:57-72、internal/logic/role/create.go:49-56、internal/logic/permission/create.go:52-58、internal/logic/application/create.go:46-69 - P2-13 消除 N+1:部门权限建树改字典一次成型,
user/fetch_pmn预加载合并|验收:/dpt/pmn_tree权限数 1000 时 SQL 数 ≤5,耗时线性|涉及:internal/logic/department/fetch_pmn.go:74-94、internal/logic/department/fetch_pmn_tree.go:82-89、internal/logic/user/fetch_pmn.go:88-99 - P2-14 列表补确定性排序与
size上限,无分页接口补分页|验收:size=100000返回 400;同参数重复翻页结果稳定|涉及:internal/logic/tool/tool.go:5-14、internal/logic/{user,application,role,department}/fetch.go - P2-15 新增审计日志表并覆盖所有写 handler|验收:每次赋权/改密/删除均在
mgt_audit_log留下操作者 ID、路由、参数与结果|涉及:internal/logic/user/set_pmn.go:70、internal/logic/role/del.go:94、internal/logic/pub/forget.go:98 - P2-16 修正
code like ?无效列与?参数不匹配|验收:/rest/mgt/app/fetch?keyword=x、/rest/mgt/pmn/fetch?keyword=x返回 200|涉及:internal/logic/application/fetch.go:36-39、internal/logic/permission/fetch.go:37-40 - P2-17 用事务内显式 SQL 替代以全零接收者执行、恒为空操作的
AfterDelete钩子路径|验收:用户/角色权限清空后mgt_link_user_app/mgt_link_role_app无 0 权限残留行(集成测试直接断言关联表)|涉及:internal/models/link_user_pmn.go:25-39、internal/models/link_role_pmn.go:25-39、internal/logic/user/set_pmn.go:108,165、internal/logic/role/set.go:110,114,164 - P2-18 为
mgt_department(app_id,name)加复合唯一索引,角色创建不做物理删除|验收:并发同名部门只成功一条;角色创建冲突时返回 409 且不删除既有角色|涉及:internal/models/department.go:9、internal/logic/department/create.go:60-70、internal/logic/role/create.go:49-60 - P2-20 MD5 兼容分支透明升级为 bcrypt 并加迁移指标|验收:旧 MD5 用户首次登录成功后库中口令变为
$2a$/$2b$前缀|涉及:internal/logic/pub/login.go:130-141 - P2-21
http.Server补超时;session 中间件与/session移除或改内网|验收:/rest/mgt/session不再对外可用;配置含 Read/Write/Idle 超时|涉及:cmd/main/main.go:34-45、internal/logic/hello/ping.go:25-35 - P2-22 CORS 改来源白名单|验收:非白名单 Origin 的预检请求被拒|涉及:
D:\work\bsm-sdk\core\middleware\cors.go:9-17 - P2-23
/app/user分页重写并对手机号/邮箱脱敏|验收:单次返回 ≤ 上限条数,total正确,手机号以掩码返回|涉及:internal/logic/application/fetch_other.go:46-53 - P2-24 依赖注入替换全局单例;配置增加必填与非默认密钥校验;移除未使用的 go-cache/etcd|验收:缺失
Databases时启动报可读错误而非 panic;MemorySerice/EtcdService引用清零|涉及:internal/impl/impl.go:13-26、internal/config/config.go:31-49 - P2-26 DB/Redis 调用贯通
context与超时|验收:客户端断开后长查询可被取消(日志可见 context canceled)|涉及:全 logic 层事务与internal/logic/pub/forget.go:58、internal/logic/pub/login.go:110 - P2-27 迁移与播种从启动路径剥离,健康检查探测 DB/Redis|验收:DB 不可用时进程仍能启动并给出 readiness 失败;迁移由独立命令执行|涉及:
internal/models/impl.go:57-74、cmd/main/main.go:41 - P3-28 校验错误信息回传前端|验收:字段校验失败响应包含具体字段与原因|涉及:
internal/libs/validator.go:78-92 - P3-29 补关键单元/集成测试(登录含 MD5 分支、
RequireAdmin/EnsureSelfOrAdmin越权、init_db幂等、DelRole差集、树环检测)|验收:上述场景用例全绿,覆盖率覆盖 6 个 logic 子包的写路径|涉及:internal/routers/register_test.go:10-35、新增internal/logic/**/*_test.go - P3-30 文档路径统一为
/rest/mgt/**并补「所需角色/权限点」|验收:文档路由集合与engine.Routes()一致(CI 校验);每接口标注权限点|涉及:doc/README.md:3-4、doc/pub.md:83、doc/*.md - P3-31 清理死代码与样例口令|验收:
SaveToken/types/types.go结构体/Admin/Staff/models/query.go有引用或删除;样例口令为占位符|涉及:internal/logic/pub/login.go:108-116、internal/types/types.go、internal/types/vars.go:5-6、internal/models/query.go
6. 审计摘要(供汇总使用)
- 问题数:P0=2 P1=9 P2=15 P3=4(编号 1-31,全文连续)
- 最高风险(一句话):root 默认口令
123456硬编码并自动播种(internal/models/init_db.go:16),叠加 JWT 密钥默认值Cblocksmesh2022C(SDKenv.go:19),使未显式配置环境变量的部署可被一条登录请求或一枚伪造 token 直接接管最高权限,而全部 50 个管理端接口只校验「是否超管」、赋权无归属校验且无审计日志。 - 最优先 3 个动作:1) 移除 root 默认口令并强制 JWT 密钥显式配置(P0-1、P0-2);2) 修复
user/modify、permission/modify的Select+结构体零值覆盖(P1-3、P1-4)并给赋权接口补app_id归属校验(P1-5);3) 为/reset、/login加限流与验证码尝试限制、为/refresh恢复过期校验(P1-6、P1-9、P1-10)。 - 未能覆盖/无法验证的部分:无 DB/Redis 实例,越权、爆破、数据损坏类结论均为静态推演(仅 GORM 零值更新语义经隔离环境 DryRun 实测确认);未审计 SDK 的 DB/Redis/日志/响应序列化实现细节;未验证
module/all与gateway是否在mgt之外叠加了限流或鉴权;未确认部署态是否真的设置了BSM_JwtSecretKey(这决定 P0-2 是否已被实际利用);54 个test/*.http未逐条核对;未运行go build/go test(gofmt -l、go vet ./...均通过,无输出)。