일부러 취약하게 만든 파이썬 파일 하나로 Bandit과 Semgrep의 종료 코드를 로컬에서 확인하고, 같은 명령을 GitHub Actions 워크플로와 Jenkins 파이프라인에 옮깁니다. 취약 코드는 이 글에서 직접 만든 연습용 파일이며, 이런 코드는 직접 만든 실습 환경에서만 실행합니다.
준비물
| 항목 | 내용 |
|---|---|
| Python | 3.12 |
| Bandit | 1.9.4 |
| Semgrep | 1.181.0 |
| GitHub 저장소 | 개인 저장소 하나 (Actions 단계에서만 필요) |
| 네트워크 | p/owasp-top-ten 룰을 내려받을 때 필요 |
| 예상 소요 시간 | 약 25분 |
작업 폴더와 가상환경을 만들고 두 도구를 설치합니다. 명령은 bash 기준이며 Windows에서는 Git Bash를 쓰고, 가상환경 활성화는 source .venv/bin/activate 대신 .venv\Scripts\activate를 씁니다.
mkdir -p ~/sast-demo/app ~/sast-demo/rules && cd ~/sast-demo
python -m venv .venv
source .venv/bin/activate
pip install bandit semgrep
bandit --version
semgrep --version
기대 출력입니다. 버전이 다르면 아래 결과 일부가 달라질 수 있습니다.
bandit 1.9.4
python version = 3.12.10 ...
1.181.0
1. 검사 대상 코드 만들기
셸에 사용자 입력을 그대로 넘기는 함수와, 비밀번호를 MD5로 해시하는 함수를 하나씩 둡니다.
cat > app/main.py <<'PY'
import hashlib
import subprocess
def ping(host):
return subprocess.run(f"ping -c 1 {host}", shell=True, capture_output=True, text=True)
def hash_password(password):
return hashlib.md5(password.encode()).hexdigest()
PY
2. Bandit을 HIGH 심각도 기준으로 돌리기
--severity-level high는 HIGH 이상만 보고합니다. 그 아래 등급은 출력에 나오지 않고 종료 코드에도 영향을 주지 않습니다.
bandit -r app --severity-level high
echo "exit=$?"
기대 출력입니다. 중간의 설명 줄은 줄였습니다.
>> Issue: [B602:subprocess_popen_with_shell_equals_true] subprocess call with shell=True identified, security issue.
Severity: High Confidence: High
Location: app/main.py:6:11
>> Issue: [B324:hashlib] Use of weak MD5 hash for security. Consider usedforsecurity=False
Severity: High Confidence: High
Location: app/main.py:10:11
...
exit=1
두 건 모두 HIGH라서 종료 코드가 1입니다. 파이프라인은 이 값을 보고 단계를 실패 처리합니다.
3. Semgrep 커스텀 룰과 공개 룰 세트 돌리기
먼저 shell=True 호출을 찾는 룰을 직접 한 개 만듭니다. 팀 규칙을 코드로 남기는 가장 작은 단위입니다.
cat > rules/no-shell-true.yml <<'YML'
rules:
- id: no-subprocess-shell-true
languages: [python]
severity: ERROR
message: >
subprocess 를 shell=True 로 호출하면 OS 커맨드 인젝션(CWE-78) 위험이 있습니다.
인자 리스트와 shell=False 를 사용하세요.
pattern: subprocess.$FUNC(..., shell=True, ...)
YML
--error 옵션을 주면 발견 사항이 있을 때 종료 코드가 1이 됩니다. 이 옵션이 없으면 Semgrep은 발견 사항이 있어도 0으로 끝나서 CI가 막지 못합니다.
semgrep --config rules --config p/owasp-top-ten --error --metrics=off app
echo "exit=$?"
기대 출력입니다. 일부만 발췌했으며, 같은 줄에 subprocess-shell-true와 insecure-hash-algorithm-md5 외에 md5-used-as-password 룰도 함께 보고됩니다. 설명 문구는 Semgrep 버전에 따라 다를 수 있습니다.
app/main.py
>>> rules.no-subprocess-shell-true
subprocess 를 shell=True 로 호출하면 OS 커맨드 인젝션(CWE-78) 위험이 있습니다. ...
6┆ return subprocess.run(f"ping -c 1 {host}", shell=True, capture_output=True, text=True)
>>> python.lang.security.audit.subprocess-shell-true.subprocess-shell-true
...
python.lang.security.insecure-hash-algorithms-md5.insecure-hash-algorithm-md5
...
exit=1
실제 화면은 상자 문자와 색이 들어간 형태로 나옵니다. 확인할 것은 두 가지입니다. 커스텀 룰과 공개 룰이 모두 발견 사항을 냈는지, 마지막에 exit=1이 찍혔는지입니다.
4. 고치면 종료 코드가 0이 되는지 확인하기
shell=True를 없애고 인자를 리스트로 넘기며, 해시는 hashlib.scrypt로 바꿉니다. 같은 검사를 다른 폴더에 돌려 봅니다.
mkdir -p fixed
cat > fixed/main.py <<'PY'
import hashlib
import subprocess
def ping(host):
return subprocess.run(["ping", "-c", "1", host], capture_output=True, text=True)
def hash_password(password, salt):
return hashlib.scrypt(password.encode(), salt=salt, n=2**14, r=8, p=1).hex()
PY
bandit -q -r fixed --severity-level high
echo "bandit exit=$?"
semgrep --config rules --config p/owasp-top-ten --error --metrics=off fixed
echo "semgrep exit=$?"
기대 출력입니다. Semgrep 요약에서 Findings가 0이어야 합니다.
bandit exit=0
• Findings: 0 (0 blocking)
• Rules run: 153
...
semgrep exit=0
Rules run 개수는 룰 세트가 갱신되면 달라집니다.
5. GitHub Actions에 같은 명령 올리기
로컬에서 확인한 두 명령을 워크플로에 그대로 옮깁니다. 저장소 루트에 app/과 rules/가 있다고 가정합니다.
mkdir -p .github/workflows
cat > .github/workflows/sast.yml <<'YML'
name: sast
on:
pull_request:
push:
branches: [main]
jobs:
sast:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install scanners
run: pip install bandit semgrep
- name: Bandit (fail on HIGH)
run: bandit -r app --severity-level high
- name: Semgrep (OWASP + 커스텀 룰)
run: semgrep --config p/owasp-top-ten --config rules --error app
YML
git checkout -b feature/sast-gate
git add .github/workflows/sast.yml app rules
git commit -m "add SAST gate"
git push -u origin feature/sast-gate
브랜치를 올린 뒤 GitHub 웹에서 Pull Request를 만들고 Checks 탭을 봅니다. sast 잡의 Bandit 단계에서 빨간 X가 나오면 정상입니다. 첫 단계가 실패하면 뒤의 Semgrep 단계는 실행되지 않습니다. Semgrep 결과까지 보려면 Bandit 단계에 continue-on-error: true를 잠시 붙여 둡니다.
게이트가 머지를 실제로 막으려면 설정이 하나 더 필요합니다. 저장소 Settings의 Branches에서 main 브랜치 보호 규칙을 추가하고 sast 체크를 필수 상태 검사로 지정합니다. 이 설정이 없으면 실패한 PR도 머지 버튼이 눌립니다.
6. Jenkins에서는 SARIF로 남기기
Jenkins 파이프라인에서는 같은 검사에 결과 파일을 보관하는 한 줄이 더 붙습니다. SAST stage만 보면 다음과 같습니다.
stage('SAST') {
steps {
sh 'pip install --quiet bandit semgrep'
sh 'bandit -r app --severity-level high'
sh 'bash ci/semgrep.sh'
}
post {
always {
archiveArtifacts artifacts: 'semgrep.sarif', allowEmptyArchive: true
}
}
}
ci/semgrep.sh가 --error 때문에 실패하면 stage의 나머지 step이 건너뛰어지므로, 보관 단계는 post { always { ... } }에 둡니다.
ci/semgrep.sh는 Semgrep 호출을 감싼 짧은 스크립트입니다.
#!/usr/bin/env bash
set -euo pipefail
semgrep \
--config rules \
--config p/owasp-top-ten \
--error \
--sarif -o semgrep.sarif \
app
로컬에서 같은 명령을 돌려 SARIF 파일이 만들어지는지 봅니다.
semgrep --config rules --config p/owasp-top-ten --error --metrics=off --sarif -o semgrep.sarif app
echo "exit=$?"
ls -l semgrep.sarif
기대 출력입니다. 발견 사항이 있으므로 SARIF를 쓰고도 종료 코드는 1입니다.
exit=1
-rw-r--r-- 1 user user ... semgrep.sarif
오탐을 억제하는 방법
확인을 마친 오탐은 해당 줄에 주석으로 억제합니다. Semgrep은 # nosemgrep: <룰 ID>, Bandit은 # nosec을 씁니다. 두 도구의 주석은 한 줄에 같이 둘 수 있습니다. nosemgrep은 적은 ID의 룰만 끄므로, 같은 줄에 걸리는 p/owasp-top-ten의 공개 룰도 ID를 함께 적어야 합니다.
def legacy(host):
return subprocess.run(f"ping {host}", shell=True) # nosemgrep: rules.no-subprocess-shell-true, python.lang.security.audit.subprocess-shell-true.subprocess-shell-true # nosec B602
이 함수를 app/main.py 끝에 더해 같은 검사를 다시 돌리면 legacy는 두 도구 모두 보고하지 않고 기존 6번 줄과 10번 줄 결과만 남습니다. 커스텀 룰 ID만 적으면 Bandit은 6, 10번 줄만 보고하지만 Semgrep은 14번 줄의 subprocess-shell-true 공개 룰을 계속 보고합니다. 룰 ID를 적어 두면 무엇을 억제했는지 나중에 찾을 수 있습니다.
자주 막히는 곳
Windows에서 Semgrep이 UnicodeDecodeError: 'charmap' codec can't decode byte 0x9d 로 종료됩니다. 룰 파일에 한글이 들어 있는데 기본 인코딩이 cp1252여서 생깁니다. 실행 전에 환경 변수 PYTHONUTF8=1을 지정하면 읽습니다. 이때 종료 코드가 2로 나오므로 발견 사항 때문에 실패한 것과 구분해야 합니다.
--error를 빼고 돌렸더니 발견 사항이 있는데도 CI가 초록색입니다. Semgrep은 --error 없이는 발견 사항이 있어도 0으로 끝납니다. 워크플로의 Semgrep 줄에 --error가 있는지 확인합니다.
룰 세트를 내려받지 못해 Semgrep이 실패합니다. p/owasp-top-ten 같은 레지스트리 룰은 실행할 때 네트워크로 받아 옵니다. 외부 접속이 막힌 러너에서는 룰 파일을 저장소에 넣고 --config rules처럼 로컬 경로만 쓰도록 바꿉니다.
Pull Request를 만들었는데 Checks 탭에 sast가 보이지 않습니다. 워크플로 파일이 .github/workflows/ 아래에 있고 커밋에 포함됐는지 봅니다. 폴더 이름이 하나라도 다르면 Actions가 파일을 읽지 않습니다.
이어서 해 볼 것
- 새 코드만 막기: 기존 코드에 발견 사항이 많으면 도입 자체가 어렵습니다. Semgrep의
--baseline-commit <커밋>옵션은 그 커밋 이후 새로 생긴 발견 사항만 실패로 처리합니다. 팀 저장소에서 기준 커밋을 정해 적용해 봅니다. - PR 코드 줄에 결과 표시: 만들어 둔
semgrep.sarif를github/codeql-action/upload-sarif단계로 올립니다. GitHub code scanning이 해당 코드 줄에 발견 사항을 보여 줍니다. 비공개 저장소에서 쓸 수 있는지는 사용 중인 플랜 문서에서 확인해야 합니다. - 룰 하나 더 만들기:
pickle.loads(...)호출처럼 금지하고 싶은 패턴을rules/아래 새 YAML로 추가해 봅니다. 위반 코드와 수정 코드를 각각 돌려 종료 코드가 1과 0으로 갈리는지 봅니다.
확인 범위
직접 실행한 부분은 1번부터 4번, 6번의 SARIF 파일 생성, 오탐 억제 주석(룰 ID 포함 여부별 비교)입니다. 환경은 Python 3.12.10 가상환경, Bandit 1.9.4, Semgrep 1.181.0입니다.
GitHub Actions 워크플로와 Pull Request 실패 화면, 브랜치 보호 설정, Jenkins stage는 실행하지 못했습니다. --baseline-commit과 SARIF 업로드 단계도 실행 전입니다. 적용 전에 직접 돌려 확인하시기 바랍니다.