OpenAI Codex 보안 취약점 탐지의 필요성에 대해 살펴봅니다.
목차
- 왜 기존 SAST 도구만으로는 부족한가
- OpenAI Codex 보안 취약점 탐지 — 프롬프트 설계가 전부다
- 실전 스캔 스크립트 만들기
- 오탐 줄이기 — 진짜 삽질한 부분
- Node.js 레거시에서 XSS 잡아낸 실제 사례
- CI/CD에 Codex 보안 스캔 붙이기
- 비용과 속도 — 현실적인 이야기
- Codex가 잘 못 잡는 것들
- 프롬프트 고도화 팁 — 질문 많이 받는 것들
- 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가 들어왔다. 직접 보니까 이름 필드에 태그 넣으면 프로필 페이지에서 그대로 실행됐다. Stored XSS였다.
급하게 해당 부분을 패치하고 나서, "다른 곳에도 있겠다" 싶어서 Codex를 돌렸다. 프로젝트가 Express 4.18 + EJS 3.1 기반이었는데 EJS에서 <%- (unescaped output)을 쓰는 곳이 14군데나 있었다. Codex한테 전체 템플릿을 넘기고 스캔했더니 그중 9개가 사용자 입력값을 unescaped로 렌더링하고 있었다.
// 취약한 코드 — EJS 템플릿
// <%- user.bio %> ← unescaped, XSS 가능
// 수정 후
// <%= user.bio %> ← escaped output
// 또는 DOMPurify로 서버사이드에서 sanitize
const createDOMPurify = require("dompurify");
const { JSDOM } = require("jsdom");
const window = new JSDOM("").window;
const DOMPurify = createDOMPurify(window);
app.locals.sanitize = (dirty) => DOMPurify.sanitize(dirty);
9개 중 3개는 관리자가 HTML을 의도적으로 입력하는 곳이었고, 나머지 6개가 진짜 취약점이었다. Codex는 9개를 전부 잡았고, 나한테 "관리자 전용인지 확인하라"는 코멘트를 같이 달아줬다. 사람이 최종 판단해야 하는 건 맞지만, 14개 파일을 일일이 열어보는 것보다는 훨� 빠르다.
아 그리고 이건 주의해야 하는데, EJS의 <%-가 전부 취약한 건 아니다. 정적 HTML 파편을 include할 때도 <%-를 쓰거든. Codex가 이 구분을 100% 완벽하게 하진 못한다. 결국 사람이 한번 봐야 한다.
CI/CD에 Codex 보안 스캔 붙이기
로컬에서 스크립트 돌리는 건 1인 프로젝트면 되는데, 팀이면 CI에 박아야 한다. GitHub Actions 기준으로 내가 쓰는 워크플로우다.
name: Codex Security Scan
on:
pull_request:
paths:
- '**.php'
- '**.js'
- '**.ts'
- '**.py'
jobs:
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get changed files
id: changed
run: |
FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E '\.(php|js|ts|py)$' || true)
echo "files<> $GITHUB_OUTPUT
echo "$FILES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: pip install openai
- name: Run Codex security scan
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
python scripts/codex_security_scan.py --files "${{ steps.changed.outputs.files }}" --output scan_result.json
- name: Comment PR with findings
if: always()
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const results = JSON.parse(fs.readFileSync('scan_result.json', 'utf8'));
if (results.length === 0) return;
let body = '## 🔒 Codex Security Scan Results\n\n';
body += `Found **${results.length}** potential vulnerabilities:\n\n`;
for (const r of results) {
body += `- **${r.severity}** in \`${r.file}\` line ${r.line}: ${r.source} → ${r.sink}\n`;
body += ` Fix: ${r.fix}\n\n`;
}
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body
});
변경된 파일만 스캔하는 게 포인트다. 전체 코드베이스를 매번 돌리면 시간도 비용도 감당이 안 된다. PR에서 수정된 .php, .js, .ts, .py 파일만 골라서 넘긴다.
Critical이 하나라도 나오면 PR 머지를 막는 것도 가능한데, 개인적으로 이건 좀 조심해야 한다고 생각한다. 오탐 때문에 배포가 막히면 팀에서 불만이 쌓이고 결국 스캔을 끄게 된다. 우리 팀은 Comment만 달고 머지는 막지 않는 방식으로 운영 중이다. Critical은 리뷰어가 반드시 확인하도록 팀 규칙만 잡았다.
비용과 속도 — 현실적인 이야기
"Codex로 보안 스캔 하면 비용이 얼마나 드냐"는 질문도 많이 받는다. 바로 이거다. 돈 얘기를 안 하면 의미가 없다.
모델별 비용 체감
우리 프로젝트 기준(Laravel + Vue, PHP 파일 약 400개, JS/TS 파일 약 200개)으로 전체 스캔을 돌리면:
o3-mini: 체감상 $3~5 정도. 속도는 전체 스캔에 15~20분.gpt-4o: 체감상 $15~25. 속도는 비슷하거나 약간 느림.gpt-4.1-mini: 아직 제한적으로 테스트했는데o3-mini와 비슷한 가격대.
PR 단위로 변경 파일만 스캔하면 평균 3~5개 파일이라 $0.01~0.05 수준이다. 이 정도면 월 $5도 안 나온다. 변경 파일만 스캔하는 게 비용 면에서도 정답이다.
속도 병목과 해결
파일을 하나씩 순차적으로 보내면 느리다. asyncio로 병렬 처리하면 체감 속도가 확 줄어든다:
import asyncio
from openai import AsyncOpenAI
aclient = AsyncOpenAI()
async def scan_file_async(file_path: str, prompt: str) -> list:
code = Path(file_path).read_text(encoding="utf-8", errors="ignore")
response = await aclient.chat.completions.create(
model="o3-mini",
messages=[
{"role": "system", "content": prompt},
{"role": "user", "content": f"File: {file_path}\n\n{code}"}
],
temperature=0.2,
response_format={"type": "json_object"}
)
try:
result = json.loads(response.choices[0].message.content)
return result if isinstance(result, list) else result.get("vulnerabilities", [])
except json.JSONDecodeError:
return []
async def scan_all(files: list, prompt: str):
# 동시 요청 10개로 제한 — rate limit 방지
semaphore = asyncio.Semaphore(10)
async def bounded_scan(f):
async with semaphore:
findings = await scan_file_async(f, prompt)
return [(f, finding) for finding in findings]
tasks = [bounded_scan(f) for f in files]
results = await asyncio.gather(*tasks)
return [item for sublist in results for item in sublist]
Semaphore(10)으로 동시 요청을 제한하는 게 중요하다. OpenAI API rate limit에 걸리면 429 에러가 연쇄로 터지면서 전체가 실패한다. 처음에 제한 없이 50개를 한꺼번에 쏴봤다가 전부 실패해서 새벽 2시에 멘붕 왔었다. 10개씩 보내면 rate limit에 안 걸리면서도 순차 대비 체감 5~7배 빨라진다.
Codex가 잘 못 잡는 것들
만능이 아니다. 솔직하게 한계를 말하자면.
인증/인가 로직의 취약점은 잘 못 잡는다. "이 API에 관리자 권한 체크가 빠져 있다"는 비즈니스 로직을 이해해야 하는데, Codex는 코드의 구문 수준에서 분석하지 비즈니스 컨텍스트를 모른다. IDOR(Insecure Direct Object Reference)도 마찬가지다. user_id를 파라미터로 받아서 다른 사용자 데이터를 조회하는 건 코드만 봐서는 의도된 건지 버그인지 구분이 안 된다.
암호화 관련 취약점은 반반이다. 약한 해시 알고리즘(md5, sha1) 사용은 잘 잡는데, 키 관리나 IV 재사용 같은 건 놓칠 때가 있다.
Race condition이나 TOCTOU 같은 타이밍 관련 취약점은 거의 못 잡는다. 이건 코드 실행 흐름을 동적으로 분석해야 하는 영역이라 정적 분석의 근본적 한계다. SAST 도구도 마찬가지고.
정리하면 Codex가 잘 잡는 건 인젝션 계열(SQL 인젝션, XSS, Command Injection, Path Traversal)이고, 못 잡는 건 로직 계열(인증 우회, 권한 상승, 비즈니스 로직 결함)이다. 이걸 인지하고 쓰는 게 중요하다.
프롬프트 고도화 팁 — 질문 많이 받는 것들
"한 번에 여러 취약점 유형을 스캔해도 되나?"
된다. 근데 추천하지 않는다. SQL 인젝션이랑 XSS를 한 프롬프트에 넣으면 모델이 하나에 집중을 못하고 둘 다 얕게 본다. 내 경험상 취약점 유형별로 프롬프트를 분리하고 각각 돌리는 게 정확도가 높다. 비용은 2배 들지만 오탐을 걸러내는 시간을 생각하면 이득이다.
"Codex CLI에서 바로 쓸 수 있나?"
OpenAI Codex CLI를 쓰면 터미널에서 바로 보안 스캔을 돌릴 수 있다. 설치 후 이런 식으로:
# Codex CLI 설치
npm install -g @openai/codex
# 특정 파일 보안 리뷰
codex "Review this file for SQL injection vulnerabilities" --file src/controllers/UserController.php
# 디렉토리 단위 스캔은 스크립트로 감싸는 게 낫다
다만 CLI는 대화형이라 대량 스캔에는 API 직접 호출이 효율적이다. 파일 1~2개 빠르게 확인할 때는 CLI가 편하고, CI 연동이나 전체 스캔은 API 스크립트가 맞다.
"결과를 어떻게 검증하나?"
Codex가 "이게 취약점이다"라고 하면 반드시 직접 확인해야 한다. 내가 쓰는 검증 프로세스:
- Codex 결과에서 Critical/High만 먼저 본다
- 해당 코드 라인을 열어서 데이터 흐름을 직접 추적한다
- 가능하면 PoC(Proof of Concept)를 만들어서 실제로 공격이 되는지 확인한다
- 확인된 취약점만 이슈로 등록한다
Medium 이하는 모아뒀다가 시간 날 때 본다. 전부 다 검증하려면 시간이 끝없이 든다. 우선순위를 잡는 게 현실적이다.
OpenAI Codex 보안 취약점 탐지, 개인적인 생각
6개월 정도 써본 감상이다. Codex 기반 보안 스캔이 전통 SAST를 대체하진 못한다. Semgrep이나 SonarQube가 여전히 1차 방어선이다. 근데 2차 방어선으로서 Codex의 가치는 확실하다. 특히 레거시 코드처럼 규칙이 안 통하는 곳에서, 코드의 "의미"를 이해하고 흐름을 따라가는 능력은 패턴 매칭으로 안 되는 영역이다.
개인적으로는 프롬프트 엔지니어링에 시간을 투자하는 게 맞다고 생각한다. 같은 Codex라도 프롬프트 하나 바꾸면 오탐이 반으로 줄거나 늘어난다. 처음에 시간 좀 들여서 자기 프로젝트에 맞는 프롬프트 세트를 만들어놓으면 그 뒤부터는 거의 자동이다.
보안 스캔 파이프라인이 어느 정도 잡혔으면, 다음 단계로 Codex를 활용한 자동 패치 생성을 시도해볼 만하다. 취약점을 찾는 것에서 한 발 더 나아가서 수정 코드까지 PR로 만들어주는 흐름인데, OpenAI 공식 API 문서를 보면 function calling과 조합해서 구현할 수 있다. 그리고 OWASP Top 10 기반으로 프롬프트 템플릿을 체계적으로 관리하는 것도 고민해볼 만하다. 취약점 유형별로 프롬프트를 버전 관리하면 팀 전체가 일관된 기준으로 스캔할 수 있고, 새로운 유형이 추가될 때도 프롬프트만 push하면 되니까 운영이 편하다.