[GitHub Actions] GitHub Actions 실습[GitHub Actions] GitHub Actions 실습

[GitHub Actions] GitHub Actions 실습

생성 일시
Aug 22, 2026 06:19 PM
최종 편집 일시
Last updated August 22, 2026
태그
GitHub Actions
작성자
@김태형
Date
GitHubGitHubGitHub - taehyung-99/secops-actions-lab

목표

GitHub Actions를 활용한 CI 파이프라인을 조립해보고, 실제 SecOps 운영에서 PR → Merge 과정에 어떤 도구를 어떻게 활용해서 안전하게 코드를 관리할 수 있는가를 직접 경험하며 배운다.

🛠️ 도구 및 용어 정리

먼저, 나에게는 생소한 도구들이 많아 각 도구들이 어떤 역할을 하는지 찾아보았다.
  1. Ruff
Ruff는 파이썬 코드의 린트와 포맷, 보안 검사 등을 해주는 도구이다.(900개 이상의 린트 규칙을 지원)
코드의 미사용 import, 문법 오류 등 린트를 탐지해 결과를 보여주고, --fix 옵션을 통해 자동 수정이 제공되는 일부 규칙을 안전한 범위에서 수정해 준다. 또한 S 규칙으로 취약한 암호화 및 해시 알고리즘, 민감한 정보가 코드 내에 노출되어 있지는 않은지 등 소스 코드 내의 잠재적인 보안 취약점과 리스크를 탐지해준다.
  1. mypy
파이썬 코드를 실행하기 전 타입 힌트를 바탕으로 오류를 미리 찾아내는 정적 타입 검사 도구이다.
아래의 예시처럼 잘못된 인자의 타입과, 반환 타입 불일치를 탐지해준다.
""" 잘못된 인자 타입 예시 """ def greet(name: str) -> None: print(f"Hello, {name}") # str 타입이 필요한데, int 타입을 전달 greet(123) """ 반환 타입 불일치 """ def add_numbers(a: int, b: int) -> int: # int 타입을 반환해야 하는데, 결과가 str 타입 return f"{a+b}"
  1. pytest
개발자가 테스트 코드에 작성한 기대값과 실제 실행 결과를 비교해주는 도구이다.
함수의 계산 결과, 비즈니스 로직, 잘못된 값을 넣었을 때 오류가 발생하는지를 검증하는 데 사용한다. 직접 코드를 돌려서 오류를 확인해보면 되지 않을까? 라는 안일한 생각을 할 수도 있지만, pytest는 어디서 어떤 값에 의해 실패했는지를 정확하게 보여줘 생산성이 좋다.
또한, pytest-cov 플러그인이 제공하는 --cov 옵션을 통해 코드 커버리지도 측정이 가능해, 작성한 코드가 실제 소스 코드의 몇 %를 실행하고 검증 하였는지 비율로 보여줘 테스트 구멍을 쉽게 찾아낼 수 있다.
  1. pip-audit
파이썬 환경과 의존성 파일을 스캔하여 패키지의 CVE를 탐지하고 수정 가능한 버전을 안내해주는 도구이다. CI 파이프라인에서 코드 내에 취약한 오픈소스 라이브러리가 포함되지 않도록 차단해주는 역할을 수행한다.
  1. Semgrep
소스코드 보안 취약점, 버그, 코딩 표준 위반을 탐지하는 정적분석 도구(SAST)이다. YAML 형식의 사용자 정의 규칙 작성이 가능해, 팀 코딩 규칙과 보안 패턴을 유연하게 정의할 수 있다.
또한, Taint 분석 기법을 지원하여 SQLi, XSS 등의 입력값 기반 취약점을 잡아내는 데 강점이 있다.
Taint 분석 - 데이터 흐름을 추적해 보안 취약점을 잡아냄
  • source - 사용자 입력 등 외부에서 들어오는 신뢰할 수 없는 데이터의 시작점
  • sink - SQL 쿼리, 시스템 명령어 실행 등 오염된 데이터가 도달하면 보안 문제가 발생할 수 있는 위험한 연산 지점
  • sanitizer - 입력값을 검증하거나 안전하게 변환하여 더 이상 위험하지 않도록 처리하는 코드 영역
  1. SARIF
SARIF(Static Analysis Results Interchange Format)는 정적 분석 도구가 생성하는 오류, 취약점, 경고 등의 진단 결과를 통일된 규격으로 교환하기 위해 만든 JSON 기반의 개방형 표준 포맷이다.
여러 도구의 보안 검사 결과를 CI 파이프라인 코어인 GitHub Actions가 읽을 수 있는 공통 형식으로 변환하는데 활용한다.
  1. gitleaks
소스코드나 Git 히스토리에서 실수로 포함된 비밀번호, API 키, PAT 등 Secrets을 탐지하는 도구이다.
  1. reviewdog
Ruff와 같은 린터의 검사 결과 및 SAST 결과를 받아 문제가 있는 코드 줄에 자동으로 코드 리뷰 코멘트를 남겨주는 도구이다.
GitHub Actions CI 파이프라인에서 PR에 자동 리뷰를 남겨준다고 이해하면 된다.
  1. Dependabot
GitHub에서 제공하는 자동 의존성 및 보안 취약점 관리 도구이다.
프로젝트에서 사용하는 라이브러리에 보안 문제가 있거나 새로운 라이브러리 버전이 나오면 이를 감지해 업데이트 PR을 자동으로 생성해준다.
  1. Rulesets
Repository 나 조직 단위에서 브랜치, 태그, 푸시 작업 등을 안전하고 일관되게 제어하기 위한 중앙 집중식 규칙 모음이라고 한다. AWS의 Permissions Boundary 처럼 최대 허용 범위를 제한한다는 점이 비슷한 것 같다.
도구들이 어떤 역할을 하는지 찾아보니, 비슷한 기능을 하는 도구들도 있는 것 같다. 결국 중요한건 조직 환경에서 코드를 관리할 때, 발생할 수 있는 오류, 보안 취약점, 버그 등이 실제 운영하는 인프라에 잘못 배포되지 않도록 보안도구들을 활용하여 이중・삼중으로 검증하는 안전한 보안 로직을 구성해야 한다는 것이다. 이게 곧 CI 파이프라인이며 SecOps의 기초가 된다.

🧪 도구 사용기

제공받은 취약한 코드 프로젝트를 보안 도구로 직접 탐지해보자.
Ruff
우선 ruff check 를 활용해 코드 린트를 점검한다.
실습은 uv 환경에서 진행, uv run ruff check app/ 명령으로 점검해보니, 아래 이미지와 같이 린트 규칙에 매핑된 에러가 13개 검출되었다.
13개 중 1개13개 중 1개
13개 중 1개
어떤 위협 또는 오류가 있는지에 대한 설명과 함께 취약 코드를 직접적으로 보여준다. 또한, 탐지결과 끝 부분에서 --fix 옵션으로 자동 수정가능한 에러의 개수도 알려준다.
매핑된 린트 규칙은 여기서 자세히 볼 수 있다.
Ruff의 다른 기능인 format은 따옴표, 공백, 줄바꿈, 들여쓰기 등 코드 스타일을 통일해줘 협업에서 팀원들이 동일한 코드 스타일을 유지할 수 있도록 한다. 문법이 잘못된 경우에는 포맷팅이 되지 않고, 오류를 보여줄 수도 있다. 스타일은 기본적으로 Ruff 포맷터가 정의한 스타일에 따라 설정되고, pyproject.toml 에서 일부 기준을 설정할 수 있다.
실습 파일에서 uv run ruff format --check . 명령으로 포맷팅이 필요한 파일을 확인해보면, app/messy_format.py 파일이 포맷팅이 필요하다고 알려준다.
notion imagenotion image
 
messy_format.py 파일의 코드는 아래와 같이 작성 되어있다. 문법 오류는 없지만, Ruff의 기본 포맷스타일에 어긋나기 때문에 탐지되었다.
def build_label(name: str, count: int) -> str: unit = 'items' if count != 1 else 'item' return f'{name}: {count} {unit}'
Ruff 포맷터는 --check 옵션 없이 format 만으로 자동으로 코드 스타일 수정을 지원해주고 있어, format 명령을 통해 Ruff 기본 코드스타일로 통일시켜보자.
notion imagenotion image
 
uv run ruff format . 명령으로 포맷팅이 되었고 파일의 코드를 확인 해보면, 싱글 쿼터(’)를 더블 쿼터(”)로 수정한 것을 확인할 수 있다.
def build_label(name: str, count: int) -> str: unit = "items" if count != 1 else "item" return f"{name}: {count} {unit}"
CI 파이프라인에서의 포맷터는 코드 스타일을 ruff format 명령 하나로 자동으로 통일해주기 때문에 diff 시에 로직 변경만이 보여, 리뷰의 효율을 높여준다.
mypy
mypy는 앞서 공부했듯이, 파이썬 코드의 타입힌트를 보고 잘못된 인자 타입, 반환 타입을 탐지하여 결과를 출력한다.
uv run mypy 명령으로 실습 파일의 타입 오류를 확인 해보면,
notion imagenotion image
type_confusion.py 파일에서 return-value 1건, arg-type 1건, 총 2건의 타입 오류가 있다고 알려준다. 파일의 코드를 확인 해보면, 인자의 타입 오류와 리턴 타입 오류가 바로 눈에 보인다.
def apply_discount(price: int, rate: float) -> int: # int 를 반환 return price * rate # int * float 의 결과는 float 타입이지만 int 타입을 반환하라고 선언 result = apply_discount("100", 0.1) # price는 int 타입이지만, 인자 값으로 str 타입을 입력
코드의 타입 불일치를 수정한 뒤에 다시 uv run mypy 명령을 입력하면, 검사를 정상적으로 통과한다.
notion imagenotion image
 
pytest
pytest는 프로젝트에서 코드 수정, 기능 추가, 버그 패치 등 프로그램에 변경이 생긴 후, 기존에 잘 작동하던 기능이 여전히 정상적으로 동작하는지와 의도하지 않은 새로운 오류가 발생하지 않았는지를 확인하기 위한 회귀테스트 목적으로 사용된다.
우선 실습 파일에 uv run pytest 명령으로 테스트를 해보면,
notion imagenotion image
tests/test_price_logic.py 에서 정의한 경계값과 다른 계산 결과가 나와 탐지된 것을 볼 수 있다.
# tests/test_price_logic.py 코드 from app.price_logic import order_total def test_bulk_discount_applies_at_exactly_ten(): assert order_total(10) == 9000
코드에서는 계산의 결과를 9000으로 기대 하였지만, 실제 함수의 결과는 10000이 나오면서 의도에 맞지 않게 동작했던 것이다.
order_total 함수를 정의한 price_logic.py 의 코드의 주석만 봐도 알 수 있듯이, >>= 의 잘못된 사용으로 인해 경계값 테스트를 만족하지 못했다. >= 를 사용하면, 쉽게 오류를 잡을 수 있다.
def order_total(quantity: int, unit_price: int = 1000) -> int: """수량에 따른 주문 총액을 계산합니다. 사양: 수량이 10개 '이상'이면 대량 구매로 보고 개당 100원을 깎아 줍니다. 미만이면 기본 단가(unit_price)를 그대로 씁니다. """ if quantity > 10: # 10개일 경우, 100원 할인 적용이 안됨(경계값 오류) unit_price = unit_price - 100 return quantity * unit_price
이번에는 pytest --cov 명령을 통해 테스트가 실행되지 않은, 코드를 확인해보도록 한다.
--cov--cov
--cov
--cov-report=term-missing--cov-report=term-missing
--cov-report=term-missing
 
출력되는 결과처럼, Miss 를 통해 테스트되지 코드의 문장 수를 확인할 수 있다. --cov-report=term-missing 옵션을 같이 사용하면, 코드 줄 번호까지 친절히 알려준다.
Semgrep
Semgrep 룰을 기반으로 매칭되면 탐지해주는 SAST 이다.
실습 파일에는 rules.yml 로 커스텀 룰이 정의되어 있다.
rules: - id: lab-taint-sql-concat mode: taint languages: [python] severity: ERROR message: > 사용자 입력이 문자열 조립을 거쳐 SQL 실행 지점까지 흘러갑니다. 문자열 조립 SQL(Ruff S608)은 이 패턴을 놓칩니다. 까닭은 README의 "패턴 매칭과 taint 분석의 검출 범위 차이"에 있습니다. pattern-sources: - pattern: flask.request.args.get(...) - pattern: flask.request.args[...] - pattern: flask.request.form.get(...) pattern-sinks: - pattern: $CURSOR.execute($QUERY, ...) focus-metavariable: $QUERY
taint 기법을 사용하는 것을 볼 수 있고, source로 들어오는 사용자의 입력 값이 sink에 사용되어 SQLi 취약점이 있는지를 탐지하는 룰인 것 같다.
제공해준 도커 명령을 활용해서 semgrep 테스트를 진행해보면,
notion imagenotion image
sqli_taint_only.py 파일의 25번 줄 코드에서 룰에 매칭된 것을 확인할 수 있다.
직접 코드를 보면, q에 검증되지 않은 taint source가 들어왔고, query에서 순수 문자열이었던 SELECT … 구문과 사용자 입력값 q가 조립되어 오염을 전파, 이를 SQL 실행함수에 전달하고 있다. 즉, SQL 구문에 사용자의 입력값이 검증없이 입력되어 SQLi 취약점이 발생한다.
def search(cur): q = request.args.get("q") prefix = "SELECT * FROM users WHERE name LIKE '%" # 순수 문자열 query = prefix + q +"%'" # 표면적으로 SELECT과 같은 SQL 키워드가 없음 cur.execute(query) # SQL 실행 함수에 전달
rules.yml 에서 정의한 규칙이 바로 위와 같은 패턴을 탐지하는 룰인 것이다. Ruff 도구의 패턴 매칭은 위 코드에서 SQLi 취약점을 잡지 못하지만, Taint 분석은 잡을 수 있다.
다만, SAST 특성 상 오탐이 발생할 가능성도 높다. 가능하면 Semgrep 규칙에 정확한 sanitizer를 추가하여 안전한 wrapper 함수 구조를 명확하게 만드는 것이 중요하다.
gitleaks
PAT, 비밀번호, API 키 등 Secrets이 소스코드에 하드코딩 되어 있거나, 이미 커밋되어 Git history에 있는 경우를 탐지할 수 있다.
gitleaks dir -v . 명령으로 현재 작업 중인 폴더의 파일에 Secrets이 들어가있는지 확인 해보면,
notion imagenotion image
실습 목적으로 일부러 파일 내에 삽입되어 있는 PAT와 RSA 개인키와 같은 Secrets를 탐지해 주는 것을 볼 수 있다. 추가로, gitleaks git -v . 명령을 이용하면 Full-history 점검을 통해 커밋된 적이 있는지도 확인할 수 있다.
notion imagenotion image
현재는 실습용 가짜 Secrets 이지만, 실제 Secrets이 커밋 된 적이 있다고 하면 즉시 키를 폐기하고 커밋 히스토리에서 제거하여야 한다.

🕹️ CI 파이프라인 조립하기

해당 실습에서 사용되는 도구들을 직접 사용해보면서, 각 도구가 어떤 기능을 하고 실제 SecOps 운영 시 어떤 점을 주의해야하는지에 대해 이해하였다.
이제 이 도구들과 GitHub Actions CI 도구를 활용해 직접 CI 파이프라인을 조립해 볼 것이다.
GitHub Actions는 레포의 .github/workflows/에 YAML 구문으로 정의되어 있는 파일의 워크플로우에 따라 동작한다.
GitHub Actions 사용을 위해 먼저 알아야 하는 워크플로 구문은 name, on, permissions, env, jobs 정도가 있다. 각 구문이 어떤 역할을 하는지 살펴보자.
  • name - GitHub Actions 의 작업 목록에서의 워크플로 이름을 지정한다.
  • on - 워크플로를 자동으로 트리거하기 위해 실행 이벤트를 정의한다.
    • # on 사용 예시 on: pull_request: # PR이 생성되면 실행 push: branches: - main # main 브랜치로 push될 때 실행 workflow_dispatch: # Actions 화면에서 수동실행 workflow_call: # 다른 워크플로가 호출하면 실행
  • permissions - GitHub 토큰에 부여된 기본 사용 권한을 수정하여 액세스의 범위를 정한다.
    • # permissions 사용 예시 permissions: contents: read # 저장소 내용을 읽을 수 있음 pull-requests: write # PR에 리뷰 코멘트를 작성할 수 있음
  • env - 작업 단계에서 사용할 수 있는 환경변수를 정의한다.
    • # env 사용 예시 env: PYTHON_VERSION: "3.12" # 모든 job과 step에서 사용하는 변수 정의
  • jobs - 실제로 실행할 작업들을 정의한다.
    • # jobs 사용 예시 jobs: <job_id>: runs-on: ubuntu-latest # jobs을 실행할 가상 머신 - GitHub steps: - uses: actions/checkout@v7 # 소유자 / 저장소 / Git 참조(버전 태그) - run: uv run ...
예시로 main 브랜치로 PR을 하면 terraform plan을 수행하는 워크플로우를 작성해보자.
name: Terraform Plan on: pull_request: branches: - main paths: - "**/*.tf" - ".terraform.lock.hcl" permissions: contents: read jobs: plan: runs-on: ubuntu-latest steps: - name: 저장소 코드 가져오기 uses: actions/checkout@v7 - name: 테라폼 설치 uses: hashicorp/setup-terraform@v4 with: terraform_version: "1.15.8" - name: Terraform Fmt run: terraform fmt -check -recursive - name: Terraform Init run: terraform init -input=false - name: Terraform Validate run: terraform validate -no-color - name: Terraform Plan run: terraform plan -input=false -no-color
대충 위와 같은 흐름이 될 것이다. 만약, AWS와 같은 클라우드 인프라에 적용하는 거라면 인증 절차가 필요하겠지만, 여기서는 GitHub Actions의 워크플로 문법을 이해하는 것만을 목표로 한다.
이제 진짜 워크플로를 조립해 CI 파이프라인을 완성시켜 보도록 하자.
01-ruff.yml
  1. reviewdog 실행 조건 채우기, PR 이벤트일 때, 돌도록 수정
- name: Ruff lint -> reviewdog (PR 인라인 리뷰) if: github.event_name == 'pull_request' # GitHub 컨텍스트 예시 참고,
  1. Ruff의 포맷터 검사와, 린트 + 보안 검사 자동화 만들기
- name: Ruff format run: uv run ruff format --check . # 고치지는 않음 - name: Ruff lint and security run: uv run ruff check app/
도구 사용기에서 활용했던 명령을 그대로 GitHub Actions가 동작하도록 해주면 된다.
02-mypy.yml
app/ 디렉터리를 mypy 도구로 정적 타입 검사하는 스텝 완성하기
- name: mypy run: uv run mypy app/
03-pytest.yml
  1. pytest로 테스트를 실행하고 커버리지 리포트를 만드는 스텝 완성하기
- name: pytest run: uv run pytest --cov=app --junitxml=pytest.xml --cov-report=xml:coverage.xml # pytest 결과를 pytest.xml 파일로 저장하고, 커버리지 결과를 coverage.xml 파일에 저장
  1. 앞 스텝이 실패해도, PR 이면 다음 동작을 수행하도록 만들기
- name: 커버리지 스티키 코멘트 if: ${{ always() }} && github.event_name == 'pull_request' # always - 앞 단계가 취소되도 단계가 항상 실행되고 true가 반환되도록 해줌
04-pip-audit.yml
lock 파일에서 requirements.txt를 뽑아 pip-audit으로 스캔하는 스텝 완성하기
- name: lock 파일에서 의존성 추출 run: uv export --format requirements-txt --no-emit-project -o requirements.txt - name: pip-audit run: uvx pip-audit -r requirements.txt --no-deps # --no-deps - 의존성 관계를 다시 계산하지 않고 파일에 적힌 버전을 그대로 검사
05-semgrep.yml
  1. Semgrep을 레지스트리 팩과 커스텀 룰로 실행하고, SARIF 파일을 만드는 스텝 완성하기
- name: Semgrep run: | semgrep scan \ --config p/python \ --config .semgrep/rules.yml \ --sarif --output semgrep.sarif \ --error # p/python 레지스트리 팩, rules.yml 커스텀 룰
  1. 앞 스텝이 실패해도 결과가 올라가도록 수정
- name: SARIF 업로드 (코드 스캐닝) if: ${{ always() }} # 앞 단계의 성공유무와 상관없이 항상 실행되고, true를 반환하는 상태 검사 함수
06-gitleaks.yml
gitleaks로 Secrets를 스캔하는 스텝 완성하기
- name: gitleaks uses: gitleaks/gitleaks-action@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # github.token도 가능
07-ci-summary.yml
  1. 트리거 이해하기 - PR, main으로 push, schedule 트리거 발생 시 실행 되도록 on 채우기
on: pull_request: # PR 이벤트 발생 시 트리거 push: branches: - main # main 브랜치로 push 이벤트 발생 시 트리거 schedule: - cron: '30 20 * * 0' # 매주 일요일 20:30 스케줄링으로 트리거
  1. concurrency 이해하기 - 동일한 동시성 그룹을 사용하는 작업 또는 워크플로가 한 번에 하나만 실행되도록 해준다.
on: pull_request: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} # 트리거 조건이 정의된 직후에 concurrency 키워드를 사용해 # 특정 분기에 대한 전체 워크플로 실행의 동시성을 제한 # 즉, PR에 새 커밋이 연속으로 push되면 이전 워크플로 실행을 취소하고 최신 실행만 계속함
  1. needs 이해하기 - 현재 작업의 직접적인 종속성으로 정의된 모든 작업으로부터의 출력을 포함한다. 즉, 동작을 실행하기 위해 선행되어야 하는 작업을 작성하는 것이다. 쉽게 생각하면, A needs: B 는 “A를 실행하려면 B가 먼저 필요하다”라고 보면 된다. 실습 파일에서는 앞의 도구들의 워크플로를 호출하여 현재 실행 안에서 실행하고, 완료된 결과를 needs로 수집하고 있다. 각 도구 워크플로를 호출하는 조건을 완성시키자
# 코드가 바뀌었고, 예약 실행이 아닐 때 ruff 호출 ruff: needs: changes if: needs.changes.outputs.code == 'true' && github.event_name != 'schedule' # 코드가 바뀌었고, 예약 실행이 아닐 때 mypy 호출 mypy: needs: changes if: needs.changes.outputs.code == 'true' && github.event_name != 'schedule' # 코드가 바뀌었고, 예약 실행이 아닐 때 pytest 호출 pytest: needs: changes if: needs.changes.outputs.code == 'true' && github.event_name != 'schedule' # 코드가 바뀌었을 때 pip-audit 호출, 예약 실행에서도 돈다 pip-audit: needs: changes if: needs.changes.outputs.code == 'true' # 코드가 바뀌었을 때 semgrep 호출, 예약 실행에서도 돈다 semgrep: needs: changes if: needs.changes.outputs.code == 'true' # 코드가 바뀌었고, 예약 실행이 아닐 때 gitleaks 호출 gitleaks: needs: changes if: needs.changes.outputs.code == 'true' && github.event_name != 'schedule'
여기까지 하게되면, CI 파이프라인 조립이 완성되었다. 이제 PR을 열어 성공 기준을 충족하는지 확인해본다. PR을 열게 되면, ci-summary.ymlon: pull_request:가 트리거되어 워크플로를 실행시킨다. 하지만, 해당 과정에서는 각 워크플로에 대한 자세한 이해를 위해, ci-summary.yml 워크플로의 트리거를 발생시키지 않고 각 워크플로를 수동으로 검증하였다.

성공 기준

01-ruff.yml
워크플로가 ‘빨간불’이 되고, 로그에 포맷 불일치(messy_format)와 문자열 조립 SQL(S608)・가변 기본 인자(B006) 같은 린트가 함께 보이면 성공
notion imagenotion image
ruff format 에서 탐지되면서, 다음 작업이 수행되지 않아 린트와 보안 규칙에 대한 결과는 확인하지 못했다.
# 01-ruff.yml의 린트와 보안 규칙 작업에 상태 검사 함수를 조건으로 추가 - name: Ruff lint and security run: uv run ruff check app/ if: ${{ always() }} # 이전 작업이 실패해도 true 반환
수정하고, 다시 워크플로를 돌려본 결과 formatterlint and security 작업이 함께 실행하며, 탐지되는 것을 확인할 수 있었다.
notion imagenotion image
02-mypy.yml
type_confusion.py에서 반환 타입 오류(return-value)와 인자 타입 오류(arg-type) 2건이 뜨고 ‘빨간불’이면 성공
notion imagenotion image
성공 기준에 정확히 부합하는 것을 확인하였다.
03-pytest.yml
test_price_logic이 1건 실패해 ‘빨간불’이 되고, PR에 커버리지 스티키 코멘트가 붙으면 성공
notion imagenotion image
notion imagenotion image
pytest 도구가 정상적으로 실행되어서 빨간불이 되었고, 커버리지 리포트도 확인할 수 있었지만 PR이 아닌 수동 워크플로 점검이다보니 커버리지 스티키 코멘트가 붙었는지 확인하지 못하였다.
확인을 위해, 03-pytest.yml 파일의 on: pull_request: 이벤트 트리거를 추가해주었다.
notion imagenotion image
github-actions 봇이 스티키 코멘트를 작성해주는 것을 확인하였다!
04-pip-audit.yml
jinja2・requests와 ‘직접 쓴 적 없는’ werkzeug의 CVE가 보고되고 워크플로가 ‘빨간불’이면 성공
notion imagenotion image
전이 의존성 CVE까지 성공적으로 탐지되는 것을 확인하였다.
05-semgrep.yml
sqli_taint_only.py에서 데이터 흐름 SQLi(lab-taint-sql-concat)가 보고되고 워크플로가 ‘빨간불’이면 성공
notion imagenotion image
Scan Summary를 보여주고 있지만, 정확한 findings을 노출해주지 않고 있다. 출력 결과를 SARIF 형식으로 변환하여 semgrep.sarif 파일로 Alert를 생성해주라고 했기 때문이다. 저장소의 Security and quality의 Code scanning을 확인해보면, 결과를 확인할 수 있다.
notion imagenotion image
p/python 레지스트리 팩과, rules.yml 의 커스텀 룰에 각각 매칭 된 것을 성공적으로 확인하였다.
06-gitleaks.yml
leaky_settings.py의 가짜 PAT와 deploy/fake_deploy_key.pem의 가짜 RSA키가 검출되어 워크플로가 ‘빨간불’이면 성공
notion imagenotion image
notion imagenotion image
secrets이 탐지되어 워크플로가 빨간불인 것을 성공적으로 확인하였다.
각 워크플로를 수동으로 검증하여, 각 워크플로의 조립이 정상적으로 된 것을 확인하였다. 최종적으로 01 ~ 06 워크플로를 재사용하여 하나의 워크플로에서 실행하는 ci-summary.yml 워크플로의 동작을 검증한다.
의도적으로 제거했던, ci-summary.yml파일의 on: pull_request: 이벤트 트리거를 넣어주고, PR을 생성한다.
커버리지 스티키 코멘트는 정상적으로 동작한 것을 확인할 수 있고,
notion imagenotion image
각 도구들의 워크플로도 정상적으로 동작한 것을 볼 수 있지만,
notion imagenotion image
gitleaks는 탐지 없이 성공적으로 통과하는 결과가 발생했다.
notion imagenotion image
원인을 찾아보니, 수동 실행과 PR 실행에서 gitleaks의 검사 범위가 다르기 때문에 결과도 다르게 나온 것이다. gitleaks actions은 PR 이벤트에서 PR에서 새로 추가된 첫 커밋 ~ 마지막 커밋 범위만을 검사한다고 한다. 그래서 이미 main 브랜치에 가짜 RSA 키와 PAT가 존재하기 때문에 이 PR에서 잡히지 않았던 것이다.
이를 검증하기 위해, main 브랜치의 secrets를 삭제하고 커밋을 생성하여 PR을 발생시켜 보았다.
notion imagenotion image
notion imagenotion image
커밋에 secrets가 포함되면서, gitleaks도 정상적으로 탐지하여 워크플로가 실패하는 것을 확인하였다.
위와 같은 검증과정을 통해 CI 파이프라인이 성공적으로 조립 됐음을 확인했다.

🚥 브랜치 보호하기

CI 파이프라인을 만들었다고 위험한 코드가 merge되지 않을 거라고 생각해서는 안된다. 우리가 앞서 한 것은 보안 도구를 이용해 PR을 생성하는 코드에 취약점이 있는지 확인하고 그 결과를 보여주기만 할 뿐, 병합을 막지는 못한다.
실제로 검사가 실패한 PR을 merge해보면, 성공적으로 merge가 되는 것을 확인할 수 있다.
notion imagenotion image
이렇게 되면, 공통으로 관리하는 코드와 레포가 취약점을 그대로 가지게 된다. 이를 막기 위해서 필요한 것이 바로 브랜치 보호 규칙이다.
레포의 Settings → Rulesets → Rulesets → New ruleset → New branch ruleset 으로 들어가면 보호 규칙을 설정할 수 있다.
notion imagenotion image
Enforcement status 를 Active로 설정하고, Target branches에 default or main 브랜치를 넣어준다.
그리고, Branch rules 에서 Require a pull request before merging 설정과, Require status checks to pass 설정을 한다.
notion imagenotion image
위 설정은 병합 전 PR을 요구하고, 2명의 승인이 있어야 merge가 가능하도록 하는 설정이다. 팀 프로젝트와 같이 공용으로 사용하는 레포에는 팀 규모에 맞게 적정한 승인 인원을 설정해주면 좋다. 지금은 실습을 위해 해당 설정은 사용하지 않도록 한다.
notion imagenotion image
위 설정은 summary 자동 검사가 성공해야만 보호된 브랜치로 PR을 merge할 수 있도록 하는 설정이다. 즉, summary 검사에 실패가 하나도 없어야 main 브랜치에 병합해준다.
이제 브랜치 보호 규칙을 생성하였으니 실제로 summary 워크플로가 실패하였을 때 병합을 막는지 확인해보자.
notion imagenotion image
실패한 검사가 있으니, merge 버튼을 활성화조차 해주지 않는다.

🧑🏻‍🔧 수리하기

CI 파이프라인에서 사용할 도구를 이해하고, 이를 자동화 해주는 워크플로를 조립하였으며, 취약한 코드가 병합되는 것을 막기 위한 브랜치 보호 규칙까지 설정하였다.
이제 취약점이 있는 코드와 파일이 모든 검사를 통과할 수 있도록 직접 수정해보자. 실습과제에서 제시하고 있는 수리 순서에 맞춰 진행하도록 한다.
pytestmypyruff lint + StaintSecretspip-audit
pytest 검사가 탐지하는 파일 → price_logic.py
# price_logic.py def order_total(quantity: int, unit_price: int = 1000) -> int: - if quantity > 10: + # 11개부터 할인되는 경계값 버그를 10개부터 할인되도록 수정 + if quantity >= 10: unit_price = unit_price - 100 return quantity * unit_price
notion imagenotion image
pytest 검사를 성공적으로 통과
mypy 검사가 탐지하는 파일 → type_confusion.py
# type_confusion.py - def apply_discount(price: int, rate: float) -> int: + # int * float 는 float 타입이므로 return-value 버그 수정 + def apply_discount(price: int, rate: float) -> float: return price * rate - result = apply_discount("100", 0.1) + price 인자가 int 타입이므로 str -> int 로 arg-type 버그 수정 + result = apply_discount(100, 0.1)
notion imagenotion image
mypy 검사도 성공적으로 통과
ruff lint + ruff 규칙 검사가 탐지하는 파일
Ruff는 ruff check --fix명령으로 일부 자동 수정이 가능하다. ruff check --diff명령으로 수정하지 않고, --fix가 수정하는 내용을 미리 확인하자!
notion imagenotion image
  1. insecure_hash.py (S324)
# insecure_hash.py def fingerprint(data): - return hashlib.md5(data).hexdigest() + # 안전하지 않은 MD5 해시 함수를 사용하고 있어 lint 보안 규칙에 탐지(S324) -> sha256 해시 함수로 수정 + return hashlib.sha256(data).hexdigest()
  1. lint_playground.py (총 7건)
  • UP035, I001, F401, UP006
# lint_playground.py - from typing import List + # 구식 타입 별칭 import가 폐기 예정 방식이어서 lint 규칙에 탐지(UP035) -> import 제거 - import os + # import 미정렬(I001) 및 미사용 import(F401) 제거 - def clamp_values(numbers: List[int]) -> List[int]: + # 구식 타입 표기를 사용하고 있어 lint 규칙에 탐지(UP006) -> List를 list로 수정 + def clamp_values(numbers: list[int]) -> list[int]: - result: List[int] = [] + result: list[int] = []
  • SIM102
# lint_playground.py - if value > 0: - if value < 100: + # 접이식 if를 사용하고 있어 lint 규칙에 탐지(SIM102) -> if ... and ... : 사용 + if value > 0 and value < 100:
  • B006
# lint_playground.py - def find_missing(value, items=[]): + # 함수 인자 기본값으로 가변 객체가 사용되면 함수의 모든 호출에 공유되기 때문에 lint 규칙에 탐지(B006) + # 불변 데이터 구조를 사용하거나 None을 기본값으로 설정 + def find_missing(value, items=None): + if items is None: + items = []
  • E711
# lint_playground.py - if value == None: + # None으로 not-is-test를 하고 있어 lint 규칙에 탐지(E711) -> is or is not을 사용 + if value is None:
  1. shell_injection.py (S602)
# shell_injection.py - def run_command(cmd): - subprocess.run("sh -c " + cmd,shell=True) + # subprocess에 shell=True를 사용하게되면, + # 공격자가 임의의 Shell 명령을 실행할 수 있어 lint 보안 규칙에 탐지(S602) + # split()로 나누어 리스트 인자를 활용하고, shell=True 제거 + # 수정하면, S603 린트가 탐지될 수 있는데 입력값 검증으로 해결할 수 있음 + # 여기서는 #noqa: S603으로 ignore -> 입력 검증을 사람이 확인하고 경고를 예외처리 + import shlex + def run_command(cmd: str) -> None: + subprocess.run(shlex.split(cmd), check=True) # noqa: S603
shlex.split()은 Shell 명령 주입을 막지만, 사용자의 rm, curl 같은 실행 프로그램 자체를 선택하는 것까지는 막지 못한다. 신뢰할 수 있는 내부 명령만 전달되는 환경에서만 사용하는 것을 권장한다.
  1. sqli_fstring.py (S608)
# sqli_fstring.py def find_user(cur, name): - cur.execute(f"SELECT * FROM users WHERE name = '{name}'") + # 하드코딩된 SQL 표현식을 사용하고 있어 lint 보안 규칙에 탐지(S608) + # 사용자 입력을 f-string과 같은 문자열 조립 방식으로 SQL 문장에 삽입할 수 있으니, + # 사용자 입력값과 SQL 구문을 분리하여야 함 + cur.execute("SELECT * FROM users WHERE name = %s", (name,)) # SQL placeholder는 DB에 따라 다르니 환경에 맞춰 사용해야 한다. # SQLite: ? / PostgreSQL・MySQL: %s
  1. unsafe_pickle.py (S301)
# unsafe_pickle.py - import pickle + import json def deserialize(blob): - return pickle.loads(blob) + # 신뢰할 수 없는 피클을 역직렬화하고 있어 lint 보안 규칙에 탐지(S301) + # 피클에 임의의 객체를 생성할 수 있어 보안에 취약 -> json 형식을 활용 + return json.loads(blob)
ruff format이 탐지하는 파일 → messy_format.py
ruff format명령으로 자동 포맷팅
# messy_format.py - unit = 'items' if count != 1 else 'item' - return f'{name}: {count} {unit}' + # 싱글 쿼터(') -> 더블 쿼터(") + unit = "items" if count != 1 else "item" + return f"{name}: {count} {unit}"
notion imagenotion image
ruff lint + S / ruff format 검사가 성공적으로 통과
Semgrep taint 검사가 탐지하는 파일 → sqli_taint_only.py
# sqli_taint_only.py def search(cur): - q = request.args.get("q") - prefix = "SELECT * FROM users WHERE name LIKE '%" - query = prefix + q + "%'" - cur.execute(query) + # 파라미터 바인딩을 통해 사용자 입력을 SQL 문장과 분리 + # rules.yml 커스텀 룰은 안전한 두 번째 파라미터 바인딩도 sink로 판단하여 탐지 + # nosemgrep: lab-taint-sql-concat으로 탐지 우회하여 처리 - 안전하다고 판단 + q = request.args.get("q", "") + cur.execute( # nosemgrep: lab-taint-sql-concat + "SELECT * FROM users WHERE name LIKE %s", + (f"%{q}%",), + )
notion imagenotion image
Semgrep taint 검사도 통과하였다.
gitleaks 검사가 탐지하는 파일
app/leaky_settings.pydeploy/fake_deploy_key.pem에 각각 PAT와 RSA 키가 하드코딩 되어 있어, gitleaks 탐지에 걸리게 된다. gitleaks는 커밋된 기록까지 full-history 점검을 하기 때문에, 커밋된 이력이 있으면 파일을 지워도 탐지되게 된다.
하지만, GitHub Actions에서의 gitleaks 검사 범위는 앞서 CI 파이프라인 조립하기에서 확인 하였듯이 PR 에서 새롭게 추가된 커밋만을 탐지한다. 따라서, fork 이후 Secrets 값을 커밋하지 않았다면 GitHub Actions에서 gitleaks검사는 문제없이 통과될 것이다.
나는 CI 파이프라인 조립하기 과정에서 gitleaks 검증을 위해 Secrets을 만들어 가짜 Secrets을 만들어 커밋하였기 때문에, 아래 이미지처럼 탐지되어 워크플로가 실패한다.
notion imagenotion image
실제 Secrets이 커밋 된거라면, Secrets을 폐기하고, 기록에서도 지워야 하지만 해당 수리 과정에서는 .gitleaksignore 파일에 gitleaks 결과의 Fingerprint값을 넣어서 해결한다.
notion imagenotion image
성공적으로 gitleaks 검사를 통과하였다.
pip-audit 검사가 탐지하는 파일 → requirements.txt
requirements.txt 파일에는 uv export 명령으로 uv.lock파일에서 전이 의존성을 포함하여 뽑아낸 패키지의 목록이 들어있다. uvx pip-audit -r requirements.txt 명령으로 패키지의 CVE 검사 결과를 출력하고, 각 패키지의 Fix Versions 확인해 수리를 진행한다.
pip-audit -r requirements.txt --no-depspip-audit -r requirements.txt --no-deps
pip-audit -r requirements.txt --no-deps
 
pyproject.toml 파일에서 Fix Versions를 기준으로 버전을 변경한다.
# pyproject.toml(의존성 조건이 정의된 파일) dependencies = [ - "flask==2.2.5", + "flask>=3.1.3", - "jinja2==3.1.2", + "jinja2>=3.1.3", - "requests==2.31.0", + "requests>=2.32.0", ] dev = [ - "pytest==8.3.5", + "pytest>=9.0.3", ] [tool.uv] - constraint-dependencies = ["werkzeug==2.2.3"] + constraint-dependencies = ["werkzeug>=2.3.8"]
수정 후에 uv lock --upgrade 명령으로 uv.lock파일을 갱신하고, uv sync --frozen 명령을 통해 의존성을 내려받는다.
그리고, uv export 명령으로 requirements.txt 파일을 다시 뽑아내고 uvx pip-audit 명령으로 결과를 확인한다.
 uvx pip-audit -r requirements.txt --no-deps uvx pip-audit -r requirements.txt --no-deps
uvx pip-audit -r requirements.txt --no-deps
패키지 목록에 CVE가 발견되지 않은 것을 확인할 수 있다.
이제 모든 수리가 끝났고, 워크플로를 정상적으로 통과해 merge가 가능해진다!
notion imagenotion image

마무리

이번 실습을 통해서 GitHub Actions 이라는 CI 도구가 작동하는 원리, 문법을 이해 하였고 팀 프로젝트와 같은 협업 구조에서 코드를 관리할 때, 코드 스타일부터 소스코드 취약점, 패키지 CVE 까지 보안을 고려해야하는 부분이 많다는 것을 알게 되었다.
우리 근두운 프로젝트에서도 IaC 기반으로 AWS 인프라를 관리하고 있으니, Terraform(HCL)에서 CI 파이프라인을 만들기 위해 사용할 수 있는 도구가 어떤 것들이 있는지 찾아보고, 어떤 흐름으로 조합해서 SecOps를 구성할지 고민해보는 시간을 가져야 할 것 같다.