OpenAI Codex 보안 취약점 탐지 5단계: SQL 인젝션·XSS 자동 스캔 실전 가이드

OpenAI Codex 보안 취약점 탐지의 필요성에 대해 살펴봅니다.

목차

결론부터 말하면, OpenAI Codex 보안 취약점 탐지는 레거시 코드에서 꽤 쓸 만하다. 완벽하진 않은데 “사람이 놓치는 것”을 잡아주는 2차 방어선 역할은 확실히 한다. 전 직장에서 10년 된 PHP 코드베이스를 넘겨받았을 때, SonarQube 돌리고 나서도 찝찝해서 Codex에 던져봤더니 SonarQube가 못 잡은 2차 SQL 인젝션 포인트를 3개나 찾아냈다. 그때부터 보안 리뷰에 Codex를 끼워 넣기 시작했다.

지금 있는 곳은 20명짜리 스타트업인데, 전담 보안 엔지니어가 없다. 이전 회사에서는 보안팀이 분기마다 코드 리뷰를 했지만 여기선 그런 리소스가 없으니까 자동화에 기댈 수밖에 없다. 그래서 Codex 기반 보안 스캔 파이프라인을 직접 만들어서 쓰고 있고, 주변에서 “어떻게 하냐”는 질문을 자주 받는다. 그 질문들을 모아서 정리한다.

왜 기존 SAST 도구만으로는 부족한가

“SonarQube 쓰면 되지 않냐”는 질문을 제일 많이 받는다. 맞다, SonarQube나 Semgrep 같은 SAST(Static Application Security Testing) 도구가 기본이다. 근데 레거시 코드에서는 한계가 뚜렷하다.

패턴 매칭의 한계

SAST 도구 대부분은 규칙 기반이다. mysql_query($_GET['id']) 같은 직접적인 패턴은 잘 잡는데, 변수가 여러 함수를 거쳐서 들어오는 경우는 놓친다. 예를 들어 이런 코드:

function getFilter($request) {
    $raw = $request->input('category');
    $cleaned = trim($raw);  // trim만 했지 이스케이프는 안 함
    return $cleaned;
}

function buildQuery($filter) {
    return "SELECT * FROM products WHERE category = '" . $filter . "'";
}

// 컨트롤러 어딘가에서
$filter = getFilter($request);
$result = DB::select(buildQuery($filter));

SonarQube는 buildQuery 함수만 보면 하드코딩된 문자열 결합으로 잡을 수도 있다. 근데 getFilter에서 들어온 값이 사용자 입력인지 아닌지는 호출 체인을 따라가야 알 수 있고, 레거시 코드는 이 체인이 파일 3~4개를 넘나든다. 여기서 Codex가 빛을 발한다. 컨텍스트 윈도우 안에 관련 코드를 다 넣어주면 흐름을 따라가면서 “이 입력값이 sanitize 없이 쿼리에 들어간다”고 짚어준다.

SAST vs Codex 비교

항목SAST (SonarQube/Semgrep)OpenAI Codex
탐지 방식규칙 기반 패턴 매칭코드 의미 이해 기반
2차 인젝션 탐지약함컨텍스트 주면 잘 잡음
오탐률중간프롬프트에 따라 다름
커스텀 규칙규칙 파일 작성 필요프롬프트로 바로 조정
비용라이선스 비용API 호출 비용
실행 속도빠름 (수초~수분)느림 (파일당 수초~수십초)

둘 다 쓰는 게 답이다. SAST로 1차 스캔하고 Codex로 2차 스캔. 이건 대체 관계가 아니라 보완 관계다.

OpenAI Codex 보안 취약점 탐지 — 프롬프트 설계가 전부다

솔직히 Codex한테 “이 코드 보안 취약점 찾아줘”라고 던지면 쓸모없는 답이 온다. 너무 일반적인 내용을 나열하거나, 있지도 않은 취약점을 만들어내거나. 프롬프트를 어떻게 짜느냐가 결과의 90%를 결정한다.

SQL 인젝션 탐지 프롬프트

내가 실제로 쓰는 시스템 프롬프트 구조다:

You are a security auditor specialized in SQL injection detection.

Analyze the following code and identify SQL injection vulnerabilities.

Rules:
1. Only report vulnerabilities where user-controlled input reaches a SQL query without proper parameterization or escaping.
2. Trace the data flow from input source to SQL execution.
3. Do NOT report false positives — if the input is properly sanitized (prepared statements, parameterized queries, allowlist validation), skip it.
4. For each finding, provide:
   - File and line number (approximate)
   - The tainted input source
   - The SQL sink where it's used
   - Severity: Critical / High / Medium
   - Suggested fix (one-liner)

Output as JSON array. If no vulnerabilities found, return empty array [].

핵심은 **”오탐을 보고하지 마라”**는 지시다. 이거 안 넣으면 prepared statement 쓴 코드까지 전부 “잠재적 위험”이라고 보고한다. 그러면 결과가 수십 개 나오는데 진짜 문제는 2~3개뿐이고 나머지는 노이즈다. 초기에 이걸 몰라서 결과 필터링하느라 시간을 엄청 날렸다.

XSS 탐지 프롬프트

XSS용은 조금 다르게 짠다:

You are a security auditor specialized in Cross-Site Scripting (XSS) detection.

Analyze the following code for XSS vulnerabilities.

Focus on:
1. Reflected XSS: user input directly rendered in HTML without encoding
2. Stored XSS: database values rendered without encoding
3. DOM-based XSS: client-side JavaScript that writes user input to DOM

Ignore:
- Values that pass through htmlspecialchars(), DOMPurify, or equivalent encoding
- Values used only in JSON responses with proper Content-Type headers
- Admin-only pages (if clearly marked)

For each finding, output JSON with: location, xss_type, input_source, output_sink, severity, fix.

“Admin-only 페이지는 무시해라”를 넣은 이유가 있다. 관리자 페이지에서 HTML을 직접 입력하는 건 의도된 동작인 경우가 많거든. 이걸 안 빼면 CMS의 에디터 관련 코드가 전부 XSS로 잡힌다.

실전 스캔 스크립트 만들기

말로만 하면 감이 안 오니까 실제 코드를 보자. Python으로 만든 스캐너 스크립트다.

import openai
import json
import sys
from pathlib import Path

client = openai.OpenAI()  # OPENAI_API_KEY 환경변수 필요

SQLI_PROMPT = """You are a security auditor. Analyze this code for SQL injection vulnerabilities only.
Report ONLY confirmed vulnerabilities where user input reaches SQL without parameterization.
Output JSON array: [{"line": int, "source": str, "sink": str, "severity": str, "fix": str}]
If none found, return []."""

def scan_file(file_path: str, prompt: str) -> list:
    code = Path(file_path).read_text(encoding="utf-8", errors="ignore")
    
    # 토큰 제한 때문에 너무 긴 파일은 분할
    if len(code) > 15000:
        chunks = [code[i:i+15000] for i in range(0, len(code), 12000)]  # 오버랩 3000자
    else:
        chunks = [code]
    
    findings = []
    for i, chunk in enumerate(chunks):
        response = client.chat.completions.create(
            model="o3-mini",  # 비용 대비 성능이 좋다
            messages=[
                {"role": "system", "content": prompt},
                {"role": "user", "content": f"File: {file_path} (chunk {i+1}/{len(chunks)})\n\n{chunk}"}
            ],
            temperature=0.2,  # 보안 분석은 낮은 temperature가 좋다
            response_format={"type": "json_object"}
        )
        
        try:
            result = json.loads(response.choices[0].message.content)
            if isinstance(result, list):
                findings.extend(result)
            elif "vulnerabilities" in result:
                findings.extend(result["vulnerabilities"])
        except json.JSONDecodeError:
            print(f"[WARN] JSON 파싱 실패: {file_path} chunk {i+1}")
    
    return findings

if __name__ == "__main__":
    target = sys.argv[1] if len(sys.argv) > 1 else "."
    target_path = Path(target)
    
    # PHP, JS, Python 파일만 스캔
    extensions = {".php", ".js", ".py", ".jsx", ".ts", ".tsx"}
    files = [f for f in target_path.rglob("*") if f.suffix in extensions]
    
    print(f"스캔 대상: {len(files)}개 파일")
    all_findings = []
    
    for f in files:
        result = scan_file(str(f), SQLI_PROMPT)
        if result:
            all_findings.extend([{**r, "file": str(f)} for r in result])
            print(f"  [!] {f}: {len(result)}건 발견")
        else:
            print(f"  [OK] {f}")
    
    # 결과 저장
    with open("security_scan_result.json", "w") as out:
        json.dump(all_findings, out, indent=2, ensure_ascii=False)
    
    print(f"\n총 {len(all_findings)}건 발견. security_scan_result.json에 저장됨.")

몇 가지 포인트. temperature를 0.2로 낮췄다. 보안 분석에서 창의적인 답변은 필요 없고, 일관된 결과가 중요하다. 그리고 o3-mini를 쓴 이유는 비용이다. gpt-4o가 정확도는 좀 더 높은데, 파일 수백 개를 스캔하면 비용이 꽤 나온다. 내 경험상 SQL 인젝션 같은 명확한 패턴은 o3-mini로도 충분했다.

파일이 길면 청크로 나누는데, 오버랩을 3000자 줘야 한다. 안 그러면 함수 정의가 잘려서 “이 변수가 어디서 왔는지 모르겠다”는 결과가 나온다. 처음에 오버랩 없이 했다가 오탐이 엄청 늘어나서 한참 헤맸다.

오탐 줄이기 — 진짜 삽질한 부분

Codex 보안 스캔에서 제일 귀찮은 게 오탐(false positive)이다. “이거 취약점 아닌데?”를 확인하는 데 시간이 더 걸리면 도구 의미가 없다.

컨텍스트를 많이 줄수록 정확도가 올라간다

당연한 소리 같지만 실제로 해보면 차이가 크다. 파일 하나만 던지면 오탐이 30~40% 나오는데, 관련 파일(라우터, 미들웨어, 유틸 함수)을 같이 넣으면 10% 이하로 떨어진다.

내가 쓰는 방법은 이렇다:

def gather_context(target_file: str, depth: int = 1) -> str:
    """타겟 파일에서 import/require하는 파일을 재귀적으로 수집"""
    import re
    
    code = Path(target_file).read_text(encoding="utf-8", errors="ignore")
    context_files = [f"// === {target_file} ===\n{code}"]
    
    # PHP require/include 추출
    php_imports = re.findall(r"(?:require|include)(?:_once)?\s*['\"](.+?)['\"]", code)
    # JS/TS import 추출
    js_imports = re.findall(r"(?:import|require)\s*\(?['\"](.+?)['\"]", code)
    
    related = php_imports + js_imports
    
    for imp in related:
        imp_path = Path(target_file).parent / imp
        if imp_path.exists() and depth > 0:
            sub_code = imp_path.read_text(encoding="utf-8", errors="ignore")
            context_files.append(f"// === {imp_path} ===\n{sub_code}")
    
    return "\n\n".join(context_files)

이렇게 import 체인을 따라가서 관련 파일을 합쳐서 넘기면 “이 함수에서 이미 htmlspecialchars 처리를 했네요”를 모델이 직접 확인하고 넘어간다.

화이트리스트로 노이즈 제거

프레임워크가 자동으로 처리하는 부분까지 Codex가 보고하는 경우가 있다. Laravel의 Eloquent ORM은 기본적으로 prepared statement를 쓰는데, 가끔 DB::raw()와 섞여 있으면 Eloquent 쪽까지 잡아버린다.

프롬프트에 이런 걸 추가한다:

Framework-specific safe patterns (do NOT flag these):
- Laravel Eloquent: Model::where(), Model::find() — uses prepared statements internally
- Express.js with Sequelize: Model.findAll({ where: {} }) — parameterized
- Django ORM: Model.objects.filter() — parameterized

사실 이건 프로젝트마다 다르니까, 자기 스택에 맞게 커스텀해야 한다. 우리 프로젝트는 Laravel + Vue라서 위 리스트를 좀 더 세부적으로 잡아놨다.

Node.js 레거시에서 XSS 잡아낸 실제 사례

지난달에 있었던 일이다. 2019년에 만들어진 Express + EJS 템플릿 프로젝트를 인수받았는데, 회원가입 페이지에서 이상한 동작이 있다는 CS가 들어왔다. 직접 보니까 이름 필드에