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

View File

@@ -74,7 +74,7 @@
```bash ```bash
# DB 접속 (NAS) # DB 접속 (NAS)
ssh hyungi@100.71.132.52 "docker exec tk-mariadb mysql -uhyungi_user -p'hyung-ddfdf3-D341@' hyungi" ssh hyungi@100.71.132.52 "docker exec tk-mariadb mysql -uhyungi_user -p\"\$MYSQL_PASSWORD\" hyungi"
# 로그 확인 (NAS) # 로그 확인 (NAS)
ssh hyungi@100.71.132.52 "cd /volume1/docker/tk-factory-services && export PATH=\$PATH:/volume2/@appstore/ContainerManager/usr/bin && docker compose logs -f --tail=50 <서비스>" ssh hyungi@100.71.132.52 "cd /volume1/docker/tk-factory-services && export PATH=\$PATH:/volume2/@appstore/ContainerManager/usr/bin && docker compose logs -f --tail=50 <서비스>"

View File

@@ -99,4 +99,16 @@ OLLAMA_TIMEOUT=120
# tkfb.technicalkorea.net → http://tk-gateway:80 # tkfb.technicalkorea.net → http://tk-gateway:80
# tkreport.technicalkorea.net → http://tk-system2-web:80 # tkreport.technicalkorea.net → http://tk-system2-web:80
# tkqc.technicalkorea.net → http://tk-system3-web:80 # tkqc.technicalkorea.net → http://tk-system3-web:80
# -------------------------------------------------------------------
# ntfy 푸시 알림 서버
# -------------------------------------------------------------------
NTFY_BASE_URL=http://ntfy:80
NTFY_PUBLISH_TOKEN=change_this_ntfy_publish_token
NTFY_EXTERNAL_URL=https://ntfy.technicalkorea.net
NTFY_SUB_PASSWORD=change_this_ntfy_subscriber_password
TKFB_BASE_URL=https://tkfb.technicalkorea.net
# -------------------------------------------------------------------
# Cloudflare Tunnel
# -------------------------------------------------------------------
CLOUDFLARE_TUNNEL_TOKEN=your_tunnel_token_here CLOUDFLARE_TUNNEL_TOKEN=your_tunnel_token_here

5
.githooks/pre-commit Executable file
View File

@@ -0,0 +1,5 @@
#!/bin/bash
# pre-commit hook — 로컬 빠른 피드백
# 역할: 커밋 전 보안 검사 (staged 파일만)
# 우회: git commit --no-verify (서버 pre-receive에서 최종 차단됨)
exec "$(git rev-parse --show-toplevel)/scripts/security-scan.sh" --staged

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

24
.securityignore Normal file
View File

@@ -0,0 +1,24 @@
# =============================================================================
# .securityignore — 보안 스캔 제외 목록
# =============================================================================
# 규칙:
# - 모든 항목에 사유 주석 필수 (없으면 경고)
# - 월 1회 정기 검토 → 불필요 항목 제거
# - 날짜 표기 권장
# =============================================================================
# 스캔 스크립트 자체 (규칙 패턴 포함)
scripts/security-scan.sh # 규칙 정의 자체 (2026-04-10)
.githooks/pre-receive-server.sh # 규칙 정의 자체 (2026-04-10)
# 환경변수 템플릿 (placeholder만 포함)
.env.example # placeholder 값만 (2026-04-10)
# 보안 감사 보고서 (발견된 패턴 인용)
SECURITY-AUDIT-20260402.md # 감사 보고서 인용 (2026-04-10)
SECURITY-FINDINGS-SUMMARY.txt # 감사 요약 인용 (2026-04-10)
SECURITY-CODE-SNIPPETS.md # 코드 스니펫 인용 (2026-04-10)
# 보안 가이드/체크리스트 (규칙 예시 포함)
SECURITY-CHECKLIST.md # 규칙 참조 예시 (2026-04-10)
docs/SECURITY-GUIDE.md # 가이드 예시 코드 (2026-04-10)

View File

@@ -95,7 +95,7 @@ cat "/volume1/Technicalkorea Document/tkfb-package/.env" | grep MYSQL
cat /volume1/docker/tkqc/tkqc-package/.env | grep POSTGRES cat /volume1/docker/tkqc/tkqc-package/.env | grep POSTGRES
# Cloudflare Tunnel 토큰 # Cloudflare Tunnel 토큰
echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker inspect tkfb_cloudflared | grep TUNNEL_TOKEN echo "${NAS_SUDO_PASSWORD}" | sudo -S /usr/local/bin/docker inspect tkfb_cloudflared | grep TUNNEL_TOKEN
``` ```
--- ---
@@ -112,12 +112,12 @@ ssh hyungi@192.168.0.3
mkdir -p /volume1/docker/backups/$(date +%Y%m%d) mkdir -p /volume1/docker/backups/$(date +%Y%m%d)
# MariaDB 백업 # MariaDB 백업
echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker exec tkfb_db \ echo "${NAS_SUDO_PASSWORD}" | sudo -S /usr/local/bin/docker exec tkfb_db \
mysqldump -u root -p<ROOT_PASSWORD> --all-databases > \ mysqldump -u root -p<ROOT_PASSWORD> --all-databases > \
/volume1/docker/backups/$(date +%Y%m%d)/mariadb-all.sql /volume1/docker/backups/$(date +%Y%m%d)/mariadb-all.sql
# PostgreSQL 백업 # PostgreSQL 백업
echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker exec tkqc-db \ echo "${NAS_SUDO_PASSWORD}" | sudo -S /usr/local/bin/docker exec tkqc-db \
pg_dumpall -U mproject > \ pg_dumpall -U mproject > \
/volume1/docker/backups/$(date +%Y%m%d)/postgres-all.sql /volume1/docker/backups/$(date +%Y%m%d)/postgres-all.sql
@@ -167,11 +167,11 @@ rm -rf ../tk-factory-services.bak
# NAS SSH # NAS SSH
# TK-FB 중지 # TK-FB 중지
cd "/volume1/Technicalkorea Document/tkfb-package" cd "/volume1/Technicalkorea Document/tkfb-package"
echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker compose down echo "${NAS_SUDO_PASSWORD}" | sudo -S /usr/local/bin/docker compose down
# TKQC 중지 # TKQC 중지
cd /volume1/docker/tkqc/tkqc-package cd /volume1/docker/tkqc/tkqc-package
echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker compose down echo "${NAS_SUDO_PASSWORD}" | sudo -S /usr/local/bin/docker compose down
``` ```
### Step 4: 통합 서비스 기동 ### Step 4: 통합 서비스 기동
@@ -180,10 +180,10 @@ echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker compose down
cd /volume1/docker/tk-factory-services cd /volume1/docker/tk-factory-services
# Docker 이미지 빌드 + 서비스 기동 # Docker 이미지 빌드 + 서비스 기동
echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker compose up --build -d echo "${NAS_SUDO_PASSWORD}" | sudo -S /usr/local/bin/docker compose up --build -d
# 로그 확인 # 로그 확인
echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker compose logs -f --tail=50 echo "${NAS_SUDO_PASSWORD}" | sudo -S /usr/local/bin/docker compose logs -f --tail=50
``` ```
### Step 5: DB 마이그레이션 ### Step 5: DB 마이그레이션
@@ -196,7 +196,7 @@ echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker compose logs -f --ta
# (system3-nonconformance/api/migrations/ → PostgreSQL init) # (system3-nonconformance/api/migrations/ → PostgreSQL init)
# 헬스체크 확인 # 헬스체크 확인
echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker compose ps echo "${NAS_SUDO_PASSWORD}" | sudo -S /usr/local/bin/docker compose ps
``` ```
### Step 6: Cloudflare Tunnel 설정 ### Step 6: Cloudflare Tunnel 설정
@@ -291,15 +291,15 @@ git log --oneline -10
```bash ```bash
# 통합 서비스 중지 # 통합 서비스 중지
cd /volume1/docker_1/tk-factory-services cd /volume1/docker_1/tk-factory-services
echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker compose down echo "${NAS_SUDO_PASSWORD}" | sudo -S /usr/local/bin/docker compose down
# TK-FB 복원 # TK-FB 복원
cd "/volume1/Technicalkorea Document/tkfb-package" cd "/volume1/Technicalkorea Document/tkfb-package"
echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker compose up -d echo "${NAS_SUDO_PASSWORD}" | sudo -S /usr/local/bin/docker compose up -d
# TKQC 복원 # TKQC 복원
cd /volume1/docker/tkqc/tkqc-package cd /volume1/docker/tkqc/tkqc-package
echo 'fukdon-riwbaq-fiQfy2' | sudo -S /usr/local/bin/docker compose up -d echo "${NAS_SUDO_PASSWORD}" | sudo -S /usr/local/bin/docker compose up -d
``` ```
--- ---

View File

@@ -206,7 +206,7 @@ TK-FB-Project(공장관리+신고)와 M-Project(부적합관리)를 **3개 독
### NAS (192.168.0.3) ### NAS (192.168.0.3)
- TK-FB 운영: `/volume1/Technicalkorea Document/tkfb-package/` - TK-FB 운영: `/volume1/Technicalkorea Document/tkfb-package/`
- TKQC 운영: `/volume1/docker/tkqc/tkqc-package/` - TKQC 운영: `/volume1/docker/tkqc/tkqc-package/`
- SSH: `hyungi` / `fukdon-riwbaq-fiQfy2` - SSH: `hyungi` / `${SSH_PASSWORD}` (비밀번호는 비밀관리 시스템 참조)
--- ---

39
SECURITY-CHECKLIST.md Normal file
View File

@@ -0,0 +1,39 @@
# 보안 PR 체크리스트 — TK Factory Services
> 공통 원칙: `claude-config/memory/feedback_security_pr_checklist.md`
> 자동 검증: `scripts/security-scan.sh` (pre-commit + pre-receive)
## 체크리스트
| # | 카테고리 | 검증 | 확인 항목 | 참조 파일 |
|---|---------|------|----------|----------|
| 1 | 비밀 정보 | **자동** #1,#2 | 코드/문서에 비밀번호·토큰·API키 하드코딩 없음 | `.env.example` |
| 2 | 인증 | 수동 | 모든 라우트에 `requireAuth` 적용 | `shared/middleware/auth.js` |
| 3 | 권한 RBAC | 수동 | 쓰기(POST/PUT/DELETE)에 `requirePage()` 또는 `requireRole()` | `shared/middleware/pagePermission.js` |
| 4 | 입력 검증 | 수동 | path traversal(`../`), 타입, 길이 검증 | `system1-factory/api/utils/validator.js` |
| 5 | 파일 업로드 | 수동 | magic number + 확장자 + MIME + 크기 제한 | `system1-factory/api/utils/fileUploadSecurity.js` |
| 6 | 네트워크 | **자동** #5 | CORS 와일드카드 없음, rate limiting 적용 | `system1-factory/api/config/cors.js` |
| 7 | DB 쿼리 | **자동** #6 | 파라미터화(`?`), `await`, `COALESCE` 패턴 | CLAUDE.md 주의사항 |
| 8 | 에러/로그 | **자동** #7 | 로그에 비밀정보 없음, 스택트레이스 prod 비노출 | `shared/utils/errors.js` |
| 9 | 보안 헤더 | 수동 | CSP, HSTS, X-Frame-Options | `system1-factory/api/config/security.js` |
| 10 | 자동 검증 | **자동** | pre-commit + pre-receive 통과 | `scripts/security-scan.sh` |
## 자동 검출 규칙
| 규칙# | 이름 | 심각도 | 동작 |
|-------|------|--------|------|
| 1 | SECRET_HARDCODE | CRITICAL | 차단 |
| 2 | SECRET_KNOWN | CRITICAL | 차단 |
| 3 | LOCALSTORAGE_AUTH | HIGH | 차단 |
| 4 | INNERHTML_XSS | HIGH | 차단 |
| 5 | CORS_WILDCARD | HIGH | 차단 |
| 6 | SQL_INTERPOLATION | HIGH | 차단 |
| 7 | LOG_SECRET | MEDIUM | 경고 (5개 초과 시 차단) |
| 8 | ENV_HARDCODE | MEDIUM | 경고 (5개 초과 시 차단) |
## 수동 확인 필요 항목 (자동화 한계)
- RBAC 설계 오류 / 인증 흐름
- 비즈니스 로직 / race condition
- third-party dependency 취약점 (`npm audit`)
- 환경변수 값 강도

View File

@@ -309,6 +309,7 @@ services:
- NTFY_BASE_URL=${NTFY_BASE_URL:-http://ntfy:80} - NTFY_BASE_URL=${NTFY_BASE_URL:-http://ntfy:80}
- NTFY_PUBLISH_TOKEN=${NTFY_PUBLISH_TOKEN} - NTFY_PUBLISH_TOKEN=${NTFY_PUBLISH_TOKEN}
- NTFY_EXTERNAL_URL=${NTFY_EXTERNAL_URL:-https://ntfy.technicalkorea.net} - NTFY_EXTERNAL_URL=${NTFY_EXTERNAL_URL:-https://ntfy.technicalkorea.net}
- NTFY_SUB_PASSWORD=${NTFY_SUB_PASSWORD}
- TKFB_BASE_URL=${TKFB_BASE_URL:-https://tkfb.technicalkorea.net} - TKFB_BASE_URL=${TKFB_BASE_URL:-https://tkfb.technicalkorea.net}
healthcheck: healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"] test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"]

157
docs/SECURITY-GUIDE.md Normal file
View File

@@ -0,0 +1,157 @@
# 보안 시스템 운영 가이드
## 개요
TK Factory Services에는 2계층 보안 검사 시스템이 적용되어 있습니다.
| 계층 | 위치 | 역할 | 우회 가능 |
|------|------|------|----------|
| pre-commit | 로컬 (개발자 PC) | 빠른 피드백 | `--no-verify` |
| pre-receive | Gitea 서버 | 최종 차단 | `[SECURITY-BYPASS: 사유]`만 |
## 개발 워크플로우
```
코드 작성 → git add → git commit
pre-commit hook
(security-scan.sh --staged)
위반 있으면 → 커밋 차단 + 상세 출력
위반 없으면 → 커밋 성공
git push
pre-receive hook (서버)
(diff 기반 검사)
위반 있으면 → push 차단
위반 없으면 → push 성공
```
## 위반 발생 시 대처
### 에러 메시지 읽기
```
[SECURITY] 2 issue(s) found:
✗ [CRITICAL] #1 SECRET_HARDCODE — 비밀정보 하드코딩
→ src/controllers/auth.js:64
password: 'my-secret-123'
```
- `[CRITICAL]` / `[HIGH]` → 차단됨, 반드시 수정
- `[MEDIUM]` → 경고, 5개 초과 시 차단
- `→ 파일:라인번호` → 수정할 위치
- 아래 줄 → 문제가 된 코드
### 수정 방법 (규칙별)
| 규칙 | 수정 방법 |
|------|----------|
| SECRET_HARDCODE | `process.env.변수명`으로 이동, `.env`에 추가 |
| LOCALSTORAGE_AUTH | HttpOnly 쿠키 또는 Authorization 헤더 사용 |
| INNERHTML_XSS | `textContent` 사용 또는 DOMPurify 적용 |
| CORS_WILDCARD | 허용 도메인을 명시적으로 나열 |
| SQL_INTERPOLATION | 파라미터화 쿼리(`?` placeholder) 사용 |
| LOG_SECRET | 로그에서 비밀정보 제거 |
## bypass 사용법 (긴급 시)
### 형식
```
git commit -m "fix: 긴급 장애 대응 [SECURITY-BYPASS: prod 서비스 다운 긴급 핫픽스]"
```
### 규칙
- **사유 필수**: `[SECURITY-BYPASS]`만으로는 거부됨
- **허용 사용자만**: 운영담당자(ahn@hyungi.net)만 bypass 가능
- **24시간 내 수정**: bypass 후 반드시 보안 이슈 수정 PR 제출
- **로그 기록**: 모든 bypass는 서버에 자동 기록됨
### bypass 후 조치
1. bypass한 코드의 보안 이슈 파악
2. 24시간 내 수정 커밋
3. `security-scan.sh --all`로 전체 검증
## 규칙 추가/수정 방법
### 새 규칙 추가
`scripts/security-scan.sh`의 RULES 배열에 추가:
```bash
'RULE_NAME|SEVERITY|설명|REGEX_PATTERN'
```
예시:
```bash
'EVAL_USAGE|HIGH|eval 사용 위험|eval\s*\('
```
### 같은 규칙을 서버에도 반영
`.githooks/pre-receive-server.sh`의 RULES 배열에도 동일하게 추가.
Gitea 서버의 hook 파일도 업데이트 필요.
## false positive 등록
### 파일 단위 제외
`.securityignore`에 추가 (주석 필수):
```
path/to/file.js # 사유 설명 (날짜)
```
### 라인 단위 제외
소스 코드에 인라인 주석:
```javascript
const pattern = /password/; // security-ignore: SECRET_HARDCODE — regex 패턴 정의
```
### 주의
- 주석 없는 항목은 스캔 시 경고
- 월 1회 `.securityignore` 검토하여 불필요 항목 제거
## 수동 검사
### 전체 프로젝트 스캔
```bash
./scripts/security-scan.sh --all
```
### 엄격 모드 (MEDIUM도 차단)
```bash
./scripts/security-scan.sh --all --strict
```
### 두 커밋 간 비교
```bash
./scripts/security-scan.sh --diff HEAD~5 HEAD
```
## 초기 설정 (새 머신)
```bash
# 1. git hooks 경로 설정
git config core.hooksPath .githooks
# 2. 전체 스캔 확인
./scripts/security-scan.sh --all
# 3. 테스트 (선택)
echo "password: 'test'" >> /tmp/test.js
git add /tmp/test.js
git commit -m "test" # → 차단되어야 함
```
## FAQ
**Q: pre-commit이 너무 느리다**
A: staged 파일만 검사하므로 보통 1초 이내. 파일이 많으면 `--no-verify`로 우회 후 push 시 서버에서 검사.
**Q: false positive가 계속 뜬다**
A: `.securityignore`에 등록하거나 라인에 `// security-ignore: RULE_NAME` 추가.
**Q: 규칙을 비활성화하고 싶다**
A: RULES 배열에서 해당 규칙을 주석 처리. 단, CRITICAL 규칙 비활성화는 비권장.
**Q: 새 서비스 추가 시**
A: 추가 설정 불필요. `.securityignore`에 제외할 파일이 있으면 등록.

355
scripts/security-scan.sh Executable file
View File

@@ -0,0 +1,355 @@
#!/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 "$@"

View File

@@ -1,5 +1,5 @@
-- Push 구독 테이블 생성 -- Push 구독 테이블 생성
-- 실행: docker exec -i tk-mariadb mysql -uhyungi_user -p'hyung-ddfdf3-D341@' hyungi < db/migrations/20260313001000_create_push_subscriptions.sql -- 실행: docker exec -i tk-mariadb mysql -uhyungi_user -p"$MYSQL_PASSWORD" hyungi < db/migrations/20260313001000_create_push_subscriptions.sql
CREATE TABLE IF NOT EXISTS push_subscriptions ( CREATE TABLE IF NOT EXISTS push_subscriptions (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

Before

Width:  |  Height:  |  Size: 2.6 MiB

After

Width:  |  Height:  |  Size: 2.6 MiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

View File

Before

Width:  |  Height:  |  Size: 369 KiB

After

Width:  |  Height:  |  Size: 369 KiB

Some files were not shown because too many files have changed in this diff Show More