시리즈파이썬으로 만드는 보안 자동화7편 · 공개 8편
첫 게이트는 통과했지만 스캐너는 당시 저장소의 8%만 봤다
Git 이력상 2026년 8월 3일에 남은 첫 시크릿 스캔 기록의 검사 범위·오탐·미탐·종료 코드를 다시 측정했다. 이후 드러난 UTF-8 판별 누락과 경로 예외를 고치고, 수동 Python 도구의 값 해시 예외와 릴리스 게이트의 별도 더미 필터를 구분해 확인했다.
목차 5
2026-08-09 업데이트 — 실험 시점과 과거·현재 검사 수치를 구분하고, UTF-8 파일 판별과 값 해시 예외의 동작을 바로잡았다. 수동 Python 도구의 허용 목록과 릴리스·CI 게이트의 더미 필터가 서로 다른 방식으로 동작한다는 점도 명확히 했다.
게이트가 초록불이면 무엇이 확인된 것인가
시크릿이 저장소에 들어가는 것을 막는 방법은 알려져 있다. 커밋 직전이나 CI에서 스캐너를 돌리고, 뭔가 찾으면 0이 아닌 코드로 종료해 배포를 막는다. 코드는 짧고 원리는 분명하다.
보존된 Git 이력과 글에 따르면 2026년 8월 3일 스캐너를 이 블로그 저장소에 돌렸고, 당시 추적 파일은 206개였다. 글에 남은 **결과는 「발견 0건, 종료 코드 0, 통과」**였으며 같은 출력의 윗줄에는 검사한 파일이 17개라고 적혀 있었다.
이 글은 그 두 줄의 간격을 재 본 기록이다. 게이트가 초록불일 때 그것이 ’시크릿이 없다’는 뜻인지, ’내가 본 곳에는 없다’는 뜻인지를 가르는 숫자가 무엇인지 확인했다.
지금 실행하면 몇 개를 보는가
2026년 8월 9일 현재 scripts/secret_scan.py는 기본 text 모드에서 확장자를 보지 않는다. 추적 파일 앞 8000바이트에 NUL이 있는지만 확인하고, 검사할 때는 UTF-8 오류를 무시하며 전체 텍스트를 읽는다. 현재 명령을 두 모드로 다시 실행한 결과는 다음과 같다.
text: 대상 282개 중 검사 156개 (55.3%) / 건너뜀 126개
ext: 대상 282개 중 검사 26개 ( 9.2%) / 건너뜀 256개
두 실행 모두 발견 0건 — 차단 0 / 허용 0, 종료 코드 0따라서 뒤에 나오는 17/206·72/206과 8.3%·35.0%는 현재 성능 수치가 아니라 첫 구현을 고쳐 가던 당시의 실험값이다. 현재 Python 감사 도구와 CI의 JavaScript 게이트는 scripts/lib/secret-rules.json을 함께 읽으며, Python 쪽은 그중 block 등급 11개 규칙을 적용한다.
세 개의 관문과 종료 코드
시크릿을 막는 자리는 셋이고 시점이 다르다.
| 관문 | 실행 시점 | 막는 것 |
|---|---|---|
| pre-commit | 로컬 커밋 직전 | 애초에 기록에 안 남긴다 |
| CI 검사 | push·Pull Request | 합쳐지기 전에 잡는다 |
| 배포 게이트 | 배포 직전 | 나가는 것을 막는다 |
앞의 것일수록 되돌리기 쉽다. 커밋되기 전에 막으면 파일 하나 고치면 끝이지만, 커밋된 뒤에는 이력에서 지워도 이미 받아 간 사람이 있을 수 있어 ’지우기’가 아니라 ‘폐기와 재발급’ 문제가 된다.
셋 다 실제로 배포를 멈추는 힘은 한 군데서 나온다. 종료 코드다.
| 종료 코드 | 의미 | CI 처리 |
|---|---|---|
0 |
차단할 것 없음 | 다음 단계 진행 |
1 이상 |
문제 발견 또는 검사 실패 | 작업 실패, 배포 중단 |
화면에 「시크릿 발견」을 아무리 크게 찍어도 프로그램이 0으로 끝나면 CI는 성공으로 읽는다. 반대로 아무 말 없이 1로 끝나면 배포가 멈춘다. 게이트에서 사람이 읽는 것은 출력이지만 기계가 읽는 것은 이 숫자 하나다.
1. 이 저장소에 그대로 돌리면
첫 실험 당시 규칙은 다섯 개였다. AWS 액세스 키, password = 형태의 대입, api_key = 형태의 대입, 개인키 헤더, Slack 토큰. 검사 대상은 확장자 목록으로 골랐다.
EXT_WHITELIST = {".py", ".js", ".ts", ".json", ".yaml", ".yml"}출력에는 규칙명·경로·줄 번호만 남기고 찾은 값 자체는 찍지 않는다. CI 로그는 대개 저장소보다 접근 범위가 넓어서, 시크릿을 찾아 놓고 그 값을 로그에 다시 뿌리면 막은 것이 아니라 옮긴 것이 된다.
대상 206개 중 검사 17개 (8.3%) / 건너뜀 189개
발견 0건 — 차단 0 / 허용 0
종료 코드: 0게이트는 통과했고, 본 파일은 206개 중 17개다.
건너뛴 189개가 무엇인지 보면 이유가 분명하다. 이 저장소는 Astro로 만들었고 글은 Markdown으로 쓴다. .astro 19개, .mjs 19개, .md 21개가 있는데 셋 다 목록에 없다. 배포 스크립트가 .sh나 .mjs에 있고 환경변수 서식이 .env.example에 있는데 그것도 없다. 확장자 목록은 「어떤 파일에 시크릿이 들어갈 수 있는가」가 아니라 「예제를 만들 때 떠오른 확장자」에 가깝다.
여기서 위험한 것은 놓친다는 사실 자체가 아니라, 놓치는 동안 화면이 초록색이라는 점이다. 번지는 경로는 이렇게 된다. 시크릿을 .astro 에 커밋 → 스캐너가 그 확장자를 안 봄 → 발견 0건 → 게이트 통과 → 배포. 어느 단계에서도 붉은 표시가 나오지 않는다.
그래서 이 스캐너에는 검사 결과보다 먼저 나와야 하는 숫자가 있다. 몇 개 중 몇 개를 봤는가. 위 출력의 첫 줄이 그것이고, 두 번째 줄만 읽으면 아무것도 알 수 없다.
2. 확장자 목록을 빼면 무엇이 나오나
파일 목록을 git ls-files로 받고, 그중 텍스트로 읽히는 것을 전부 검사하도록 바꿨다. 당시 구현은 확장자 대신 내용을 보되, 앞 4096바이트에 NUL이 있거나 그 조각이 UTF-8로 디코딩되지 않으면 건너뛰었다.
대상 206개 중 검사 72개 (35.0%) / 건너뜀 134개
발견 2건 — 차단 2 / 허용 0
[차단] AWS Access Key: docs/EDITORIAL_GUIDE.md:77
[차단] AWS Access Key: src/content/posts/aws-cloudtrail-investigation.md:128
종료 코드: 1검사 범위가 17개에서 72개로 늘었다. 당시에는 나머지 134개가 폰트와 이미지라서 ’검사할 수 있는 것을 다 검사했다’고 결론 냈지만, 이 판단은 나중에 틀린 것으로 드러났다. 4096바이트에서 잘린 한글의 중간 바이트 때문에 정상 텍스트 13개가 UTF-8 디코딩에 실패했고, 글 3편과 src/data/resume.ts까지 이진 파일로 오인돼 빠져 있었다. 현재 구현이 UTF-8 성공 여부를 버리고 앞 8000바이트의 NUL만 보는 이유다.
그리고 게이트가 막혔다. 발견된 2건은 둘 다 오탐이다. 하나는 편집 가이드의 마스킹 규칙표이고 하나는 그 규칙을 적용한 아티클 본문인데, 양쪽 다 AWS 공식 문서의 예시값 AKIAIOSFODNN7EXAMPLE을 쓰고 있다. 실제 자격 증명이 없다는 것을 보이려고 문서용 예시값으로 바꿔 둔 자리가, 형식이 진짜와 같기 때문에 정확히 걸린 것이다.
규칙은 틀리지 않았다. AKIA 뒤에 대문자·숫자 16자는 실제 AWS 키의 형식이 맞고, 예시값도 그 형식을 지키도록 만들어졌다. 형식만 보는 스캐너는 형식이 같은 것을 구분할 수 없다.
여기서 규칙을 느슨하게 만들면 진짜도 같이 놓친다. 처음에는 경로와 규칙을 묶어 예외를 명시했다.
ALLOWLIST = [
{
"path_glob": "docs/EDITORIAL_GUIDE.md",
"rule": "AWS Access Key",
"reason": "마스킹 규칙표의 AWS 공식 문서 예시값",
"owner": "본인",
"expires": "2027-01-01",
},
...
]이 설계는 너무 넓었다. 같은 파일에 같은 규칙으로 잡히는 진짜 키가 새로 들어와도 경로 예외에 함께 통과할 수 있기 때문이다. 현재 수동 Python 도구는 scripts/secrets-allow.json에서 정규식이 캡처한 값의 SHA-256과 규칙을 대조할 수 있다. 아래는 현재 등록값이 아니라 그 스키마다.
{
"values": [{
"sha256": "<캡처한 값의 차단 출력 해시>",
"rule": "<탐지 결과의 규칙명>",
"reason": "검증한 공개 예시값",
"owner": "본인",
"expires": "2027-01-01"
}],
"paths": []
}이 기능을 쓸 때는 어느 파일에 같은 캡처 값이 나타나도 원문을 목록에 적지 않고 통과시키며, 다른 값은 다른 해시라 다시 차단된다. 경로 예외는 값이 바뀌어도 통과하는 더 느슨한 장치다. 현재 실제 외부 목록은 values와 paths가 모두 빈 배열이다. AWS 공식 예시값은 발견 항목을 만들기 전에 코드의 더미 필터가 거르므로, 외부 목록의 사유·담당자·만료일이 적용되는 활성 예외가 아니다.
여기에는 게이트별 차이도 있다. secrets-allow.json을 읽는 것은 수동 감사용 scripts/secret_scan.py뿐이다. release와 CI가 호출하는 JavaScript 게이트는 같은 정규식 규칙 원본을 읽지만, example·redacted·dummy와 AWS 공식 예시값을 코드에 내장한 필터로 통과시킨다. 이 통과에는 사유·담당자·만료일이 없다. 따라서 값 해시 예외 설계가 현재 CI에도 적용된다고 말할 수는 없다.
발견 2건 — 차단 0 / 허용 2
[허용] AWS Access Key: docs/EDITORIAL_GUIDE.md:77 — 마스킹 규칙표의 AWS 공식 문서 예시값 (만료 2027-01-01)
[허용] AWS Access Key: src/content/posts/aws-cloudtrail-investigation.md:128 — 아티클 본문에 실린 마스킹 예시값 (만료 2027-01-01)
종료 코드: 0위 출력은 경로 예외를 쓰던 당시 기록이다. 허용한 것을 ’발견 0건’으로 지우지 않은 점은 맞지만, 같은 파일의 진짜 키가 반드시 다시 차단된다는 설명은 틀렸다. 수동 Python 도구에서 값 해시 예외를 실제로 등록했을 때만 새 값이 다른 해시로 분리돼 차단된다. 현재 AWS 예시값은 내장 더미 필터에서 먼저 빠지므로 위와 같은 허용 출력도 나오지 않는다.
3. 무엇을 못 잡나
알고 있는 실제 시크릿이나 정답 라벨이 없어 현재 저장소만으로는 미탐률을 잴 수 없다. 그래서 당시에는 형식만 맞춘 더미 값을 심은 시험용 저장소를 따로 만들었다. 일곱 자리에 넣었고 그중 셋은 스캐너를 피하려고 일부러 비틀었다.
| 심은 자리 | 모양 | 확장자 목록 | 텍스트 전부 |
|---|---|---|---|
src/client.ts |
password = "..." |
잡음 | 잡음 |
scripts/deploy.sh |
AWS_ACCESS_KEY_ID=AKIA... |
안 봄 | 잡음 |
src/page.astro |
api_key = "..." |
안 봄 | 잡음 |
.env.local |
API_KEY="..." |
안 봄 | 잡음 |
config.json |
"api_key": "..." |
봤지만 못 잡음 | 봤지만 못 잡음 |
src/split.py |
"AKIA" + "2E0F..." |
봤지만 못 잡음 | 봤지만 못 잡음 |
src/encoded.py |
base64로 감싼 키 | 봤지만 못 잡음 | 봤지만 못 잡음 |
확장자 목록으로는 7개 중 1개, 텍스트 전부를 보면 7개 중 4개를 잡았다. 다만 그 시험용 파일·실행 명령·원본 로그는 저장소에 남아 있지 않고, 현재 스캐너는 값에 DUMMY가 들어가면 내장 필터로 건너뛴다. 따라서 이 표는 당시 관찰 기록이지 현재 코드로 그대로 재현되는 시험 결과가 아니다.
못 잡은 것이 두 종류로 갈린다는 점이 중요하다.
- 안 본 파일 — 확장자 목록을 고치면 해결된다. 실제로 목록을 빼자 세 개가 바로 잡혔다.
- 본 파일인데 규칙이 못 본 것 — 세 개가 여기 해당한다.
config.json은 JSON이라api_key: "값"이지api_key = "값"이 아니어서, 대입 기호를 보는 규칙에 안 걸린다. 나머지 둘은 문자열을 나눠 두거나 base64로 감싼 것이라 원래 모양이 파일에 없다.
앞쪽은 스캐너를 고쳐서 줄일 수 있지만 뒤쪽은 그렇지 않다. 규칙을 몇 개 더 붙이면 이 세 가지는 잡히겠지만, 같은 방식의 변형은 얼마든지 만들 수 있다. 정규식 스캐너로 막을 수 있는 것은 실수로 붙여 넣은 시크릿이지 숨기려고 작정한 시크릿이 아니다. 게이트를 붙였다고 「시크릿이 없다」로 읽으면 안 되는 이유가 이쪽이다.
4. 통과·차단·실패를 구분하기
마지막은 의도한 실험이 아니라 위 작업 중에 실제로 걸린 것이다. 스캐너를 셸 스크립트로 감싸 돌렸더니 이렇게 끝났다.
[1/1] 시크릿 스캔
대상 206개 중 검사 72개 (35.0%) / 건너뜀 134개
Traceback (most recent call last):
...
UnicodeEncodeError: 'cp949' codec can't encode character '—'
게이트 종료 코드: 1시크릿은 하나도 발견되지 않았다. 결과 줄을 출력하다가 콘솔 인코딩 때문에 죽었을 뿐이다. 그런데 파이썬은 처리되지 않은 예외에 대해 종료 코드 1을 돌려주고, 게이트 입장에서 그것은 「시크릿 발견」과 완전히 같은 신호다.
방향이 안전한 쪽이라 다행이지만 — 배포는 멈췄다 — 원인을 구분할 수 없다는 것이 문제다. 게이트가 빨간불일 때 「시크릿이 나왔다」와 「스캐너가 고장 났다」는 해야 할 일이 정반대다. 앞의 것은 코드를 고쳐야 하고 뒤의 것은 스캐너를 고쳐야 하는데, 종료 코드가 같으면 로그를 읽기 전까지 알 수 없다.
상태를 세 개로 나눴다.
try:
code = main()
except SystemExit:
raise
except BaseException as exc:
print(f"스캐너 실패: {exc.__class__.__name__}: {exc}", file=sys.stderr)
code = 2
raise SystemExit(code)| 코드 | 뜻 | 게이트 |
|---|---|---|
0 |
차단할 것 없음 | 통과 |
1 |
시크릿 발견 | 차단 — 코드를 고친다 |
2 |
스캐너가 실패 | 중단 — 스캐너를 고친다 |
셸 쪽에서도 두 상태를 갈라 받게 했다.
if python scripts/secret_scan.py "$TARGET" --mode text --allowlist; then
echo "게이트 통과 — 배포 가능"
else
code=$?
if [ "$code" -eq 2 ]; then
echo "게이트 중단 — 스캐너가 실패했다. 배포하지 않는다 (발견과는 다른 상태)"
else
echo "게이트 차단 — 시크릿이 발견됐다"
fi
exit "$code"
fi세 경우를 각각 돌려 확인했다.
### 저장소 → 게이트 통과 — 배포 가능 (0)
### 시험용 저장소 → 게이트 차단 — 시크릿이 발견됐다 (1)
### git 아닌 경로 → 게이트 중단 — 스캐너가 실패했다 (2)둘 다 배포를 막는다는 점은 같고, 다음에 무엇을 할지가 다르다. 이 구분이 없으면 스캐너가 조용히 고장 난 채로 며칠 지나도 「요즘 게이트가 자주 걸리네」로 읽힌다.
여기서 걸린 원인이 출력 인코딩이었다는 것도 짚어 둘 만하다. 이 오류는 Linux CI에서는 나지 않는다. 로컬에서만 나거나 CI에서만 나는 실패가 있고, 그런 실패일수록 「발견」과 구분되는 신호가 필요하다.
종료 코드 2는 앞으로 그대로 쓰기로 했다. 게이트를 한 번이라도 붙일 거라면 「막혔다」는 신호는 어차피 나오게 되어 있고, 그때 원인을 보려고 매번 로그를 뒤지는 것보다 코드 하나로 갈라 두는 편이 낫다. 붙이는 비용도 예외 처리 몇 줄이라 미룰 이유가 없었다.
게이트를 믿을 수 있게 만드는 순서
2026년 8월 3일의 네 실행에서는 다섯 규칙을 고정하고 검사 범위·허용 목록·종료 코드를 바꿨다. 이후 규칙 원본도 JSON 하나로 통합되고 block 규칙이 11개로 늘었으므로, 다음 표는 당시 실험의 변화만 요약한다.
| 바꾼 것 | 규칙 변경 | 결과 |
|---|---|---|
| 확장자 목록 → 텍스트 판별 | 없음 | 검사 범위 17개 → 72개, 미탐 3건 해소 |
| 허용 목록 도입 | 없음 | 오탐 2건으로 막히던 게이트가 통과 |
| 종료 코드 분리 | 없음 | 「고장」과 「발견」이 구분됨 |
그래서 게이트를 붙일 때 확인하는 순서를 이렇게 잡았다.
- 몇 개 중 몇 개를 봤는지 먼저 출력한다. 발견 건수만 있는 출력은 초록불의 근거가 되지 못한다. 이 숫자가 없었으면 8.3%라는 사실을 몰랐다.
- 검사 대상을 확장자가 아니라 내용으로 고른다. 새 확장자가 저장소에 들어올 때마다 목록을 고쳐야 하는 구조는 언젠가 반드시 뒤처진다.
- 오탐 처리 방식도 게이트별로 밝힌다. 수동 Python 도구의 외부 예외는 캡처 값의 SHA-256·규칙·사유·담당자·만료일을 쓸 수 있고 경로 예외는 두지 않는다. 현재 릴리스·CI 게이트는 이 목록을 읽지 않고 내장 더미 필터를 쓰므로 같은 감사 흔적을 남기지 못한다.
- 종료 코드로 「없음」·「발견」·「고장」을 나눈다.
- 잡을 수 없는 것을 문서에 적어 둔다. 나눠 쓰거나 인코딩한 시크릿은 이 방식으로 안 잡힌다. 게이트는 실수를 막는 장치이지 악의를 막는 장치가 아니다.
재확인 방법
python scripts/secret_scan.py . --mode text --allowlist # 현재 Python 감사 도구
python scripts/secret_scan.py . --mode ext --allowlist # 과거 확장자 방식과 비교할 때만
npm run check:secrets:repo # release·CI가 쓰는 저장소 게이트--mode 두 값을 같은 저장소에 번갈아 돌리면 첫 줄의 검사 비율이 바로 갈린다. Python 도구의 종료 코드는 0=차단 없음, 1=시크릿 발견, 2=스캐너 실패다. git 저장소가 아닌 경로를 주면 2가 나온다. 현재 release와 CI는 같은 정규식 규칙 원본을 읽는 check:secrets:repo를 사용하고, Python 도구는 수동 감사와 두 모드 비교용으로 남아 있다. 정규식은 공유하지만 허용 처리는 앞서 적은 대로 서로 다르다.
미탐을 다시 재려면 실제로 발급되지 않은 합성 값과 기대 결과가 든 fixture를 별도로 보존하고 테스트 전용 경로로 실행해야 한다. 실제 값을 쓰지 않는다. 이 글의 과거 더미 값은 DUMMY·FAKE 같은 문자열을 포함했다. 현재 운영 스캐너는 DUMMY가 든 값을 건너뛰지만 FAKE는 별도 더미 키워드가 아니어서 규칙에 따라 탐지될 수 있다. 당시 시험을 정확히 반복하려면 운영 필터를 약하게 만드는 대신 기대 결과와 필터 조건을 고정한 전용 테스트 장치가 필요하다. 이 장치는 아직 만들지 않았다.
8월 3일 첫 실험 기록의 측정 환경은 Python 3.14.6, 추적 파일 206개였다. 2026년 8월 9일 재측정은 현재 설치된 Python 3.12.0에서 했으며 추적 파일은 282개다.
첫 실험에서는 게이트를 실제 CI 워크플로에 붙이지 않고 로컬에서만 돌렸다. 이후 JavaScript 저장소 게이트가 release와 Linux CI에 연결됐지만, 이 글의 시험용 저장소 7건을 그 게이트와 전문 탐지 도구에 다시 대조하지는 않았다. 당시 셸 래퍼의 세 종료 코드 출력도 원본 로그와 실행 스크립트가 보존되지 않아 역사적 관찰로만 남는다. 커밋 이력도 여전히 검사하지 않으므로 지금 파일에서 지운 시크릿이 과거 커밋에 남은 경우는 이 명령으로 잡지 못한다.