Files
full/docs/audit/module-base-mgt.md
yanweidong 63aeedc2fe docs: add per-module audit reports (18 modules)
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.
2026-09-14 22:16:27 +08:00

691 lines
62 KiB
Markdown
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.
# 审计报告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` + GORM `v1.31.2`MySQL/PostgreSQL+ Redis + `gin-contrib/sessions` cookie store + etcd`go.mod:5-14`)。
- 鉴权模型JWTHS256`Authorization` 头)+ SDK `middleware.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/prodprod 的 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 / 问题 4TODO 编号 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|gwRuntime|NewServeMux|RegisterHandlerServer|Gateway"``module/base/mgt` 零命中;`*.pb.go`/`*.proto` 零命中 → 该跨模块模板缺陷对本模块**不适用**,见 2.5 |
| 命名返回值/裸 `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**,无需为本模块计入风险。依据:
1. mgt 是纯 Gin HTTP 模块,**没有任何 gRPC/网关/protobuf 代码**——全模块 `Select-String "internal/server|gwRuntime|NewServeMux|RegisterHandlerServer|Gateway"` 零命中,且 `module/base/mgt` 下**不存在任何 `.pb.go``.proto` 文件**。
2. 独立部署入口自建 `*gin.Engine` 并直接注册路由:`cmd/main/main.go:32-42``app := gin.Default()``routers.Register(ServiceKey, app)`),全程不经过 gRPC Mux。
3. `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`
- **证据**
```go
// internal/models/init_db.go:14-17
salt := utils.UUID()
account := "root"
password := "123456"
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password+salt), bcrypt.DefaultCost)
```
```go
// internal/models/impl.go:69-71 —— AutoMigrate 后紧跟 root 播种
if config.InitRootUserEnabled() {
InitRootUserData()
```
```yaml
# etc/mgt_dev.yaml
InitRootUser: true
```
```markdown
<!-- 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`
- **证据**
```go
// D:\work\bsm-sdk\core\env\env.go:17-22
Runtime = &types.RuntimeEnv{
Workspace: GetEnvDefault("BSM_Workspace", "default"),
JwtSecretKey: GetEnvDefault("BSM_JwtSecretKey", "Cblocksmesh2022C"),
```
```go
// internal/logic/pub/login.go:90
resp.Token, err = token.New(env.Runtime.JwtSecretKey).GenerateJwt(uint(user.ID), user.Identity, c.ClientIP(), "", nil, extend)
```
默认值长度恰为 16 字节,通过 `core/crypto/token/jwt.go:34-37` 的 `keyLen == 16 || 24 || 32` 校验,因此**在未设置 `BSM_JwtSecretKey` 的部署里签发与验签都会静默使用这个公开字符串**。
- **影响**:攻击者用公开默认密钥自行 HS256 签名一个 `{"id":1,...}` 的 token 即可通过 `JwtAuth(true)` 的过期校验与验签,被 `RequireAdmin` 认定为 rootID=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`
- **证据**
```go
// 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)
```
隔离环境对 gorm v1.31.2 的 DryRun 实测(同构结构体 + 同 `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`
- **证据**
```go
// 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`
- **证据**
```go
// 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})
```
部门侧有归属校验(可对照),用户/角色侧缺失:
```go
// 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`
- **证据**
```go
// 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) // 重置密码
```
```go
// 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`),可用于枚举。
```go
// 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`
- **证据**
```go
// rbac.go:14-16, 30-36
const ( superAdminRoleName = "超级管理员"; rootAccount = "root" )
...
for _, role := range user.Roles {
if role.Name == superAdminRoleName { return true }
}
```
```go
// 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`
- **证据**
```go
// 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 时,只要某用户同时拥有另一角色 R2R2 也含权限 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`
- **证据**
```go
// 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` 里的过期校验SDK `middleware/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` 未挂任何限流中间件
- **证据**
```go
// 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
```
```go
// 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`
- **证据**
```go
// 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) }
}
```
```go
// 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`
- **证据**
```go
// 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`
- **证据**
```go
// 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)
}
```
```go
// 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`(仅默认值,无上限)
- **证据**
```go
// 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 // 没有最大值截断
}
```
```go
// 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`
- **证据**
```go
// user/set_pmn.go:70
printer.Info("用户权限设置成功: userId=%d", request.Id)
```
```go
// 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`
- **证据**
```go
// application/fetch.go:36-39 —— mgt_application 没有 code 列;两个 ? 只传一个参数
if request.Keyword != "" {
likeStr := "%" + request.Keyword + "%"
db = db.Where("title like ? or code like ? ", likeStr)
}
```
```go
// 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`
- **证据**
```go
// 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; ...
```
```go
// 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 {
```
GORM v1.31.2 源码(`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`
- **证据**
```go
// 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`
- **证据**
```go
// internal/types/vars.go:4-9
KeyPrefix = "/SMS/Code/"
Admin = "SYSADMIN" // 全模块无引用
Staff = "STAFF" // 全模块无引用
DefaultPage = 1
DefaultSize = 10
```
```go
// 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」的代码
- **证据**
```go
// 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`
- **证据**
```go
// 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`
- **证据**
```go
// 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`
- **证据**
```go
// 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`
- **证据**
```go
// impl.go:13-18 —— 包级可变单例,被 logic 层直接读取
var (
RedisService *redis.RedisClient
EtcdService *clientv3.Client
MemorySerice *cache.Cache
DBService *gorm.DB
)
```
```go
// 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`
- **证据**
```go
// config.go:35-36
// 配置校验 服务端口如果不合规,则随机分配端口
Spec.Port = conf.CheckPort(Spec.Port)
```
```yaml
# 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` + DB `TIMESTAMP``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`
- **证据**
```go
// 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`
- **证据**
```go
// 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`
- **证据**
```go
// 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 个,纯请求样例)
- **证据**
```go
// 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`
- **证据**
```markdown
<!-- doc/README.md:3-4 -->
接口基础地址:`http(s)://{host}:{port}/mgt/`
除公开接口外,需在请求头携带:`Authorization: Bearer {token}`。
```
实际路由前缀为 `/rest/mgt``register.go:21``register_test.go:26-28` 还专门断言不存在 `/mgt/*` 旧前缀——文档与测试相互矛盾。
```markdown
<!-- doc/pub.md:83 -->
**说明**:基于当前请求中的 JWT 签发新 token。通常需在 Header 中带有效 Authorization。
```
实际匿名可调且不校验过期P1-9。全部文档都**未说明管理端接口需要「超级管理员」角色**。
- **影响**:接入方按文档用 `/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
- **证据**
```go
// 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**
1. 鉴权基础设施硬化SDK 侧要求显式配置 JWT 密钥(缺失即 Fatal`mgt` 启动时校验密钥强度;`/refresh` 挂 `JwtAuth(true)` 并复核账号状态;`/login`、`/reset` 加 Redis 限流IP+账号)与验证码错误计数。
2. 口令治理:移除 `init_db.go` 的 `123456`,改为「环境变量初始口令 / 随机生成 + 强制首登改密」;保留 MD5 兼容分支但在登录成功后透明升级为 bcrypt并加迁移进度指标。
3. 数据损坏修复:把 `user/modify.go`、`permission/modify.go` 的 `Select+结构体` 改成 `map[string]interface{}`(只装显式字段);为 `/user/modify` 增加 root 保护。
**第二层补结构1-2 周,对应 P1-5/P1-7/P1-8 与 P2 主体)**
4. 真正的接口级授权:建立 `路由 → permission.Code` 映射中间件,逐个接口挂载权限点;把超管判定从「角色名」改为不可变标识;清理或启用 `Admin`/`Staff`。
5. 赋权链路收敛:抽出 `grantPermissions(scope, id, appId, pmnIds)` 统一入口,强制「权限必须属于 appId」校验 + 事务内「先删后插」+ 变更审计;`role/del` 复用 `user/set_role.go` 的差集算法;应用关联清理由钩子改为事务内显式 SQL。
6. 树与递归安全:所有父子遍历加 `visited` 环检测,统一到 `internal/logic/tree` 工具包;权限/部门的 `parent_id` 写入前校验同应用 + 非自身 + 非后代。
7. 审计日志:新增 `mgt_audit_log` 表,所有写 handler 记录操作者/路由/关键参数/前后值/IP日志脱敏并结构化zap
8. 查询与索引:为 `mgt_department(app_id,name)` 加复合唯一索引;补 `Order` 确定性与 `size` 上限;把 `department/fetch_pmn*` 的 O(n²) 建树改为字典;`application/fetch_other.go` 的分页重写。
**第三层:工程化(持续,对应 P2 剩余与 P3**
9. 依赖注入替换全局单例;`config.New` 增加必填项/非默认密钥/端口校验;移除未使用的 go-cache/etcd。
10. `http.Server` 补超时CORS 改白名单;删除 `SessionDemo` 或迁到内网。
11. context 贯通DB/Redis 全量 `WithContext`+ 关键写操作语句超时。
12. 测试与文档:补齐登录/越权/初始化/赋权差集/树环检测的单元与集成测试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`SDK `env.go:19`),使未显式配置环境变量的部署可被一条登录请求或一枚伪造 token 直接接管最高权限,而全部 50 个管理端接口只校验「是否超管」、赋权无归属校验且无审计日志。
- 最优先 3 个动作1) 移除 root 默认口令并强制 JWT 密钥显式配置P0-1、P0-22) 修复 `user/modify`、`permission/modify` 的 `Select+结构体` 零值覆盖P1-3、P1-4并给赋权接口补 `app_id` 归属校验P1-53) 为 `/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 ./...` 均通过,无输出)。