-
개인 프로젝트 12개에 보안 스캔 훅 달기 — pip-audit·bandit·gitleaks 3중 방어IT 2026. 4. 28. 23:40
이번엔 또 보안이다
내 홈 서버에는 개인 프로젝트 12개가 돌아간다. 지난 며칠 동안 차례로 네 가지 품질 축을 자동화해왔다.
- ruff로 린트 경고 0
- mypy로 타입 에러 0
- pytest-cov로 테스트 커버리지 baseline-lock
- 그리고 지금: 보안 스캔
처음엔 "개인 프로젝트에 보안 스캔까지 필요한가"라는 생각이 있었다. 공개 서비스도 아니고, 주로 홈 네트워크 안에서만 동작한다. 그런데 몇 가지가 마음에 걸렸다.
- 의존성은 수시로 업데이트된다. 내가 1년 전에 `pip install`한 라이브러리 중 어떤 게 지금 CVE를 맞고 있는지 모른다.
- 내 코드에
subprocess.run(..., shell=True)같은 위험한 패턴이 섞여 들어갔는지도 모른다. - 시크릿이 실수로 코드에 하드코딩됐을 수도 있다.
확인해볼 방법이 있는데 안 하고 있는 건, 다른 품질 축에서는 용납하지 않았던 상태다. 그래서 이번에도 같은 루프를 돌리기로 했다.
도구 선택 — trivy, snyk 탈락
보안 스캔 도구는 대략 네 계열이 있다.
도구 범위 비용 내 상황 적합도 trivy fs 컨테이너 이미지 + 파일시스템 + IaC + 시크릿 무료, 로컬 컨테이너 없는 프로젝트엔 오버킬 snyk 상업 SCA 계정·토큰 필요 개인 프로젝트엔 오버헤드 pip-audit Python 의존성 CVE (PyPI Advisory DB + OSV) 무료, 가벼움 ★★★★ bandit Python SAST (코드 패턴) 무료, pip 설치 ★★★★ trivy는 전천후 도구지만 내 프로젝트엔 Dockerfile이 없다. Kubernetes YAML도 없다. trivy를 띄워도 주로 보는 건 결국 "Python 의존성 취약점". 그럼 같은 일만 하는 가벼운 도구를 쓰는 게 낫다.
snyk은 계정을 만들고 API 토큰을 등록해야 한다. 개인 머신에 상업 도구 계정을 연결하는 것 자체가 불편하고, 무료 플랜에도 사용량 제한이 있다. 홈 프로젝트에 도입할 가치가 없다고 판단.
결론: pip-audit(의존성 CVE)와 bandit(Python SAST) 두 개로 시작. 둘은 대체 관계가 아니라 상호 보완이다. pip-audit이 "쓰는 라이브러리에 CVE 있냐", bandit이 "코드가 위험한 패턴을 쓰냐"를 본다.
시크릿 검출용
gitleaks는 처음엔 보류했다. 하드코딩된 토큰·키는 검출 0건이었고, 모든 프로젝트가.env+os.environ패턴을 일관되게 쓰고 있어서 당장 ROI가 낮아 보였기 때문이다. 결국 이 판단은 뒤에서 뒤집혔고, 글 뒷부분에서 gitleaks까지 붙이게 된 과정을 적는다.pip-audit: 초기 상태
전역 설치 후(
pip install --user --break-system-packages pip-audit bandit) 12개 프로젝트 전체에 baseline 스캔.프로젝트 성격 pip-audit 결과 A, D, C, E, G, F, B, ... (8개) CLEAN — No known vulnerabilities J (RAG 시스템) 1건: diskcache 5.6.3CVE-2025-69872I, gpu-scheduler, F (3개) 의존성 매니페스트 없음 (스캔 skip) 발견된 건 1건.
diskcache는 RAG 시스템이 Qdrant 클라이언트 내부에서 transitive로 끌고 들어오는 의존성이고, 업스트림 패치가 아직 나오지 않은 상태였다. 당장 고칠 수는 없어서 baseline-lock 전략대로.pip-audit-ignore에 CVE ID를 적어두고 "지금은 알고 있다"고 표시만 했다.향후에 새로운 CVE가 어떤 프로젝트 의존성에서 발견되면 훅이 잡아낸다. 그게 더 중요하다.
bandit: 첫 스캔에서 쏟아진 High 5건
진짜 흥미로운 건 bandit이었다. 12개 프로젝트 스캔 결과:
프로젝트 H H:2 M:2 L:16 프로젝트 K H:1 M:1 L:22 프로젝트 J H:1 M:0 L:18 프로젝트 E H:1 M:1 L:5 나머지 8개 H:0 M:0~1 L:0~16High severity만 5건. "드디어 잡았다"고 생각하고 하나씩 까봤다.
4건은 SHA-1/MD5 false positive
bandit B324 경고. "약한 해시 알고리즘을 쓰고 있다. security 목적이면 SHA-256 이상 써라."
코드를 들여다보니 네 곳 모두 비-security 용도였다.
# 프로젝트 H: Immich 서버의 체크섬 형식과 맞추기 위해 SHA-1 계산 def compute_sha1(file_path): h = hashlib.sha1() ... # 프로젝트 J: Qdrant point ID를 결정적으로 만들기 위해 MD5 해시 def make_point_id(file_path, chunk_index): key = f"{file_path}:{chunk_index}" h = hashlib.md5(key.encode()).hexdigest() return f"{h[:8]}-{h[8:12]}-..." # 프로젝트 E: 파일명 충돌 회피용 8자리 해시 h = hashlib.md5(str(src).encode()).hexdigest()[:8] dest = backup_dir / f"{src.stem}_{h}{src.suffix}"전부 파일 체크섬이나 결정적 ID 생성 같은 목적이다. 비밀번호를 해싱하는 것도 아니고, 토큰을 서명하는 것도 아니다. "약한 해시"라는 경고는 여기선 맞지 않다.
Python 표준 라이브러리는 이 상황을 위해 3.9부터
usedforsecurity플래그를 제공한다. 이 한 줄로 bandit을 만족시키면서 코드 의도도 문서화된다.# Before h = hashlib.sha1() # After h = hashlib.sha1(usedforsecurity=False)네 곳 모두 같은 패턴으로 수정. 커밋 메시지에 "Immich 호환성 체크섬", "Qdrant point ID 결정적 생성", "파일명 충돌 회피" 같은 의도를 명시해둬서, 6개월 뒤에 내가 이 코드를 다시 읽어도 "왜 MD5를 썼는지" 바로 이해할 수 있게 했다.
나머지 1건은 진짜 위험이었다 — Jinja2 autoescape=False
프로젝트 K의 포토북 렌더러. HTML 템플릿 엔진 Jinja2를 이렇게 세팅하고 있었다.
env = Environment( loader=FileSystemLoader(str(TEMPLATE_DIR)), autoescape=False, ) ... template = env.get_template("book.html") html = template.render( title=title, subtitle=subtitle, chapters=chapters, prefaces=prefaces, ... )Jinja2의
autoescape는{{ variable }}자리에 들어가는 값의 HTML 특수문자(<,>,&,")를 자동으로 안전한 엔티티로 바꿔줄지 결정한다.True면<가<로 변환되고,False면 날것 그대로 HTML에 삽입된다.이 렌더러에 들어가는 값들을 다시 봤다.
title,subtitle— 포토북 제목chapters,prefaces— LLM이 생성한 내러티브- 사진 캡션, 장소 이름 — Immich 메타데이터, Overpass API 응답
전부 외부에서 온다. LLM 출력은 내가 완전히 통제하지 못한다. Overpass API는 위키데이터 기반이라 누구나 편집한다. 그 중 어떤 문자열에
<script>alert('x')</script>같은 게 섞여 있으면, 생성된 포토북 HTML을 브라우저로 열 때 그 스크립트가 실행된다.개인 감상용이라 공격자가 일부러 노릴 상황은 아니다. 하지만 언젠가 이 포토북을 가족에게 이메일이나 웹 링크로 공유하게 되는 순간 공격 표면이 열린다. 개인 소비인 동안에도 LLM 주입이 이론적으론 가능하다.
해결은 Jinja2 표준 방식을 따르면 된다.
# renderer_book.py env = Environment( loader=FileSystemLoader(str(TEMPLATE_DIR)), autoescape=True, # ← 변경 )이렇게만 하면 대부분의 값이 자동으로 안전하게 이스케이프된다. 단 한 가지 예외가 있었다.
<!-- templates/book.html --> {% if inline_css %} <style>{{ css_content }}</style> {% endif %}여기서 헷갈릴 수 있는 지점을 짚고 가자. "
<style>태그 자체가 이미 HTML인데, autoescape가 그것부터 망가뜨리지 않는가?"답: Jinja2의 autoescape는 템플릿 안의 고정된 HTML(
<style>,<h1>,<p>같이 파일에 적힌 마크업)을 절대 건드리지 않는다. 오직{{ 변수 }}자리에 끼워 넣는 값만 이스케이프한다. 템플릿의 골격은 신뢰되는 내 코드이고, 위험은 "바깥에서 들어오는 값"에만 있기 때문이다.그래서
<style>{{ css_content }}</style>의 경우<style>태그 자체는 그대로 출력되고, 문제는 중간에 들어가는css_content다. CSS 내용에는body > p { color: red; }처럼>같은 문자가 당연히 섞여 있다. autoescape가 이 값에 적용되면>로 변환돼 브라우저가 CSS를 제대로 파싱하지 못한다.| safe는 Jinja2에게 "이 한 값만큼은 HTML로 취급하고 건드리지 마라"라고 명시하는 필터다. 이걸 붙이는 순간 autoescape는css_content변수에만 건너뛰고, 다른 모든 변수({{ title }},{{ chapters }}등)에는 여전히 적용된다.<style>{{ css_content | safe }}</style>정리하면:
- 템플릿에 적힌 고정 HTML 태그 → 항상 그대로 (autoescape 무관)
{{ 변수 }}→ 기본은 자동 이스케이프{{ 변수 | safe }}→ 해당 변수만 예외적으로 원본 HTML로
"안전을 기본값으로 하고, 예외만 콕 집어서 명시한다"는 원칙이
| safe한 줄에 담겨 있다.테스트 247개 전부 통과. bandit High 1 → 0.
baseline-lock 전략
이번에도 ruff/mypy/coverage에서 썼던 같은 철학을 적용했다.
현재 발견된 이슈를 snapshot으로 잠근다. 그 이상으로 악화되면 차단한다. 증가(완화)는 장려하지만 강제하지 않는다.
구체적으로:
- pip-audit: 프로젝트 루트에
.pip-audit-ignore파일(CVE ID 한 줄씩)을 두고 grandfathered 등록. - bandit:
bandit -r . -f json -o .bandit_baseline.json으로 현재 findings를 JSON 스냅샷으로 저장. 이후 스캔은--baseline플래그로 대비해서 신규 이슈만 보고.
처음엔 High 5건이 baseline에 포함된 상태로 시작했는데, 위에서 설명한 것처럼 결국 5건을 다 해결해서 지금은 baseline에 High 0개다. 새로 High가 발견되면 훅이 즉시 잡아낸다.
Hook으로 강제 — ruff/mypy/coverage와 같은 패턴
설정 파일에만 기준을 두면 개발자가 까먹는다. 다른 품질 축들처럼 Hook에 걸었다.
# ~/scripts/hooks/pipelines/security.py def track_edit(file_path, data): if file_path.name in {"pyproject.toml", "requirements.txt", ...}: dirty_deps.add(project) # pip-audit 재스캔 필요 if file_path.suffix == ".py": dirty_code.add(project) # bandit 재스캔 필요 def track_bash(cmd, data): # pip-audit 실행 성공 시 dirty_deps 해제 # bandit 실행 성공 시 dirty_code 해제 ... def pre_commit(cmd, data): # git commit 직전: dirty 프로젝트 재스캔 # 신규 HIGH 발견 시 commit 차단 ... def check_completion(state): # 세션 종료(Stop) 시점: dirty 프로젝트 재스캔 # 신규 HIGH 발견 시 Stop 차단 ...특이한 건 dirty 세트가 둘로 나뉜다는 점이다.
dirty_deps와dirty_code. 편집한 파일 종류에 따라 재실행할 도구가 다르기 때문이다.pyproject.toml만 고쳤는데 bandit이 재실행되면 낭비고,.py만 고쳤는데 pip-audit이 CVE DB를 fetch하면 느려진다.한 가지 더 — gitleaks로 commit 직전 secret 차단
pip-audit + bandit으로 훅을 돌리기 시작한 뒤, 마음에 걸리는 장면이 하나 남아 있었다. 지금까지 내 프로젝트에 하드코딩된 secret은 없다. 그런데 앞으로도 그럴 거라는 보장이 있는가?
실수는 늘 예측을 벗어나 일어난다. 새 API를 테스트하다가 토큰을 임시로 코드에 박아두고
.env로 옮기기 전에git add해버린다든지. AI가 만든 코드 샘플에 더미 같지만 실제로는 유효한 키가 섞여 있다든지. 이런 건bandit의 규칙으로는 잡기 어렵다. bandit은 Python 패턴을 보는 도구고, 문자열 엔트로피 기반의 secret 탐지는 영역이 다르다.그래서 결국 gitleaks까지 붙이기로 했다. 하드코딩이 "지금" 없다는 게 "계속" 없다는 뜻은 아니다. 훅의 가치는 사고가 나기 전에 막는 데 있고, 이 자리는 바로 그런 자리다.
설치와 baseline
# ARM64 binary 다운로드 wget https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_arm64.tar.gz tar xzf gitleaks_8.21.2_linux_arm64.tar.gz mv gitleaks ~/bin/12개 프로젝트 전체 스캔 결과: leak 0건. 완벽한 출발점이다. baseline 파일을 만들 필요도 없이, 앞으로 새로 생기는 secret만 막으면 된다.
훅 통합 — 기존 패턴과 다른 점
pip-audit/bandit은 "편집된 파일 종류에 따라 dirty 세트에 넣고, Stop/commit 시점에 재스캔" 패턴이었다. gitleaks는 다르다.
gitleaks는 staged 변경사항을 본다. staged 내용은
git add할 때마다 달라진다. "프로젝트가 dirty냐 아니냐"를 추적하는 게 의미가 없다. commit 시도할 때마다 무조건 돌리면 된다.def pre_commit(cmd, data): # gitleaks는 dirty 상태 무관 — 매번 staged 검사 for project in TRACKED_PROJECTS: if project in cmd: rc, summary = _run_gitleaks_staged(proj_dir) if rc > 0: return True, f"⛔ {project}: staged에 secret 감지 → commit 차단\n - {summary}" # 기존 pip-audit/bandit dirty 검증 (이어서) ...pip-audit/bandit보다 먼저 실행한다. 이유는 단순하다. secret이 staged에 있다면 CVE나 코드 패턴 검사는 부차적이다. secret이 git에 들어가면 히스토리에 영구히 남고, 원격까지 push되면 사실상 유출된 것으로 간주해야 한다. 이게 가장 비가역적인 사고다.
검증
fake GitHub Personal Access Token과 Stripe live key 패턴을 임시 파일에 넣고
git add한 다음 훅을 호출해봤다.$ python3 -c " from pipelines import security blocked, msg = security.pre_commit('cd ~/projects/X && git commit -m x', {}) print(f'blocked={blocked}') print(msg[:200]) " blocked=True ⛔ X: gitleaks — staged 변경사항에 secret 감지 → commit 차단 - 3:50PM WRN leaks found: 3 - 해당 파일에서 secret 제거 후 git add 재실행3건 감지 → 차단. 테스트 후 staged 롤백 + 파일 삭제로 원상복구. 정상 동작 확인.
재미있는 건 처음 시도했을 때는 AWS의 공식 예시 키
AKIAIOSFODNN7EXAMPLE을 썼는데 차단이 안 됐다. 알고 보니 gitleaks 기본 룰셋이 AWS 문서의 대표 예시 키들을 allowlist로 처리하고 있었다. "테스트 예시"까지 잘 구분하는 룰셋 설계가 인상적이었다. 반면 GitHub PAT 형태(ghp_...)와 Stripe live key(sk_live_...)는 정확히 잡아냈다.결과 — 3중 방어 체계
12개 프로젝트 전체에 보안 스캔 3종 도입 완료. 현재 상태:
- pip-audit HIGH/CRITICAL: 0건 (1건은 baseline grandfathered, 나머지 모두 CLEAN)
- bandit HIGH: 0건 (5건 발견 → 4건 false positive 해결 → 1건 실제 리스크 해결)
- gitleaks leak: 0건 (baseline부터 완전 클린)
- Medium/Low는 baseline에 남아 있음. 신규 추가만 차단.
세 도구가 각자 다른 단계를 지킨다.
도구 트리거 역할 pip-audit 의존성 매니페스트 편집 → Stop/commit 쓰는 라이브러리의 CVE bandit .py 편집 → Stop/commit 위험한 코드 패턴 (SAST) gitleaks git commit 시도 (항상) staged 변경사항의 secret 이제 새 라이브러리를 추가하면 CVE 체크, 코드를 수정하면 패턴 검사, commit 시도하면 secret 최종 차단. 이전에는 인간이 기억해야 했던 3가지가 전부 훅에 넘어갔다.
정리 — 이번에도 같은 교훈
린트, 타입, 커버리지에 이어 보안도 결국 같은 패턴이었다.
- 현재 수준 측정 — source 명시, 외부 제외, 정직한 baseline 확보.
- 쉬운 것부터 해결 — SHA-1/MD5 false positive 네 건은
usedforsecurity=False한 줄. - 진짜 리스크는 제대로 수정 — Jinja2 autoescape는 템플릿까지 같이 손대야 했다.
- baseline-lock으로 후퇴 방지 — 완벽한 상태를 고집하지 않고 "지금부터 악화되면 차단".
- Hook으로 강제 — 설정만 두면 잊는다. 훅이 잊지 않는다.
이번에도 가장 큰 교훈은 "false positive와 진짜 리스크를 구분하는 게 스캔 도구 도입의 핵심"이었다. bandit이 던진 5건 중 4건은 코드 의도를 도구가 이해 못해서 생긴 것이고, 1건은 진짜 수정이 필요한 것이었다. 둘을 섞어서 "5건 High 있다"고만 말했으면 가치가 반감됐을 것이다.
그리고 한 가지 더 — "지금 문제없음"을 "앞으로도 문제없음"으로 착각하지 말 것. gitleaks를 처음엔 "하드코딩 0건이니 낮은 우선순위"로 넘겼는데, 훅의 가치는 현재 상태를 검증하는 게 아니라 미래의 실수를 막는 데 있었다. 0건이었기 때문에 지금이야말로 도입하기 좋은 타이밍이었다.
이렇게 4-축 품질 훅 체계(ruff + mypy + pytest-cov + pip-audit/bandit/gitleaks)가 완성됐다. 다음
.py편집이 일어나면 네 파이프라인이 동시에 돌면서 각각의 조건을 확인하고, 다음git commit에서는 gitleaks가 마지막 관문으로 선다. 내가 잊어도 훅은 잊지 않는다. 그게 이 루프의 전부다.
이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.
'IT' 카테고리의 다른 글
PlantUML — 17년 된 UML Diagram-as-Code의 현재와 문법 5가지 (0) 2026.05.01 Android Skills 해부 — 에이전트에게 전문성을 주입하는 방법 (1) 2026.04.30 Android CLI — Agent를 위한 SDK라는 새로운 패러다임 (0) 2026.04.30 Sphinx — Python API 문서를 코드에서 자동으로 뽑아내는 표준 (0) 2026.04.29 docstring 완전 가이드 — PEP 257부터 LLM 에이전트 context까지 (0) 2026.04.29 테스트 커버리지 11개 프로젝트에 도입하기 — baseline-lock + 외부 wrapper 제외 전략 (2) 2026.04.28 mypy로 파이썬 프로젝트 5개에 타입 에러 0 만들기 — 단계적 도입과 Hook 강제 (0) 2026.04.28 shellcheck로 bash 스크립트 정리하기 — 그리고 Hook으로 재발 방지까지 (0) 2026.04.28 ruff로 10개 프로젝트 린트 통과시키기 — 246 → 0, 그리고 실제 버그 2건 발견 (1) 2026.04.28 하네스 엔지니어링 9단계 베스트 프랙티스 #8. 측정과 회고 (0) 2026.04.27