288 lines
12 KiB
Bash
Executable File
288 lines
12 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# check-compliance.sh — 部署前合规检查(对照 CLAUDE.md + LESSONS-LEARNED.md)
|
||
# 用法: bash scripts/check-compliance.sh [--strict]
|
||
# 退出码: 0=通过, 1=违规
|
||
|
||
set -o pipefail
|
||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
||
PASS=0; WARN=0; FAIL=0
|
||
PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||
|
||
ok() { echo -e " ${GREEN}[✓]${NC} $1"; ((PASS++)); }
|
||
warn() { echo -e " ${YELLOW}[!]${NC} $1"; ((WARN++)); }
|
||
err() { echo -e " ${RED}[✗]${NC} $1"; ((FAIL++)); }
|
||
|
||
echo "========================================="
|
||
echo " 部署前合规检查"
|
||
echo "========================================="
|
||
echo ""
|
||
|
||
# ============================================================
|
||
# D1: healthcheck 禁止用 curl
|
||
# ============================================================
|
||
echo "--- D1: healthcheck 禁止 curl ---"
|
||
for f in "$PROJECT_ROOT"/*/docker-compose.yml; do
|
||
site=$(basename "$(dirname "$f")")
|
||
if grep -A2 'healthcheck' "$f" 2>/dev/null | grep -q 'curl'; then
|
||
err "$site: healthcheck 使用了 curl(必须用 wget --spider 或 node fetch)"
|
||
else
|
||
ok "$site"
|
||
fi
|
||
done
|
||
echo ""
|
||
|
||
# ============================================================
|
||
# D2: 含数据库的站点容器必须添加 healthcheck
|
||
# ============================================================
|
||
echo "--- D2: 含数据库容器 healthcheck ---"
|
||
DB_SITES="issue-ai assets-ai oa-ai monitor-ai gitea-ai"
|
||
for site in $DB_SITES; do
|
||
f="$PROJECT_ROOT/$site/docker-compose.yml"
|
||
[ ! -f "$f" ] && continue
|
||
if grep -q 'healthcheck' "$f"; then
|
||
ok "$site"
|
||
else
|
||
err "$site: 含数据库但缺少 healthcheck"
|
||
fi
|
||
done
|
||
echo ""
|
||
|
||
# ============================================================
|
||
# D3: 禁止 entrypoint 延迟构建
|
||
# ============================================================
|
||
echo "--- D3: 禁止 entrypoint 延迟构建 ---"
|
||
# 仅检查 entrypoint.sh(Dockerfile 中 builder 阶段的 npm run build 是合法的多阶段构建)
|
||
for f in "$PROJECT_ROOT"/*/entrypoint.sh; do
|
||
[ ! -f "$f" ] && continue
|
||
site=$(echo "$f" | sed 's|.*/\([^/]*\)/.*|\1|')
|
||
if grep -q 'npm run build\|npm run dev\|next build\|next dev' "$f" 2>/dev/null; then
|
||
err "$site: entrypoint.sh 中包含构建命令"
|
||
fi
|
||
done
|
||
ok "所有 entrypoint.sh 不含构建命令"
|
||
echo ""
|
||
|
||
# ============================================================
|
||
# D4: 必须使用 docker compose V2(禁止 docker-compose)
|
||
# ============================================================
|
||
echo "--- D4: docker compose V2 ---"
|
||
if grep -rn 'docker-compose ' "$PROJECT_ROOT/scripts/" --include='*.sh' 2>/dev/null | grep -v '^[[:space:]]*#' | grep -v 'docker compose' | grep -q .; then
|
||
err "脚本中存在 docker-compose(V1),应使用 docker compose(V2)"
|
||
else
|
||
ok "所有脚本使用 docker compose V2"
|
||
fi
|
||
echo ""
|
||
|
||
# ============================================================
|
||
# 时区规范: 禁止 Date.toISOString() 格式化本地日期
|
||
# ============================================================
|
||
echo "--- 时区: 禁止 Date.toISOString() ---"
|
||
# 仅标记直接 toISOString()(无 UTC+8 偏移的用法),允许 +8h 偏移模式
|
||
VIOLATIONS=$(grep -rn 'new Date()\.toISOString()\|new Date\.toISOString()' "$PROJECT_ROOT"/*/src --include='*.ts' --include='*.tsx' 2>/dev/null | grep -v node_modules | grep -v 'shared/')
|
||
if [ -n "$VIOLATIONS" ]; then
|
||
echo "$VIOLATIONS" | while IFS= read -r line; do
|
||
err "$line"
|
||
done
|
||
else
|
||
ok "所有站点未使用裸 Date.toISOString()"
|
||
fi
|
||
echo ""
|
||
|
||
# ============================================================
|
||
# 时区规范: SQLite datetime('now') 必须带 +8 hours
|
||
# ============================================================
|
||
echo "--- 时区: SQLite datetime('now') → +8 hours ---"
|
||
VIOLATIONS=$(grep -rn "datetime('now')" "$PROJECT_ROOT"/*/src --include='*.ts' --include='*.tsx' 2>/dev/null | grep -v "+8 hours" | grep -v node_modules | grep -v shared)
|
||
if [ -n "$VIOLATIONS" ]; then
|
||
echo "$VIOLATIONS" | while IFS= read -r line; do
|
||
err "$line"
|
||
done
|
||
else
|
||
ok "所有 SQLite datetime('now') 均带 +8 hours"
|
||
fi
|
||
echo ""
|
||
|
||
# ============================================================
|
||
# JWT_SECRET + COOKIE_DOMAIN 在所有站点一致
|
||
# ============================================================
|
||
echo "--- JWT_SECRET + COOKIE_DOMAIN 一致性 ---"
|
||
JWT_VALUES=""
|
||
COOKIE_VALUES=""
|
||
for f in "$PROJECT_ROOT"/*/docker-compose.yml; do
|
||
site=$(basename "$(dirname "$f")")
|
||
jwt=$(grep -o "JWT_SECRET=\${JWT_SECRET:-[^}]*}\|JWT_SECRET=[^ ]*" "$f" 2>/dev/null | head -1)
|
||
cookie=$(grep -o "COOKIE_DOMAIN=[^ ]*" "$f" 2>/dev/null | head -1)
|
||
[ -n "$jwt" ] && JWT_VALUES="$JWT_VALUES\n$site: $jwt"
|
||
[ -n "$cookie" ] && COOKIE_VALUES="$COOKIE_VALUES\n$site: $cookie"
|
||
done
|
||
echo -e "$JWT_VALUES" | sed '/^$/d'
|
||
echo -e "$COOKIE_VALUES" | sed '/^$/d'
|
||
# Check consistency
|
||
jwt_count=$(echo -e "$JWT_VALUES" | grep -o 'JWT_SECRET=[^ ]*' | sort -u | wc -l)
|
||
cookie_count=$(echo -e "$COOKIE_VALUES" | grep -o 'COOKIE_DOMAIN=[^ ]*' | sort -u | wc -l)
|
||
[ "$jwt_count" -le 1 ] && ok "JWT_SECRET 一致" || err "JWT_SECRET 不一致($jwt_count 个不同值)"
|
||
[ "$cookie_count" -le 1 ] && ok "COOKIE_DOMAIN 一致" || err "COOKIE_DOMAIN 不一致($cookie_count 个不同值)"
|
||
echo ""
|
||
|
||
# ============================================================
|
||
# LESSONS-LEARNED #24: cookie domain 必须设置
|
||
# ============================================================
|
||
echo "--- LESSONS-LEARNED #24: cookie domain ---"
|
||
for f in "$PROJECT_ROOT"/*/docker-compose.yml; do
|
||
site=$(basename "$(dirname "$f")")
|
||
if grep -q 'COOKIE_DOMAIN' "$f"; then
|
||
ok "$site"
|
||
else
|
||
warn "$site: docker-compose.yml 未设置 COOKIE_DOMAIN"
|
||
fi
|
||
done
|
||
echo ""
|
||
|
||
# ============================================================
|
||
# LESSONS-LEARNED #30: globals.css 含 @source "../../shared"
|
||
# ============================================================
|
||
echo "--- LESSONS-LEARNED #30: globals.css @source ---"
|
||
for f in "$PROJECT_ROOT"/issue-ai/src/app/globals.css "$PROJECT_ROOT"/assets-ai/src/app/globals.css "$PROJECT_ROOT"/oa-ai/src/app/globals.css "$PROJECT_ROOT"/monitor-ai/src/app/globals.css; do
|
||
[ ! -f "$f" ] && continue
|
||
site=$(echo "$f" | sed 's|.*/\([^/]*\)/src/.*|\1|')
|
||
if grep -q '@source.*shared' "$f" 2>/dev/null; then
|
||
ok "$site"
|
||
else
|
||
warn "$site: globals.css 缺少 @source \"../../shared\""
|
||
fi
|
||
done
|
||
echo ""
|
||
|
||
# ============================================================
|
||
# LESSONS-LEARNED #35: outputFileTracingIncludes
|
||
# ============================================================
|
||
echo "--- LESSONS-LEARNED #35: outputFileTracingIncludes ---"
|
||
for f in "$PROJECT_ROOT"/issue-ai/next.config.ts "$PROJECT_ROOT"/assets-ai/next.config.ts "$PROJECT_ROOT"/oa-ai/next.config.ts "$PROJECT_ROOT"/monitor-ai/next.config.ts; do
|
||
[ ! -f "$f" ] && continue
|
||
site=$(echo "$f" | sed 's|.*/\([^/]*\)/next.*|\1|')
|
||
if grep -q 'outputFileTracingIncludes' "$f" 2>/dev/null; then
|
||
ok "$site"
|
||
else
|
||
warn "$site: next.config.ts 缺少 outputFileTracingIncludes"
|
||
fi
|
||
done
|
||
echo ""
|
||
|
||
# ============================================================
|
||
# LESSONS-LEARNED #40: sso cert 使用 Let's Encrypt
|
||
# ============================================================
|
||
echo "--- LESSONS-LEARNED #40: sso nginx cert ---"
|
||
SSO_CONF="$PROJECT_ROOT/nginx-proxy-ai/conf.d/sso-ai.conf"
|
||
if [ -f "$SSO_CONF" ]; then
|
||
if grep -q 'www.tlyq.ai-0001\|letsencrypt' "$SSO_CONF" 2>/dev/null; then
|
||
ok "sso-ai.conf 使用 Let's Encrypt 证书"
|
||
else
|
||
err "sso-ai.conf 未使用 Let's Encrypt 证书(LESSONS-LEARNED #40)"
|
||
fi
|
||
else
|
||
warn "sso-ai.conf 不存在"
|
||
fi
|
||
echo ""
|
||
|
||
# ============================================================
|
||
# LESSONS-LEARNED #37: 禁用 request.url
|
||
# ============================================================
|
||
echo "--- LESSONS-LEARNED #37: 禁用 request.url ---"
|
||
# 仅检查 V2 middleware(旧 middleware.ts 保持向后兼容,迁移后删除)
|
||
VIOLATIONS=$(grep -rn 'request\.url' "$PROJECT_ROOT/shared/lib/auth/middleware-v2.ts" 2>/dev/null | grep -v '\.origin\|node_modules')
|
||
if [ -n "$VIOLATIONS" ]; then
|
||
echo "$VIOLATIONS" | while IFS= read -r line; do
|
||
err "$line"
|
||
done
|
||
else
|
||
ok "middleware-v2 未直接使用 request.url"
|
||
fi
|
||
echo ""
|
||
|
||
# ============================================================
|
||
# LESSONS-LEARNED #41: 容器内禁止 docker exec 获取凭据
|
||
# ============================================================
|
||
echo "--- LESSONS-LEARNED #41: docker exec in container ---"
|
||
VIOLATIONS=$(grep -rn "execFileSync.*docker.*exec\|execSync.*docker.*exec\|exec('docker'" "$PROJECT_ROOT"/*/src --include='*.ts' --include='*.tsx' 2>/dev/null | grep -v node_modules)
|
||
if [ -n "$VIOLATIONS" ]; then
|
||
echo "$VIOLATIONS" | while IFS= read -r line; do
|
||
warn "$line (容器内可能无 docker CLI,见 LESSONS-LEARNED #41)"
|
||
done
|
||
else
|
||
ok "无 docker exec 依赖"
|
||
fi
|
||
echo ""
|
||
|
||
# ============================================================
|
||
# OA: LLDAP_ADMIN_PASSWORD 环境变量注入
|
||
# ============================================================
|
||
echo "--- OA: LLDAP_ADMIN_PASSWORD ---"
|
||
if grep -q 'LLDAP_ADMIN_PASSWORD' "$PROJECT_ROOT/oa-ai/docker-compose.yml" 2>/dev/null; then
|
||
ok "oa-ai docker-compose.yml 含 LLDAP_ADMIN_PASSWORD"
|
||
else
|
||
err "oa-ai docker-compose.yml 缺少 LLDAP_ADMIN_PASSWORD(LESSONS-LEARNED #41)"
|
||
fi
|
||
echo ""
|
||
|
||
# ============================================================
|
||
# D8 + LESSONS-LEARNED #48: OIDC token_endpoint_auth_method 一致性
|
||
# ============================================================
|
||
echo "--- D8: OIDC token_endpoint_auth_method 一致性 ---"
|
||
SSO_CONFIG="$PROJECT_ROOT/sso-ai/config/authelia/configuration.yml"
|
||
if [ -f "$SSO_CONFIG" ]; then
|
||
# 列出每个 OIDC client 的 token_endpoint_auth_method
|
||
while IFS= read -r line; do
|
||
if echo "$line" | grep -q 'client_id:'; then
|
||
current_client=$(echo "$line" | sed "s/.*client_id: *'\(.*\)'/\1/" | sed 's/.*client_id: *"\(.*\)"/\1/' | sed 's/.*client_id: *//' | tr -d "'\"")
|
||
fi
|
||
if echo "$line" | grep -q 'token_endpoint_auth_method:'; then
|
||
method=$(echo "$line" | grep -o "client_secret_[a-z]*")
|
||
if [ "$method" = "client_secret_post" ]; then
|
||
ok "$current_client: $method"
|
||
elif [ "$method" = "client_secret_basic" ]; then
|
||
err "$current_client: $method(应为 client_secret_post,见 LESSONS-LEARNED #48)"
|
||
fi
|
||
current_client=""
|
||
fi
|
||
done < "$SSO_CONFIG"
|
||
# 检查是否有 OIDC client 完全没有 token_endpoint_auth_method(Authelia 默认 client_secret_basic)
|
||
# 注意:在 YAML 中 token_endpoint_auth_method 可能出现在任意位置,需读完整个 client 块
|
||
unset_client=""
|
||
prev_client=""
|
||
while IFS= read -r line; do
|
||
# 遇到新 client_id → 结算上一个
|
||
if echo "$line" | grep -q '^[[:space:]]*-[[:space:]]*client_id:'; then
|
||
if [ -n "$prev_client" ] && [ "$prev_has_method" -eq 0 ]; then
|
||
err "$prev_client: 未设置 token_endpoint_auth_method(默认 client_secret_basic → 与代码 client_secret_post 不匹配)"
|
||
fi
|
||
prev_client=$(echo "$line" | sed "s/.*client_id: *'\(.*\)'/\1/" | sed 's/.*client_id: *"\(.*\)"/\1/' | sed 's/.*client_id: *//' | tr -d "'\"")
|
||
prev_has_method=0
|
||
fi
|
||
if echo "$line" | grep -q 'token_endpoint_auth_method:'; then
|
||
prev_has_method=1
|
||
fi
|
||
done < "$SSO_CONFIG"
|
||
# 结算最后一个 client
|
||
if [ -n "$prev_client" ] && [ "$prev_has_method" -eq 0 ]; then
|
||
err "$prev_client: 未设置 token_endpoint_auth_method(默认 client_secret_basic → 与代码 client_secret_post 不匹配)"
|
||
fi
|
||
else
|
||
warn "sso-ai/config/authelia/configuration.yml 不存在"
|
||
fi
|
||
echo ""
|
||
|
||
# ============================================================
|
||
# 结果
|
||
# ============================================================
|
||
echo "========================================="
|
||
echo -e " 通过: ${GREEN}${PASS}${NC} 警告: ${YELLOW}${WARN}${NC} 违规: ${RED}${FAIL}${NC}"
|
||
echo "========================================="
|
||
|
||
if [ "$FAIL" -gt 0 ]; then
|
||
echo -e "${RED}存在 ${FAIL} 项违规,部署前必须修复${NC}"
|
||
exit 1
|
||
elif [ "$WARN" -gt 0 ]; then
|
||
echo -e "${YELLOW}存在 ${WARN} 项警告,建议修复后部署${NC}"
|
||
fi
|
||
exit 0
|