Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .claude/rules/general.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# PR 생성

PR을 만들기 전에 **반드시** `.github/pull_request_template.md`를 먼저 읽고 템플릿 형식을 따를 것.

# 빌드 명령어

## C++ 빌드
Expand Down
14 changes: 14 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,20 @@ While (세션 활성):
- Atomic으로 카운터 증가
```

### 세션 I/O 최적화

- `proxy::Session`은 작은 패킷을 반복해서 읽고 쓰는 경로에서 syscall 수를 줄이기 위해 내부 read/write buffer를 유지한다.
- 서버 응답 경로는 `RelayBuffer`가 header+payload를 버퍼에 누적한 뒤 필요 시 flush 하며, result set 중간에도 임계치 기반으로 분할 전송한다.
- 클라이언트 요청 경로는 `ClientReadBuffer`가 `async_read_some` 기반으로 패킷을 누적 읽어 2단계 read(header, payload)를 단일 버퍼 흐름으로 줄인다.
- 세션 버퍼는 단일 MySQL 패킷 최대 크기(3-byte length field 기준)를 넘겨 확장하지 않으며, 큰 패킷 처리 후 버퍼가 비면 초기 크기로 축소해 장기 세션의 상주 메모리를 제한한다.
- packet write는 `write_packet_raw()`가 header와 payload를 scatter-gather로 묶어 전송해 별도 serialize 버퍼 할당을 피한다.

### 로깅과 통계

- `StructuredLogger`는 UTC ISO8601 타임스탬프를 `strftime + snprintf` 로 생성해 문자열 스트림 기반 포맷 비용을 줄인다.
- 허용/차단/에러 로그는 세션 ID, 사용자, SQL prefix, 평가 경로를 남겨 parser, policy, proxy의 fail-close 판단을 추적 가능하게 한다.
- `StatsCollector`는 atomic 기반 누적 카운터를 유지하고, UDS stats 서버와 health check는 같은 snapshot을 읽기 전용으로 소비한다.

## Fail-Close 원칙 (절대 위반 금지)

**Fail-Close의 의미:**
Expand Down
31 changes: 31 additions & 0 deletions docs/failure-modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,37 @@
- `StructuredLogger` 종료 시 `flush()` 보장
- 테스트는 로거 수명 종료 이후 파일을 읽도록 구성

### 4) Static Analysis (clang-tidy) 실패
- 증상:
- `cppcoreguidelines-avoid-const-or-ref-data-members`
- `readability-braces-around-statements`
- `misc-const-correctness`
- `modernize-return-braced-init-list`
- Boost.Asio `awaitable.hpp` 경유 `clang-analyzer-core.NullDereference`
- 원인:
- 세션 내부 보조 버퍼 클래스가 reference data member를 유지함
- coroutine early return 분기에 brace가 빠져 style check에 걸림
- 변경되지 않는 scatter-gather 전송 버퍼가 `const`로 선언되지 않음
- 단순 문자열 반환식이 최신 초기화 스타일과 맞지 않음
- clang static analyzer가 coroutine + Boost.Asio awaitable 경로를 추적하며 외부 헤더 false positive를 낼 수 있음
- 복구:
- non-owning stream 참조는 pointer 등 재바인딩 가능한 형태로 저장
- 단일문 분기에도 brace를 사용
- 불변 로컬 버퍼는 `const`로 선언
- 단순 생성 반환은 braced-init-list 사용
- `clang-tidy -p build/default <file>.cpp` 단독 실행 대신 CI와 같은 extra arg/별도 compile DB 구성으로 재현한다
- 외부 Boost 헤더에서만 발생한 `clang-analyzer-core.NullDereference`는 `.clang-tidy`에서 `WarningsAsErrors` 제외 항목이므로, 프로젝트 소스 error가 없으면 CI 실패 원인으로 보지 않는다

### 5) devcontainer에서 Integration Test 확인이 불안정한 경우
- 증상:
- `tests/integration/test_scenarios.sh` 실행 시 direct/proxy 단계가 모두 실패하거나 환경에 따라 결과가 달라짐
- 원인:
- devcontainer 내부에서는 Docker daemon 제어가 불가하고, compose MySQL 접근도 실행 권한/네트워크 제약의 영향을 받음
- 스크립트는 stderr를 버리므로 접속 실패 원인이 바로 드러나지 않음
- 복구:
- compose가 올린 `mysql` 서비스에 직접 접속 가능한 실행 경로에서 검증
- 필요 시 동일한 `mysql` 명령을 독립 실행해 실제 접속 오류를 먼저 확인

## 런타임 실패 모드

### UDS 바인드 실패
Expand Down
49 changes: 48 additions & 1 deletion docs/runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ vcpkg 의존성 컴파일 시간 절감을 위해 바이너리 캐시를 사용
| 실패 job | 원인 | 조치 |
|---|---|---|
| Build & Test | 빌드 오류 또는 테스트 실패 | 로컬 `cmake --preset default && ctest` 재현 |
| Static Analysis | clang-tidy error 또는 cppcheck 오류 | `clang-tidy -p build/debug <파일>.cpp` 로컬 실행 |
| Static Analysis | clang-tidy error 또는 cppcheck 오류 | `build/default/compile_commands.json` 기준으로 CI와 동일한 extra arg를 넣어 `clang-tidy` 재현 |
| ASan | 메모리 오염/누수 | `cmake --preset asan && ctest` 로컬 실행 |
| TSan | 데이터레이스 | `cmake --preset tsan && ctest` 로컬 실행 |
| Go CI | 린트 오류 또는 테스트 실패 | `cd tools && golangci-lint run` 로컬 실행 |
Expand All @@ -193,6 +193,53 @@ vcpkg 의존성 컴파일 시간 절감을 위해 바이너리 캐시를 사용
TSan job은 `ubuntu-24.04` (x86_64 고정) 에서만 실행한다.
GCC ThreadSanitizer가 aarch64에서 불안정하므로 runner 아키텍처를 명시적으로 제한한다.

### 로컬 clang-tidy 재현

CI의 static analysis job은 `build/default/compile_commands.json` 에서 `src/*.cpp` 엔트리만 추려 별도 compile DB를 만들고, 아래 인자를 추가해 `clang-tidy`를 실행한다.

```bash
db_path="build/default/compile_commands.json"
tidy_db_dir="/tmp/clang-tidy-db"
tidy_db="${tidy_db_dir}/compile_commands.json"
mkdir -p "${tidy_db_dir}"
src_root="$(realpath src)"

jq --arg root "${src_root}" '
def cmd:
if .command then .command
elif ((.arguments | type) == "array") then (.arguments | join(" "))
else "" end;
[
.[]
| select(
(.file | startswith($root + "/"))
and (cmd | test("(^| )-std=(gnu\\+\\+23|c\\+\\+23)( |$)"))
)
| if .command then . else (. + {command: cmd}) end
]
| sort_by(.file)
| unique_by(.file)
' "${db_path}" > "${tidy_db}"

gcc_major="$(g++-14 -dumpfullversion -dumpversion | cut -d. -f1)"
gcc_triple="$(g++-14 -dumpmachine)"
args=(
-p "${tidy_db_dir}"
--extra-arg=-std=c++23
--extra-arg=--gcc-toolchain=/usr
)
if [ -d "/usr/include/c++/${gcc_major}" ]; then
args+=(--extra-arg=-isystem --extra-arg="/usr/include/c++/${gcc_major}")
fi
if [ -d "/usr/include/${gcc_triple}/c++/${gcc_major}" ]; then
args+=(--extra-arg=-isystem --extra-arg="/usr/include/${gcc_triple}/c++/${gcc_major}")
fi

clang-tidy "${args[@]}" "$(realpath src/proxy/session.cpp)"
```

로컬에서 단일 파일만 확인할 때도 위 인자 구성을 유지해야 CI와 같은 결과를 얻는다.

## Docker 배포 (멀티 인스턴스 + HAProxy)

### 아키텍처 개요
Expand Down
19 changes: 19 additions & 0 deletions docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,24 @@

> 참조: `docs/architecture.md:955-957`

### 3.2.1 ReDoS (정규식 기반 DoS) — DON-80 완화

| 항목 | 설명 |
|------|------|
| 공격 시나리오 | 파서의 `std::regex` 평가를 악용하여 백트래킹 폭발(catastrophic backtracking) 유도, CPU 고갈 |
| 이전 상태 | `sql_parser.cpp`의 `is_start_transaction_statement`, `extract_tables_for_keyword`, `has_where_keyword`가 `std::regex` 사용 → ReDoS 가능 |
| 현재 상태 (DON-80) | **완화됨** — `sql_parser.cpp` 세 함수가 `std::string::find` + 수동 토큰 파싱으로 교체. 추가로 `injection_detector.cpp`에 fast anchor 사전필터 도입 |
| 위험도 | **완화** |
| 잔존 위협 | `injection_detector.cpp`의 `std::regex`는 유지됨 (패턴 수 제한 + 입력 길이 제한 + fast anchor 사전필터로 완화) |

**fast anchor 사전필터 (DON-80)**:
- `injection_detector.cpp`의 `InjectionDetector::check()`에 `extract_literal_anchor` 기반 사전필터 적용.
- 각 패턴의 가장 긴 리터럴 토큰을 `sql_upper.find()`로 먼저 검색하여 앵커가 없으면 `std::regex_search` 를 건너뜀.
- 정상 쿼리에서 regex 호출 횟수를 90%+ 감소시켜 ReDoS 노출 면적을 축소.
- **alternation 패턴(`|` 포함) 보안 주의**: 앵커 추출 시 하나의 토큰이 모든 대안을 대표할 수 없으므로 `|` 포함 패턴은 앵커를 빈 문자열로 처리하여 regex 를 항상 실행(false negative 방지). 기본 10패턴 중 piggyback 패턴(`;\s*(DROP|...)`)이 해당.

> 참조: `src/parser/sql_parser.cpp` (DON-80), `src/parser/injection_detector.cpp` (`extract_literal_anchor`, `check()`)

### 3.3 UDS 비인가 접근

| 항목 | 설명 |
Expand Down Expand Up @@ -206,6 +224,7 @@
| 2.3 | 변수 간접 참조 | 고 | 미완화 | block_dynamic_sql로 간접 차단 |
| 3.1 | 악성 패킷 | 중 | fail-close 적용 | Fuzz 테스트 확대 |
| 3.2 | DoS | 중 | 부분 완화 (MAX_CONNECTIONS) | 타임아웃·속도 제한 강화 |
| 3.2.1 | ReDoS | 중 | **완화** (DON-80 — sql_parser std::regex 제거 + injection_detector fast anchor 사전필터) | injection_detector 패턴 수 제한 유지 |
| 3.3 | UDS 비인가 접근 | 하 | 파일시스템 권한 보호 | - |
| 3.4 | TLS 공격 | 중 | OpenSSL 기반 TLS 지원 | TLS 1.2+ 강제 |
| 4.1 | Monitor 모드 + 미등록 사용자 우회 | 고 | **해소** (DON-49 수정) | - |
Expand Down
15 changes: 13 additions & 2 deletions scripts/hooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,19 @@ if [[ ${#CPP_FILES[@]} -gt 0 ]]; then
_CF_MAJOR=$(clang-format --version 2>/dev/null | grep -oE '[0-9]+' | head -1)
if [[ -n "$_CF_MAJOR" && "$_CF_MAJOR" -lt 19 ]]; then
echo "[pre-commit] ⚠️ clang-format ${_CF_MAJOR} < 19 (Standard: c++23 미지원) — C++ 포맷 검사 skip (CI가 최종 검증)"
elif ! echo "" | clang-format --stdin-filename=test.cpp >/dev/null 2>&1; then
echo "[pre-commit] ⚠️ clang-format 설정 오류(.clang-format 비호환) — C++ 포맷 검사 skip (CI가 최종 검증)"
else
_CF_FILENAME_ARG=""
if echo "" | clang-format --stdin-filename=test.cpp >/dev/null 2>&1; then
_CF_FILENAME_ARG="--stdin-filename=test.cpp"
elif echo "" | clang-format --assume-filename=test.cpp >/dev/null 2>&1; then
_CF_FILENAME_ARG="--assume-filename=test.cpp"
fi

if [[ -z "$_CF_FILENAME_ARG" ]]; then
echo "[pre-commit] ⚠️ clang-format stdin filename 옵션 비호환 — C++ 포맷 검사 skip (CI가 최종 검증)"
elif ! echo "" | clang-format "$_CF_FILENAME_ARG" >/dev/null 2>&1; then
echo "[pre-commit] ⚠️ clang-format 설정 오류(.clang-format 비호환) — C++ 포맷 검사 skip (CI가 최종 검증)"
else
CPP_ERRORS=()
for f in "${CPP_FILES[@]}"; do
if ! clang-format --dry-run --Werror "$f" 2>/dev/null; then
Expand All @@ -43,6 +53,7 @@ if [[ ${#CPP_FILES[@]} -gt 0 ]]; then
else
echo "[pre-commit] ✅ C++ 포맷 OK"
fi
fi
fi
fi
fi
Expand Down
124 changes: 85 additions & 39 deletions src/logger/structured_logger.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@
#include <chrono>
#include <cstdio>
#include <ctime>
#include <iomanip>
#include <sstream>
#include <string>

namespace {

Expand All @@ -40,10 +39,22 @@ std::string format_iso8601(const std::chrono::system_clock::time_point& tp) {
}
#endif

std::ostringstream oss;
oss << std::put_time(&tm_val, "%Y-%m-%dT%H:%M:%S") << '.' << std::setfill('0') << std::setw(3)
<< millis.count() << 'Z';
return oss.str();
// strftime + snprintf로 타임스탬프 포맷 (ostringstream 대비 ~10x 빠름)
std::array<char, 32> date_buf{};
const auto date_len =
std::strftime(date_buf.data(), date_buf.size(), "%Y-%m-%dT%H:%M:%S", &tm_val);
if (date_len == 0) {
return "1970-01-01T00:00:00.000Z";
}

std::array<char, 48> buf{};
(void)std::snprintf(buf.data(), // NOLINT(cppcoreguidelines-pro-type-vararg)
buf.size(),
"%.*s.%03dZ",
static_cast<int>(date_len),
date_buf.data(),
static_cast<int>(millis.count()));
return {buf.data()};
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -177,15 +188,24 @@ void StructuredLogger::log_connection(const ConnectionLog& entry) {
return;
}

// JSON 구성
std::ostringstream json;
json << R"({"event":")" << escape_json_string(entry.event) << R"(","session_id":)"
<< entry.session_id << R"(,"client_ip":")" << escape_json_string(entry.client_ip)
<< R"(","client_port":)" << entry.client_port << R"(,"db_user":")"
<< escape_json_string(entry.db_user) << R"(","timestamp":")"
<< format_iso8601(entry.timestamp) << R"("})";

logger_->info(json.str());
std::string json;
json.reserve(256);

json += R"({"event":")";
json += escape_json_string(entry.event);
json += R"(","session_id":)";
json += std::to_string(entry.session_id);
json += R"(,"client_ip":")";
json += escape_json_string(entry.client_ip);
json += R"(","client_port":)";
json += std::to_string(entry.client_port);
json += R"(,"db_user":")";
json += escape_json_string(entry.db_user);
json += R"(","timestamp":")";
json += format_iso8601(entry.timestamp);
json += R"("})";

logger_->info(json);
}

// ---------------------------------------------------------------------------
Expand All @@ -196,26 +216,39 @@ void StructuredLogger::log_query(const QueryLog& entry) {
return;
}

// JSON 구성
std::ostringstream json;
json << R"({"event":"query","session_id":)" << entry.session_id << R"(,"db_user":")"
<< escape_json_string(entry.db_user) << R"(","client_ip":")"
<< escape_json_string(entry.client_ip) << R"(","raw_sql":")"
<< escape_json_string(entry.raw_sql) << R"(","command_raw":)"
<< static_cast<int>(entry.command_raw) << R"(,"tables":[)";
std::string json;
json.reserve(256 + entry.raw_sql.size());

json += R"({"event":"query","session_id":)";
json += std::to_string(entry.session_id);
json += R"(,"db_user":")";
json += escape_json_string(entry.db_user);
json += R"(","client_ip":")";
json += escape_json_string(entry.client_ip);
json += R"(","raw_sql":")";
json += escape_json_string(entry.raw_sql);
json += R"(","command_raw":)";
json += std::to_string(static_cast<int>(entry.command_raw));
json += R"(,"tables":[)";

for (size_t i = 0; i < entry.tables.size(); ++i) {
if (i > 0) {
json << ',';
json += ',';
}
json << R"(")" << escape_json_string(entry.tables[i]) << R"(")";
json += '"';
json += escape_json_string(entry.tables[i]);
json += '"';
}

json << R"(],"action_raw":)" << static_cast<int>(entry.action_raw) << R"(,"timestamp":")"
<< format_iso8601(entry.timestamp) << R"(","duration_us":)" << entry.duration.count()
<< R"(})";
json += R"(],"action_raw":)";
json += std::to_string(static_cast<int>(entry.action_raw));
json += R"(,"timestamp":")";
json += format_iso8601(entry.timestamp);
json += R"(","duration_us":)";
json += std::to_string(entry.duration.count());
json += '}';

logger_->info(json.str());
logger_->info(json);
}

// ---------------------------------------------------------------------------
Expand All @@ -226,21 +259,34 @@ void StructuredLogger::log_block(const BlockLog& entry) {
return;
}

// JSON 구성
// would_block==true: dry-run 모드에서 차단됐을 것임을 나타냄 (실제 차단 아님)
const char* event_name = entry.would_block ? "query_would_block" : "query_blocked";
const char* would_block_val = entry.would_block ? "true" : "false";

std::ostringstream json;
json << R"({"event":")" << event_name << R"(","session_id":)" << entry.session_id
<< R"(,"db_user":")" << escape_json_string(entry.db_user) << R"(","client_ip":")"
<< escape_json_string(entry.client_ip) << R"(","raw_sql":")"
<< escape_json_string(entry.raw_sql) << R"(","matched_rule":")"
<< escape_json_string(entry.matched_rule) << R"(","reason":")"
<< escape_json_string(entry.reason) << R"(","would_block":)" << would_block_val
<< R"(,"timestamp":")" << format_iso8601(entry.timestamp) << R"("})";

logger_->warn(json.str());
std::string json;
json.reserve(256 + entry.raw_sql.size());

json += R"({"event":")";
json += event_name;
json += R"(","session_id":)";
json += std::to_string(entry.session_id);
json += R"(,"db_user":")";
json += escape_json_string(entry.db_user);
json += R"(","client_ip":")";
json += escape_json_string(entry.client_ip);
json += R"(","raw_sql":")";
json += escape_json_string(entry.raw_sql);
json += R"(","matched_rule":")";
json += escape_json_string(entry.matched_rule);
json += R"(","reason":")";
json += escape_json_string(entry.reason);
json += R"(","would_block":)";
json += would_block_val;
json += R"(,"timestamp":")";
json += format_iso8601(entry.timestamp);
json += R"("})";

logger_->warn(json);
}

// ---------------------------------------------------------------------------
Expand Down
Loading