#!/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 "$@"