12451 471b61d277 fix(test)+fix(deploy)+docs: 探针状态语义反转修复、退出码补齐、文档校订
1) probe_export.py:任务状态语义**搞反了**(导出成功也会被判失败)
   - download_tasks.status 的权威语义是 0 排队 / 1 处理中 / **2 完成** / 3 失败
     (db/migrate.rs:225 列注释;写入侧 charge_records.rs::mark_task_completed 写的就是 2)。
   - 该脚本此前**等 status == 1(处理中)当成功、把 status == 2(完成)判成"导出失败"**
     —— 典型假红。已按权威语义订正,并与另外两处**本来就正确**的口径对齐:
     web/src/pages/charge-records/DownloadCenter.tsx::statusText、
     test/schema_alignment_e2e.py(等 status in (2,3) 再断言 == 2)。
   - 同批修正退出码分类:账号缺 charge_record:export(403)由 exit 1 改为
     **exit 2「未验证」** —— 本项要验证的是"降权成 pms 后能否往 exports/ 落盘",
     没权限根本走不到那一步,报 1 是**把夹具缺陷说成产品缺陷**(误报与假绿同样有害)。
   - 允许用 PMS_PROBE_PHONE / PMS_PROBE_PASSWORD 换成有权限的账号。
   - **C 项结论(由落盘物证直接判定,不依赖该脚本)**:测试环境
     exports/充电记录__-.xlsx 5559 B、生产同类产物 5457 B,属主均为 pms:pms;
     两环境 exports/uploads 目录均 pms:pms,两 api-server 单元均 User=pms/Group=pms
     ⇒ 降权进程确实能落盘。已在 README 写明并给出取证依据。

2) deploy-prod.ps1 的退出码(此前失败的探针只打印 ❌ 就继续,脚本仍以 0 退出)
   - 消费链路探测失败(exit 1)现在**记入失败清单**并以 exit 1 收尾;
     "未验证"(隧道没起)默认仍不计失败(环境问题会误报),
     但新增 **-StrictVerify** 开关供 CI/无人值守使用,打开即 exit 1。
   - 好处:把这条从"打印了 ❌ 却 exit 0"(仓库第 8 条假绿)里摘出来。

3) 统一退出码 0/1/2 落地到本批脚本,并对每个失败路径实测取证:
   - test/tcp_e2e_smoke.py:13/13 通过(含未登录业务报文被静默丢弃);先复位夹具再跑。
   - test/security-hardening/ 新增 verdict.py 判据容器(未跟踪 → 本次入库);
     4 个探针全部按失败→exit 1 改造,并各实测过失败路径。
   - 实测结果(2026-09-20 发布后回归):smoke_prod 4/4 exit 0、
     probe_prod 5/5 exit 0、probe_upload 6/6 exit 0。

4) 工具与文档
   - tools/db.py 默认库由**生产 pms 改为测试 pms_dev**(可用 PMS_MYSQL_DB 覆盖,
     切生产时打印醒目警告)—— "以为在测测试库、实际打生产"的隐患。
   - tools/db_schema_diff.py:连不上必须是 exit 2,绝不伪装成"无漂移"的 0。
   - 新增 tools/dev-tunnel.ps1(入库,SSH 隧道;口令走 PMS_SSH_PASSWORD 环境变量),
     配套 tools/dev-tunnel-run.ps1 自愈启动器**保持 gitignore**(内含口令)。
   - deploy/nginx/nginx.conf 镜像随线上同步(移除 stream 转发;与线上逐字节一致)。
   - AGENTS.md / README.md:补「踩过的坑」第 7/8 条(掐断上游管道会杀被测进程、
     打印 ❌ 却 exit 0 是假绿)、退出码约定、本地开发改 SSH 隧道、
     在线判据口径订正、order_timeout 守门补齐、小程序 vue-tsc 门禁与其抓到的两个真 BUG。

发布验收(本次统一发布,按 device→api 顺序):
- 测试环境与生产环境四服务 active,端口 9000/9001/3000/3001 正确,ERROR 均为 0;
- 生产迁移按判据 grep '更新列注释' 实测对齐 cabinets.status 列注释;
- 结构漂移 python tools/db_schema_diff.py = 0 处漂移 / exit 0(--vs-ddl 两库同样 0);
- schema_alignment_e2e --env dev 全部通过 exit 0;
- 生产域名 /api/pms/cabinets -> 401、/pms/ -> 200;消费链路探测通过。
2026-09-20 04:31:42 +08:00

68 lines
6.4 KiB
Markdown
Raw Permalink 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.

# 安全加固实测脚本(2026-09)
本目录是「存储型 XSS / 登录爆破 / 服务降权 / nginx 与 CORS 收敛」四项加固的**可复现实测脚本**与部署脚本留存。
所有脚本都在**服务器上**(或在本地直连测试库时)运行,改动前均已备份并验证。
| 文件 | 用途 | 运行位置 |
|------|------|----------|
| `probe_upload.py` | A 项:登录 → 上传 `xss.svg`(应 400)/ 改名 `.png` 的 SVG(应 400,魔数兜底)/ 真 PNG(应 200);头像与反馈图两个入口都测 | 服务器(打 `127.0.0.1:3000`) |
| `probe_bruteforce.py` | B 项:成功登录清零计数、5 次失败锁定(第 6 次与"正确密码"都返回统一文案)、challenge 限流(第 11 次起 429)、并发闸门观察 | 服务器(打 `127.0.0.1:3000`) |
| `probe_prod.py` | A/D 项(生产):用服务端 `JWT_SECRET` 临时签一个 token,只打"应被拒绝"的路径 + 反馈图测试文件(末尾自删),并打印 CORS 白名单内外行为 | 服务器(打 `127.0.0.1:9000` + 走域名) |
| `probe_export.py` | C 项:Excel 导出(写 `./exports`)在降权后是否可用(测试环境) | 服务器(打 `127.0.0.1:3000`) |
| `smoke_prod.py` | 生产冒烟:SPA 资源、challenge、登录参数校验,并清理测试产生的 Redis 计数键 | 服务器 |
| `make_test_user.py` | 在 `pms_dev` 建/重置实测账号 `13900000009 / SecTest#2026`(role=0,无组织) | 本地(经 SSH 隧道 `127.0.0.1:13306`,先跑 `pwsh tools/dev-tunnel.ps1`;⛔ 不再直连 `10.8.0.252`) |
| `nginx-hardening.sh` | D 项:把两份 conf 落到 `/etc/nginx/conf.d/`,在 `nginx.conf` 的 `http{}` 内插入 `limit_req_zone`,`nginx -t` 通过才 reload,失败回滚 | 服务器(先 `pscp` conf 到 `/tmp`) |
| `switch-user-test.sh` | C 项:测试环境两单元 `User=root → User=pms`,失败立即回滚 | 服务器 |
| `deploy-prod-sec.sh` | 生产:二进制原子替换 + 两单元降权,失败回滚(单元 + 二进制) | 服务器 |
## 退出码约定(2026-09-20 定稿,全仓统一)
| 退出码 | 含义 |
|--------|------|
| `0` | 全部判据通过 |
| `1` | **有判据失败**(失败项额外打到 stderr) |
| `2` | 环境/连接问题(读不到 `.env`、连不上 Redis/后端 —— 各脚本用 `raise SystemExit(...)` 报出) |
- 探针的判据与退出码统一由**同目录的 `verdict.py`** 提供(`Verdict.check(label, passed, detail)` + `exit_code()`)。
- ⛔ **"打印了 ❌ 却 exit 0"是假绿**:`probe_upload` / `probe_prod` / `probe_bruteforce` / `smoke_prod`
早期结尾写死 `return 0`,**无论安全控制是否生效都 exit 0**,与 `tsc --noEmit` 空跑同类。
已改为"失败 → exit 1",并按仓库铁律**实测过失败路径**:
把 `probe_upload.py` 的 A1 判据翻成 `st == 200` 后在服务器上跑 → **exit 1**;
恢复后 → **6/6 判据通过、exit 0**(`verdict.py` 本身另有 0/1/空判据 三种自测)。
- ⚠️ **拿不准阈值的现象不写成判据**(如 challenge 限流"第几次触发 429"、并发耗时)——只作观察输出,
否则会误报;误报与假绿同样有害。
- ✅ **C 项(降权后 Excel 导出落盘)结论:可用 —— 由落盘物证直接判定(2026-09-20)**
- 测试环境:`/data/pms_dev/api-server/exports/充电记录__-.xlsx`,**5559 B,属主 `pms:pms`**,
时间 04:29(与 `test/schema_alignment_e2e.py` 的成功导出同一时刻);
- 生产环境:同类产物 5457 B、属主同为 `pms:pms`;
- 两环境 `exports/` 与 `uploads/` 目录均为 `drwxr-xr-x pms pms`,两个 api-server 单元均为
`User=pms` / `Group=pms`(`systemctl show -p User -p Group` 实测)。
- ⇒ 降权成 `pms` 的进程**确实能往 `exports/` 落盘**(这正是 C 项要验证的事)。
- ⚠️ 上述结论**不依赖 `probe_export.py`**:该脚本因测试库唯一账号没有任何 `user_roles`(无
`charge_record:export`)仍返回 `exit 2`「未验证」(见下条)。想让它也给出判决,二选一补齐夹具。
- ⚠️ **`probe_export.py` 当前是"未验证"(exit 2),不是"失败"** —— 实测测试库 `pms_dev`
**只有 1 个账号(`13900000009`)且 `user_roles` 表为空**,即它**一个权限码都没有**:
创建导出任务恒 `403 缺少权限: charge_record:export`。该探针要回答的是
"**降权成 `pms` 后还能不能往 `exports/` 写文件**",而没权限根本走不到那一步 ——
早期把这种情形报成 `exit 1` 属**误报**(把夹具缺陷说成产品缺陷)。
现已改为 `exit 2`(环境/夹具问题,"未验证")。**补齐夹具后必须重跑**,二选一:
① 给该账号配含 `charge_record:export` 的角色;
② 用 `PMS_PROBE_PHONE` / `PMS_PROBE_PASSWORD` 环境变量给一个超管账号。
- 🐞 **`probe_export.py` 曾把任务状态语义搞反(已修,值得记住)**:
`download_tasks.status` 的**权威语义是 `0 排队 / 1 处理中 / 2 完成 / 3 失败`**
(`db/migrate.rs:225` 的列注释;写入侧 `routes/charge_records.rs::mark_task_completed` 写的就是 `2`)。
该脚本此前**等 `status == 1`(处理中)当成功、把 `status == 2`(完成)当"导出任务失败"** ——
即**导出成功也会被判失败**(假红)。已按权威语义订正,并与另外两处正确口径对齐:
`web/src/pages/charge-records/DownloadCenter.tsx::statusText`、
`test/schema_alignment_e2e.py`(等 `status in (2,3)` 再断言 `== 2`)。
> 教训:**状态码这种"看着像布尔"的字段,一定要回权威 DDL 的列注释核对,别照字面猜**;
> 三处读同一个字段就更要三处对齐(本次就是两处对一个错)。
## 前置说明
- 脚本内有 `ENV_FILE` 常量指向线上 `.env`(读 `REDIS_URL` / `JWT_SECRET`),**不要把 .env 内容复制到仓库**。
- `probe_upload.py` / `probe_bruteforce.py` 需要从 Redis 读 `challenge:{cid}` 的正确偏移量来模拟"人正确拖动滑块"(服务器没装 `redis-cli`,
脚本里自带一个 30 行的最小 RESP 客户端)。
- 实测账号 `13900000009 / SecTest#2026` 目前**仍留在测试库 `pms_dev`**(role=0,无组织,看不到任何数据)。
如不再需要:`DELETE FROM users WHERE phone='13900000009';`(同时可删除 `uploads/avatars`、`uploads/feedback` 下的测试 PNG)。