#!/usr/bin/env bash # S01 定向整改 F1 / F4 / F5 —— smoke-test.sh 敏感信息泄漏防护测试 # # 覆盖: # F1 取码机制(双重保护 / 不依赖应用日志 / 格式校验 / 清理) # F4 xtrace 泄漏:**真实以 `bash -x` 执行被测脚本**,捕获 stdout+stderr 并扫描敏感标记 # F4 source 模式不把敏感变量带回调用方 # F5 数据库密码不进入 mysql argv(真实执行容器侧脚本文本,检查假 mysql 的实际 argv) # §7 文档门禁只针对**活跃文件列表**,不对历史验收报告做全 docs 扫描 # # 测试使用假的 docker / curl / mysql(PATH 注入),不要求 Docker 或真实 MySQL。 # 仓库内不写入真实验证码、JWT 或数据库口令;测试结束扫描输出确认标记未泄露。 # # 运行: bash docs/tests/smoke-test-sms-code-test.sh set -u REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" SMOKE="$REPO_ROOT/docs/smoke-test.sh" # ── 固定测试标记(均为假值,非真实凭据)───────────────────────── TEST_CODE="739251" TEST_PHONE="19900009999" TEST_JWT="FAKEJWT_MARKER_QA7X" TEST_DBPASS="FAKEDBPASS_MARKER_QA7X" MARKER_MSG="test OTP retrieval is disabled" TMP="$(mktemp -d)" FAKEBIN="$TMP/bin" mkdir -p "$FAKEBIN" export FAKE_DIR="$TMP" trap 'rm -rf "$TMP"' EXIT PASS=0 FAIL=0 ok() { echo "[PASS] $1"; PASS=$((PASS + 1)); } bad() { echo "[FAIL] $1"; FAIL=$((FAIL + 1)); } check() { if [ "$1" = "1" ]; then ok "$2"; else bad "$2"; fi; } # ── 假 docker:真实执行被传入的容器侧 sh -c 脚本(含假 mysql)────── cat > "$FAKEBIN/docker" <<'FAKEDOCKER' #!/usr/bin/env bash # 记录完整 argv(用于 F5 断言:宿主机侧命令行不得含密码) printf '%s\n' "$*" >> "${FAKE_DIR:?}/docker-argv.log" script="" for a in "$@"; do script="$a"; done # 记录容器侧脚本文本(变量名可以出现,值不可以) printf '%s\n' "$script" > "${FAKE_DIR}/container-script.txt" case "${FAKE_DOCKER_MODE:-ok}" in fail) exit 1 ;; empty) cat >/dev/null 2>&1 || true; exit 0 ;; garbage) cat >/dev/null 2>&1 || true; printf '%s\n' "${FAKE_GARBAGE:-not-a-code}"; exit 0 ;; short) cat >/dev/null 2>&1 || true; printf '%s\n' "12345"; exit 0 ;; esac # 真实以本地 sh 执行容器侧脚本,使假 mysql 成为真正被检查 argv 的进程 exec sh -c "$script" FAKEDOCKER chmod +x "$FAKEBIN/docker" # ── 假 mysql:记录 argv 与 stdin(SQL),并按 sms_verification_codes 真实语义作答 ── # 它**不是**无条件返回验证码:先校验 SQL 使用的列。 # 真实 MySQL 对未知列返回 ERROR 1054 (42S22),这里如实复现, # 使脚本里字段名写错(例如 expires_at)无法被"总能拿到验证码"掩盖。 # # FAKE_ROWS 未设置 → 保留历史行为(输出 FAKE_CODE),供 F1/F5 既有用例使用。 # FAKE_ROWS 设置 → 按行套用 WHERE / ORDER BY 语义作答。行格式: # id|phone|purpose|used|expire_offset_s|created_at|code # expire_offset_s > 0 表示 expired_at 晚于 NOW();查询用严格大于,故 =0 不命中。 cat > "$FAKEBIN/mysql" <<'FAKEMYSQL' #!/usr/bin/env bash printf '%s\n' "$*" > "${FAKE_DIR:?}/mysql-argv.log" sql="$(cat)" printf '%s' "$sql" > "${FAKE_DIR}/mysql-stdin.sql" # ── 表结构契约检查:sms_verification_codes 的真实列是 expired_at ── case "$sql" in *sms_verification_codes*) case "$sql" in *expires_at*) printf "ERROR 1054 (42S22): Unknown column 'expires_at' in 'where clause'\n" >&2 exit 1 ;; esac ;; esac # 未提供假数据:保持历史行为 if [ -z "${FAKE_ROWS:-}" ]; then printf '%s\n' "${FAKE_CODE:-}" exit 0 fi # 从 SQL 解析查询参数(形态与应用脚本一致) phone="$(printf '%s' "$sql" | sed -n "s/.*phone *= *'\([^']*\)'.*/\1/p" | head -n 1)" purpose="$(printf '%s' "$sql" | sed -n "s/.*purpose *= *'\([^']*\)'.*/\1/p" | head -n 1)" # 逐行套用 WHERE 语义,按 created_at DESC, id DESC 取第一条 best_key="" best_code="" while IFS='|' read -r rid rphone rpurpose rused roffset rcreated rcode; do [ -n "${rid:-}" ] || continue [ "$rphone" = "$phone" ] || continue [ "$rpurpose" = "$purpose" ] || continue [ "$rused" = "0" ] || continue case "$roffset" in ''|*[!0-9-]*) continue ;; esac [ "$roffset" -gt 0 ] || continue key="$(printf '%s|%010d' "$rcreated" "$rid")" if [ -z "$best_key" ] || [[ "$key" > "$best_key" ]]; then best_key="$key" best_code="$rcode" fi done <<< "${FAKE_ROWS}" [ -n "$best_code" ] && printf '%s\n' "$best_code" exit 0 FAKEMYSQL chmod +x "$FAKEBIN/mysql" # ── 假 curl:记录请求;登录响应含固定测试 JWT ──────────────────── cat > "$FAKEBIN/curl" <<'FAKECURL' #!/usr/bin/env bash url=""; want_stdin=0 for a in "$@"; do case "$a" in http*) url="$a" ;; --data-binary) want_stdin=1 ;; *@-) want_stdin=1 ;; esac done has_code="no" if [ "$want_stdin" = "1" ]; then body="$(cat)" case "$body" in *[0-9][0-9][0-9][0-9][0-9][0-9]*) has_code="yes" ;; esac fi case "$url" in *"/auth/login"*) echo "login-attempted has6digit=$has_code" >> "${FAKE_DIR:?}/flow.log" printf '{"code":200,"data":{"accessToken":"%s"}}\n200\n' "${FAKE_JWT:-FAKEJWT}" ;; *"/auth/sms/send"*) printf '{}\n200\n' ;; *"/health"*) printf '{"code":200}\n200\n' ;; *"/notifications/unread-count"*) printf '{"unreadCount":1}\n200\n' ;; *"/notifications/"*) printf '{"message":"marked as read"}\n200\n' ;; *"/notifications"*) printf '{"items":[{"id":88}]}\n200\n' ;; *"/applications/"*) printf '{"status":"VIEWED"}\n200\n' ;; *"/applications"*) printf '{"id":77}\n200\n' ;; *"/jobs/public/"*) printf '{"title":"x"}\n200\n' ;; *"/match"*) printf '{"rulesVersion":"v1"}\n200\n' ;; *"/jobs"*) printf '{"items":[{"id":11,"title":"x"}]}\n200\n' ;; *) printf '{}\n200\n' ;; esac exit 0 FAKECURL chmod +x "$FAKEBIN/curl" export PATH="$FAKEBIN:$PATH" export FAKE_JWT="$TEST_JWT" # 数据库凭据标记:让容器侧脚本的凭据前置检查通过, # 同时用固定标记值验证它绝不出现在 argv / stdout / stderr / 文件中 export MYSQL_USER="smoke_readonly" export MYSQL_PASSWORD="$TEST_DBPASS" export MYSQL_DATABASE="mydreamjob" # 扫描给定文本是否含任一敏感标记;输出命中的标记名(不含值本身) scan_hits() { local text="$1" hits="" case "$text" in *"$TEST_CODE"*) hits="$hits CODE" ;; esac case "$text" in *"$TEST_PHONE"*) hits="$hits PHONE" ;; esac case "$text" in *"$TEST_JWT"*) hits="$hits JWT" ;; esac case "$text" in *"$TEST_DBPASS"*) hits="$hits DBPASS" ;; esac case "$text" in *"Authorization: Bearer"*) hits="$hits AUTH" ;; esac case "$text" in *"LOGIN_BODY="*) hits="$hits LOGIN_BODY" ;; esac printf '%s' "$hits" } echo "=== S01 F1/F4/F5: smoke-test.sh 敏感信息泄漏防护测试 ===" echo "smoke-test.sh: $SMOKE" echo "固定测试标记已加载(值不打印)。" echo "" # ══════════════════════════════════════════════════════════════ # Part 1 — F1 取码保护(进程内,可观测 REPLY) # ══════════════════════════════════════════════════════════════ run_sms_code() { local mock="$1" allow="$2" mode="$3" garbage="${4:-}" local out out="$(cd "$REPO_ROOT" && bash -c ' set -u SMOKE_TEST_LIB_ONLY=1 export SMOKE_TEST_LIB_ONLY if [ "$1" != "__UNSET__" ]; then export SMS_MOCK_ENABLED="$1"; else unset SMS_MOCK_ENABLED; fi if [ "$2" != "__UNSET__" ]; then export ALLOW_TEST_OTP_DB_READ="$2"; else unset ALLOW_TEST_OTP_DB_READ; fi export FAKE_DOCKER_MODE="$3" export FAKE_CODE="'"$TEST_CODE"'" export FAKE_GARBAGE="$4" # shellcheck disable=SC1090 . "'"$SMOKE"'" if sms_code; then if [ -n "${REPLY:-}" ]; then printf "RESULT=ok\n"; else printf "RESULT=ok_but_empty\n"; fi unset REPLY else printf "RESULT=fail\n" fi ' _ "$mock" "$allow" "$mode" "$garbage" 2>&1)" printf '%s' "$out" } # ── 以指定假表数据运行 sms_code,并比对取回的验证码 ───────────── # 只输出 RESULT=ok/fail 与 MATCH=yes/no,**绝不输出验证码本身**。 run_sms_code_with_rows() { local rows="$1" expect="$2" local out out="$(cd "$REPO_ROOT" && bash -c ' set -u export SMOKE_TEST_LIB_ONLY=1 export SMS_MOCK_ENABLED=true ALLOW_TEST_OTP_DB_READ=true FAKE_DOCKER_MODE=ok export FAKE_ROWS="$1" export STUDENT_PHONE="'"$TEST_PHONE"'" export MYSQL_USER="smoke_readonly" MYSQL_PASSWORD="$2" MYSQL_DATABASE="mydreamjob" # shellcheck disable=SC1090 . "'"$SMOKE"'" if sms_code; then if [ -n "${REPLY:-}" ]; then printf "RESULT=ok\n" if [ "${REPLY}" = "$3" ]; then printf "MATCH=yes\n"; else printf "MATCH=no\n"; fi else printf "RESULT=ok_but_empty\n" fi unset REPLY else printf "RESULT=fail\n" fi ' _ "$rows" "$TEST_DBPASS" "$expect" 2>&1)" printf '%s' "$out" } # ── 端到端运行指定脚本(用于验证「字段错误时不继续登录」)──────── # 脚本置于 $TMP 下镜像 docs/ 的结构,使其相对定位 backend-infra/compose.yaml 生效。 run_full_script() { local script="$1" : > "$TMP/flow.log" (cd "$REPO_ROOT" && \ SMS_MOCK_ENABLED=true ALLOW_TEST_OTP_DB_READ=true FAKE_DOCKER_MODE=ok \ FAKE_CODE="$TEST_CODE" FAKE_JWT="$TEST_JWT" \ MYSQL_PASSWORD="$TEST_DBPASS" MYSQL_USER="smoke_readonly" MYSQL_DATABASE="mydreamjob" \ STUDENT_PHONE="$TEST_PHONE" BASE_URL="http://127.0.0.1:1/api/v1" \ bash "$script" 2>&1) || true } r="$(run_sms_code true true ok)" check "$(printf '%s' "$r" | grep -q 'RESULT=ok' && echo 1 || echo 0)" "1. mock=true且ALLOW=true时可取得测试验证码" check "$(printf '%s' "$r" | grep -q "$TEST_CODE" && echo 0 || echo 1)" "10a. 取码调用 stdout/stderr 不含验证码" r="$(run_sms_code false true ok)" check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "2. mock=false 时拒绝查询" check "$(printf '%s' "$r" | grep -qF "$MARKER_MSG" && echo 1 || echo 0)" "2b. 拒绝时输出固定提示" r="$(run_sms_code __UNSET__ true ok)" check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "3. 配置缺失时拒绝查询" r="$(run_sms_code true __UNSET__ ok)" check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "4. ALLOW_TEST_OTP_DB_READ 缺失时拒绝查询" r="$(run_sms_code true false ok)" check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "5. ALLOW_TEST_OTP_DB_READ=false 时拒绝查询" r="$(run_sms_code true true empty)" check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "6. 没有可用验证码时安全失败" r="$(run_sms_code true true fail)" check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "7. 查询失败时不继续" r="$(run_sms_code true true garbage 'not-a-code')" check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "8a. 非数字结果被拒绝" r="$(run_sms_code true true short)" check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "8b. 长度非法结果被拒绝" # ══════════════════════════════════════════════════════════════ # Part 2 — F4:真实 `bash -x` 执行,扫描 stdout+stderr # ══════════════════════════════════════════════════════════════ run_smoke_xtrace() { local mode="$1" : > "$TMP/flow.log"; : > "$TMP/docker-argv.log"; : > "$TMP/mysql-argv.log" (cd "$REPO_ROOT" && \ SMS_MOCK_ENABLED=true ALLOW_TEST_OTP_DB_READ=true FAKE_DOCKER_MODE="$mode" \ FAKE_CODE="$TEST_CODE" FAKE_JWT="$TEST_JWT" \ MYSQL_PASSWORD="$TEST_DBPASS" MYSQL_USER="smoke_readonly" MYSQL_DATABASE="mydreamjob" \ STUDENT_PHONE="$TEST_PHONE" BASE_URL="http://127.0.0.1:1/api/v1" \ bash -x "$SMOKE" 2>&1) || true } out_x="$(run_smoke_xtrace ok)" hits_x="$(scan_hits "$out_x")" check "$(printf '%s' "$hits_x" | grep -q 'CODE' && echo 0 || echo 1)" "F4-1. bash -x 下不泄露验证码" check "$(printf '%s' "$hits_x" | grep -q 'PHONE' && echo 0 || echo 1)" "F4-2. bash -x 下不泄露完整手机号" check "$(printf '%s' "$hits_x" | grep -q 'JWT' && echo 0 || echo 1)" "F4-3. bash -x 下不泄露 JWT" check "$(printf '%s' "$hits_x" | grep -q 'AUTH' && echo 0 || echo 1)" "F4-4. bash -x 下不泄露 Authorization 头" check "$(printf '%s' "$hits_x" | grep -q 'DBPASS' && echo 0 || echo 1)" "F4-5. bash -x 下不泄露数据库密码" check "$(printf '%s' "$hits_x" | grep -q 'LOGIN_BODY' && echo 0 || echo 1)" "F4-6. bash -x 下不泄露 LOGIN_BODY" # 正常流程仍可完成(登录被调用且带 6 位码;登录判定 PASS) check "$(grep -q 'login-attempted has6digit=yes' "$TMP/flow.log" 2>/dev/null && echo 1 || echo 0)" \ "F4-7. bash -x 下正常流程仍可完成登录" check "$(printf '%s' "$out_x" | grep -q '\[PASS\] login' && echo 1 || echo 0)" \ "F4-8. bash -x 下登录步骤判定 PASS" # xtrace 未在脚本内被重新启用:脚本内不存在可执行的 set -x setx_code="$(grep -n 'set -x' "$SMOKE" | grep -v '^[0-9]*:[[:space:]]*#' | wc -l)" check "$([ "$setx_code" -eq 0 ] && echo 1 || echo 0)" "F4-9. 脚本中不存在可执行的 set -x(不会恢复 xtrace)" # 失败流程同样不泄露 out_f="$(run_smoke_xtrace empty)" hits_f="$(scan_hits "$out_f")" check "$([ -z "$hits_f" ] && echo 1 || echo 0)" "F4-10. 失败流程(无可用验证码)同样不泄露敏感值" # ══════════════════════════════════════════════════════════════ # Part 3 — F4:source 模式不把敏感值带回调用方 # ══════════════════════════════════════════════════════════════ src_out="$(cd "$REPO_ROOT" && bash -c ' set -u export SMOKE_TEST_LIB_ONLY=1 export SMS_MOCK_ENABLED=true ALLOW_TEST_OTP_DB_READ=true export FAKE_DOCKER_MODE=ok FAKE_CODE="'"$TEST_CODE"'" . "'"$SMOKE"'" trap -p EXIT > "'"$TMP"'/trap.txt" 2>/dev/null || true if sms_code; then if [ -n "${REPLY:-}" ]; then echo "reply-nonempty"; else echo "reply-empty"; fi unset REPLY fi # 清理后不得残留任何非空敏感变量 leak=0 for v in REPLY SMS OTP LOGIN_BODY LOGIN TOKEN AUTH; do eval "val=\${$v:-}" [ -n "$val" ] && leak=1 done echo "leak=$leak" ' 2>&1)" check "$(printf '%s' "$src_out" | grep -q 'reply-nonempty' && echo 1 || echo 0)" "F4-11. source 模式下取码契约(REPLY)可用" check "$(printf '%s' "$src_out" | grep -q 'leak=0' && echo 1 || echo 0)" "F4-12. source 模式下清理后无敏感变量残留" check "$(grep -q 'cleanup_secrets' "$TMP/trap.txt" 2>/dev/null && echo 1 || echo 0)" "F4-13. source 模式下 EXIT trap 已登记清理函数" check "$(grep -q 'PHONE' "$SMOKE" && grep -q 'TOKEN' "$SMOKE" && grep -q 'AUTH' "$SMOKE" && grep -q 'COMPANY_TOKEN' "$SMOKE" && echo 1 || echo 0)" \ "F4-14. cleanup_secrets 覆盖 PHONE/TOKEN/AUTH/COMPANY_TOKEN" # ══════════════════════════════════════════════════════════════ # Part 4 — F5:数据库密码不进入 mysql argv # ══════════════════════════════════════════════════════════════ : > "$TMP/mysql-argv.log"; : > "$TMP/docker-argv.log" _f5="$(run_sms_code true true ok)" mysql_argv="$(cat "$TMP/mysql-argv.log" 2>/dev/null)" docker_argv="$(cat "$TMP/docker-argv.log" 2>/dev/null)" container_script="$(cat "$TMP/container-script.txt" 2>/dev/null)" mysql_stdin="$(cat "$TMP/mysql-stdin.sql" 2>/dev/null)" check "$(printf '%s' "$mysql_argv" | grep -qF "$TEST_DBPASS" && echo 0 || echo 1)" "F5-1. mysql argv 不含数据库密码值" check "$(printf '%s' "$docker_argv" | grep -qF "$TEST_DBPASS" && echo 0 || echo 1)" "F5-2. 宿主机 docker 命令行不含数据库密码值" check "$(printf '%s' "$container_script" | grep -qF "$TEST_DBPASS" && echo 0 || echo 1)" "F5-3. 容器侧脚本文本不含密码值" check "$(printf '%s' "$container_script" | grep -q 'MYSQL_PASSWORD' && echo 1 || echo 0)" "F5-4. 容器侧脚本以变量名引用 MYSQL_PASSWORD" check "$(printf '%s' "$mysql_argv" | grep -qE '(^| )-p' && echo 0 || echo 1)" "F5-5. mysql argv 不含 -p<密码>" check "$(printf '%s' "$mysql_argv" | grep -q -- '--password' && echo 0 || echo 1)" "F5-6. mysql argv 不含 --password" check "$(printf '%s' "$container_script" | grep -q 'MYSQL_ROOT_PASSWORD' && echo 0 || echo 1)" "F5-7. 不使用 root 凭据(无 MYSQL_ROOT_PASSWORD)" check "$(printf '%s' "$container_script" | grep -q 'uroot' && echo 0 || echo 1)" "F5-8. 不使用 -uroot" check "$(printf '%s' "$mysql_argv" | grep -q 'smoke_readonly' && echo 1 || echo 0)" "F5-9. 使用应用账号 MYSQL_USER" check "$(printf '%s' "$mysql_argv" | grep -q 'mydreamjob' && echo 1 || echo 0)" "F5-10. 使用 MYSQL_DATABASE" check "$(printf '%s' "$mysql_stdin" | grep -qi '^SELECT' && echo 1 || echo 0)" "F5-11. SQL 经 stdin 传入" check "$(printf '%s' "$mysql_stdin" | grep -qiE 'INSERT|UPDATE|DELETE|DROP|ALTER' && echo 0 || echo 1)" "F5-12. 查询仅包含 SELECT(无写操作)" check "$(printf '%s' "$mysql_argv" | grep -q 'MYSQL_PWD\|MYSQL_PASSWORD' && echo 0 || echo 1)" "F5-13. 密码不经 argv 传递" check "$(printf '%s' "$container_script" | grep -q 'MYSQL_PWD' && echo 1 || echo 0)" "F5-14. 密码经 MYSQL_PWD 环境变量传递" check "$(printf '%s' "$container_script" | grep -q 'MYSQL_PASSWORD' && grep -q 'exit 3' "$TMP/container-script.txt" && echo 1 || echo 0)" \ "F5-15. 凭据缺失时安全失败(不回退 root)" check "$(printf '%s' "$_f5" | grep -q 'RESULT=ok' && echo 1 || echo 0)" "F5-16. argv 安全前提下取码仍可用" # docker 不可用(容器不存在)时不泄露且安全失败 : > "$TMP/mysql-argv.log" r="$(run_sms_code true true fail)" check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "F5-17. 查询失败时安全失败" check "$(printf '%s' "$r" | grep -q "$TEST_DBPASS" && echo 0 || echo 1)" "F5-18. 失败路径 stderr 不含数据库密码" # ══════════════════════════════════════════════════════════════ # Part 5 — F1 静态保证 + §7 活跃文件文档门禁 # ══════════════════════════════════════════════════════════════ check "$(grep -qE 'grep[^|]*\.log|grep[^|]*stdout' "$SMOKE" && echo 0 || echo 1)" "13. smoke-test.sh 不 grep 应用日志" check "$(grep -q 'MOCK SMS CODE' "$SMOKE" && echo 0 || echo 1)" "14. smoke-test.sh 中不存在 MOCK SMS CODE" mech=1 grep -q 'unset REPLY' "$SMOKE" || mech=0 grep -q 'unset SMS' "$SMOKE" || mech=0 grep -q 'trap cleanup_secrets EXIT' "$SMOKE" || mech=0 grep -q 'unset LOGIN_BODY' "$SMOKE" || mech=0 check "$mech" "15. 存在 REPLY/SMS/LOGIN_BODY 清理与 EXIT trap" check "$(grep -qE 'curl.*(-v|--trace|--trace-ascii)' "$SMOKE" && echo 0 || echo 1)" "20. 未使用 curl -v/--trace/--trace-ascii" check "$(grep -q 'report_fields' "$SMOKE" && echo 1 || echo 0)" "21. 登录响应经 report_fields(不把响应全文交给打印函数)" # §7:仅扫描**活跃操作文档与正式代码**,不把历史验收报告计入 ACTIVE_FILES="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" active_viol=0 for f in $ACTIVE_FILES; do [ -f "$REPO_ROOT/$f" ] || continue c=$(grep -c "验证码.*打印到日志" "$REPO_ROOT/$f" 2>/dev/null); c=${c:-0} c2=$(grep -c "从日志获取验证码" "$REPO_ROOT/$f" 2>/dev/null); c2=${c2:-0} c3=$(grep -c "MOCK SMS CODE" "$REPO_ROOT/$f" 2>/dev/null); c3=${c3:-0} [ $((c + c2 + c3)) -ne 0 ] && active_viol=$((active_viol + 1)) done check "$([ "$active_viol" -eq 0 ] && echo 1 || echo 0)" "22. 活跃操作文档不存在『从日志取码』有效指导" src_viol=$(grep -rn "MOCK SMS CODE" "$REPO_ROOT/mydreamjob-server/src/main/java" 2>/dev/null | wc -l) check "$([ "$src_viol" -eq 0 ] && echo 1 || echo 0)" "23. 正式 Java 代码中不存在 MOCK SMS CODE" hist_ok=1 [ -f "$REPO_ROOT/docs/s01-independent-mysql-acceptance-20260911.md" ] || hist_ok=0 [ -f "$REPO_ROOT/docs/s01-remediation-f1f2f3-20260911.md" ] || hist_ok=0 check "$hist_ok" "24. 历史验收报告仍保留(作为历史失败证据,不参与活跃文档扫描)" # ══════════════════════════════════════════════════════════════ # Part 6 — F6:SQL 字段契约(expired_at)与查询语义 # ══════════════════════════════════════════════════════════════ # 背景:expires_at 在本仓库中是 personal_data_export_tasks(V30)、 # ability_attempts(V35)、talent_invitations(V40) 三张表的**真实列**; # 只有 sms_verification_codes 使用 expired_at。因此断言必须限定在该表的查询上, # **不能**写成"全库不存在 expires_at"——那会误报。 # # 真实列清单从**创建该表的 Flyway** 派生,不在测试里手工维护一份与生产无关的 # 假字段列表来自证正确。 FLYWAY="$REPO_ROOT/mydreamjob-server/src/main/resources/db/migration/V1__base_schema.sql" real_cols="$(awk ' /^CREATE TABLE (IF NOT EXISTS )?sms_verification_codes/ { in_t=1; next } in_t && /^\)/ { in_t=0 } in_t { line=$0 sub(/^[[:space:]]+/, "", line) if (line ~ /^(INDEX|KEY|PRIMARY|CONSTRAINT|UNIQUE|FOREIGN)/) next if (line ~ /^[a-z_]+[[:space:]]/) { split(line, a, /[[:space:]]/); print a[1] } } ' "$FLYWAY" | sort -u | tr '\n' ' ')" # 取一次真实查询文本(此调用未设置 FAKE_ROWS,走历史路径) : > "$TMP/mysql-stdin.sql" _f6="$(run_sms_code true true ok)" sql_text="$(cat "$TMP/mysql-stdin.sql" 2>/dev/null)" check "$(printf '%s' "$sql_text" | grep -q 'expired_at > NOW()' && echo 1 || echo 0)" \ "F6-1. otp_db_query 使用 expired_at > NOW()" check "$(printf '%s' "$sql_text" | grep -q 'expires_at' && echo 0 || echo 1)" \ "F6-2. otp_db_query 不再出现错误字段 expires_at" # 解析查询里使用的列名(去掉字符串字面量与 SQL 关键字) used_cols="$(printf '%s' "$sql_text" \ | sed "s/'[^']*'//g" \ | grep -oE '\b[a-z_][a-z0-9_]*\b' \ | grep -vEx 'select|from|where|and|or|order|by|desc|asc|limit|now|sms_verification_codes' \ | sort -u)" unknown_cols="" for c in $used_cols; do case " $real_cols " in *" $c "*) ;; *) unknown_cols="$unknown_cols $c" ;; esac done check "$([ -z "$unknown_cols" ] && echo 1 || echo 0)" \ "F6-3. 查询只使用 sms_verification_codes 的真实列${unknown_cols:+(命中不存在的列:$unknown_cols)}" # 防止"派生失败得到空清单"导致上一条恒真 check "$(printf '%s' "$real_cols" | grep -q 'expired_at' && echo 1 || echo 0)" \ "F6-4. 真实列清单由 Flyway 派生且包含 expired_at(非空、非自证)" check "$(printf '%s' "$sql_text" | grep -qiE '^SELECT code FROM sms_verification_codes' && echo 1 || echo 0)" \ "F6-5. 假 mysql 捕获到完整 stdin SQL(不是无条件返回验证码)" # 字段错误 → 假 mysql 必须失败(复现真实 MySQL 的 ERROR 1054) printf '%s\n' "SELECT code FROM sms_verification_codes WHERE phone='x' AND expires_at > NOW();" \ | "$FAKEBIN/mysql" >/dev/null 2>"$TMP/err1054.txt" || rc1054=$? rc1054="${rc1054:-0}" check "$([ "$rc1054" -ne 0 ] && echo 1 || echo 0)" \ "F6-6. SQL 字段错误时假 mysql 返回失败(非 0)" check "$(grep -q '1054' "$TMP/err1054.txt" 2>/dev/null && echo 1 || echo 0)" \ "F6-6b. 字段错误时报 ERROR 1054 (42S22)" # 端到端:把字段改错的副本跑完整流程,验证不会继续登录 BUGDIR="$TMP/bugrepo" mkdir -p "$BUGDIR/docs" "$BUGDIR/backend-infra" cp "$REPO_ROOT/backend-infra/compose.yaml" "$BUGDIR/backend-infra/compose.yaml" 2>/dev/null || true sed 's/expired_at > NOW()/expires_at > NOW()/' "$SMOKE" > "$BUGDIR/docs/smoke-test.sh" out_bug="$(run_full_script "$BUGDIR/docs/smoke-test.sh")" check "$(printf '%s' "$out_bug" | grep -q '\[FAIL\] login skipped' && echo 1 || echo 0)" \ "F6-7. 字段错误时 sms_code() 不继续登录" check "$(grep -q 'login-attempted' "$TMP/flow.log" 2>/dev/null && echo 0 || echo 1)" \ "F6-7b. 字段错误时未发出任何 login 请求" # 正确字段 → 取码成功并可完成登录 out_ok="$(run_full_script "$SMOKE")" check "$(grep -q 'login-attempted has6digit=yes' "$TMP/flow.log" 2>/dev/null && echo 1 || echo 0)" \ "F6-8. 正确字段时取码成功并完成登录" # ── 过期 / used / purpose / phone / 最新一条 语义 ────────────── # 行格式 id|phone|purpose|used|expire_offset_s|created_at|code P="$TEST_PHONE" R_FUTURE="1|$P|login|0|300|2026-09-11 10:00:00|$TEST_CODE" R_EXACT="1|$P|login|0|0|2026-09-11 10:00:00|$TEST_CODE" R_PAST="1|$P|login|0|-300|2026-09-11 10:00:00|$TEST_CODE" R_USED="1|$P|login|1|300|2026-09-11 10:00:00|$TEST_CODE" R_PURPOSE="1|$P|register|0|300|2026-09-11 10:00:00|$TEST_CODE" R_PHONE="1|19900001111|login|0|300|2026-09-11 10:00:00|$TEST_CODE" R_OLD="1|$P|login|0|300|2026-09-11 10:00:00|111111" R_NEW="2|$P|login|0|300|2026-09-11 11:00:00|222222" r="$(run_sms_code_with_rows "$R_EXACT" "$TEST_CODE")" check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" \ "F6-9. expired_at 恰好等于 NOW() 时不得取出" r="$(run_sms_code_with_rows "$R_FUTURE" "$TEST_CODE")" check "$(printf '%s' "$r" | grep -q 'MATCH=yes' && echo 1 || echo 0)" \ "F6-10. expired_at 晚于 NOW() 时可以取出" r="$(run_sms_code_with_rows "$R_PAST" "$TEST_CODE")" check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" \ "F6-11. expired_at 早于 NOW() 时不得取出" r="$(run_sms_code_with_rows "$R_USED" "$TEST_CODE")" check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" \ "F6-12. used=1 不得取出" r="$(run_sms_code_with_rows "$R_PURPOSE" "$TEST_CODE")" check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" \ "F6-13. purpose 不匹配不得取出" r="$(run_sms_code_with_rows "$R_PHONE" "$TEST_CODE")" check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" \ "F6-14. phone 不匹配不得取出" r="$(run_sms_code_with_rows "$(printf '%s\n%s' "$R_OLD" "$R_NEW")" "222222")" check "$(printf '%s' "$r" | grep -q 'MATCH=yes' && echo 1 || echo 0)" \ "F6-15. 多条有效记录只取 created_at/id 最新一条" # 逆序输入仍取同一条,证明按 ORDER BY 取值而非"最后一行胜出" r="$(run_sms_code_with_rows "$(printf '%s\n%s' "$R_NEW" "$R_OLD")" "222222")" check "$(printf '%s' "$r" | grep -q 'MATCH=yes' && echo 1 || echo 0)" \ "F6-15b. 与输入顺序无关,按 created_at/id 排序取值" echo "" echo "============================================" echo "PASS=$PASS FAIL=$FAIL" echo "============================================" # 最终泄露扫描(测试自身输出也不得含标记) if printf '%s' "$out_x$out_f$r$src_out" | grep -qF "$TEST_CODE"; then echo "LEAK DETECTED: code"; exit 1 fi if printf '%s' "$out_x$out_f$r$src_out" | grep -qF "$TEST_JWT"; then echo "LEAK DETECTED: jwt"; exit 1 fi if printf '%s' "$out_x$out_f$r$src_out" | grep -qF "$TEST_DBPASS"; then echo "LEAK DETECTED: dbpass"; exit 1 fi [ "$FAIL" -eq 0 ]