security: 보안 강제 시스템 구축 + 하드코딩 비밀번호 제거

보안 감사 결과 CRITICAL 2건, HIGH 5건 발견 → 수정 완료 + 자동화 구축.

[보안 수정]
- issue-view.js: 하드코딩 비밀번호 → crypto.getRandomValues() 랜덤 생성
- pushSubscriptionController.js: ntfy 비밀번호 → process.env.NTFY_SUB_PASSWORD
- DEPLOY-GUIDE.md/PROGRESS.md/migration SQL: 평문 비밀번호 → placeholder
- docker-compose.yml/.env.example: NTFY_SUB_PASSWORD 환경변수 추가

[보안 강제 시스템 - 신규]
- scripts/security-scan.sh: 8개 규칙 (CRITICAL 2, HIGH 4, MEDIUM 2)
  3모드(staged/all/diff), severity, .securityignore, MEDIUM 임계값
- .githooks/pre-commit: 로컬 빠른 피드백
- .githooks/pre-receive-server.sh: Gitea 서버 최종 차단
  bypass 거버넌스([SECURITY-BYPASS: 사유] + 사용자 제한 + 로그)
- SECURITY-CHECKLIST.md: 10개 카테고리 자동/수동 구분
- docs/SECURITY-GUIDE.md: 운영자 가이드 (워크플로우, bypass, FAQ)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-10 09:44:21 +09:00
parent bbffa47a9d
commit ba9ef32808
257 changed files with 786 additions and 18 deletions

168
.githooks/pre-receive-server.sh Executable file
View File

@@ -0,0 +1,168 @@
#!/bin/bash
# =============================================================================
# pre-receive-server.sh — Gitea 서버용 보안 게이트
# =============================================================================
# 설치: Gitea 웹 관리자 → 저장소 → Settings → Git Hooks → pre-receive
# 또는: cp pre-receive-server.sh $REPO_PATH/custom/hooks/pre-receive
#
# 동작: push 시 변경 내용을 regex 검사, 위반 시 push 차단
# bypass: 커밋 메시지에 [SECURITY-BYPASS: 사유] 포함 시 통과 + 로그
# =============================================================================
set -uo pipefail
# --- 설정 ---
BYPASS_LOG="/data/gitea/security-bypass.log"
ALLOWED_BYPASS_EMAILS="ahn@hyungi.net hyungi@technicalkorea.net"
MEDIUM_THRESHOLD=5
ZERO_REV="0000000000000000000000000000000000000000"
# --- 검출 규칙 (security-scan.sh와 동일, 자체 내장) ---
RULES=(
'SECRET_HARDCODE|CRITICAL|비밀정보 하드코딩|(password|token|apiKey|secret|api_key)\s*[:=]\s*[\x27"][^${\x27"][^\x27"]{3,}'
'SECRET_KNOWN|CRITICAL|알려진 비밀번호 패턴|(fukdon-riwbaq|hyung-ddfdf3|djg3-jj34|tkfactory-sub)'
'LOCALSTORAGE_AUTH|HIGH|localStorage 인증정보|localStorage\.(setItem|getItem).*\b(token|password|auth|credential)'
'INNERHTML_XSS|HIGH|innerHTML XSS 위험|\.innerHTML\s*[+=]'
'CORS_WILDCARD|HIGH|CORS 와일드카드|origin:\s*[\x27"`]\*[\x27"`]'
'SQL_INTERPOLATION|HIGH|SQL 문자열 보간|query\(`.*\$\{'
'LOG_SECRET|MEDIUM|로그에 비밀정보|console\.(log|error|warn).*\b(password|token|secret|apiKey)'
'ENV_HARDCODE|MEDIUM|환경설정 하드코딩|NODE_ENV\s*[:=]\s*[\x27"]development[\x27"]'
)
EXCLUDE_PATTERNS="node_modules|\.git|__pycache__|package-lock\.json|\.min\.js|\.min\.css"
# --- 메인 ---
while read -r oldrev newrev refname; do
# 브랜치 삭제 시 스킵
if [[ "$newrev" == "$ZERO_REV" ]]; then
continue
fi
# 신규 브랜치
if [[ "$oldrev" == "$ZERO_REV" ]]; then
# 첫 push: 최근 커밋만 검사 (또는 스킵)
echo "[SECURITY] New branch detected — scanning latest commit only"
oldrev=$(git rev-parse "${newrev}~1" 2>/dev/null || echo "$ZERO_REV")
if [[ "$oldrev" == "$ZERO_REV" ]]; then
continue
fi
fi
# --- bypass 확인 ---
BYPASS_FOUND=false
BYPASS_REASON=""
while IFS= read -r msg; do
if echo "$msg" | grep -qP '\[SECURITY-BYPASS:\s*.+\]'; then
BYPASS_FOUND=true
BYPASS_REASON=$(echo "$msg" | grep -oP '\[SECURITY-BYPASS:\s*\K[^\]]+')
elif echo "$msg" | grep -q '\[SECURITY-BYPASS\]'; then
echo "[SECURITY] ERROR: Bypass requires reason: [SECURITY-BYPASS: hotfix 사유]"
exit 1
fi
done < <(git log --format='%s' "$oldrev".."$newrev" 2>/dev/null)
if [[ "$BYPASS_FOUND" == "true" ]]; then
AUTHOR=$(git log -1 --format='%ae' "$newrev" 2>/dev/null || echo "unknown")
# 사용자 제한
ALLOWED=false
for email in $ALLOWED_BYPASS_EMAILS; do
if [[ "$AUTHOR" == "$email" ]]; then
ALLOWED=true
break
fi
done
if [[ "$ALLOWED" != "true" ]]; then
echo "[SECURITY] Bypass not allowed for: $AUTHOR"
exit 1
fi
# 로그 기록
echo "$(date -Iseconds) | user=$AUTHOR | ref=$refname | commits=$oldrev..$newrev | reason=$BYPASS_REASON | TODO=24h내 수정 필수" \
>> "$BYPASS_LOG" 2>/dev/null || true
echo "[SECURITY] ⚠ Bypass accepted — reason: $BYPASS_REASON (logged, 24h 내 수정 필수)"
continue
fi
# --- diff 기반 보안 검사 ---
VIOLATIONS=0
MEDIUM_COUNT=0
OUTPUT=""
DIFF_OUTPUT=$(git diff -U0 --diff-filter=ACMRT "$oldrev" "$newrev" 2>/dev/null || true)
if [[ -z "$DIFF_OUTPUT" ]]; then
continue
fi
CURRENT_FILE=""
CURRENT_LINE=0
while IFS= read -r line; do
if [[ "$line" =~ ^diff\ --git\ a/(.+)\ b/(.+)$ ]]; then
CURRENT_FILE="${BASH_REMATCH[2]}"
continue
fi
if [[ "$line" =~ ^\+\+\+\ b/(.+)$ ]]; then
CURRENT_FILE="${BASH_REMATCH[1]}"
continue
fi
if [[ "$line" =~ ^@@.*\+([0-9]+) ]]; then
CURRENT_LINE="${BASH_REMATCH[1]}"
continue
fi
if [[ "$line" =~ ^\+ ]] && [[ ! "$line" =~ ^\+\+\+ ]]; then
local_content="${line:1}"
# 제외 패턴
if echo "$CURRENT_FILE" | grep -qEi "$EXCLUDE_PATTERNS" 2>/dev/null; then
CURRENT_LINE=$((CURRENT_LINE + 1))
continue
fi
# 인라인 ignore 체크 + 규칙 검사
for i in "${!RULES[@]}"; do
IFS='|' read -r r_name r_sev r_desc r_pat <<< "${RULES[$i]}"
if echo "$local_content" | grep -qP "$r_pat" 2>/dev/null; then
# 라인 단위 ignore
if echo "$local_content" | grep -qP "security-ignore:\s*$r_name" 2>/dev/null; then
continue
fi
RNUM=$((i + 1))
TRIMMED=$(echo "$local_content" | sed 's/^[[:space:]]*//' | head -c 100)
if [[ "$r_sev" == "CRITICAL" || "$r_sev" == "HIGH" ]]; then
OUTPUT+="$(printf "\n ✗ [%s] #%d %s — %s\n → %s:%d\n %s\n" \
"$r_sev" "$RNUM" "$r_name" "$r_desc" "$CURRENT_FILE" "$CURRENT_LINE" "$TRIMMED")"
VIOLATIONS=$((VIOLATIONS + 1))
else
OUTPUT+="$(printf "\n ⚠ [%s] #%d %s — %s\n → %s:%d\n %s\n" \
"$r_sev" "$RNUM" "$r_name" "$r_desc" "$CURRENT_FILE" "$CURRENT_LINE" "$TRIMMED")"
MEDIUM_COUNT=$((MEDIUM_COUNT + 1))
fi
fi
done
CURRENT_LINE=$((CURRENT_LINE + 1))
fi
done <<< "$DIFF_OUTPUT"
TOTAL=$((VIOLATIONS + MEDIUM_COUNT))
if [[ $TOTAL -gt 0 ]]; then
echo ""
echo "[SECURITY] $TOTAL issue(s) found in push to $refname:"
echo "$OUTPUT"
echo ""
if [[ $MEDIUM_COUNT -gt $MEDIUM_THRESHOLD ]]; then
echo "[SECURITY] MEDIUM violations ($MEDIUM_COUNT) exceed threshold ($MEDIUM_THRESHOLD) — blocking"
VIOLATIONS=$((VIOLATIONS + 1))
fi
if [[ $VIOLATIONS -gt 0 ]]; then
echo "Push rejected. Fix violations or use [SECURITY-BYPASS: 사유] in commit message."
echo ""
exit 1
else
echo "Warnings only ($MEDIUM_COUNT MEDIUM) — push allowed."
fi
fi
done
exit 0