Github Action 실습Github Action 실습

Github Action 실습

생성 일시
Aug 22, 2026 06:38 AM
최종 편집 일시
Last updated August 23, 2026
태그
작성자
@양혜민
Date
GitHubGitHubGitHub - kintoun-secops/secops-actions-lab

0단계. 준비단계

  1. 해당 레포를 fork하기
  1. git cloneuv sync로 환경 맞추기 (uv 설치는 공식 문서 참고)
  1. 워크플로를 채우기 전에 아래 로컬에서 먼저 돌리기로 각 도구를 손으로 한 번씩 돌려보기
notion imagenotion image
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_requestpush 같은 트리거를 직접 걸지 않았다. 대신 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-auditsemgrep은 코드가 그대로여도 결과가 달라질 수 있다. 새 CVE가 공개되거나 룰이 갱신되면 어제와 같은 커밋이 오늘 다른 결과를 낼 수 있어서, 조건을 "코드가 바뀌었을 때" 하나만 걸어 예약 실행에도 돌게 했다.
정리하면, 01~06이 도구 하나를 어떻게 돌릴지를 다뤘다면 07은 그 도구들을 언제, 어떤 조건으로 묶을지를 다루는 파일이라고 보면 된다.
최종 상태 확인
일부러 심어둔 버그를 아직 고치지 않은 상태로 PR을 열어서, 도구들이 실제로 문제를 잡아내는지 확인했다.
notion imagenotion image
  • 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)을 잡아내지 못해서 원인을 찾아봤다.
찾아본 결과,
  1. fork할 때 시크릿 파일이 들어간 커밋까지 전체 히스토리를 그대로 가져옴
  1. 그 위에 워크플로 파일만 고친 새 커밋 5~6개를 추가했고, 시크릿 파일 자체는 건드리지 않음.
  1. 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 선택
notion imagenotion image
여기서 핵심은 개별 도구(ruff, mypy 등)가 아니라 summary 하나만 필수 체크로 건다는 점이다.

문서 전용 PR로 검증하기

test/docs-only 브랜치를 만들고 README만 수정해서 commit, push한 뒤 PR을 열어봤다.
notion imagenotion image
  • mypy, pip-audit, pytest, ruff, semgrep , gitleaksSkipped
  • changes, summarySuccessful
summary 체크에 Required가 붙어있음 → 브랜치 보호 룰셋이 정상 작동하고 있다는 증거이다.
정리하면,
  1. 도구들이 안 돌아도, summary만 통과하면 merge 가능
  1. 코드가 실제로 바뀌었는데 도구가 하나라도 실패하면 summary도 fail되면서 merge 불가

3단계. 수리하기

일부러 심어둔 버그들을 도구가 잡아내는지, 그리고 실제로 고칠 수 있는지 하나씩 확인했다.

(1) pytest: 가격 계산 로직

assert order_total(10) == 9000 E assert 10000 == 9000
10개를 주문하면 개당 100원 할인이 들어가야 하는 로직이다. app/price_logic.py의 조건을 if quantity >= 10:으로 고치니 통과했다.
notion imagenotion image

(2) mypy: 리턴 타입 오류

app/type_confusion.py 12, 15번째 줄에서 리턴 타입이 맞지 않았다. intfloat로, 문자열 "100"을 숫자 100으로 바꿔서 해결했다.
notion imagenotion image
notion imagenotion image
코드 오류 수리 후, 재확인해보면 정상적으로 통과하는 것을 확인했다.

(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건으로 줄었다.
notion imagenotion image