# S01 定向整改报告(F1 / F2 / F3) - **整改日期**:2026-09-11 - **分支**:`fix/resume-upload-safety` - **范围**:仅修复独立验收确认的 F1、F2、F3,并完成对应自动化与干净构建 - **本报告不包含**:数据库口令、.env 内容、JWT、验证码值、完整手机号、明文测试口令、非法 principal 敏感标记原文 - **整改期间状态**:`S01独立验收未通过,定向整改中。` --- ## 1. F1 的修复方式 `docs/smoke-test.sh` 的 `sms_code()` **彻底删除**了对应用日志的依赖(grep `MOCK SMS CODE`),改为**双重保护 + 容器内部只读 SQL 查询**。 同时删除:`SERVER_LOG` 变量、`resolve_log()` 函数、全部日志 grep。未恢复任何验证码日志。 | 项 | 整改前 | 整改后 | |---|---|---| | 取码来源 | `grep -a "MOCK SMS CODE.*phone=$PHONE" <日志>` | 容器内 `SELECT code FROM sms_verification_codes …` | | 前置授权 | 无 | `SMS_MOCK_ENABLED=true` **且** `ALLOW_TEST_OTP_DB_READ=true` | | 取码返回值 | stdout(进入 command substitution) | `REPLY` 变量约定(不打印) | | 请求体传参 | `-d "<含验证码>"`(进入进程参数) | `--data-binary @-`(经 stdin) | | 凭据处理 | 需 `SERVER_LOG` | 容器自身 `MYSQL_ROOT_PASSWORD` / `MYSQL_DATABASE`(不在本机命令行拼写口令) | | 清理 | 无 | `unset REPLY/SMS/LOGIN_BODY` + `trap cleanup_secrets EXIT` | 脚本仍为 Bash,兼容当前 Git Bash;`bash -n` 语法校验通过。 ## 2. smoke-test.sh 的新取码机制 ``` sms_code() ├─ require_mock_sms_mode() # SMS_MOCK_ENABLED 必须为 true ├─ require_test_otp_db_read_permission() # ALLOW_TEST_OTP_DB_READ 必须为 true ├─ 校验 PHONE 为 11 位纯数字(防注入) ├─ 记录并关闭 xtrace(set +x),执行后精确恢复原状 ├─ IFS= read -r code < <(otp_db_query ...) # 不使用 command substitution 取值 ├─ 校验 code 为 6 位数字(当前 code-length / CODE_PATTERN) ├─ 失败 → unset code + 固定提示 + return 1(绝不携带空值继续登录) └─ 成功 → REPLY="$code"; unset code; return 0 ``` `otp_db_query()` 关键点: - **容器名不硬编码**:`compose_mysql_container()` 用 `awk` 解析项目实际 `backend-infra/compose.yaml` 中 `mysql` 服务的 `container_name`;用 `BASH_SOURCE` 定位自身目录(被 `source` 时同样正确) - SQL 经 **stdin**(heredoc 风格 `printf | docker exec -i` ),不出现在任何进程参数中 - 查询条件完整:`phone` + `purpose` + `used = 0` + `expired_at > NOW()` (更正:本行原写作 `expires_at`,该列在 `sms_verification_codes` 中**不存在**,是 F6 记录的错误字段名;已于第四轮一并更正,详见文末「第四轮定向整改(F6)」) - `ORDER BY created_at DESC, id DESC LIMIT 1`,只取 `code` 列 - 不使用 `curl -v`;失败诊断不打印请求体 ## 3. 为什么不会把验证码输出到终端 / 日志 / 文件 1. 取码函数**从不 `echo`/`printf` 验证码**,只写 `REPLY`;调用方 `SMS="$REPLY"` 后立即 `unset REPLY` 2. `set +x` 包住取值与查询,且**按调用前状态精确恢复**,不无条件开启 xtrace 3. 登录请求体经 `--data-binary @-` 从 stdin 送入 curl,验证码不进入 argv 4. 请求体只在 `LOGIN_BODY` 局部变量中存在,用完 `unset`;**不落盘** 5. `trap cleanup_secrets EXIT` 兜底清理 `OTP/REPLY/LOGIN_BODY` 6. 脚本控制台只输出 `PHONE=$(mask_phone "$PHONE")`(脱敏为 `199****9999`),不打印完整手机号 7. 应用侧本就不输出验证码(S01 已整改),Mock 发送器仅输出固定文本 ## 4. `ALLOW_TEST_OTP_DB_READ` 的强制条件 ```bash is_true() { v=$(printf '%s' "${1:-}" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]'); [ "$v" = "true" ]; } require_mock_sms_mode() { is_true "${SMS_MOCK_ENABLED:-}"; } require_test_otp_db_read_permission() { is_true "${ALLOW_TEST_OTP_DB_READ:-}"; } ``` - 两者同时为 `true`(大小写/空白不敏感)才允许查询 - 任一不满足:立即返回非 0,输出固定提示 `test OTP retrieval is disabled`,**不执行任何 docker/SQL 调用** - 提示中不含手机号、验证码、数据库口令、SQL 结果或容器名 ## 5. 未授权取码的实际结果 | 场景 | 实际结果 | |---|---| | `SMS_MOCK_ENABLED` 缺失 | 拒绝;输出 `test OTP retrieval is disabled`;**docker 未被调用** | | `SMS_MOCK_ENABLED=false` | 拒绝;同上 | | `ALLOW_TEST_OTP_DB_READ` 缺失 | 拒绝;同上 | | `ALLOW_TEST_OTP_DB_READ=false` | 拒绝;同上 | | 两者为 true 但无可用验证码 | `test OTP retrieval failed`,**不继续登录** | | 两者为 true 但查询失败/结果非 6 位数字 | 同上,**不继续登录** | ## 6. F2 修改的全部文档与行 | 文件 | 行(整改后) | 修改前 | 修改后要点 | |---|---|---|---| | `docs/acceptance-week1.md` | 94 | `# Mock 登录(从日志获取验证码)` | 指向受控取码方式 | | `docs/acceptance-week1.md` | 148 | 注: 验证码从应用日志中获取 | 明确日志不输出;列出双开关;生产禁止查表 | | `docs/acceptance-week1.md` | 152 | 验证码打印到日志 | 明确不输出;标注明文存储遗留 | | `docs/api-v0.1.md` | 27 | Mock环境验证码打印到日志 | 明确不输出 + 受控取得 | | `docs/api-v0.1.md` | 174 | 验证码打印到日志 | 明确不输出;未接入供应商返回服务不可用 | | `docs/frontend-integration.md` | 37-49(§3 整节重写) | 「Mock 验证码日志位置」+ 旧示例行 `MOCK SMS CODE: … code=******` + 「看终端输出/看日志文件/取最新一条」 | 改为「本地获取测试验证码(不通过日志)」:双开关 + 受控脚本 + 生产禁止查表 + 明文遗留风险 | | `docs/frontend-integration.md` | 149 | 验证码打印在服务日志 | 改为不再输出 + 受控取得 | | `docs/offer-api.md` | 262 | 验证码见应用日志 | 改为受控取得 + 生产禁止查表 | | `docs/resume-api.md` | 189 | 登录学生账号(Mock 验证码,见日志) | 改为受控取得 + 生产禁止查表 | | `docs/smoke-test.sh` | 全文头部与取码段 | 依赖日志取码 | 见第 1-5 节 | **文档统一口径(已落实)**: 1. 删除所有「从控制台/日志查看验证码」的操作指导 2. 明确应用日志不会输出验证码 3. dev/test 本地自动化使用受控测试夹具/数据库内部只读查询 4. 冒烟脚本仅在 `mock=true` **且** `ALLOW_TEST_OTP_DB_READ=true` 时查表 5. 真实生产环境**禁止**查询验证码表辅助登录 6. `mock=false` 且无真实供应商时返回「服务不可用」 7. 明确当前尚未接入真实短信供应商 8. **未声称**验证码已加密/哈希存储 9. 明确验证码当前仍为数据库**明文**遗留风险 10. 示例中不含真实手机号、验证码、JWT 或数据库口令 **在途文件保护**:`docs/frontend-integration.md` 与 `docs/resume-api.md` 仅改动取码相关段落;`git diff` 复核确认简历上传加固内容(文件名净化、PDF/DOC/DOCX 结构校验、上传替换一致性、恶意软件扫描边界等)**逐字保留**,未覆盖、未还原、未重新格式化。 ## 7. 全仓遗留搜索结果 ``` grep -rn -i "MOCK SMS CODE|控制台.*验证码|验证码.*打印到日志|从日志获取|查看.*验证码" docs README.md mydreamjob-server/src ``` - **「打印到日志 / 从日志获取」命中数:0** - `MOCK SMS CODE` 在 `mydreamjob-server/src` 与 `docs/` 中的命中数:**0** - 剩余匹配全部为**禁止性表述或无关标识符**(如「日志不得包含验证码」「不得写入日志」「SMS_CODE_INVALID」枚举名) - `README.md:167` 本身即「不写入敏感信息」的正确约定,未修改 ## 8. F3 使用的 Bean 装配方案 采用**推荐的单 `@Configuration` + 单 `@Bean` 方案**(不再依赖两套互斥 `@ConditionalOnProperty`): ```java @Configuration public class SmsSenderConfiguration { private static final String MOCK_ENABLED_VALUE = "true"; @Bean public SmsCodeSender smsCodeSender( @Value("${app.sms.mock.enabled:false}") String rawEnabled) { if (rawEnabled != null && MOCK_ENABLED_VALUE.equalsIgnoreCase(rawEnabled.trim())) { return new MockSmsCodeSender(); } return new UnavailableSmsCodeSender(); } } ``` - `MockSmsCodeSender` / `UnavailableSmsCodeSender` 已移除 `@Component` 与 `@ConditionalOnProperty`,成为普通类 - 满足:始终恰好一个 Bean;仅显式 `true` 启用 Mock;非法值不启用 Mock;**不根据 profile 偷偷启用**;`application-dev.yml` 仍显式 `true`;`application.yml` 默认 `false` - 不把选择逻辑放回 `SmsVerificationService`;不新增第二套发送流程;不用 try/catch Bean 缺失兜底;不在每次发送时读配置;**不输出配置原值到日志** ## 9. 各配置取值对应的实现 | `app.sms.mock.enabled` | 装配实现 | |---|---| | `true` / `TRUE` / `True` / `" true "` | `MockSmsCodeSender` | | `false` | `UnavailableSmsCodeSender` | | 缺失 | `UnavailableSmsCodeSender` | | 空字符串(`=`) | `UnavailableSmsCodeSender` | | 仅空白(`= `) | `UnavailableSmsCodeSender` | | 非法值(`yes`/`1`/`on`/`enabled`/`null`/`mock`/`TRUE1`) | `UnavailableSmsCodeSender`(安全回退,**绝不启用 Mock**) | ## 10. 每种配置的 Bean 数量 由 `SmsSenderConfigurationTest.everySupportedConfigurationCreatesExactlyOneSenderBean` 断言(真实 `ApplicationContextRunner`,含 9 种取值 + 缺失属性): **每种取值 `getBeanNamesForType(SmsCodeSender.class).length == 1`**。 ## 11. 空值启动结果 | 层级 | 结果 | |---|---| | 快速装配测试(`ApplicationContextRunner`) | 上下文**启动成功**;恰好一个 Bean;类型 `UnavailableSmsCodeSender`;消费者构造注入成功;发送抛固定安全 `SERVICE_UNAVAILABLE` | | **全应用上下文测试**(`SmsEmptyPropertyStartupIntegrationTest`,真实 H2 + Flyway + 全 Bean) | 应用上下文**启动成功**;恰好一个 `SmsCodeSender`;类型 `UnavailableSmsCodeSender`;`SmsVerificationService` **可注入**;发送返回固定安全服务不可用 ✅ | 整改前该取值导致 `APPLICATION FAILED TO START … required a bean of type 'com.mydreamjob.auth.sms.SmsCodeSender' that could not be found`(独立验收阶段已复现并记录在验收报告 F3)。 > 本轮按要求**未操作 Docker / 未执行真实 MySQL 黑盒**,故未做真实 JAR 启动;全应用上下文启动已由 H2+Flyway 覆盖,真实环境启动仍待独立复验。 ## 12. 是否恢复验证码日志 **未恢复。** `grep -rn "MOCK SMS CODE" mydreamjob-server/src` 命中 0;`MockSmsCodeSender` 仅输出固定文本 `SMS verification code generated (mock sender)`;`SmsVerificationService` 仅两处 `log.warn`,参数为 `maskPhone(phone)`;`AuthService` 仅记录 `id`。 ## 13. 原 S01 权限与事务回归结果 | 原 S01 要求 | 结果 | |---|---| | 不恢复验证码日志 | ✅(第 12 节) | | 不输出完整手机号 | ✅ `SmsVerificationSecurityTest` 10/10 | | Mock 发送器不打印验证码 | ✅ | | mock=false 不返回虚假成功 | ✅ `SmsSenderConfigurationTest` + 回滚集成测试 | | mock=false 发送失败事务回滚 | ✅ `SmsUnavailableSenderRollbackIntegrationTest` 3/3(真实 Spring + H2 + Flyway:失败后该手机号**无任何验证码行**、总数不变、无虚假成功审计) | | 匿名 POST /jobs = 401 | ✅ `AuthenticationBoundaryIntegrationTest` 15/15 | | STUDENT/ADMIN 创建岗位 = 403/2002 | ✅ | | COMPANY 合法创建保持成功 | ✅ | | 非法 principal 返回 401 | ✅ `CurrentUserAccessorTest` 11/11 | | 不返回 NumberFormatException / 内部 principal | ✅ | | OpenAPI 173/211 | ✅ | | 无新增公网取码接口 | ✅(OpenAPI 173/211 不变) | | 无新 Flyway/表/索引/ErrorCode | ✅ | ## 14. Shell 测试数量与结果 `docs/tests/smoke-test-sms-code-test.sh`(新增):**22 用例,PASS=22 / FAIL=0** 覆盖:mock=true+ALLOW=true 可取码、mock=false 拒绝、配置缺失拒绝、ALLOW 缺失/为 false 拒绝、无可用验证码安全失败、查询失败不继续、结果非数字/长度非法拒绝、查询为空时不继续登录(无 login 请求)、stdout/stderr 不含验证码、不含完整手机号、生成文件不含验证码、不 grep 应用日志、不存在 `MOCK SMS CODE`、REPLY/SMS/LOGIN_BODY 清理与 EXIT trap 存在、未授权时不触碰数据库、拒绝提示不含手机号、**授权模式下可完成登录流程(登录请求已发出且携带 6 位码、登录步骤判定 PASS)**。 测试通过 PATH 注入假 `docker`/`curl` 实现,**不要求 Docker 或真实 MySQL**,仓库内不写入真实验证码,结束时扫描输出确认标记未泄露。 ## 15. Maven 专项、关联与全量测试数 | 命令 | Tests run | |---|---| | `-Dtest="SmsSenderConfigurationTest,SmsVerificationSecurityTest,SmsVerificationServiceTest,CurrentUserAccessorTest,AuthenticationBoundaryIntegrationTest"` | **56** | | `-Dtest="*Auth*Test,*Security*Test,*Sms*Test,*Job*Test"` | **222** | | `mvn test`(最终,含本轮新增 2 个集成测试) | **1671** | | `mvn clean verify` | **1671** | S01 相关类明细: | 测试类 | Tests | |---|---| | `SmsSenderConfigurationTest`(F3,由 4 扩展至 12) | 12 | | `SmsVerificationSecurityTest` | 10 | | `SmsVerificationServiceTest` | 8 | | `CurrentUserAccessorTest` | 11 | | `AuthenticationBoundaryIntegrationTest` | 15 | | `SmsUnavailableSenderRollbackIntegrationTest`(本轮新增) | 3 | | `SmsEmptyPropertyStartupIntegrationTest`(本轮新增) | 2 | ## 16. Failures / Errors / Skipped **Failures=0、Errors=0、Skipped=0**(全量与 clean verify 一致) ## 17. clean verify 结果 `mvn clean verify` → **BUILD SUCCESS**(4:34),产出 `target/mydreamjob-server-0.0.1-SNAPSHOT.jar`。 ## 18. OpenAPI paths / operations | 来源 | paths | operations | |---|---|---| | 静态 `docs/verify_openapi.py` | **173** | **211** | | 校验输出 | `All checks passed!` | — | 未新增/删除路径或 operation;无新增测试取码端点。 ## 19. 精确修改文件 **修改(已跟踪,本轮 F1/F2)** | 文件 | 变更 | |---|---| | `docs/smoke-test.sh` | +176/−…(取码机制整体替换、双保护、脱敏输出、库模式钩子) | | `docs/acceptance-week1.md` | 3 处取码表述 | | `docs/api-v0.1.md` | 2 处取码表述 | | `docs/frontend-integration.md` | §3 整节重写 + 1 处(**在途文件,最小改动**) | | `docs/offer-api.md` | 1 处取码表述 | | `docs/resume-api.md` | 1 处取码表述(**在途文件,最小改动**) | **修改(S01 新建文件,本轮扩展,F3)** | 文件 | 变更 | |---|---| | `auth/sms/MockSmsCodeSender.java` | 移除 `@Component`/`@ConditionalOnProperty`,改为普通类 | | `auth/sms/UnavailableSmsCodeSender.java` | 同上 | | `auth/SmsSenderConfigurationTest.java` | 由 4 用例扩展至 12(真实 ApplicationContextRunner + 消费者探针) | **新增** | 文件 | 用途 | |---|---| | `auth/sms/SmsSenderConfiguration.java` | F3 单 Bean 装配 | | `auth/SmsUnavailableSenderRollbackIntegrationTest.java` | 真实 Spring+H2:失败不残留验证码 | | `auth/SmsEmptyPropertyStartupIntegrationTest.java` | 空值全应用上下文启动 | | `docs/tests/smoke-test-sms-code-test.sh` | F1 shell 安全测试(22 用例) | ## 20. Git 状态 - 分支:`fix/resume-upload-safety`(未变) - **暂存区:空** - `git diff --check`:无空白错误(仅 CRLF 提示) - 迁移改动:**无**(V1–V41 共 41 个文件,最大版本 41) - 删除文件:**0** - 历史未跟踪文件:未删除、未移动、未覆盖(含 `docs/acceptance/s01-20260911/` 全部验收证据 43 个文件与验收报告,保持原样) ## 21. 是否修改 Flyway、表、ErrorCode 或公网接口 **均未修改。** - Flyway:无新增/修改,V1–V41 原样 - 数据库表/索引:未新增或修改(验证码仍明文存储于 `sms_verification_codes`,属已记录遗留风险) - `ErrorCode`:未被改动 - 公网接口:OpenAPI 保持 173 paths / 211 operations;未新增取码接口 ## 22. 明确未执行 git add -A / commit / push **未执行** `git add -A`、**未 commit**、**未 push**(亦未 `reset`/`restore`/`checkout`/`clean`)。 ## 23. 明确未操作 Docker、未执行真实 MySQL 黑盒 **未执行任何 Docker 命令**、**未执行真实 MySQL 黑盒**。本轮全部验证均为:shell 测试(假 docker/curl 注入)+ Maven 单元/集成测试(H2 + Flyway + 真实 Spring 上下文)。 ## 24. 真实 MySQL / Docker 复验仍待独立验收 F1 的数据库取码路径(`docker exec` + 容器内 SQL)**尚未在真实 MySQL 环境端到端执行**;F3 的运行期 JAR 启动(含空值 / 缺失 / 非法值)亦未在真实环境启动验证。二者**必须由独立复验在真实 MySQL/Docker 环境完成**,不得以本轮 H2 结果代替。 --- ## 遗留与后续 1. **验证码仍为数据库明文存储**(本轮未改表结构,属已知遗留) 2. **真实短信供应商未接入**;生产 `mock=false` 下发码一律安全失败,需产品确认上线策略 3. **存量 43 处直接身份解析**未收口(均不可匿名到达;`DeletionGuardInterceptor` 对 20 位数字溢出仍可能 500)——非本轮范围 4. **安全层 401 响应不含 requestId**(既有 `AuthenticationEntryPoint` 固定载荷),与业务层错误响应格式不一致 5. 上一轮验收的其余观察项(失败后残留 `sms:rate` 限流键 60s;Spring Boot 默认 `generated security password` 入日志)**本轮未处理**,仍属非阻断遗留 --- # 第三轮定向整改(F4 / F5) - **整改日期**:2026-09-11(第二轮之后) - **独立复验结论**:S01 仍 FAIL —— F2/F3 已通过,但新发现 F4(xtrace 泄露)与 F5(数据库密码进入 mysql argv)两个阻断项 - **本轮范围**:仅修 F4/F5,并相应扩展 shell 测试、修正文档测试口径 - **本轮修改文件**:`docs/smoke-test.sh`、`docs/tests/smoke-test-sms-code-test.sh`、本报告 - **本轮未触碰**:S01 Java 生产代码(时间戳仍为 15:45,本轮为 17:38+ 仅改 docs) ## T1. F4 修复前的泄露位置 以 `bash -x` 执行时,以下位置被 trace 到 stderr(原文来自复验报告,此处仅列位置): | 位置 | 泄露内容 | 原因 | |---|---|---| | `PHONE="${STUDENT_PHONE}"` / 生成分支 | 完整手机号 | 脚本开头未关闭 xtrace | | `SMS="$REPLY"` | 验证码 | `sms_code()` 返回前 `set -x` 恢复,赋值被 trace | | `LOGIN_BODY="{...code...}"` | 验证码 + 完整手机号 | 赋值被 trace | | `printf '%s' "$LOGIN_BODY" \| curl ...` | 登录请求体(含验证码) | 命令与参数被 trace | | `LOGIN="$(...)"` | 登录响应全文(含 JWT) | 赋值被 trace | | `TOKEN="$(extract_token "$LOGIN")"` | JWT | 赋值被 trace | | `AUTH="Authorization: Bearer $TOKEN"` | JWT | 赋值被 trace | | 后续 `curl ... -H "$AUTH"` | JWT | 命令参数被 trace | | `sms_code()` 内部 `set +x` / `set -x` | 只保护了取码阶段 | 保护范围不足 | ## T2. 全局 xtrace 处理方式 采用「**开头立即关闭且全程不恢复**」(任务书推荐方案): ```bash #!/usr/bin/env bash # ── xtrace 防护(S01 第三轮 F4)── # 必须在读取任何环境变量、组装任何请求之前关闭 xtrace。 # 脚本是独立可执行程序,安全优先:全程不重新 set -x。 case "$-" in *x*) set +x ;; esac set -u ``` - 位置:`set +x` 位于**任何环境变量读取与任何请求组装之前**(仅在 shebang 与注释之后) - `sms_code()` 内部的 `xtrace_was_on` 与恢复 `set -x` 逻辑**已彻底删除**(`grep -c xtrace_was_on` = 0) - 脚本中**不存在可执行的 `set -x`**(仅 1 处注释提及,测试 F4-9 已断言) - 保护范围覆盖全脚本:验证码取码、手机号、请求体、登录响应、TOKEN、AUTH 及**后续所有携带 Authorization 的 curl 调用** - 未使用 `curl -v`/`--trace`/`--trace-ascii`(测试第 20 条断言) - 登录响应全文不再交给打印函数:新增 `extract_status_fields()` + `report_fields()`,登录步骤只把 http / business code / requestId 传出去(测试第 21 条断言) ## T3. `bash -x` 真实执行结果 由测试脚本内部驱动(捕获被测脚本 stdout+stderr 后扫描标记,只输出 PASS/FAIL): | 用例 | 结果 | |---|---| | F4-1 bash -x 下不泄露验证码 | PASS | | F4-2 bash -x 下不泄露完整手机号 | PASS | | F4-3 bash -x 下不泄露 JWT | PASS | | F4-4 bash -x 下不泄露 Authorization 头 | PASS | | F4-5 bash -x 下不泄露数据库密码 | PASS | | F4-6 bash -x 下不泄露 LOGIN_BODY | PASS | | F4-7 bash -x 下正常流程仍可完成登录 | PASS | | F4-8 bash -x 下登录步骤判定 PASS | PASS | | F4-9 脚本中不存在可执行的 set -x | PASS | | F4-10 失败流程(无可用验证码)同样不泄露 | PASS | | F4-11 source 模式下 REPLY 契约可用 | PASS | | F4-12 source 模式下清理后无敏感变量残留 | PASS | | F4-13 source 模式下 EXIT trap 已登记清理函数 | PASS | | F4-14 cleanup_secrets 覆盖 PHONE/TOKEN/AUTH/COMPANY_TOKEN | PASS | 另外单独执行 `bash -x docs/tests/smoke-test-sms-code-test.sh` → exit=0、PASS=51/FAIL=0; 该模式下测试脚本自身的假值可能被 trace(任务书已明确属预期,不作为安全判定), 但**测试结论输出(stdout)中标记值命中数为 0**。 ## T4. stdout/stderr 敏感标记命中数 以 `bash -x` 执行被测 `smoke-test.sh`(正常流程与失败流程各一次),对捕获的 stdout+stderr 递归扫描: | 标记类别 | 命中数 | |---|---| | 测试验证码 | **0** | | 完整手机号 | **0** | | 测试 JWT | **0** | | `Authorization: Bearer` | **0** | | 数据库密码标记 | **0** | | `LOGIN_BODY=` | **0** | (报告不记录标记值本身;标记值均为测试用固定假值,仅存在于测试脚本内。) ## T5. TOKEN / AUTH 生命周期 | 变量 | 生命周期 | |---|---| | `REPLY` | `sms_code()` 成功时写入;调用方立即 `SMS="$REPLY"; unset REPLY` | | `SMS` | 组装 `LOGIN_BODY` 后立即 `unset SMS` | | `LOGIN_BODY` | 经 `--data-binary @-` 送入 curl 后立即 `unset LOGIN_BODY` | | `LOGIN`(响应全文) | 仅用于提取 http/code/requestId 与 token;`extract_token` 后立即 `unset LOGIN` | | `LOGIN_FIELDS` | 只含可安全打印的三字段,用完 `unset` | | `TOKEN` | **保留到流程结束**(后续接口需要),但不打印、不写文件、不进验收产物 | | `AUTH` | 由 TOKEN 派生,仅作为 curl 头参数使用;EXIT trap 统一清理 | | `COMPANY_TOKEN` | 调用方传入,仅作 curl 头参数;EXIT trap 统一清理 | | `PHONE` / `STUDENT_PHONE` | EXIT trap 统一清理;控制台只输出脱敏形式 | `cleanup_secrets()` 现覆盖:`PHONE SMS OTP REPLY LOGIN_BODY LOGIN TOKEN AUTH` 与 `STUDENT_PHONE COMPANY_TOKEN`,由 `trap cleanup_secrets EXIT` 统一清理。 注意:登记块**不再**给 `PHONE`/`STUDENT_PHONE`/`COMPANY_TOKEN` 赋空值(避免覆盖调用方传入值)。 ## T6. F5 修复前后的 mysql 调用形式 **修复前** ```bash docker exec -i "$container" sh -c \ 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -N -B "${MYSQL_DATABASE:-mydreamjob}"' ``` 容器内 shell 会把 `$MYSQL_ROOT_PASSWORD` 展开为 `-p<真实密码>`,进入 mysql 进程 argv。 **修复后** ```bash docker exec -i "$container" sh -c ' if [ -z "${MYSQL_USER:-}" ] || [ -z "${MYSQL_PASSWORD:-}" ]; then echo "db credentials unavailable" >&2 exit 3 fi MYSQL_PWD="$MYSQL_PASSWORD" exec mysql \ -u"$MYSQL_USER" \ -N -B \ "${MYSQL_DATABASE:-mydreamjob}"' 2>/dev/null \ | head -n 1 ``` ## T7. mysql argv 是否含密码 测试用假 `mysql` 记录自身真实 argv(它正是被检查的进程),实测结果形态为: ``` -u<应用账号> -N -B <数据库名> ``` | 断言 | 结果 | |---|---| | F5-1 mysql argv 不含数据库密码值 | PASS | | F5-2 宿主机 docker 命令行不含密码值 | PASS | | F5-3 容器侧脚本文本不含密码值 | PASS | | F5-4 容器侧脚本以变量名引用 MYSQL_PASSWORD | PASS | | F5-5 mysql argv 不含 `-p<密码>` | PASS | | F5-6 mysql argv 不含 `--password` | PASS | | F5-13 密码不经 argv 传递 | PASS | | F5-14 密码经 `MYSQL_PWD` 环境变量传递 | PASS | ## T8. 使用的数据库账号类型 - 使用**应用账号**(容器环境变量 `MYSQL_USER` / `MYSQL_PASSWORD` / `MYSQL_DATABASE`) - **不使用 root**;`MYSQL_ROOT_PASSWORD` 在脚本中命中数为 0,`-uroot` 命中数为 0(F5-7 / F5-8 PASS) - 凭据缺失时 `exit 3` 安全失败,**不回退 root**(F5-15 PASS) - 未读取、未打印、未落盘凭据;未修改 compose.yaml;未修改 MySQL 用户权限;查询仅 `SELECT`(F5-11/F5-12 PASS) ## T9. Shell 测试实际数量 `docs/tests/smoke-test-sms-code-test.sh`:**51 用例,PASS=51 / FAIL=0** 构成:F1 取码保护 10 项 + F4 xtrace/源模式 14 项 + F5 argv 18 项 + 静态保证与文档门禁 9 项。 ## T10. Maven 专项 / 关联 / 全量数量 | 命令 | Tests run | |---|---| | 7 类安全专项(含空值与回滚集成测试) | **61** | | `*Auth*Test,*Security*Test,*Sms*Test,*Job*Test` | **227** | | `mvn test` | **1671** | | `mvn clean verify` | **1671** | ## T11. Failures / Errors / Skipped **Failures=0、Errors=0、Skipped=0**(专项、关联、全量、clean verify 全部一致) ## T12. clean verify `mvn clean verify` → **BUILD SUCCESS**(4:34),产出最新 JAR。 ## T13. OpenAPI 数量 **173 paths / 211 operations**(`All checks passed!`);本轮未改 Controller 映射、未新增取码接口。 ## T14. 精确修改文件(本轮) | 文件 | 变更 | |---|---| | `docs/smoke-test.sh` | 开头新增全局 xtrace 关闭(全程不恢复);删除 `xtrace_was_on`/恢复逻辑;`otp_db_query` 改为应用账号 + `MYSQL_PWD`(去 root、去 `-p`);`cleanup_secrets` 覆盖面扩展;新增 `report_fields`/`extract_status_fields`,登录响应不再交给打印函数 | | `docs/tests/smoke-test-sms-code-test.sh` | 扩展至 51 用例:真实 `bash -x` 执行与标记扫描、假 docker 执行容器侧脚本 + 假 mysql 记录 argv、source 模式泄漏防护、活跃文件文档门禁 | | `docs/s01-remediation-f1f2f3-20260911.md` | 新增本「第三轮定向整改」章节 | **未修改**:任何 Java 生产代码、任何既有测试、`docs/smoke-test.sh` 以外的文档正文、`.env`、compose.yaml、Flyway。 ## T15. Git 状态 - 分支:`fix/resume-upload-safety`(未变) - 暂存区:**空** - `git diff --check`:无空白错误(仅 CRLF 提示) - 迁移改动:**无**(V1–V41 共 41 个) - 删除文件:**0**;历史未跟踪文件:**145 个全部保留** - 本轮已跟踪文件改动仅 `docs/smoke-test.sh`(S01 Java 文件时间戳仍为 15:45,本轮未触碰) ## T16. 是否修改 Flyway、表、ErrorCode 或接口 **均未修改**:无新增/修改迁移、无表/索引变更、`ErrorCode` 未改、OpenAPI 保持 173/211。 ## T17. 明确未暂存、未 commit、未 push **未执行** `git add` / `git add -A`、**未 commit**、**未 push**(亦未 `reset`/`restore`/`checkout`/`clean`)。 ## T18. 真实 MySQL / Docker 最终复验仍待独立执行 本轮**未执行任何 Docker 命令**,**未连接真实 MySQL**: - F5 的 `docker exec` + `MYSQL_PWD` 路径仅在**假 docker / 假 mysql** 下验证了 argv 与 stdin 形态; 真实容器中 `MYSQL_USER` / `MYSQL_PASSWORD` 是否存在、`MYSQL_PWD` 是否被 mysql 客户端接受,**必须由独立复验在真实 Docker/MySQL 环境确认**。 - F4 的 xtrace 防护已由真实 `bash -x` 本地执行验证(无需容器),但**完整真实登录链路**仍待真实环境复验。 ## T19. 文档测试口径修正(§7) 历史验收报告 `docs/s01-independent-mysql-acceptance-20260911.md` 与本报告**合理保留修复前缺陷证据**(含修复前的日志依赖描述与旧命令),因此**不得**以「整个 docs 目录命中数为 0」作为门禁结论。 修正后的口径(已落入 shell 测试第 22-24 条断言): - **A. 活跃操作文档与正式代码**(必须无「从日志取码」有效指导): `docs/acceptance-week1.md`、`docs/api-v0.1.md`、`docs/frontend-integration.md`、`docs/offer-api.md`、`docs/resume-api.md`、`docs/smoke-test.sh`、`README.md`、`mydreamjob-server/src/main/java` - **B. 历史验收报告**(允许保留修复前失败证据,须明示为历史): `docs/s01-independent-mysql-acceptance-20260911.md`、`docs/s01-remediation-f1f2f3-20260911.md` 测试使用**精确活跃文件列表**扫描(不扫描全部 docs),并额外断言两份历史报告仍然存在。 ## T20. 本轮遗留(非阻断,待后续) 1. JWT 仍作为 `-H "Authorization: Bearer "` 的 curl 参数存在于**进程 argv**(非 xtrace/stdout/stderr 问题;如需消除,应改用 `curl --config` 经 stdin 传头) 2. 验证码仍为数据库明文存储(未改表结构) 3. 真实短信供应商未接入 4. 存量 43 处直接身份解析未收口;安全层 401 响应不含 requestId 5. 上轮观察项(失败后残留 `sms:rate` 限流键 60s;Spring Boot 默认 `generated security password` 入日志)本轮未处理 --- **S01第三轮定向整改完成,待独立代码复验及真实MySQL/Docker黑盒复验。** --- # 第四轮定向整改(F6) - **整改日期**:2026-09-14 - **独立复验结论**:S01 仍 FAIL —— F4 与 F5 已通过(bash -x 不泄露 / Shell 51 项 / Maven 安全专项 61 项 / 密码不进 mysql argv 均通过),新阻断项 **F6**:冒烟脚本使用了 `sms_verification_codes` 不存在的列 `expires_at` - **本轮范围**:仅修 F6,并补真实表结构契约测试 - **本轮修改文件**:`docs/smoke-test.sh`、`docs/tests/smoke-test-sms-code-test.sh`、`docs/tests/smoke-test-schema-contract-test.sh`(新增)、本报告 - **本轮未触碰**:全部 Java 生产代码、实体、Repository、Flyway、数据库表、`ErrorCode`、公网接口 ## G1. F6 错误位置 | 项 | 内容 | |---|---| | 文件 | `docs/smoke-test.sh` | | 行 | **L122** | | 修复前 | `" AND expires_at > NOW()" \` | | 修复后 | `" AND expired_at > NOW()" \` | | 后果 | 真实 MySQL 报 ERROR 1054,冒烟脚本取不到验证码,**F1 未闭环** | ## G2. 真实 MySQL SHOW COLUMNS 结果摘要 独立复验在真实 MySQL 执行 `SHOW COLUMNS FROM sms_verification_codes`,实际字段为: ``` id phone code purpose attempts max_attempts expired_at used created_at ``` **不存在 `expires_at`。** 该结论与两个契约来源一致: | 来源 | 位置 | 结果 | |---|---|---| | 实体 `@Column` | `auth/SmsVerificationCode.java:46` | `@Column(name = "expired_at", nullable = false)` | | 建表 Flyway | `db/migration/V1__base_schema.sql:37` | `expired_at DATETIME NOT NULL` | ## G3. 修复前的真实报错 ``` ERROR 1054 (42S22): Unknown column 'expires_at' in 'where clause' ``` ## G4. 修复后的 SQL ```sql SELECT code FROM sms_verification_codes WHERE phone = '' AND purpose = '' AND used = 0 AND expired_at > NOW() ORDER BY created_at DESC, id DESC LIMIT 1; ``` 除列名外**未改动任何查询口径**,以下均保持不变:phone 精确匹配、purpose 精确匹配、`used = 0`、`expired_at > NOW()`、`ORDER BY created_at DESC, id DESC`、`LIMIT 1`、仅 SELECT、使用应用数据库账号(非 root)、密码经 `MYSQL_PWD` 不进 argv、SQL 经 stdin、验证码不输出。 ## G5. 新增的 Shell 断言 `docs/tests/smoke-test-sms-code-test.sh` 由 **51 项扩展至 69 项**,新增 Part 6 共 **18 条**: | 编号 | 断言 | |---|---| | F6-1 | `otp_db_query` 使用 `expired_at > NOW()` | | F6-2 | `otp_db_query` 不再出现错误字段 `expires_at` | | F6-3 | 查询只使用 `sms_verification_codes` 的真实列 | | F6-4 | 真实列清单由 Flyway 派生且含 `expired_at`(非空、非自证) | | F6-5 | 假 mysql 捕获到完整 stdin SQL(非无条件返回验证码) | | F6-6 | SQL 字段错误时假 mysql 返回失败(非 0) | | F6-6b | 字段错误时报 ERROR 1054 (42S22) | | F6-7 | 字段错误时 `sms_code()` 不继续登录 | | F6-7b | 字段错误时未发出任何 login 请求 | | F6-8 | 正确字段时取码成功并完成登录 | | F6-9 | `expired_at` 恰好等于 NOW() 时不得取出 | | F6-10 | `expired_at` 晚于 NOW() 时可以取出 | | F6-11 | `expired_at` 早于 NOW() 时不得取出 | | F6-12 | `used=1` 不得取出 | | F6-13 | `purpose` 不匹配不得取出 | | F6-14 | `phone` 不匹配不得取出 | | F6-15 | 多条有效记录只取 `created_at`/`id` 最新一条 | | F6-15b | 与输入顺序无关,按 `created_at`/`id` 排序取值 | **另新增独立契约门禁** `docs/tests/smoke-test-schema-contract-test.sh`(**15 条**),契约来源为**实体 `@Column` + 建表 Flyway**,并交叉校验二者列集合一致: C1–C4 来源存在性与解析非空 → C5 两个独立来源列集合一致 → C6 `expired_at` 存在 → C7 **`expires_at` 不存在**(表限定)→ C8 实体映射存在 → C9 `expires_at` 确实是其他表的真实列 → C10–C15 脚本 SQL 的表名、列存在性、列不含 `expires_at`、所用列全部真实。 ## G6. fake docker / fake mysql 是否校验 SQL **本轮之前:不校验。** 旧假 mysql 只是 `cat` 掉 stdin 后无条件 `printf "${FAKE_CODE}"`,因此错误的 `expires_at` 也能"取到"验证码并通过测试——这正是 F6 能逃过 51 项测试的原因。 **本轮之后:校验。** 假 mysql 改为: 1. 完整读取并保存 stdin SQL(不再丢弃); 2. 若 SQL 命中 `sms_verification_codes` 且包含 `expires_at` → 如实复现真实 MySQL 的 `ERROR 1054 (42S22)` 并 `exit 1`; 3. 设置 `FAKE_ROWS` 时,按行套用 `WHERE phone/purpose/used` 与 `expired_at > NOW()` 语义、按 `created_at DESC, id DESC` 取值;未设置时保留历史行为供 F1/F5 既有用例使用。 ## G7. 过期 / 有效 / used / purpose / phone 口径 | 场景 | 假表语义 | 期望 | 结果 | |---|---|---|---| | `expired_at` 恰好 = NOW() | offset = 0 | 不取出(查询为严格 `>`) | PASS | | `expired_at` 晚于 NOW() | offset = +300 | 取出 | PASS | | `expired_at` 早于 NOW() | offset = −300 | 不取出 | PASS | | `used = 1` | — | 不取出 | PASS | | `purpose` 不匹配 | 行 purpose=`register`,查 `login` | 不取出 | PASS | | `phone` 不匹配 | 行 phone 为另一号码 | 不取出 | PASS | | 多条有效 | 两条均有效,`created_at` 不同 | 只取最新一条 | PASS | ## G8. F4 / F5 回归结果 全部保持通过(本轮未改动 xtrace 防护与 `MYSQL_PWD` 路径): | 回归项 | 结果 | |---|---| | `bash -x` 不泄露验证码 / 完整手机号 / JWT / Authorization / 数据库密码 / LOGIN_BODY | PASS(F4-1 ~ F4-6) | | `bash -x` 下正常流程仍可完成登录 | PASS(F4-7、F4-8) | | 脚本中不存在可执行的 `set -x` | PASS(F4-9) | | 失败流程同样不泄露 | PASS(F4-10) | | source 模式 REPLY 契约 / 无敏感变量残留 / EXIT trap | PASS(F4-11 ~ F4-14) | | mysql argv 不含密码值、不含 `-p`/`--password` | PASS(F5-1 ~ F5-6) | | 不使用 root / 使用应用账号与数据库名 | PASS(F5-7 ~ F5-10) | | SQL 经 stdin 且仅 SELECT | PASS(F5-11、F5-12) | | 密码经 `MYSQL_PWD` 传递、不经 argv | PASS(F5-13、F5-14) | | 凭据缺失时安全失败(不回退 root) | PASS(F5-15) | | 失败路径 stderr 不含数据库密码 | PASS(F5-18) | | 不恢复 `MOCK SMS CODE` 日志、活跃文档不指导从日志取码 | PASS(14、22、23) | ## G9. Shell 测试数量 | 文件 | 用例数 | 结果 | |---|---|---| | `docs/tests/smoke-test-sms-code-test.sh` | **69** | PASS=69 / FAIL=0 | | `docs/tests/smoke-test-schema-contract-test.sh` | **15** | PASS=15 / FAIL=0 | 合计 **84** 条,全部通过(真实 Git Bash 执行)。 **断言非空转的证据(测试自检)**:把 `smoke-test.sh` 还原为 buggy 版本(`expires_at`)放入镜像目录,用**同一套测试**执行: - `smoke-test-sms-code-test.sh` → `PASS=56 FAIL=13`,其中 **F6-1 / F6-2 / F6-3 翻红**,F6-3 直接点名不存在的列 `expires_at`;F6-8/10/15/15b 因取不到码一并失败; - `smoke-test-schema-contract-test.sh` → `PASS=12 FAIL=3`,**C12 / C13 / C14 翻红**,C14 点名 `expires_at`。 即:本轮断言确实能识别**不存在的 SQL 列**,而非"改前改后都通过"。 ## G10. Maven 专项 / 关联 / 全量测试数 | 命令 | Tests run | |---|---| | `-Dtest="SmsSenderConfigurationTest,SmsVerificationSecurityTest,SmsVerificationServiceTest,CurrentUserAccessorTest,AuthenticationBoundaryIntegrationTest,SmsEmptyPropertyStartupIntegrationTest,SmsUnavailableSenderRollbackIntegrationTest"` | **61** | | `-Dtest="*Auth*Test,*Security*Test,*Sms*Test,*Job*Test"` | **227** | | `mvn test` | **1671** | | `mvn clean verify` | **1671** | 本轮仅改 `docs/` 下的脚本与报告,未增删任何 Java 测试,故总数与上一轮一致(1671)。 ## G11. Failures / Errors / Skipped **Failures=0、Errors=0、Skipped=0**(专项、关联、全量、clean verify 四者一致)。 ## G12. clean verify `mvn clean verify` → **BUILD SUCCESS**(Total time 02:11),产出 `target/mydreamjob-server-0.0.1-SNAPSHOT.jar`。 ## G13. OpenAPI 计数 `python docs/verify_openapi.py` → **All checks passed!**,**173 paths / 211 operations**(与上一轮一致,未新增/删除路径或 operation)。 ## G14. Git 状态 - 分支:`fix/resume-upload-safety`(未变) - **暂存区:空** - `git diff --check`:无空白错误(仅 CRLF 提示) - 迁移改动:**无**(`git diff --name-only -- mydreamjob-server/src/main/resources/db/migration` 无输出;目录内 41 个文件,最大版本 `V41__message_recall.sql`) - 本轮文件时间戳:`docs/smoke-test.sh` 09:22、`smoke-test-sms-code-test.sh` 09:24、`smoke-test-schema-contract-test.sh` 09:27、本报告 09:32(均 2026-09-14) - **S01 Java 生产代码时间戳仍为 2026-09-11 15:45**,本轮未触碰 - 删除文件:**0**;历史未跟踪文件:未删除、未移动、未覆盖 ## G15. 明确未修改 Java、Flyway、表、ErrorCode 或接口 **均未修改。** - Java 生产代码:**0 处改动**(时间戳可证) - 实体与 Repository:未改 - Flyway:未新增/修改,V1–V41 原样 - 数据库表/列/索引:未新增或修改;**未为迁就脚本把列改成 `expires_at`** - `ErrorCode`:未改 - 公网接口:OpenAPI 保持 173/211,未新增取码接口 - **未恢复验证码日志**(`grep -rn "MOCK SMS CODE" mydreamjob-server/src` 命中 0) ## G16. 明确未暂存、commit、push **未执行** `git add` / `git add -A`、**未 commit**、**未 push**(亦未 `reset`/`restore`/`checkout`/`clean`)。**未操作 Docker**,未进入 S02。 ## G17. 真实 MySQL 完整冒烟链仍待最终独立复验 本轮**未执行任何 Docker 命令、未连接真实 MySQL**。全部验证均为:shell 测试(假 docker / 假 mysql 注入)+ Maven H2/Flyway 测试。 因此以下**必须由独立复验在真实 MySQL/Docker 环境完成**: 1. `SHOW COLUMNS FROM sms_verification_codes` 的 Field 集合与 Flyway 列集合对账(含 `expired_at`、不含 `expires_at`); 2. 修复后的取码 SQL 在真实 MySQL 端到端执行成功(不再出现 ERROR 1054); 3. F1 完整冒烟链(发码 → 取码 → 登录 → 后续接口)闭环。 **不得以本轮假库结果代替真实环境验收。** ## G18. 一处必须记录的旁证(不在本轮修改范围) `docs/acceptance/s01-20260911/snapshot-sms.sql:4` 同样使用 `expires_at` 查询 `sms_verification_codes`: ```sql SELECT id, purpose, CHAR_LENGTH(code) AS code_len, TIMESTAMPDIFF(SECOND, NOW(), expires_at) AS expire_delta_s, ... FROM sms_verification_codes ``` 这是同一字段错误的传播。该文件属上一轮独立验收证据,**不在本轮允许修改的文件范围内**,故保持原样未改,仅在此记录,供后续处理。 ## G19. 字段名易混淆的说明(供后续维护) `expires_at` 并非拼写错误,它是本仓库中**三张表的真实列**: | 表 | 迁移 | |---|---| | `personal_data_export_tasks.expires_at` | V30 | | `ability_attempts.expires_at` | V35 | | `talent_invitations.expires_at`(含索引) | V40 | 只有 `sms_verification_codes` 用 `expired_at`。因此本轮门禁**按表限定**,未写成"全库禁用 `expires_at`"——那样会误报并掩盖真实错误。契约门禁 C9 专门守卫这一区分。 --- **S01第四轮定向整改完成,待最终独立代码复验及真实MySQL/Docker黑盒复验。**