#!/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