You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
548 lines
28 KiB
548 lines
28 KiB
#!/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 ]
|
|
|