보안 감사 결과 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>
356 lines
11 KiB
Bash
Executable File
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 "$@"
|