Files
tk-factory-services/scripts/security-scan.sh
Hyungi Ahn ba9ef32808 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>
2026-04-10 09:44:21 +09:00

356 lines
11 KiB
Bash
Executable File

#!/bin/bash
# =============================================================================
# security-scan.sh — TK Factory Services 보안 스캔 엔진
# =============================================================================
# 용도: pre-commit hook, pre-receive hook, 수동 전체 검사
# 모드:
# --staged staged 파일만 검사 (pre-commit 기본)
# --all 프로젝트 전체 파일 검사
# --diff OLD NEW 두 커밋 간 변경 검사 (pre-receive용)
# --strict MEDIUM도 차단
#
# 커버리지 한계 (PR 리뷰에서 수동):
# - RBAC 설계 오류 / 인증 흐름
# - 비즈니스 로직 / race condition
# - third-party dependency (npm audit 영역)
# - 환경변수 값 강도
# =============================================================================
set -euo pipefail
# --- 색상 ---
RED='\033[0;31m'
YELLOW='\033[0;33m'
GREEN='\033[0;32m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
# --- 설정 ---
MEDIUM_THRESHOLD=5
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
IGNORE_FILE="$PROJECT_ROOT/.securityignore"
# --- 검출 규칙: NAME|SEVERITY|설명|PATTERN ---
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_DIRS="node_modules|\.git|__pycache__|\.next|dist|build|coverage"
EXCLUDE_FILES="package-lock\.json|yarn\.lock|\.min\.js|\.min\.css|\.map"
# --- 파싱 함수 ---
parse_rule() {
local rule="$1"
RULE_NAME=$(echo "$rule" | cut -d'|' -f1)
RULE_SEVERITY=$(echo "$rule" | cut -d'|' -f2)
RULE_DESC=$(echo "$rule" | cut -d'|' -f3)
RULE_PATTERN=$(echo "$rule" | cut -d'|' -f4-)
}
# --- .securityignore 로드 ---
load_ignore_list() {
IGNORED_FILES=()
if [[ -f "$IGNORE_FILE" ]]; then
while IFS= read -r line; do
# 빈 줄, 순수 주석 스킵
[[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue
# 파일명 추출 (주석 앞부분)
local filepath
filepath=$(echo "$line" | sed 's/#.*$//' | xargs)
[[ -z "$filepath" ]] && continue
# 주석 없는 항목 경고
if ! echo "$line" | grep -q '#'; then
echo -e "${YELLOW}[WARN] .securityignore: '$filepath' 에 사유 주석이 없습니다${NC}" >&2
fi
IGNORED_FILES+=("$filepath")
done < "$IGNORE_FILE"
fi
}
is_ignored_file() {
local file="$1"
for ignored in "${IGNORED_FILES[@]}"; do
[[ "$file" == "$ignored" || "$file" == *"/$ignored" ]] && return 0
done
return 1
}
is_line_ignored() {
local line_content="$1"
local rule_name="$2"
echo "$line_content" | grep -qP "security-ignore:\s*$rule_name" && return 0
return 1
}
# --- diff 파싱 + 검사 ---
scan_diff() {
local diff_input="$1"
local violations=0
local medium_count=0
local current_file=""
local current_line=0
local results=""
while IFS= read -r line; do
# 파일명 추출
if [[ "$line" =~ ^diff\ --git\ a/(.+)\ b/(.+)$ ]]; then
current_file="${BASH_REMATCH[2]}"
continue
fi
# +++ b/filename
if [[ "$line" =~ ^\+\+\+\ b/(.+)$ ]]; then
current_file="${BASH_REMATCH[1]}"
continue
fi
# hunk header → 라인 번호
if [[ "$line" =~ ^@@.*\+([0-9]+) ]]; then
current_line="${BASH_REMATCH[1]}"
continue
fi
# 추가된 라인만 검사
if [[ "$line" =~ ^\+ ]] && [[ ! "$line" =~ ^\+\+\+ ]]; then
local content="${line:1}" # + 제거
current_line=$((current_line))
# 제외 디렉토리/파일 체크
if echo "$current_file" | grep -qEi "($EXCLUDE_DIRS)" 2>/dev/null; then
current_line=$((current_line + 1))
continue
fi
if echo "$current_file" | grep -qEi "($EXCLUDE_FILES)" 2>/dev/null; then
current_line=$((current_line + 1))
continue
fi
# .securityignore 체크
if is_ignored_file "$current_file"; then
current_line=$((current_line + 1))
continue
fi
# 각 규칙 검사
for i in "${!RULES[@]}"; do
parse_rule "${RULES[$i]}"
if echo "$content" | grep -qP "$RULE_PATTERN" 2>/dev/null; then
# 라인 단위 ignore 체크
if is_line_ignored "$content" "$RULE_NAME"; then
continue
fi
local rule_num=$((i + 1))
local trimmed
trimmed=$(echo "$content" | sed 's/^[[:space:]]*//' | head -c 100)
if [[ "$RULE_SEVERITY" == "CRITICAL" || "$RULE_SEVERITY" == "HIGH" ]]; then
results+="$(printf "\n ${RED}✗ [%s] #%d %s${NC} — %s\n → %s:%d\n %s\n" \
"$RULE_SEVERITY" "$rule_num" "$RULE_NAME" "$RULE_DESC" \
"$current_file" "$current_line" "$trimmed")"
violations=$((violations + 1))
else
results+="$(printf "\n ${YELLOW}⚠ [%s] #%d %s${NC} — %s\n → %s:%d\n %s\n" \
"$RULE_SEVERITY" "$rule_num" "$RULE_NAME" "$RULE_DESC" \
"$current_file" "$current_line" "$trimmed")"
medium_count=$((medium_count + 1))
fi
fi
done
current_line=$((current_line + 1))
fi
done <<< "$diff_input"
# 결과 출력
local total=$((violations + medium_count))
if [[ $total -gt 0 ]]; then
echo ""
echo -e "${BOLD}[SECURITY] ${total} issue(s) found:${NC}"
echo -e "$results"
echo ""
# MEDIUM 임계값 체크
if [[ $medium_count -gt $MEDIUM_THRESHOLD ]]; then
echo -e "${RED}[SECURITY] MEDIUM violations ($medium_count) exceed threshold ($MEDIUM_THRESHOLD) — blocking${NC}"
violations=$((violations + 1))
fi
# strict 모드
if [[ "${STRICT_MODE:-false}" == "true" && $medium_count -gt 0 ]]; then
echo -e "${RED}[SECURITY] --strict mode: MEDIUM violations also block${NC}"
violations=$((violations + 1))
fi
if [[ $violations -gt 0 ]]; then
echo -e "${RED}Push/commit rejected. Fix violations or use [SECURITY-BYPASS: 사유] in commit message.${NC}"
echo ""
return 1
else
echo -e "${YELLOW}Warnings only — commit/push allowed.${NC}"
echo ""
return 0
fi
fi
return 0
}
# --- 전체 파일 검사 (--all 모드) ---
scan_all() {
local violations=0
local medium_count=0
local results=""
load_ignore_list
local files
files=$(find "$PROJECT_ROOT" -type f \
\( -name "*.js" -o -name "*.py" -o -name "*.ts" -o -name "*.jsx" -o -name "*.tsx" \
-o -name "*.md" -o -name "*.sql" -o -name "*.yml" -o -name "*.yaml" \
-o -name "*.json" -o -name "*.sh" -o -name "*.html" \) \
! -path "*/node_modules/*" \
! -path "*/.git/*" \
! -path "*/__pycache__/*" \
! -path "*/dist/*" \
! -path "*/build/*" \
! -path "*/coverage/*" \
! -path "*/.claude/worktrees/*" \
! -name "package-lock.json" \
! -name "*.min.js" \
! -name "*.min.css" \
2>/dev/null || true)
while IFS= read -r filepath; do
[[ -z "$filepath" ]] && continue
local relpath="${filepath#$PROJECT_ROOT/}"
# .securityignore 체크
if is_ignored_file "$relpath"; then
continue
fi
for i in "${!RULES[@]}"; do
parse_rule "${RULES[$i]}"
local matches
matches=$(grep -nP "$RULE_PATTERN" "$filepath" 2>/dev/null || true)
[[ -z "$matches" ]] && continue
while IFS= read -r match; do
local linenum content
linenum=$(echo "$match" | cut -d: -f1)
content=$(echo "$match" | cut -d: -f2- | sed 's/^[[:space:]]*//' | head -c 100)
# 라인 단위 ignore
local full_line
full_line=$(sed -n "${linenum}p" "$filepath" 2>/dev/null || true)
if is_line_ignored "$full_line" "$RULE_NAME"; then
continue
fi
local rule_num=$((i + 1))
if [[ "$RULE_SEVERITY" == "CRITICAL" || "$RULE_SEVERITY" == "HIGH" ]]; then
results+="$(printf "\n ${RED}✗ [%s] #%d %s${NC} — %s\n → %s:%s\n %s\n" \
"$RULE_SEVERITY" "$rule_num" "$RULE_NAME" "$RULE_DESC" \
"$relpath" "$linenum" "$content")"
violations=$((violations + 1))
else
results+="$(printf "\n ${YELLOW}⚠ [%s] #%d %s${NC} — %s\n → %s:%s\n %s\n" \
"$RULE_SEVERITY" "$rule_num" "$RULE_NAME" "$RULE_DESC" \
"$relpath" "$linenum" "$content")"
medium_count=$((medium_count + 1))
fi
done <<< "$matches"
done
done <<< "$files"
# 결과 출력
local total=$((violations + medium_count))
if [[ $total -gt 0 ]]; then
echo ""
echo -e "${BOLD}[SECURITY] Full scan: ${total} issue(s) found:${NC}"
echo -e "$results"
echo ""
if [[ $medium_count -gt $MEDIUM_THRESHOLD ]]; then
echo -e "${RED}[SECURITY] MEDIUM violations ($medium_count) exceed threshold ($MEDIUM_THRESHOLD)${NC}"
violations=$((violations + 1))
fi
if [[ "${STRICT_MODE:-false}" == "true" && $medium_count -gt 0 ]]; then
echo -e "${RED}[SECURITY] --strict mode: MEDIUM violations also count${NC}"
violations=$((violations + 1))
fi
if [[ $violations -gt 0 ]]; then
echo -e "${RED}${violations} blocking violation(s) found.${NC}"
return 1
else
echo -e "${YELLOW}Warnings only (${medium_count} MEDIUM).${NC}"
return 0
fi
else
echo -e "${GREEN}[SECURITY] Full scan: 0 violations found.${NC}"
return 0
fi
}
# --- 메인 ---
main() {
local mode="staged"
local old_rev="" new_rev=""
STRICT_MODE="false"
while [[ $# -gt 0 ]]; do
case "$1" in
--staged) mode="staged"; shift ;;
--all) mode="all"; shift ;;
--diff) mode="diff"; old_rev="$2"; new_rev="$3"; shift 3 ;;
--strict) STRICT_MODE="true"; shift ;;
-h|--help)
echo "Usage: security-scan.sh [--staged|--all|--diff OLD NEW] [--strict]"
echo " --staged Check staged files (default, for pre-commit)"
echo " --all Scan entire project"
echo " --diff Check changes between two commits (for pre-receive)"
echo " --strict Block MEDIUM violations too"
exit 0
;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
done
load_ignore_list
case "$mode" in
staged)
local diff_output
diff_output=$(git diff --cached -U0 --diff-filter=ACMRT 2>/dev/null || true)
if [[ -z "$diff_output" ]]; then
echo -e "${GREEN}[SECURITY] No staged changes to scan.${NC}"
exit 0
fi
scan_diff "$diff_output"
;;
all)
scan_all
;;
diff)
if [[ -z "$old_rev" || -z "$new_rev" ]]; then
echo "Error: --diff requires OLD and NEW revisions"
exit 1
fi
local diff_output
diff_output=$(git diff -U0 --diff-filter=ACMRT "$old_rev" "$new_rev" 2>/dev/null || true)
if [[ -z "$diff_output" ]]; then
echo -e "${GREEN}[SECURITY] No changes to scan.${NC}"
exit 0
fi
scan_diff "$diff_output"
;;
esac
}
main "$@"