fix: Phase 5 진단 핫픽스 + Phase 6 run_sql 안전 가드

진단 보고서(plans/...phase5-사용자체크리스트.md) 기반 7건 코드 이슈
수정 + Phase 6 잔여 항목 중 최우선인 run_sql 가드 구현.

핫픽스:
- nl2sql_worker.py: _list_drawings 파싱 버그(문자열 분리) HIGH
- nl2sql_worker.py: 5개 async 함수 blocking DB 연결 → to_thread MED
- ExperionDbContext.cs: KB DDL의 {} 문자가 String.Format placeholder로
  오인되어 부팅 실패 → 별도 NpgsqlCommand 사용 HIGH
- KbIngestWorker: 단일 청크 임베딩 실패 시 전체 abort → 부분 인덱싱 LOW
- KbAuthService: 초기 비번 로그 평문 → 마스킹 + 콘솔 분리 출력 LOW
- KbQdrantClient: new HttpClient → IHttpClientFactory LOW
- OllamaController: plant_context.md 매 요청 파일 읽기 → mtime 캐시 LOW

Phase 6 — run_sql 가드:
- _validate_sql 강화: \b 단어 경계로 updated_at 오탐 제거, WITH 허용,
  TRUNCATE/COPY 추가, 다중 세미콜론 차단
- _apply_sql_guards: LIMIT 미지정 시 SELECT * FROM (...) _capped LIMIT 1000
- _execute_sql_internal: 매 호출 SET statement_timeout = 30000
- SQL_MAX_ROWS / SQL_STATEMENT_TIMEOUT_MS 환경변수화
- 응답 JSON에 row_limit 필드 추가
- nl2sql_worker.py의 _run_sql / _query_with_nl에도 동일 적용

기타:
- .gitignore: storage/ 추가 (KB 업로드 원본 디렉토리)
- opencode.json: 모델 항목을 실제 서빙 모델(Qwen3.6-27B-FP8 / 256K)로 동기화

검증:
- dotnet build: 경고 0건, 에러 0건
- python3 -m py_compile: OK
- _apply_sql_guards / _validate_sql 스모크 테스트 통과

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
windpacer
2026-05-14 05:18:06 +09:00
parent 908bfe151f
commit 5a9d60e8a8
13 changed files with 473 additions and 136 deletions

View File

@@ -16,6 +16,7 @@ Usage: python nl2sql_worker.py <port>
from __future__ import annotations
import sys
import os
import re
# mcp-server 디렉토리를 Python 경로에 추가
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@@ -51,6 +52,45 @@ def _get_db_connection():
import psycopg
return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT)
async def _aget_db_connection():
"""비동기 환경에서 안전하게 DB 연결 획득 (blocking connect를 to_thread로 격리)."""
import asyncio
return await asyncio.to_thread(_get_db_connection)
# ── SQL 가드 ─────────────────────────────────────────────────────────────────
SQL_MAX_ROWS = int(os.environ.get("SQL_MAX_ROWS", "1000"))
SQL_STATEMENT_TIMEOUT_MS = int(os.environ.get("SQL_STATEMENT_TIMEOUT_MS", "30000"))
_RE_LIMIT_TAIL = re.compile(r"\bLIMIT\b\s+\d+(\s+OFFSET\s+\d+)?\s*$", re.IGNORECASE)
_DANGEROUS_KW = ('EXEC', 'DROP', 'DELETE', 'UPDATE', 'INSERT', 'ALTER', 'CREATE', 'GRANT', 'REVOKE', 'TRUNCATE', 'COPY')
def _validate_sql(sql: str) -> tuple[bool, str]:
"""SELECT/WITH만 허용, 위험 키워드/다중 문장 차단."""
if not sql or len(sql) > 2000:
return False, "쿼리가 비어있거나 2000자를 초과했습니다."
upper = sql.upper()
for kw in _DANGEROUS_KW:
if re.search(rf"\b{kw}\b", upper):
return False, f"허용되지 않은 키워드 '{kw}'"
head = upper.lstrip().lstrip('(').lstrip()
if not (head.startswith('SELECT') or head.startswith('WITH')):
return False, "SELECT 또는 WITH 쿼리만 허용됩니다."
if ';' in sql.rstrip().rstrip(';'):
return False, "다중 문장(세미콜론)은 허용되지 않습니다."
return True, ""
def _apply_sql_guards(sql: str, max_rows: int = SQL_MAX_ROWS) -> str:
s = sql.strip().rstrip(';').strip()
if _RE_LIMIT_TAIL.search(s):
return s
return f"SELECT * FROM ({s}) _capped LIMIT {max_rows}"
# ── LLM 클라이언트 ───────────────────────────────────────────────────────────
@lru_cache(maxsize=1)
@@ -206,11 +246,17 @@ async def execute(request: Request):
return {"success": False, "error": str(e)}
async def _run_sql(sql: str) -> str:
"""SQL 실행."""
conn = _get_db_connection()
"""SQL 실행 (가드: SELECT/WITH만, auto-LIMIT, statement_timeout)."""
valid, err = _validate_sql(sql)
if not valid:
return {"success": False, "error": f"SQL 검증 실패: {err}"}
capped_sql = _apply_sql_guards(sql)
conn = await _aget_db_connection()
try:
with conn.cursor() as cur:
cur.execute(sql)
cur.execute(f"SET statement_timeout = {SQL_STATEMENT_TIMEOUT_MS}")
cur.execute(capped_sql)
if cur.description:
columns = [desc[0] for desc in cur.description]
rows = cur.fetchall()
@@ -219,6 +265,7 @@ async def _run_sql(sql: str) -> str:
"success": True,
"columns": columns,
"count": len(data),
"row_limit": SQL_MAX_ROWS,
"data": data,
}
else:
@@ -227,6 +274,8 @@ async def _run_sql(sql: str) -> str:
"success": True,
"message": f"Query executed successfully. {cur.rowcount} rows affected.",
}
except Exception as e:
return {"success": False, "error": f"SQL 실행 실패: {e}"}
finally:
conn.close()
@@ -235,7 +284,7 @@ async def _query_pv_history(tag_names: list[str], time_from: str, time_to: str,
if not tag_names:
return {"success": False, "error": "tag_names is required"}
conn = _get_db_connection()
conn = await _aget_db_connection()
try:
with conn.cursor() as cur:
cur.execute(
@@ -266,7 +315,7 @@ async def _query_pv_history(tag_names: list[str], time_from: str, time_to: str,
async def _get_tag_metadata(query: str, limit: int = 10) -> str:
"""태그 메타데이터 검색."""
conn = _get_db_connection()
conn = await _aget_db_connection()
try:
with conn.cursor() as cur:
cur.execute(
@@ -301,7 +350,7 @@ async def _get_tag_metadata(query: str, limit: int = 10) -> str:
async def _list_drawings(unit_no: str = None) -> str:
"""단위별 도면 목록 조회."""
conn = _get_db_connection()
conn = await _aget_db_connection()
try:
with conn.cursor() as cur:
if unit_no:
@@ -322,14 +371,13 @@ async def _list_drawings(unit_no: str = None) -> str:
ORDER BY name
"""
)
columns = ["name"]
rows = cur.fetchall()
data = [dict(zip(columns, row[0])) for row in rows]
names = [row[0] for row in rows]
return {
"success": True,
"unit_no": unit_no,
"count": len(data),
"names": [d["name"] for d in data],
"count": len(names),
"names": names,
}
finally:
conn.close()
@@ -343,10 +391,17 @@ async def _query_with_nl(question: str) -> str:
if not sql:
return json.dumps({"success": False, "sql": "", "error": "LLM이 SQL을 생성하지 못했습니다."}, ensure_ascii=False)
conn = _get_db_connection()
# LLM 생성 SQL도 동일 가드 적용
valid, err = _validate_sql(sql)
if not valid:
return {"success": False, "sql": sql, "error": f"SQL 검증 실패: {err}"}
capped_sql = _apply_sql_guards(sql)
conn = await _aget_db_connection()
try:
with conn.cursor() as cur:
cur.execute(sql)
cur.execute(f"SET statement_timeout = {SQL_STATEMENT_TIMEOUT_MS}")
cur.execute(capped_sql)
if cur.description:
columns = [desc[0] for desc in cur.description]
rows = cur.fetchall()
@@ -356,6 +411,7 @@ async def _query_with_nl(question: str) -> str:
"sql": sql,
"columns": columns,
"count": len(data),
"row_limit": SQL_MAX_ROWS,
"data": data,
}
else: