올해 1,000개의 GitHub 저장소를 리뷰하며 발견한 모든 개발자가 저지르는 7가지 실수
요약
1,000개 이상의 GitHub 저장소를 분석하여 개발자들이 공통적으로 저지르는 7가지 실수를 정리했습니다. 특히 Git 히스토리에 남은 비밀 정보(Secrets) 유출의 위험성과 이를 올바르게 해결하는 방법을 다룹니다.
핵심 포인트
- .gitignore에 추가해도 이미 커밋된 파일은 Git 히스토리에 남음
- 유출된 API 키나 자격 증명은 즉시 교체(Rotating)해야 함
- git filter-repo를 사용하여 Git 히스토리를 재작성해야 함
- 비밀 정보가 커밋된 순간 이미 노출된 것으로 간주해야 함
이것은 내부 감사로 시작되었습니다.
우리는 한 프로젝트를 위해 오픈 소스 의존성 (open source dependencies)을 검토하고 있었는데, 결국 토끼굴에 빠지듯 계속 파고들게 되었습니다. 하나의 저장소 (repo)가 다른 저장소로 이어졌고, 하나의 패턴이 나타나더니, 또 다른 패턴이, 그리고 또 다른 패턴이 나타났습니다. 우리가 이 작업을 공식적으로 기록했을 때쯤에는, 일 년 동안 개인 프로젝트, 스타트업 코드베이스 (codebases), 오픈 소스 라이브러리 (open source libraries), 그리고 실수로 공개된 내부 도구들이 뒤섞인 1,000개 이상의 저장소를 검토한 상태였습니다.
우리가 발견한 것들은 모호한 예외 사례(edge cases)라는 측면에서는 놀랍지 않았습니다. 하지만 똑같은 실수들이 어디에서나 나타난다는 점에서는 놀라웠습니다. 별점(stars)이 5개인 저장소와 5,000개인 저장소 모두에서, 그리고 한 명이 관리하는 프로젝트와 50명의 기여자 (contributors)가 있는 프로젝트 모두에서 나타났습니다. 문제는 경력 수준에 따라 차별을 두지 않습니다. 문제는 당신이 실수를 저지르기 전에 누군가가 그에 대해 말해준 적이 있는지 여부에 따라 갈립니다.
가장 일관되게 나타난 7가지는 다음과 같습니다.
실수 1 — .gitignore가 제거할 수 없는 Git 히스토리 속의 비밀 정보 (Secrets)
이것은 목록에서 가장 위험한 실수이자, 해결 방법이 가장 오해받고 있는 부분입니다.
유출된 API 키 하나, 노출된 데이터베이스 자격 증명 (database credential), 또는 실수로 커밋된 .env 파일은 대규모 데이터 유출, 무단 액세스, 심지어 금전적 손실로 이어질 수 있으며, 프라이빗 저장소 (private repositories) 또한 이로부터 안전하지 않습니다.
대부분의 개발자가 잘못 알고 있는 부분은 다음과 같습니다: 이미 한 번 커밋한 후에 .gitignore에 .env를 추가하는 것은 아무런 효과가 없습니다. 그 파일은 당신의 git 히스토리 (git history)에 영구적으로 남아 있습니다. 저장소를 클론 (clone)하고 git log를 실행하는 사람은 누구나 그것을 찾을 수 있습니다. 저장소를 포크 (fork)하는 사람은 누구나 그것을 함께 가져갑니다.
`# 많은 저장소의 히스토리가 다음과 같은 모습입니다
git log --all --full-history -- .env
다음과 같은 내용을 발견하게 될 것입니다:
commit a3f9d12
Author: Developer Name
Date: Mon Jan 15 2024
"remove .env file" ← 너무 늦었습니다`
특정 시점에 자격 증명(credentials)을 삭제하더라도, 그것들은 git 히스토리에 남아 있어 결심을 굳힌 공격자가 해당 자산에 접근할 수 있게 합니다. 코드 확산(code sprawl)과 지속성(persistence)이 결합되면, 개발자가 프로젝트를 떠난 지 한참 뒤에도 코드 내의 비밀 정보(secrets)가 악용될 수 있음을 의미합니다.
실질적인 해결책은 git filter-branch(사용이 권장되지 않으며 속도가 느림)가 아닌 git filter-repo를 사용하여 히스토리를 다시 쓰는 것이며, 그 후 커밋되었던 모든 자격 증명을 교체(rotating)해야 합니다. 단순히 삭제하는 것이 아니라, 교체해야 합니다. 핵심은 해당 정보가 커밋에 닿는 순간 이미 노출(compromised)된 것으로 간주해야 한다는 점입니다.
`# Install git-filter-repo (pip install git-filter-repo)
git filter-repo --path .env --invert-paths
그 후 즉시 해당 파일에 나타났던 모든 키(key), 토큰(token), 자격 증명(credential)을 교체하세요.
단 하나도 빠짐없이 전부 다.
`
예방책은 git-secrets나 trufflehog를 사용한 pre-commit hook을 설정하여, 정보가 히스토리에 들어가기 전에 커밋을 차단하는 것입니다. 기기당 한 번만 설정해 두면 다시는 신경 쓸 필요가 없습니다.
`# Install trufflehog as a pre-commit hook
pip install pre-commit
.pre-commit-config.yaml에 추가:
- repo: https://github.com/trufflesecurity/trufflehog
hooks:
- id: trufflehog`
실수 2 — Main 브랜치에 대한 브랜치 보호(Branch Protection) 미설정
우리는 기여자가 한 명 이상인 대부분의 저장소에서 이 문제를 발견했습니다. Main 브랜치가 완전히 보호되지 않은 채 방치되어 있어, 쓰기 권한(write access)이 있는 사람이라면 누구나 직접 푸시(push)하거나, 다른 사람의 작업물을 강제 푸시(force push)로 덮어쓰거나, 브랜치를 완전히 삭제할 수 있는 상태였습니다.
브랜치 보호 규칙(branch protection rules)과 필수 리뷰(required reviews)를 사용하면 위험을 크게 줄일 수 있습니다. 이러한 설정이 없으면 개발자가 의도치 않게 보안 위험을 초래하거나 직접 푸시를 통해 운영 환경(production)을 망가뜨릴 수 있습니다. Technical Ustad
대부분의 저장소에서 누락된 구체적인 설정은 다음과 같습니다:
GitHub Settings → Branches → Branch protection rules → Add rule
✅ 병합 전에 풀 리퀘스트(Pull Request) 요구
✅ 승인 최소 개수: 1개 요구
✅ 새 커밋이 푸시될 때 오래된 풀 리퀘스트 승인은 무효화
✅ 병합 전에 상태 검사(status checks) 통과 필수
✅ 병합 전에 브랜치가 최신 상태여야 함
✅ 위의 설정을 우회하는 것을 허용하지 않음
❌ 강제 푸시 허용 → 비활성화 (OFF)
❌ 삭제 허용 → 비활성화 (OFF)
'우회를 허용하지 않음(Do not allow bypassing)' 설정이 가장 많은 사람이 놓치는 부분입니다. 이 설정이 없으면 관리자나 리포지토리 소유자가 방금 설정한 모든 규칙을 우회할 수 있게 되는데, 이는 주 개발자가 금요일 오후에 잘못된 것을 푸시할 가능성이 가장 높은 어떤 리포에서도 보호가 무의미해진다는 뜻입니다.
실수 3 — 너무 커서 아무도 실제로 리뷰하지 않는 PR
이것은 보안 스캔에서는 나타나지 않습니다. 대신, 11분 만에 승인된 4,000줄짜리 PR로 커밋 기록에 나타납니다.
저희는 이 패턴을 반복적으로 목격했습니다. 개발자가 기능 하나를 위해 2주 동안 작업하고, 15개 파일에 걸쳐 변경 사항을 누적한 다음, 그 모든 것을 담아 단 하나의 풀 리퀘스트를 여는 것입니다. 리뷰어는 한 시간도 안 되는 시간에 제대로 검토하기 물리적으로 불가능한 diff(변경분)에 직면합니다. 그래서 눈에 띄는 부분만 스캔하고, 변수 이름에 대한 댓글을 남긴 다음, 승인합니다. 실질적인 로직은 검토되지 않은 채 넘어갑니다.
이것에 대한 연구는 명확합니다. 코드 리뷰 효과성에 관한 연구들은 PR 크기가 커짐에 따라 리뷰어들이 라인당 발견하는 결함의 수가 점진적으로 줄어든다는 것을 일관되게 보여줍니다. 변경된 코드가 약 400줄을 넘어서면, 리뷰 품질이 현저하게 저하됩니다. 1,000줄을 넘어가면 사실상 형식적인 절차에 불과합니다.
해결책은 규율의 문제가 아니라 구조적인 문제입니다:
규칙: 변경된 코드가 400줄을 초과하면 PR 병합 금지.
강제화: 라인 수가 초과되면 병합을 차단하는 GitHub Actions 상태 검사.
name: PR Size Check
on: [pull_request]
jobs:
size-check:
runs-on: ubuntu-latest
steps:
- name: Check PR size
run: |
LINES=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.number }}"
| jq '.additions + .deletions')
if [ "$LINES" -gt 400 ]; then
echo "PR too large: $LINES lines changed. Break it into smaller PRs."
exit 1
fi
Large PRs are a process failure before they're a code quality failure. The automation removes the conversation entirely.
실수 4 — 프로젝트가 무엇인지 설명하는 README, 어떻게 사용하는지 설명하지 않는 경우
기술적인 README 실패는 보안 침해만큼 극적이지는 않지만, 프로젝트 채택(adoption) 및 유지보수성 측면에서 가장 지속적으로 손상을 입히는 실수 중 하나입니다.
가장 자주 목격한 패턴은 다음과 같습니다. 프로젝트가 무엇을 하는지 한 단락으로 설명하고, 기능 목록을 나열한 다음, 필수 조건(prerequisites), 환경 설정(environment setup), 예상 출력(expected output) 또는 문제가 발생했을 때 어떻게 해야 하는지에 대한 맥락 없이 설치 명령어 하나만 던져주는 README입니다.
실제로 작동하는 README에는 다음 내용이 포함되어야 합니다:
`## Prerequisites (필수 조건)
- Node.js 18+ (16이나 20은 안 됩니다. 이 프로젝트는 18에서 네이티브로 사용 가능한 fetch를 사용합니다)
- PostgreSQL 14+
.env.example을 기반으로 한.env파일 (먼저 복사한 후 값을 채우세요)
Installation (설치)
cp .env.example .env
npm install
npm run db:migrate
npm run dev
Expected output (예상 출력)
서버가 http://localhost:3000에서 실행됩니다.
데이터베이스 연결 상태: true
Common errors (일반적인 오류)
ECONNREFUSED 5432: PostgreSQL이 실행되고 있지 않습니다.brew services start postgresql로 시작하세요.MODULE_NOT_FOUND: 먼저npm install을 실행하세요.`}{
"Common errors" (공통 오류) 섹션은 거의 아무도 작성하지 않지만, 모든 새로운 기여자(contributor)의 디버깅 시간을 30분씩 아껴주는 섹션입니다. 기억을 되살려 작성하세요. 처음 이 환경을 설정했을 때 정확히 무엇이 잘못되었는지 당신은 이미 알고 있습니다.
실수 5 — 템플릿, 라벨, 분류(Triage)가 없는 Issue와 PR
활발한 기여자가 있음에도 이슈(issue) 구조가 전혀 없는 저장소들입니다. 모든 이슈는 빈 텍스트 필드로 되어 있습니다. 라벨(labels)도 없고, 담당자(assignees)도 없으며, 마일스톤(milestones)도 없습니다. 그 결과, 이슈 트래커(issue tracker)는 바빠 보이지만 아무도 책임지지 않는 할 일 목록(to-do list)처럼 작동하게 됩니다.
`# 대부분의 저장소가 가진 모습:
Issue #47: "작동하지 않아요"
Issue #48: "로그인 버그"
Issue #49: "다크 모드 추가해 줄 수 있나요"
유지보수가 가능한 저장소가 가진 모습:
[BUG] 리다이렉트 URI(redirect URI)에 쿼리 파라미터(query params)가 포함될 때 OAuth 제공업체와 함께 로그인이 실패함
Labels: bug, authentication, priority:high
Assignee: @dev-name
Milestone: v2.1.0`
GitHub 이슈 템플릿(issue templates)은 설정하는 데 20분밖에 걸리지 않으며, 보고자가 재현 단계(reproduction steps), 환경 정보(environment info), 그리고 기대 결과와 실제 결과(expected versus actual behaviour)를 반드시 제공하도록 강제함으로써 "작동하지 않아요" 유형의 이슈를 통째로 제거해 줍니다.
`# .github/ISSUE_TEMPLATE/bug_report.yml
name: Bug Report
description: File a bug report
body:
- type: textarea id: what-happened attributes: label: What happened? description: What did you expect to happen? validations: required: true
- type: textarea id: reproduction attributes: label: Steps to reproduce validations: required: true
- type: dropdown id: version attributes: label: Version options: - Latest - Other (specify in description) validations: required: true`
실수 6 — 최초 커밋(Initial Commit) 이후 아무도 감사(Audit)하지 않은 의존성(Dependencies)
이 실수는 조용히 눈덩이처럼 불어납니다. 프로젝트가 시작되고, 의존성(Dependencies)이 설치되고, 프로젝트가 배포된 후, 18개월 동안 아무도 npm audit이나 pip check를 다시 실행하지 않습니다. 그 시점이 되면 의존성 트리(Dependency tree)에는 이미 공개적으로 알려지고 최신 버전에서 패치되었음에도 불구하고, 자동 알림(Automated alerts)을 설정해두지 않아 완전히 무시된 여러 개의 알려진 취약점(Known vulnerabilities)이 쌓이게 됩니다.
GitHub는 사용자 보안을 보호하기 위해 매우 적극적으로 움직입니다. 민감한 정보가 노출되는 것을 방지하기 위한 도구들을 개발해 왔습니다. 하지만 GitHub 관련 보안 사고의 대부분은 플랫폼의 결함이 아니라 사용자의 실수로 인해 발생합니다.
Dependabot은 활성화하는 데 4분밖에 걸리지 않으며 자동으로 실행됩니다:
`# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "npm" directory: "/" schedule: interval: "weekly" open-pull-requests-limit: 10 labels:
- "dependencies"
- "automerge-patch"`
automerge-patch 레이블을 패치 레벨(Patch-level) 업데이트에 대한 Dependabot PR을 자동 병합(Auto-merge)하는 GitHub Action과 결합하면, 사소한 보안 수정(Minor security fixes)에 대한 유지보수 부담을 완전히 제거할 수 있습니다. 마이너(Minor) 및 메이저(Major) 업데이트는 수동으로 검토하십시오. 패치(Patches)는 스스로 병합됩니다.
# .github/workflows/dependabot-automerge.yml name: Dependabot Auto-merge on: pull_request jobs: automerge: runs-on: ubuntu-latest if: github.actor == 'dependabot[bot]' steps: - name: Auto-merge patch updates run: gh pr merge --auto --squash "$PR_URL" env: PR_URL: ${{ github.event.pull_request.html_url }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
실수 7 — 과도한 권한을 가진 GitHub Actions 워크플로(Workflows)
이것은 우리가 올해 목격한 GitHub 보안 실수 중 가장 빠르게 증가하고 있는 카테고리이며, 실수를 저지르는 개발자들이 가장 적게 이해하고 있는 부분이기도 합니다.
기본적으로 GitHub Actions 워크플로(workflows)는 실제로 필요한 것보다 더 넓은 저장소 권한(repository permissions)을 부여받을 수 있습니다. 가장 간과되는 보안 기능 중 하나가 바로 권한 설정(permissions setting)입니다. Technical Ustad
많은 워크플로에서 기본으로 제공되는 GITHUB_TOKEN은 전체 저장소에 대한 쓰기 권한(write access)을 가집니다. 테스트를 실행하고 댓글을 게시하기만 하면 되는 워크플로가 배포(deployments), 패키지(packages) 또는 저장소 콘텐츠(repository contents)에 대한 쓰기 권한을 가질 이유는 전혀 없습니다. 하지만 기본 설정이 허용적(permissive)이기 때문에, 대부분의 워크플로는 권한 범위를 제한(scoped)하지 않은 채 사용됩니다.
# 대부분의 워크플로 모습 (위험한 기본값):
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm test
# GITHUB_TOKEN은 기본적으로 모든 것에 대한 쓰기 권한을 가짐
# 권장되는 모습:
permissions:
contents: read # checkout에 필요한 권한만 부여
checks: write # 테스트 보고에 필요한 권한만 부여
pull-requests: write # PR 댓글을 게시하는 경우에만 부여
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm test
원칙은 워크플로당 최소 권한(minimal permissions)을 부여하는 것입니다. 빌드 작업(build job)에는 contents: read가 필요합니다. 배포 작업(deployment job)에는 특정 배포 권한이 필요합니다. 릴리스 작업(release job)에는 contents: write가 필요합니다. 그 어떤 것도 전체 저장소에 대한 포괄적인 쓰기 권한(blanket write access)을 가질 필요는 없습니다.
모든 워크플로 파일 상단에 기본값으로 다음을 추가하세요:
permissions: read-all # 기본적으로 모든 것을 거부하고, 작업(job)별로 명시적으로 권한을 부여함
7가지 실수에 흐르는 공통적인 패턴
우리가 검토한 모든 사례를 살펴보면, 공통적인 실체는 무능함이나 부주의가 아닙니다. 이러한 실수들은 문제가 터지기 전까지는 보이지 않는다는 점입니다. Git 히스토리에 포함된 .env 파일은 스스로를 알리지 않습니다. 보호되지 않은 메인 브랜치(main branch)는 누군가 금요일에 강제 푸시(force-push)를 하기 전까지는 문제를 일으키지 않습니다. 오래된 의존성(dependencies)은 CVE(취약점)에 포함되기 전까지는 조용히 자리 잡고 있을 뿐입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기