GitHub - kintoun-secops/secops-actions-lab
Contribute to kintoun-secops/secops-actions-lab development by creating an account on GitHub.
0단계. 준비단계
- 해당 레포를 fork하기
git clone후uv sync로 환경 맞추기 (uv 설치는 공식 문서 참고)
- 워크플로를 채우기 전에 아래 로컬에서 먼저 돌리기로 각 도구를 손으로 한 번씩 돌려보기

22개 패키지 설치 성공, .venv 가상환경 완성, ruff==0.9.10, mypy==1.15.0 등 문서에서 말한 버전도 일치
이것저것 도구 시도해보기
워크플로를 채우기 전, 도구를 로컬에서 먼저 실행해보고 실제로 어떤 문제를 잡아내는지 확인해보았다.
명령어 | 결과 |
ruff check | 코드 스타일 + 보안 문제 13건 발견 |
ruff format | 포맷 안 맞는 파일 1개 |
mypy | 타입 오류 2건 |
pytest | 테스트 실패 1건 |
pip-audit | 의존성 CVE 18건 |
1단계. 워크플로 채우기
TODO로 비워진 부분은 크게 두 종류였다.
- 도구 실행:
uv run mypy app/같은 실제 명령어 한 줄만 넣으면 되는 부분
- 이벤트 분기:
on:,concurrency,if:조건을 채우는 부분
다 설명하기에는 내용이 너무 길어져서 중요한 부분 2개 정도만 골라서 자세하게 설명하겠다.
01 - ruff.yml
이 워크플로는 Ruff로 코드 스타일과 보안 이슈를 검사하는 부분이다.
name: "01 - Ruff (format + lint + security)" on: workflow_call: workflow_dispatch: permissions: contents: read pull-requests: write # reviewdog 인라인 코멘트용 jobs: ruff: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v5 - run: uv sync --frozen - uses: reviewdog/action-setup@v1 with: reviewdog_version: latest - name: Ruff lint -> reviewdog (PR 인라인 리뷰) if: github.event_name == 'pull_request' env: REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | uv run ruff check app/ --output-format=rdjson | reviewdog -f=rdjson -reporter=github-pr-review -level=warning - name: Run Ruff (formatter) run: uv run ruff format --check . - name: Run Ruff (linter) run: uv run ruff check app/
1. 두 가지 트리거만 남겨두기
on: #언제 시작될지 workflow_call: workflow_dispatch:
이 파일 자체에서는
pull_request나 push 같은 트리거를 직접 걸지 않았다. 대신 workflow_call로 열어두고, 07-ci-summary.yml이 이 파일을 불러 쓰는 구조가 된다. workflow_dispatch는 이 워크플로 하나만 따로 테스트해보고 싶을 때를 위해 남겨둔 것이라고 생각하면 된다. 2. 검사는 두 부분으로 나누기
- name: Run Ruff (formatter) run: uv run ruff format --check . - name: Run Ruff (linter) run: uv run ruff check app/
Ruff 하나가 두 가지 일을 한다.
- formatter: 스타일이 통일돼 있는지만 본다. 자동으로 고쳐주지 않고
-check으로 검사만 하는 이유는자동 수정이 diff와 커밋 이력을 지저분하게 만들기 때문이다.
- linter: 진짜 문제를 찾는 친구라고 생각하면 된다. SQL 인젝션, 안전하지 않은 pickle 사용 같은 보안 패턴까지 걸러낸다.
이 두 과정이 실제로 merge를 막는 게이트 역할을 한다.
3. if 조건 채우기
if: github.event_name == 'pull_request'
reviewdog가 PR에 인라인 코멘트를 다는 경우)
main에 직접 push하거나 예약 실행(schedule)일 때는 애초에 코멘트를 달 PR이 없다. 조건 없이 두면 이 구간은 매번 토큰만 쓰고 아무 일도 하지 않게 된다.
07- CI summary (orchestrator)
01~06이 도구 하나씩을 맡았다면, 07은 그 도구들을 한 실행 안에 모아 최종 결과를 만드는 역할을 한다.
name: "07 - CI summary (orchestrator)" on: workflow_dispatch: pull_request: push: branches: [main] schedule: - cron: '30 20 * * 0' concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: {} jobs: changes: runs-on: ubuntu-latest permissions: contents: read outputs: code: ${{ steps.filter.outputs.code || 'true' }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - id: filter env: BASE: ${{ github.event.pull_request.base.sha || github.event.before }} run: | if [ "$GITHUB_EVENT_NAME" != "pull_request" ] && [ "$GITHUB_EVENT_NAME" != "push" ]; then echo "code=true" >> "$GITHUB_OUTPUT"; exit 0 fi if [ -z "$BASE" ] || ! git cat-file -e "${BASE}^{commit}" 2>/dev/null; then echo "code=true" >> "$GITHUB_OUTPUT"; exit 0 fi changed=$(git diff --name-only "$BASE" HEAD) code_changed=$(echo "$changed" | grep -Ev '\.md$|^LICENSE$|^\.gitignore$|^_solution/' || true) [ -n "$code_changed" ] && echo "code=true" >> "$GITHUB_OUTPUT" || echo "code=false" >> "$GITHUB_OUTPUT" ruff: needs: changes if: needs.changes.outputs.code == 'true' && github.event_name != 'schedule' uses: ./.github/workflows/01-ruff.yml permissions: { contents: read, pull-requests: write } mypy: needs: changes if: needs.changes.outputs.code == 'true' && github.event_name != 'schedule' uses: ./.github/workflows/02-mypy.yml permissions: { contents: read } pytest: needs: changes if: needs.changes.outputs.code == 'true' && github.event_name != 'schedule' uses: ./.github/workflows/03-pytest.yml permissions: { contents: read, pull-requests: write } pip-audit: needs: changes if: needs.changes.outputs.code == 'true' uses: ./.github/workflows/04-pip-audit.yml permissions: { contents: read } semgrep: needs: changes if: needs.changes.outputs.code == 'true' uses: ./.github/workflows/05-semgrep.yml permissions: { contents: read, security-events: write } gitleaks: needs: changes if: needs.changes.outputs.code == 'true' && github.event_name != 'schedule' uses: ./.github/workflows/06-gitleaks.yml permissions: { contents: read } secrets: inherit summary: runs-on: ubuntu-latest needs: [changes, ruff, mypy, pytest, pip-audit, semgrep, gitleaks] if: always() permissions: {} steps: - name: 결과 표를 잡 요약에 기록 env: CHANGES: ${{ needs.changes.result }} CODE: ${{ needs.changes.outputs.code }} RUFF: ${{ needs.ruff.result }} MYPY: ${{ needs.mypy.result }} PYTEST: ${{ needs.pytest.result }} PIP_AUDIT: ${{ needs['pip-audit'].result }} SEMGREP: ${{ needs.semgrep.result }} GITLEAKS: ${{ needs.gitleaks.result }} run: | fail=0 row() { case "$2" in success) mark="통과" ;; skipped) mark="건너뜀" ;; *) mark="실패/취소"; fail=1 ;; esac printf '| %s | %s |\n' "$1" "$mark" } { echo "## 보안 CI 요약" echo "| 도구 | 결과 |" echo "|---|---|" row "Ruff" "$RUFF" row "mypy" "$MYPY" row "pytest" "$PYTEST" row "pip-audit" "$PIP_AUDIT" row "Semgrep" "$SEMGREP" row "gitleaks" "$GITLEAKS" } >> "$GITHUB_STEP_SUMMARY" [ "$CHANGES" != "success" ] && fail=1 [ "$fail" -ne 0 ] && exit 1 exit 0
1. 트리거 3개 추가하기
기존에 있던 workflow_dispatch에 pull_request, push(main), schedule(주 1회) 세 가지를 추가했다.
- pull_request: 인라인 리뷰와 커버리지 코멘트가 여기 붙는다.
- push (main): merge된 상태를 전부 스캔한다.
- schedule: 코드가 그대로여도 새 CVE가 공개되거나 Semgrep 룰이 갱신되면, 어제 정상이던 커밋이 오늘 오류가 될 수 있어서 매주 한 번씩 다시 돌린다.
2. 도구마다 다른 조건 걸기
- 코드가 바뀌었는가
- 예약 실행인가(schedule)
ruff·mypy·pytest·gitleaks는 코드가 그대로면 결과도 그대로라, 예약 실행에서 다시 돌 필요가 없다.
그래서 "코드가 바뀌었고, 예약 실행이 아닐 때"라는 조건을 걸게 된다.
반면 pip-audit과 semgrep은 코드가 그대로여도 결과가 달라질 수 있다. 새 CVE가 공개되거나 룰이 갱신되면 어제와 같은 커밋이 오늘 다른 결과를 낼 수 있어서, 조건을 "코드가 바뀌었을 때" 하나만 걸어 예약 실행에도 돌게 했다.
정리하면, 01~06이 도구 하나를 어떻게 돌릴지를 다뤘다면 07은 그 도구들을 언제, 어떤 조건으로 묶을지를 다루는 파일이라고 보면 된다.
최종 상태 확인
일부러 심어둔 버그를 아직 고치지 않은 상태로 PR을 열어서, 도구들이 실제로 문제를 잡아내는지 확인했다.

- 6 failing:
pytest,ruff,semgrep,summary, mypy,pip-audit→ 버그·보안 이슈를 잡아냄
- 3 successful:
changes,gitleaks,Code scanning results→ 정상적으로 통과
여기서 눈여겨볼 부분은
summary도 함께 failing으로 뜬다는 점이다. summary는 여섯 도구의 결과를 모아 최종 판정을 내리는 잡이라, 하나라도 실패하면 summary 도 실패로 끝난다. 반면
gitleaks는 통과했는데, 이건 fork 히스토리 구조상 이번 PR의 새 커밋에는 시크릿 파일이 포함되지 않아서 애초에 스캔 대상이 안되었다.번외) gitleaks가 못 잡은 이유?
나머지 도구는 다 정상적으로 오류를 잡아내는데, gitleaks만 가짜 시크릿 파일(
leaky_settings.py, fake_deploy_key.pem)을 잡아내지 못해서 원인을 찾아봤다.찾아본 결과,
- fork할 때 시크릿 파일이 들어간 커밋까지 전체 히스토리를 그대로 가져옴
- 그 위에 워크플로 파일만 고친 새 커밋 5~6개를 추가했고, 시크릿 파일 자체는 건드리지 않음.
gitleaks-action은 PR 스캔 시 브랜치에 새로 추가하는 커밋만 검사함. 즉, 시크릿 파일이 든 옛날 커밋은 과거 상태로 취급돼서 검사대상에 해당하지 않았음
workflow가 잘못 짜인 게 아니라, 내가 실습자료를 fork하는 과정에서 구조상 생겼던 문제였다.
2단계. 브랜치 보호 설정
summary 체크를 필수 상태 체크로 등록하고, 실제로 게이트 역할을 하는지 검증했다.
Ruleset 만들기
Settings → Rulesets → New branch ruleset 순서로 들어가서 설정
- Enforcement status: Active
- Branch targeting criteria: Include default branch
- Require status checks to pass:
summary선택

여기서 핵심은 개별 도구(ruff, mypy 등)가 아니라 summary 하나만 필수 체크로 건다는 점이다.
문서 전용 PR로 검증하기
test/docs-only 브랜치를 만들고 README만 수정해서 commit, push한 뒤 PR을 열어봤다.
mypy,pip-audit,pytest,ruff,semgrep,gitleaks→ Skipped
changes,summary→ Successful
summary 체크에 Required가 붙어있음 → 브랜치 보호 룰셋이 정상 작동하고 있다는 증거이다.정리하면,
- 도구들이 안 돌아도,
summary만 통과하면 merge 가능
- 코드가 실제로 바뀌었는데 도구가 하나라도 실패하면
summary도 fail되면서 merge 불가
3단계. 수리하기
일부러 심어둔 버그들을 도구가 잡아내는지, 그리고 실제로 고칠 수 있는지 하나씩 확인했다.
(1) pytest: 가격 계산 로직
assert order_total(10) == 9000 E assert 10000 == 9000
10개를 주문하면 개당 100원 할인이 들어가야 하는 로직이다.
app/price_logic.py의 조건을 if quantity >= 10:으로 고치니 통과했다.
(2) mypy: 리턴 타입 오류
app/type_confusion.py 12, 15번째 줄에서 리턴 타입이 맞지 않았다.
int를 float로, 문자열 "100"을 숫자 100으로 바꿔서 해결했다.

코드 오류 수리 후, 재확인해보면 정상적으로 통과하는 것을 확인했다.
(3) ruff: 린트 + 보안
가장 오류가 많았던 부분이다.
파일 | 문제 | 해결 |
insecure_hash.py | hashlib.md5 사용 (S324) | sha256으로 교체 |
lint_playground.py | typing.List deprecated,
미사용 import,
중첩 if, 가변 기본 인자(B006),
== None(E711) | list로 교체, import 정리, if 병합, None 기본값 후 함수 내 초기화 |
shell_injection.py | subprocess.run(..., shell=True) (S602) | 문자열 조합 대신 리스트 인자 + shell=False |
sqli_fstring.py | f-string으로 SQL 조립 (S608) | 파라미터 바인딩으로 교체 |
unsafe_pickle.py | pickle.loads 사용 (S301) | json으로 교체 |
(4) Semgrep: SQL Injection (taint)
처음엔 f-string 조립을 그대로 두고 값만 살짝 바꿔봤는데 계속 오류가 났다. 값이 아니라 파라미터 바인딩(
? placeholder)으로 완전히 바꾸면 해결이 되는 문제이다.query = "SELECT * FROM users WHERE name LIKE ?" like_pattern = "%" + q + "%" cur.execute(query, (like_pattern,))
(5) pip-audit: 의존성 CVE
flask, jinja2, requests, werkzeug 버전을 전부 최신 버전으로 올렸다.원래 18건이던 CVE가 1건으로 줄었다.

