← 아티클 목록

관련 프로젝트CJ AI Security Wave 최종 프로젝트(AI를 활용한 침투테스트) →

시리즈파이썬으로 만드는 보안 자동화8편 · 공개 8편

파이썬 보안 자동화

탐지 출력 넷을 잇는 데 든 것은 새 알고리즘이 아니라 어댑터 네 개였다

규칙·세션 ML·시크릿 탐지기의 실행 결과와 저장해 둔 웹 퍼저 출력에 어댑터를 붙여, 공통 스키마·등급 게이트·HTML 이스케이프·실패 종료 코드를 점검한 기록.

목차 4

2026-08-09 정정 — 통합 실행에서 직접 실행한 탐지기와 저장된 퍼저 결과를 구분했다. 전체 124건의 게이트 결과와 Finding 한 건을 사용한 임계값 시험도 서로 다른 검증으로 바로잡았다.

2026-09-25 정정 — 실습용 차단 등급과 확인된 취약점을 구분하고, 종료 코드 1 이후에는 탐지 근거를 검토해야 한다는 점을 보완했다. 기존 집계와 종료 코드 기록은 그대로 두었다.

탐지기를 여러 개 만들었으면 잇는 건 쉬운 일일까

앞선 실습에는 출력 형식이 다른 네 탐지 경로가 있었다. 로그의 시그니처를 잡는 규칙 탐지, IP 행동을 세션으로 보는 ML, 저장소의 시크릿을 찾는 스캐너, 웹 입력점을 두드리는 퍼저다. 이번 통합에서는 앞의 세 탐지기를 로컬에서 실행했고, 웹 퍼저는 대상 환경을 다시 띄우지 않고 앞선 실습에서 저장한 Finding 2건을 입력했다. 따라서 확인한 범위는 세 탐지기의 실행과 네 출력 형식의 어댑터·후속 단계 연결이다.

새 탐지 알고리즘이 필요할 것 같지 않았다. 이미 다 만들었으니 순서대로 부르기만 하면 되는, 금방 끝날 조립이라고 봤다.

막상 이어 보니 걸린 것은 탐지 로직이 아니라 그 바깥이었다. 네 종류의 출력이 서로 달라, 통합이 한 일의 대부분은 그 넷을 한 스키마로 번역하는 어댑터를 쓰는 것이었다. 그리고 그렇게 이어 놓고 나서야 보이는 문제가 셋 더 있었다 — 등급 하나가 배포를 정하고, 탐지 결과를 기록한 리포트가 스스로 취약점이 되고, 리포트 생성이 실패해도 배포를 막아야 했다.

파이프라인이 잇는 것

전체 흐름은 이렇다. 탐지 결과를 공통 형식으로 모아 등급을 매기고, 리포트를 그리고, 마지막에 종료 코드 하나로 배포를 막거나 연다.

로그 → 규칙 탐지 ┐
로그 → 세션 ML   ├→ 공통 Finding → 등급 분류 → HTML 리포트 → 종료 코드
저장소 → 시크릿 ┘         (저장된 웹 퍼저 출력도 같은 자리로)

여기서 ’공통 Finding’이 핵심이다. 탐지기마다 다른 딕셔너리를 돌려주면 등급 분류와 리포트와 게이트가 저마다 네 가지 모양을 다 알아야 한다. 등급 분류 전 최소 공통 필드는 detector, category, ip, path, evidence로 정했다. classify가 이 공통 입력을 판정한 뒤 severity를 추가한다.

대상은 로컬 로그 파일과 시크릿 스캔용 임시 저장소뿐이다. 바깥으로 나가는 요청은 없다.

1. 네 종류의 탐지 출력은 서로 다른 모양이다

규칙·세션 ML·시크릿은 직접 실행 결과를 받았고, 웹 퍼저는 저장해 둔 결과를 같은 입력 자리로 넣었다. 네 출력의 원래 모양은 이렇게 달랐다.

규칙 탐지 원래 출력 : 줄 단위 매치 (lineno, ip, type)
세션 ML  원래 출력 : IP 색인 DataFrame + 이상 점수
시크릿   원래 출력 : {rule, path, line, sha256}
웹 퍼저  원래 출력 : {param, payload, status, delta_len, elapsed, reflected}

하나는 로그 줄 번호로, 하나는 IP를 색인으로 한 표로, 하나는 파일 경로와 해시로, 하나는 요청 신호 뭉치로 결과를 낸다. 공통점이 거의 없다. 이 넷을 그대로 리포트에 넘기면 리포트 코드가 네 가지 형식을 전부 분기해야 하고, 게이트도 마찬가지가 된다.

그래서 각 출력 형식마다 어댑터를 하나씩 두고 공통 스키마로 번역했다.

Python
def from_secret(findings):
    return [{"detector": "secret", "category": "secret",
             "ip": "", "path": f"{f['path']}:{f['line']}",
             "evidence": f["rule"]} for f in findings]

네 종류의 입력을 번역하고 합친 결과다. 아래 fuzz 2는 이번에 퍼저가 새로 보낸 요청의 결과가 아니라 저장 출력 2건이다.

공통 스키마로 번역 후: rule 116 / ml 5 / secret 1 / fuzz 2 = 총 124건

통합이 실제로 더한 것은 이 어댑터 네 개다. 새 탐지 로직은 쓰지 않았다. 캡스톤의 몸통이 ’무엇을 더 탐지할까’가 아니라 ’이미 탐지한 결과의 입출력을 어떻게 맞출까’였다는 뜻이다. 직접 실행한 세 탐지기의 내부는 손대지 않았고, 퍼저 쪽도 저장된 출력 형식 앞에 번역기만 붙였다.

이 어댑터 계층이 있으면 탐지기를 바꿔 끼우기도 쉬워진다. 규칙 탐지기의 내부가 바뀌어도 from_rule 어댑터의 출력만 같으면 뒤 단계는 아무것도 모른다. 반대로 어댑터 없이 각 탐지기 출력을 리포트가 직접 알면, 탐지기 하나를 고칠 때마다 리포트와 게이트가 함께 흔들린다.

2. 등급 하나가 배포 여부를 정한다

124건을 등급으로 분류하고 집계했다. 등급 규칙은 「SQLi이며 3초 이상 지연 → Critical」, 「시그니처 매치 → High」처럼 탐지 신호를 실습용 차단 우선순위로 바꾼다. 여기서 Critical·High는 게이트 정책의 등급이며, 취약점이 확인됐다는 판정은 아니다. 앞선 규칙 탐지의 116건에도 오탐 16건이 있었고, 퍼저의 반사·응답 길이·지연도 추가 확인이 필요한 신호다.

등급 집계: {'Critical': 1, 'High': 118, 'Medium': 5}
차단 등급(Critical/High) 존재 → 종료 코드 1

파이프라인 전체가 마지막에 종료 코드 하나로 수렴한다. 차단 등급(Critical·High)이 하나라도 있으면 1(배포 차단), 없으면 0(허용). 124건의 Finding이 결국 이 한 비트로 눌린다. GitHub Actions도 공식 문서에서 종료 코드 0은 성공, 0이 아닌 값은 실패로 처리한다고 설명한다.

전체 124건 실행은 차단 등급이 여러 건이라 종료 코드가 1이다. 임계값 한 항목의 영향만 보려고, 그와 별도로 길이가 59바이트 달라진 SQLi Finding 한 건만 classify와 gate에 넣어 단위 시험을 했다.

  Δlen=59, 길이 임계값  50 → 등급 High  → 종료 코드 1
  Δlen=59, 길이 임계값 100 → 등급 Low   → 종료 코드 0

한 건만 넣은 시험에서는 같은 발견이 임계값에 따라 차단되기도 통과하기도 한다. 그러나 이 결과를 전체 124건 실행에 대입하면 안 된다. 전체 입력에는 다른 High Finding이 남아 있으므로 이 한 건을 Low로 내려도 종료 코드는 계속 1이다. 이 임계값(50바이트, 3초)은 앞선 글에서 ’대상마다 재서 정해야 한다’고 했던 값이고, 등급 규칙은 리포트 장식이 아니라 게이트의 입력이다.

3. 탐지 결과를 기록한 리포트가 스스로 취약점이 된다

리포트는 발견한 페이로드를 그대로 표에 싣는다. 그 페이로드 중에는 <svg/onload=alert(1)> 같은 XSS 문자열이 있다. HTML 이스케이프를 켤 때와 끌 때의 리포트 셀을 비교했다.

이스케이프 안 함 (|e 생략):
    payload=<svg/onload=alert(1)>
이스케이프 함 (|e 적용):
    payload=&lt;svg/onload=alert(1)&gt;

이스케이프를 빼면 <svg onload=...>가 리포트 HTML에 문자열이 아니라 태그로 들어간다. 브라우저가 인라인 이벤트 처리기를 허용하는 문맥이라면 onload가 실행될 수 있어, 공격을 기록하려고 만든 문서가 그 공격의 실행 매체가 될 수 있다. 이번 실습은 브라우저 실행까지 재현하지 않고 생성된 HTML 구조만 확인했다.

이 HTML 본문 문맥에서는 템플릿에서 {{ payload }} 대신 {{ payload|e }}를 쓰면 <가 &lt;가 되어 태그가 아니라 글자로 남는다. 문제는 이 처리를 빠뜨려도 리포트가 겉보기에는 정상으로 그려진다는 점이다. URL·JavaScript·CSS처럼 다른 문맥에는 각각 맞는 인코딩과 허용 규칙이 필요하므로 |e 하나를 보고 보고서 전체가 안전하다고 판단하지 않는다.

4. 리포트가 실패해도 배포를 막아야 한다

마지막은 실패의 처리다. 파이프라인은 여러 단계라 중간이 깨질 수 있다 — 리포트 렌더링이 예외를 던지거나, 파일 저장이 실패하거나. 이때 종료 코드가 무엇이 되느냐를 봤다.

정상 리포트: 종료 코드 1
리포트 렌더 중 예외: 종료 코드 2  (0이 아니어야 한다 — 발견은 이미 있었다)

리포트 생성이 실패했다고 종료 코드가 0이 되면 안 된다. 그 순간 이미 차단 등급 발견이 있었는데, 리포트를 못 그렸다는 이유로 배포가 허용되면 발견이 통째로 사라진다. 그래서 실패는 2로 끝낸다 — 시크릿 스캔 게이트를 다룬 글에서 ’발견’과 ’스캐너 고장’을 종료 코드로 나눴던 것과 같은 구조다.

세 상태를 나누면 이렇다.

종료 코드 뜻 CI 처리
0 차단 등급 없음 배포 허용
1 차단 등급의 탐지 결과 있음 배포 차단 — 탐지 근거를 검토한다
2 파이프라인 실패 배포 차단 — 도구를 고친다

1과 2는 CI 관점에서 둘 다 배포를 멈추지만, 다음에 할 일이 다르다. 1이면 원본 로그와 재현 조건을 확인해 공격 시도·오탐·실제 취약점을 구분하고, 확인된 문제에 맞는 조치를 정한다. 2이면 실패한 파이프라인을 복구하고 다시 검사한다. 후보만으로 배포를 보류할 수는 있지만, 그 종료 코드만으로 취약점을 확정할 수는 없다. 종료 코드가 이 둘을 구분하지 않으면, 리포트 버그로 파이프라인이 며칠 멈춰 있어도 「요즘 보안 게이트가 자주 걸리네」로 읽힌다.

조립이 바꾼 것은 탐지가 아니라 연결이었다

네 가지 관찰이 한 곳을 가리킨다. 직접 실행한 세 탐지기와 저장된 퍼저 출력의 형식은 앞선 실습에서 정한 그대로였고, 통합이 더한 것은 그것들을 잇는 방식이었다.

통합이 더한 것 없으면
공통 Finding 어댑터 리포트·게이트가 네 형식을 다 분기해야 한다
등급 → 종료 코드 매핑 124건이 배포 가부로 수렴하지 못한다
리포트 이스케이프 리포트가 XSS 실행 매체가 된다
실패의 별도 종료 코드 도구 고장이 ’발견’과 뭉개진다

그래서 탐지기들을 파이프라인으로 조립할 때 확인하는 순서를 이렇게 잡았다.

  1. 먼저 공통 스키마를 정하고 각 탐지기 앞에 어댑터를 둔다. 탐지 로직과 조립을 분리해야 한 탐지기를 고쳐도 나머지가 안 흔들린다.
  2. 등급 규칙이 곧 게이트임을 인식한다. 임계값은 리포트 장식이 아니라 배포를 막는 조건이다. 그 값은 대상마다 재서 정한다.
  3. 탐지 결과가 들어가는 출력 문맥에 맞춰 인코딩한다. 이 실습의 HTML 본문 셀은 Jinja 이스케이프를 적용했고, 다른 문맥은 별도로 검증한다.
  4. 발견·통과·실패를 다른 종료 코드로 나눈다. 차단 등급이 없는 통과만 0으로 배포를 허용하고, 발견과 파이프라인 실패는 0으로 바뀌어서는 안 된다.

이 넷은 어느 것도 ’무엇을 탐지하는가’를 바꾸지 않는다. 이번 실행에서 새로 확인한 것은 탐지 성능이 아니라 결과를 하나의 판단으로 모으는 연결이었다. 특히 퍼저는 저장 출력을 썼으므로 대상에 대한 재탐지나 재현 성공을 이 글의 결과로 주장하지 않는다.

당시 실행 기록과 재확인 한계

아래 명령은 앞선 실습 산출물을 같은 로컬 폴더에 두고 실행했던 기록이다. 현재 블로그 저장소와 Git 이력에는 gen_log.py, parse.py, pipeline.py, pipeline_run.py가 포함되어 있지 않으므로 이 저장소만 받아서는 그대로 재현할 수 없다.

bash
python gen_log.py --enum         # 로그 탐지 글들이 쓴 합성 로그 (없으면 먼저 생성)
python parse.py --enum
python pipeline_run.py           # 세 탐지기 실행 + 저장 퍼저 출력으로 통합 실험

당시 pipeline.py의 from_rule·from_session_ml·from_secret·from_fuzz가 각 출력 어댑터였고, classify가 등급, render_report가 HTML, gate가 종료 코드를 맡았다. Jinja는 공식 API 문서에서도 HTML·XML 템플릿에 자동 이스케이프를 권고한다. 이 글의 비교는 로컬 합성 페이로드에 한정했고, 이스케이프를 끈 보고서를 브라우저에서 열어 실행하지는 않았다.

측정 환경은 Python 3.14.6 / pandas 3.0.3 / scikit-learn 1.9.0 / jinja2 3.1.6이다. 당시 직접 실행한 범위는 규칙·세션 ML·시크릿 탐지기와 네 어댑터, 등급 분류, HTML 렌더링, 종료 코드까지다. 웹 퍼저의 Finding은 DVWA를 다시 띄우지 않고 앞선 실습에서 저장한 2건을 넣었다. 2026-08-09 정정에서는 로컬 실습 패키지가 현재 저장소에 없어 124건 집계와 종료 코드를 다시 실행하지 못했고, 본문에 남은 출력과 실행 범위의 내부 일관성만 대조했다.

이 글에서 확인하지 못한 것은 세 가지다. 첫째, 세 탐지기와 저장된 퍼저 출력을 한 프로세스에서 순서대로 처리했고, 실제 운영에서 쓰는 큐·재시도·병렬 실행은 다루지 않았다. 둘째, 등급 규칙을 수업용 기준(3초·50바이트)으로 그대로 썼다 — 실제 대상의 정상 변동을 재서 조정하는 일은 각 대상의 몫으로 남겼다. 셋째, 리포트를 브라우저로 열어 이스케이프 누락이 실제로 스크립트를 실행시키는지는 HTML 구조로만 확인했고, 브라우저에서 재현하지 않았다.