diff --git a/docker/security/docker-compose.csrf-matrix.yml b/docker/security/docker-compose.csrf-matrix.yml new file mode 100644 index 0000000..7b4e3ab --- /dev/null +++ b/docker/security/docker-compose.csrf-matrix.yml @@ -0,0 +1,79 @@ +services: + mysql: + image: mysql:8.0 + command: + - --default-authentication-plugin=mysql_native_password + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: ${MYSQL_DATABASE} + ports: + - "${MYSQL_PORT:-3308}:3306" + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h localhost -uroot -p$${MYSQL_ROOT_PASSWORD} --silent"] + interval: 5s + timeout: 3s + retries: 30 + + redis: + image: redis:7-alpine + ports: + - "${REDIS_HOST_PORT:-6381}:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 3s + timeout: 2s + retries: 30 + + app: + image: gradle:8.10-jdk17 + working_dir: /workspace + command: ["bash", "-lc", "./gradlew bootRun --no-daemon"] + volumes: + - ../..:/workspace + - gradle-cache:/home/gradle/.gradle + environment: + SPRING_PROFILE: ${SPRING_PROFILE} + DB_HOST: jdbc:mysql://mysql:3306/${MYSQL_DATABASE}?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 + DB_USERNAME: root + DB_PASSWORD: ${MYSQL_ROOT_PASSWORD} + REDIS_HOST: redis + REDIS_PORT: 6379 + DDL_TYPE: update + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} + GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET} + JWT_SECRET_KEY: ${JWT_SECRET_KEY} + MAIL_USER_NAME: ${MAIL_USER_NAME} + MAIL_APP_PASSWORD: ${MAIL_APP_PASSWORD} + SYNCLY_LINK: ${SYNCLY_LINK} + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID} + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY} + AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME} + AWS_CLOUDFRONT_DOMAIN: ${AWS_CLOUDFRONT_DOMAIN} + AWS_CLOUDFRONT_KEY_PAIR_ID: ${AWS_CLOUDFRONT_KEY_PAIR_ID} + AWS_CLOUDFRONT_PRIVATE_KEY: ${AWS_CLOUDFRONT_PRIVATE_KEY} + LIVEKIT_INGRESS_API_KEY: ${LIVEKIT_INGRESS_API_KEY} + LIVEKIT_INGRESS_API_SECRET: ${LIVEKIT_INGRESS_API_SECRET} + LIVEKIT_ADMIN_API_KEY: ${LIVEKIT_ADMIN_API_KEY} + LIVEKIT_ADMIN_API_SECRET: ${LIVEKIT_ADMIN_API_SECRET} + LIVEKIT_WEBHOOK_KEY: ${LIVEKIT_WEBHOOK_KEY} + LIVEKIT_WEBHOOK_SECRET: ${LIVEKIT_WEBHOOK_SECRET} + LIVEKIT_URL: ${LIVEKIT_URL} + LIVEKIT_TURN_ENABLED: ${LIVEKIT_TURN_ENABLED} + LIVEKIT_TURN_UDP_PORT: ${LIVEKIT_TURN_UDP_PORT} + LIVEKIT_TURN_TLS_PORT: ${LIVEKIT_TURN_TLS_PORT} + LIVEKIT_TURN_DOMAIN: ${LIVEKIT_TURN_DOMAIN} + LIVEKIT_TURN_USERNAME: ${LIVEKIT_TURN_USERNAME} + LIVEKIT_TURN_PASSWORD: ${LIVEKIT_TURN_PASSWORD} + DISCORD_ERROR_WEBHOOK_URL: "" + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + ports: + - "${APP_PORT:-8081}:8080" + +volumes: + gradle-cache: diff --git a/docker/security/env.csrf-matrix.example b/docker/security/env.csrf-matrix.example new file mode 100644 index 0000000..0563ea8 --- /dev/null +++ b/docker/security/env.csrf-matrix.example @@ -0,0 +1,43 @@ +# Runtime +SPRING_PROFILE=local +APP_PORT=8081 +MYSQL_PORT=3308 +REDIS_HOST_PORT=6381 + +# DB +MYSQL_DATABASE=syncly +MYSQL_ROOT_PASSWORD=syncly_root_pw + +# Test account +MATRIX_EMAIL=csrf.matrix@syncly.local +MATRIX_PASSWORD=Aa!12345 +MATRIX_PASSWORD_BCRYPT='$2a$10$SHBeLpz3IRNpx6gvHLW6Je6IcJa4p/zdNBrFOGtFK93eZUKov4qO2' + +# Token signing +JWT_SECRET_KEY=10E9wxTvZquQWfNFVyBAjFNihDHwSZdYghS3mehdzmUVps9jau1/mC5oDYpdj3c15IFgDBiNZB+7du7oJ0kLbg== + +# Dummy values for required properties +GOOGLE_CLIENT_ID=dummy-google-client-id +GOOGLE_CLIENT_SECRET=dummy-google-client-secret +MAIL_USER_NAME=dummy@example.com +MAIL_APP_PASSWORD=dummy-app-password +SYNCLY_LINK=http://localhost:5173 +AWS_ACCESS_KEY_ID=dummy +AWS_SECRET_ACCESS_KEY=dummy +AWS_S3_BUCKET_NAME=dummy-bucket +AWS_CLOUDFRONT_DOMAIN=dummy.cloudfront.net +AWS_CLOUDFRONT_KEY_PAIR_ID=dummy-keypair +AWS_CLOUDFRONT_PRIVATE_KEY=dummy-private-key +LIVEKIT_INGRESS_API_KEY=dummy +LIVEKIT_INGRESS_API_SECRET=dummy +LIVEKIT_ADMIN_API_KEY=dummy +LIVEKIT_ADMIN_API_SECRET=dummy +LIVEKIT_WEBHOOK_KEY=dummy +LIVEKIT_WEBHOOK_SECRET=dummy +LIVEKIT_URL=http://localhost:7880 +LIVEKIT_TURN_ENABLED=false +LIVEKIT_TURN_UDP_PORT=3478 +LIVEKIT_TURN_TLS_PORT=5349 +LIVEKIT_TURN_DOMAIN=localhost +LIVEKIT_TURN_USERNAME=dummy +LIVEKIT_TURN_PASSWORD=dummy diff --git a/docs/security/csrf-attack-matrix-validation.md b/docs/security/csrf-attack-matrix-validation.md new file mode 100644 index 0000000..7a9c53c --- /dev/null +++ b/docs/security/csrf-attack-matrix-validation.md @@ -0,0 +1,70 @@ +## 근본 목적 +Cross-site 구조에서 SameSite 쿠키 단독 방어가 불충분한 전제를 기준으로, 현재 Origin/Referer 기반 CSRF 필터가 공격 요청을 차단하고 정상 요청을 통과시키는지 자동화 실험으로 검증한다. + +## 비목적 +인증 설계 변경, CSRF 필터 로직 변경, 쿠키 정책 변경은 이번 문서의 범위가 아니다. + +## 실험 질문 +- 공격 요청(악성 Origin/Referer + 쿠키)을 안정적으로 차단하는가? +- 정상 요청(신뢰 Origin/Referer + 쿠키)을 안정적으로 통과시키는가? +- 헤더 조합 혼합(악성 Origin + 신뢰 Referer)에서 어떤 판정이 발생하는가? + +## 검증 키 포인트 +- 필터 적용 조건: `/api/auth/reissue`, `/api/auth/logout`, 비안전 메서드, `REFRESH` 쿠키 존재 +- 차단 신호: `HTTP 403` + 응답 코드 `CSRF403` +- 집계 지표 + - TP: 공격 차단 + - FN: 공격 미차단 + - FP: 정상 오차단 + - TN: 정상 통과 + +## 시나리오 매트릭스 +원본: `scripts/security/csrf-attack-matrix.json` + +- 정상(C): C1~C4 +- 공격(A): A1~A6 + +## 자동화 실행 +```bash +cp docker/security/env.csrf-matrix.example docker/security/env.csrf-matrix.local +ENV_FILE=$(pwd)/docker/security/env.csrf-matrix.local \ +./scripts/security/run-csrf-attack-matrix.sh +``` + +## 산출물 +- 요청 결과: `/tmp/syncly-csrf-matrix/results-.ndjson` +- 집계 결과: `/tmp/syncly-csrf-matrix/summary-.json` +- 실행 메타: `/tmp/syncly-csrf-matrix/meta-.json` + +## 결과 해석 원칙 +- request level은 개별 요청 판정 정확도를 평가한다. +- incident level은 시나리오 단위 방어 성공률을 평가한다. +- `악성 Origin + 신뢰 Referer`처럼 혼합 헤더에서 미탐이 발생하면 정책 한계로 기록한다. +- 포트폴리오 문구는 측정값과 한계를 함께 제시한다. + +## 이슈/PR 기재 필수 한계 +- Origin/Referer 중 하나만 신뢰되어도 통과되므로, 혼합 헤더 시나리오에서 우회 여지가 있는지 실측 결과로 명시한다. +- CORS 허용 목록과 CSRF trusted 목록의 불일치로 정상 요청 차단이 발생할 수 있음을 결과 해석에 포함한다. + +## 2026-02-26 1차 실행 결과 +- 실행 명령: `./scripts/security/run-csrf-attack-matrix.sh` +- 결과 파일: `/tmp/syncly-csrf-matrix/results-20260226T113929Z.ndjson` +- 집계 파일: `/tmp/syncly-csrf-matrix/summary-20260226T113929Z.json` + +요청 단위(request level)에서 두 가지 관점으로 분리해 해석했다. + +- `security_block_level` (HTTP 403 차단 기준) + - TP=6, FN=0, FP=0, TN=3 + - detection_rate=1.0, false_positive_rate=0.0 + - 의미: 측정 대상 공격 6건은 모두 차단됐다. + +- `csrf_filter_level` (`CSRF403` 코드 기준) + - TP=2, FN=4, FP=0, TN=3 + - detection_rate=0.3333, false_positive_rate=0.0 + - 의미: 공격 차단의 상당수는 CSRF 필터 응답(`CSRF403`)이 아니라 다른 계층(주로 CORS 403)에서 발생했다. + +시나리오 해석: +- A3/A4는 `CSRF403`로 차단되어 CSRF 필터가 직접 동작했다. +- A1/A2/A5/A6은 403 차단이지만 `CSRF403` 코드가 없어, 실행 관찰상 CORS 단계 차단으로 분류된다. +- C1/C2/C3는 정상 통과(200)로 오차단은 관찰되지 않았다. +- C4(쿠키 없음)는 필터 적용 대상이 아니므로 측정 지표에서 제외하고 동작 확인용 진단 케이스로만 사용했다. diff --git a/scripts/security/csrf-attack-matrix.json b/scripts/security/csrf-attack-matrix.json new file mode 100644 index 0000000..1387a01 --- /dev/null +++ b/scripts/security/csrf-attack-matrix.json @@ -0,0 +1,104 @@ +[ + { + "id": "C1", + "group": "normal", + "title": "신뢰 Origin + 쿠키로 재발급", + "gt_attack": false, + "endpoint": "reissue", + "origin": "trusted", + "referer": "none", + "send_cookie": true + }, + { + "id": "C2", + "group": "normal", + "title": "신뢰 Referer + 쿠키로 재발급", + "gt_attack": false, + "endpoint": "reissue", + "origin": "none", + "referer": "trusted", + "send_cookie": true + }, + { + "id": "C3", + "group": "normal", + "title": "신뢰 Origin + 쿠키로 로그아웃", + "gt_attack": false, + "endpoint": "logout", + "origin": "trusted", + "referer": "trusted", + "send_cookie": true + }, + { + "id": "C4", + "group": "normal", + "title": "쿠키 없는 재발급 요청(필터 스킵 경로)", + "gt_attack": false, + "endpoint": "reissue", + "origin": "trusted", + "referer": "trusted", + "send_cookie": false, + "measurable": false + }, + + { + "id": "A1", + "group": "attack", + "title": "악성 Origin 단독 + 쿠키", + "gt_attack": true, + "endpoint": "reissue", + "origin": "malicious", + "referer": "none", + "send_cookie": true + }, + { + "id": "A2", + "group": "attack", + "title": "악성 Origin + 악성 Referer + 쿠키", + "gt_attack": true, + "endpoint": "reissue", + "origin": "malicious", + "referer": "malicious", + "send_cookie": true + }, + { + "id": "A3", + "group": "attack", + "title": "Origin/Referer 모두 없음 + 쿠키", + "gt_attack": true, + "endpoint": "reissue", + "origin": "none", + "referer": "none", + "send_cookie": true + }, + { + "id": "A4", + "group": "attack", + "title": "악성 Referer 단독 + 쿠키", + "gt_attack": true, + "endpoint": "reissue", + "origin": "none", + "referer": "malicious", + "send_cookie": true + }, + { + "id": "A5", + "group": "attack", + "title": "악성 Origin + 신뢰 Referer 혼합 + 쿠키", + "gt_attack": true, + "endpoint": "reissue", + "origin": "malicious", + "referer": "trusted", + "send_cookie": true + }, + { + "id": "A6", + "group": "attack", + "title": "유사 도메인 Origin(syncly-io.com.evil.com) + 쿠키", + "gt_attack": true, + "endpoint": "reissue", + "origin": "lookalike", + "referer": "none", + "send_cookie": true + } +] diff --git a/scripts/security/run-csrf-attack-matrix.sh b/scripts/security/run-csrf-attack-matrix.sh new file mode 100755 index 0000000..a396088 --- /dev/null +++ b/scripts/security/run-csrf-attack-matrix.sh @@ -0,0 +1,357 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" + +MATRIX_FILE="${MATRIX_FILE:-$ROOT_DIR/scripts/security/csrf-attack-matrix.json}" +COMPOSE_FILE="${COMPOSE_FILE:-$ROOT_DIR/docker/security/docker-compose.csrf-matrix.yml}" +ENV_FILE="${ENV_FILE:-$ROOT_DIR/docker/security/env.csrf-matrix.example}" +OUTPUT_DIR="${OUTPUT_DIR:-/tmp/syncly-csrf-matrix}" +RUN_ID="${RUN_ID:-$(date -u +%Y%m%dT%H%M%SZ)}" +RESULT_FILE="${RESULT_FILE:-$OUTPUT_DIR/results-$RUN_ID.ndjson}" +SUMMARY_FILE="${SUMMARY_FILE:-$OUTPUT_DIR/summary-$RUN_ID.json}" +META_FILE="${META_FILE:-$OUTPUT_DIR/meta-$RUN_ID.json}" + +TRUSTED_ORIGIN="${TRUSTED_ORIGIN:-http://localhost:5173}" +TRUSTED_REFERER="${TRUSTED_REFERER:-http://localhost:5173/matrix}" +MALICIOUS_ORIGIN="${MALICIOUS_ORIGIN:-https://evil.example}" +MALICIOUS_REFERER="${MALICIOUS_REFERER:-https://evil.example/attack}" +LOOKALIKE_ORIGIN="${LOOKALIKE_ORIGIN:-https://syncly-io.com.evil.com}" +USER_AGENT="${USER_AGENT:-CsrfMatrix/1.0}" + +SKIP_STACK_UP="${SKIP_STACK_UP:-0}" +KEEP_STACK="${KEEP_STACK:-0}" +DEFAULT_BCRYPT='$2a$10$SHBeLpz3IRNpx6gvHLW6Je6IcJa4p/zdNBrFOGtFK93eZUKov4qO2' + +log() { + printf '[csrf-matrix] %s\n' "$*" +} + +fail() { + printf '[csrf-matrix][error] %s\n' "$*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || fail "필수 명령어 없음: $1" +} + +compose() { + docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@" +} + +cleanup() { + if [[ "$KEEP_STACK" == "1" || "$SKIP_STACK_UP" == "1" ]]; then + return + fi + log "스택 종료" + compose down -v >/dev/null 2>&1 || true +} + +extract_http_status() { + local header_file="$1" + awk '/^HTTP/{code=$2} END{print code+0}' "$header_file" +} + +extract_refresh_cookie() { + local header_file="$1" + tr -d '\r' < "$header_file" | sed -n 's/^Set-Cookie: REFRESH=\([^;]*\).*/\1/p' | tail -n 1 +} + +json_code() { + local body_file="$1" + jq -r '.code // empty' "$body_file" 2>/dev/null || true +} + +origin_value() { + local mode="$1" + case "$mode" in + trusted) + printf '%s' "$TRUSTED_ORIGIN" + ;; + malicious) + printf '%s' "$MALICIOUS_ORIGIN" + ;; + lookalike) + printf '%s' "$LOOKALIKE_ORIGIN" + ;; + none) + printf '' + ;; + *) + fail "지원하지 않는 origin mode: $mode" + ;; + esac +} + +referer_value() { + local mode="$1" + case "$mode" in + trusted) + printf '%s' "$TRUSTED_REFERER" + ;; + malicious) + printf '%s' "$MALICIOUS_REFERER" + ;; + none) + printf '' + ;; + *) + fail "지원하지 않는 referer mode: $mode" + ;; + esac +} + +endpoint_path() { + local endpoint="$1" + case "$endpoint" in + reissue) + printf '/api/auth/reissue' + ;; + logout) + printf '/api/auth/logout' + ;; + *) + fail "지원하지 않는 endpoint: $endpoint" + ;; + esac +} + +mysql_exec() { + local sql="$1" + compose exec -T mysql mysql -uroot -p"$MYSQL_ROOT_PASSWORD" "$MYSQL_DATABASE" -e "$sql" >/dev/null +} + +wait_for_mysql() { + log "MySQL 준비 대기" + local i + for i in $(seq 1 60); do + if compose exec -T mysql mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -e 'SELECT 1' >/dev/null 2>&1; then + return + fi + sleep 2 + done + fail "MySQL 준비 실패" +} + +wait_for_api() { + log "API 준비 대기" + local i code + for i in $(seq 1 180); do + code="$(curl -s -o /dev/null -w '%{http_code}' "$API_BASE/api/auth/login" || true)" + if [[ "$code" != "000" ]]; then + return + fi + sleep 2 + done + fail "API 준비 실패: $API_BASE" +} + +seed_member() { + log "테스트 멤버 시드" + local hash="${MATRIX_PASSWORD_BCRYPT:-$DEFAULT_BCRYPT}" + local email_esc="${MATRIX_EMAIL//\'/\\\'}" + local hash_esc="${hash//\'/\\\'}" + + mysql_exec "DELETE FROM members WHERE email='${email_esc}';" + mysql_exec "INSERT INTO members (email, password, name, social_login_provider, is_deleted, created_at, updated_at) VALUES ('${email_esc}', '${hash_esc}', 'csrf-matrix-user', 'LOCAL', 0, NOW(), NOW());" +} + +login_refresh() { + local header_file body_file status refresh + header_file="$(mktemp)" + body_file="$(mktemp)" + + curl -sS -D "$header_file" -o "$body_file" -X POST "$API_BASE/api/auth/login" \ + -H "Content-Type: application/json" \ + -H "User-Agent: $USER_AGENT" \ + --data "{\"email\":\"$MATRIX_EMAIL\",\"password\":\"$MATRIX_PASSWORD\"}" >/dev/null + + status="$(extract_http_status "$header_file")" + if [[ "$status" != "200" ]]; then + cat "$body_file" >&2 + rm -f "$header_file" "$body_file" + fail "로그인 실패: HTTP $status" + fi + + refresh="$(extract_refresh_cookie "$header_file")" + if [[ -z "$refresh" ]]; then + rm -f "$header_file" "$body_file" + fail "로그인 성공했지만 REFRESH 쿠키가 없음" + fi + + rm -f "$header_file" "$body_file" + printf '%s' "$refresh" +} + +should_run_scenario() { + local scenario_id="$1" + if [[ -z "${SCENARIOS:-}" ]]; then + return 0 + fi + local token + IFS=',' read -r -a tokens <<< "$SCENARIOS" + for token in "${tokens[@]}"; do + if [[ "$token" == "$scenario_id" ]]; then + return 0 + fi + done + return 1 +} + +write_result() { + local scenario_id="$1" + local title="$2" + local group="$3" + local gt_attack="$4" + local endpoint="$5" + local origin_mode="$6" + local referer_mode="$7" + local send_cookie="$8" + local measurable="${9}" + local status="${10}" + local body_code="${11}" + local blocked="${12}" + local csrf_detected="${13}" + + jq -nc \ + --arg scenario_id "$scenario_id" \ + --arg title "$title" \ + --arg group "$group" \ + --arg endpoint "$endpoint" \ + --arg origin_mode "$origin_mode" \ + --arg referer_mode "$referer_mode" \ + --arg status "$status" \ + --arg body_code "$body_code" \ + --arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --argjson gt_attack "$gt_attack" \ + --argjson send_cookie "$send_cookie" \ + --argjson measurable "$measurable" \ + --argjson blocked "$blocked" \ + --argjson csrf_detected "$csrf_detected" \ + '{scenario_id:$scenario_id,title:$title,group:$group,gt_attack:$gt_attack,endpoint:$endpoint,origin_mode:$origin_mode,referer_mode:$referer_mode,send_cookie:$send_cookie,measurable:$measurable,status:$status,body_code:$body_code,detected:$blocked,blocked:$blocked,csrf_detected:$csrf_detected,timestamp:$timestamp}' \ + >> "$RESULT_FILE" +} + +run_scenario() { + local scenario_json="$1" + + local scenario_id title group gt_attack endpoint origin_mode referer_mode send_cookie + local measurable + scenario_id="$(jq -r '.id' <<< "$scenario_json")" + title="$(jq -r '.title' <<< "$scenario_json")" + group="$(jq -r '.group' <<< "$scenario_json")" + gt_attack="$(jq -r '.gt_attack' <<< "$scenario_json")" + endpoint="$(jq -r '.endpoint' <<< "$scenario_json")" + origin_mode="$(jq -r '.origin' <<< "$scenario_json")" + referer_mode="$(jq -r '.referer' <<< "$scenario_json")" + send_cookie="$(jq -r '.send_cookie' <<< "$scenario_json")" + measurable="$(jq -r 'if has("measurable") then .measurable else true end' <<< "$scenario_json")" + + if ! should_run_scenario "$scenario_id"; then + return + fi + + log "시나리오 시작: $scenario_id ($title)" + + local path origin referer refresh header_file body_file status body_code blocked csrf_detected + path="$(endpoint_path "$endpoint")" + origin="$(origin_value "$origin_mode")" + referer="$(referer_value "$referer_mode")" + + refresh="" + if [[ "$send_cookie" == "true" ]]; then + refresh="$(login_refresh)" + fi + + header_file="$(mktemp)" + body_file="$(mktemp)" + + local -a args + args=( -sS -D "$header_file" -o "$body_file" -X POST "$API_BASE$path" -H "User-Agent: $USER_AGENT" ) + + if [[ -n "$origin" ]]; then + args+=( -H "Origin: $origin" ) + fi + if [[ -n "$referer" ]]; then + args+=( -H "Referer: $referer" ) + fi + if [[ "$send_cookie" == "true" ]]; then + args+=( -H "Cookie: REFRESH=$refresh" ) + fi + + curl "${args[@]}" >/dev/null + + status="$(extract_http_status "$header_file")" + body_code="$(json_code "$body_file")" + + blocked=false + csrf_detected=false + if [[ "$status" == "403" ]]; then + blocked=true + fi + if [[ "$status" == "403" && "$body_code" == "CSRF403" ]]; then + csrf_detected=true + fi + + write_result "$scenario_id" "$title" "$group" "$gt_attack" "$endpoint" "$origin_mode" "$referer_mode" "$send_cookie" "$measurable" "$status" "$body_code" "$blocked" "$csrf_detected" + + rm -f "$header_file" "$body_file" +} + +main() { + require_cmd docker + require_cmd curl + require_cmd jq + + [[ -f "$MATRIX_FILE" ]] || fail "매트릭스 파일 없음: $MATRIX_FILE" + [[ -f "$COMPOSE_FILE" ]] || fail "compose 파일 없음: $COMPOSE_FILE" + [[ -f "$ENV_FILE" ]] || fail "env 파일 없음: $ENV_FILE" + + # shellcheck disable=SC1090 + set -a; source "$ENV_FILE"; set +a + + API_BASE="${API_BASE:-http://localhost:${APP_PORT:-8081}}" + MATRIX_EMAIL="${MATRIX_EMAIL:-csrf.matrix@syncly.local}" + MATRIX_PASSWORD="${MATRIX_PASSWORD:-Aa!12345}" + MATRIX_PASSWORD_BCRYPT="${MATRIX_PASSWORD_BCRYPT:-$DEFAULT_BCRYPT}" + + mkdir -p "$OUTPUT_DIR" + : > "$RESULT_FILE" + + if [[ "$SKIP_STACK_UP" != "1" ]]; then + log "docker compose 스택 시작" + compose up -d + fi + + trap cleanup EXIT + + wait_for_mysql + wait_for_api + seed_member + + while IFS= read -r scenario_json; do + [[ -z "$scenario_json" ]] && continue + run_scenario "$scenario_json" + done < <(jq -c '.[]' "$MATRIX_FILE") + + jq -nc \ + --arg run_id "$RUN_ID" \ + --arg api_base "$API_BASE" \ + --arg matrix_file "$MATRIX_FILE" \ + --arg compose_file "$COMPOSE_FILE" \ + --arg env_file "$ENV_FILE" \ + --arg result_file "$RESULT_FILE" \ + --arg generated_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '{run_id:$run_id,api_base:$api_base,matrix_file:$matrix_file,compose_file:$compose_file,env_file:$env_file,result_file:$result_file,generated_at:$generated_at}' > "$META_FILE" + + "$ROOT_DIR/scripts/security/score-csrf-attack-matrix.sh" "$RESULT_FILE" "$MATRIX_FILE" > "$SUMMARY_FILE" + + log "실행 완료" + log "결과 파일: $RESULT_FILE" + log "집계 파일: $SUMMARY_FILE" + log "메타 파일: $META_FILE" +} + +main "$@" diff --git a/scripts/security/score-csrf-attack-matrix.sh b/scripts/security/score-csrf-attack-matrix.sh new file mode 100755 index 0000000..0b045d8 --- /dev/null +++ b/scripts/security/score-csrf-attack-matrix.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +set -euo pipefail + +RESULT_FILE="${1:-}" +MATRIX_FILE="${2:-}" + +if [[ -z "$RESULT_FILE" ]]; then + echo "usage: $0 [matrix.json]" >&2 + exit 1 +fi + +if [[ ! -f "$RESULT_FILE" ]]; then + echo "results file not found: $RESULT_FILE" >&2 + exit 1 +fi + +if [[ -n "$MATRIX_FILE" && ! -f "$MATRIX_FILE" ]]; then + echo "matrix file not found: $MATRIX_FILE" >&2 + exit 1 +fi + +jq -s \ + --arg result_file "$RESULT_FILE" \ + --arg matrix_file "${MATRIX_FILE:-}" \ + ' + def safe_div($a; $b): if $b == 0 then 0 else ($a / $b) end; + + def confusion($rows; $field): + { + tp: ($rows | map(select(.gt_attack == true and .[$field] == true)) | length), + fn: ($rows | map(select(.gt_attack == true and .[$field] != true)) | length), + fp: ($rows | map(select(.gt_attack != true and .[$field] == true)) | length), + tn: ($rows | map(select(.gt_attack != true and .[$field] != true)) | length) + }; + + def finalize($c): + $c + { + detection_rate: safe_div($c.tp; ($c.tp + $c.fn)), + false_positive_rate: safe_div($c.fp; ($c.fp + $c.tn)) + }; + + . as $rows + | ($rows | map(select(.scenario_id != null and .measurable != false))) as $valid + | (confusion($valid; "blocked") | finalize(.)) as $security_block_level + | (confusion($valid; "csrf_detected") | finalize(.)) as $csrf_filter_level + | ($valid + | group_by(.scenario_id) + | map({ + scenario_id: .[0].scenario_id, + title: .[0].title, + gt_attack: (.[0].gt_attack == true), + measurable: (.[0].measurable != false), + incident_blocked: (any(.[]; .blocked == true)), + incident_csrf_detected: (any(.[]; .csrf_detected == true)), + requests: length, + blocked_count: (map(select(.blocked == true)) | length), + csrf_detect_count: (map(select(.csrf_detected == true)) | length), + statuses: (group_by(.status) | map({status: .[0].status, count: length})), + codes: (group_by(.body_code) | map({code: .[0].body_code, count: length})) + }) + ) as $incidents + | ($incidents | map(select(.measurable == true))) as $incident_valid + | (confusion($incident_valid | map({gt_attack, blocked: .incident_blocked}); "blocked") | finalize(.)) as $incident_security_block_level + | (confusion($incident_valid | map({gt_attack, csrf_detected: .incident_csrf_detected}); "csrf_detected") | finalize(.)) as $incident_csrf_filter_level + | { + generated_at: (now | todateiso8601), + source: { + results_file: $result_file, + matrix_file: (if $matrix_file == "" then null else $matrix_file end) + }, + request_level: { + security_block_level: $security_block_level, + csrf_filter_level: $csrf_filter_level + }, + incident_level: { + security_block_level: $incident_security_block_level, + csrf_filter_level: $incident_csrf_filter_level + }, + counts: { + total_requests: ($rows | length), + measurable_requests: ($valid | length), + blocked_requests: ($rows | map(select(.blocked == true)) | length), + csrf_detect_requests: ($rows | map(select(.csrf_detected == true)) | length), + allowed_requests: ($rows | map(select(.blocked != true)) | length) + }, + scenario_breakdown: $incidents + } + ' "$RESULT_FILE"