Sentry Profiles에 찍힌 스캐너 요청을 Caddy에서 끊기

@givemethatsewon· May 29, 2026· 5 min read

상황 설명 도식
상황 설명 도식

Sentry Profiles에 .env, credentials, actuator, SQL dump probe가 반복해서 찍혔을 때 처음 할 수 있는 일은 Sentry에서 숨기는 것이었다. 하지만 그건 dashboard만 조용하게 만들 뿐, 요청은 여전히 app container까지 들어온다. 그래서 이 변경의 기준은 "보안 장비를 만들자"가 아니라 "명백한 scanner traffic은 reverse proxy에서 끝내고 정상 route와 예외 정책은 검증하자"였다.

요약

  • 문제: Sentry Profiles와 spans에 .env, credentials, config, actuator, SQL dump 같은 scanner path가 반복해서 찍혔다.
  • 판단: Sentry에서 숨기는 대신, app 앞단의 shared Caddy에서 명백한 probe를 빈 404로 끝내는 편이 맞았다.
  • 검증: 정상 route와 health check는 그대로 통과시키고, 차단된 probe가 앱 로그와 Sentry profile에 더 이상 남지 않는지 확인했다.
  • 기준: 전역 blocklist는 오탐 가능성이 있으므로, config.js, app.json처럼 정상 public asset이 될 수 있는 이름은 host/path 단위 예외 정책을 같이 둬야 한다.

상황

운영 중인 서비스의 Sentry Profiles를 보다가 사용자 행동과 맞지 않는 transaction들이 보였다.

https://*/.env
https://*/backend/.env.local
https://*/.config/gcloud/credentials.db
https://*/config/application.properties
https://*/app/actuator/heapdump
https://*/wp-json/gravitysmtp/v1/config
https://*/backup.sql.gz

이런 요청은 사용자 플로우와 연결되지 않는다. 웹 앱 화면을 누르거나 API를 쓰는 과정에서 .env, credentials.db, heapdump, wp-config.php를 요청할 이유는 없다.

그래서 먼저 결론을 이렇게 나눴다.

  • 이 트래픽은 공격 또는 자동 스캔으로 보는 게 맞다.
  • 하지만 이 로그만으로 서버가 뚫렸다고 말할 수는 없다.
  • 처리 목표는 "침해 대응"이 아니라 "origin까지 불필요한 probe가 들어오지 않게 줄이는 것"이다.

왜 Sentry에서만 숨기지 않았나

가장 쉬운 방법은 Sentry에서 해당 transaction을 필터링하는 것이다. 그러면 대시보드는 조용해진다.

하지만 요청 흐름은 그대로 남는다.

scanner -> reverse proxy -> app container -> framework routing -> 404 -> Sentry

이 경우 앱은 여전히 request를 받고, 로그도 만들고, middleware도 통과하고, 경우에 따라 profile/span도 남긴다. 보안상 민감한 파일이 실제로 없는 서비스라 해도, 이런 요청을 매번 앱까지 보내는 것은 이득이 없다.

그래서 차단 위치를 Sentry가 아니라 앱 앞단의 Caddy로 잡았다.

scanner -> reverse proxy -> empty 404
normal user -> reverse proxy -> app container

이 조치는 인증이나 권한 검사의 대체물이 아니다. 앱은 여전히 자체 인증, 라우팅, 권한 검사를 해야 한다. Caddy blocklist는 공개 인터넷의 흔한 스캐너 요청을 origin 앱에 닿기 전에 줄이는 방어층이다.

무엇을 차단했나

처음부터 모든 수상한 문자열을 막으면 정상 트래픽도 같이 막을 수 있다. 그래서 실제 Sentry에 찍힌 경로와 널리 알려진 probe 패턴을 기준으로 카테고리를 나눴다.

  • 환경 변수 파일: .env, .env.*, URL-encoded .env
  • 클라우드 credential: .aws, .gcloud, .config/gcloud
  • secret/config 파일: credentials, service-account.json, application.*, config.*, settings.*
  • framework debug endpoint: actuator, heapdump, threaddump, _profiler
  • PHP/WordPress probe: phpinfo.php, wp-config*, wp-json/*
  • DB/backup artifact: *.sql, *.sql.gz, dump.*, backup.*, *.zip
  • VCS/editor/deploy artifact: .git, .npmrc, .pypirc, .vscode, .idea, docker-compose*.yml, Jenkinsfile

중요한 점은 이 목록이 "모든 공격을 막는 보안 장비"가 아니라는 점이다. 지금 실제로 맞고 있는 자동 probe를 앱 앞에서 빨리 끝내는 rule이다.

Caddy 규칙

운영 Caddy는 여러 서비스를 같은 reverse proxy 뒤에 두고 있었다. 그래서 각 서비스마다 따로 복사하지 않고, 재사용 가능한 snippet으로 만들었다.

공개 글에 맞게 줄이면 이런 형태다.

(sensitive_probe_block) {
	@sensitive-probe-paths {
		path /.env /.env.* */.env* /%2e%65%6e%76 */%2e%65%6e%76
		path /.git* */.git* /.npmrc /.pypirc /.netrc /.vscode/* /.idea/*
		path /.aws/* */.aws/* /.gcloud/* */.gcloud/* /.config/gcloud/* */.config/gcloud/*
		path /actuator* */actuator* /heapdump */heapdump /threaddump */threaddump */_profiler*
		path /credentials */credentials */*credentials*.json */service-account.json
		path /application.* */application.* /config.* */config.* */config/application.*
		path */backup.sql* */dump.* *.sql *.sql.gz *.key *.zip
		path /local-config.php */config.php */settings.php */database.php */wp-config* *.php
	}

	respond @sensitive-probe-paths 404
}

example.com {
	import sensitive_probe_block
	reverse_proxy app:3000
}

실제 운영 규칙은 더 길지만 구조는 같다.

  1. shared Caddy snippet에 민감 probe matcher를 둔다.
  2. host별 reverse_proxy보다 먼저 import한다.
  3. 매칭되면 body 없는 404로 끝낸다.
  4. 매칭되지 않으면 기존 app upstream으로 보낸다.

Caddy 공식 문서 기준으로 matcher token은 directive가 적용될 request 범위를 제한한다. @sensitive-probe-paths처럼 이름을 붙인 matcher는 여러 path 조건을 묶어 재사용할 수 있다. path matcher는 기본적으로 정확히 매칭하고, prefix나 suffix가 필요하면 *를 명시해야 한다. 이 글의 규칙이 /.env, /.env.*, */.env*처럼 여러 패턴을 나눠 둔 이유도 이 때문이다.

respond @sensitive-probe-paths 404는 매칭된 요청에 고정 응답을 쓰는 directive다. body를 주지 않았기 때문에 여기서는 빈 404를 돌려준다. 그리고 snippet은 import로 site block 안에 끼워 넣는다. 이 구조 덕분에 각 host의 reverse_proxy 앞에서 같은 probe 차단 규칙을 재사용할 수 있었다.

여러 서비스가 같은 Caddy를 쓰고 있다면 이 방식이 편하다. 한 번 고치면 새 서비스에도 같은 기본 방어층을 적용하기 쉽다.

검증

이런 규칙은 넣는 것보다 확인이 더 중요하다. 특히 전역 규칙이면 정상 route를 망가뜨리지 않았는지도 같이 봐야 한다.

먼저 대표 probe가 404와 빈 body로 끝나는지 확인했다.

curl -sS -o /dev/null -w 'status=%{http_code} bytes=%{size_download}\n' \
  https://<host>/%2e%65%6e%76

curl -sS -o /dev/null -w 'status=%{http_code} bytes=%{size_download}\n' \
  https://<host>/service-account.json

기대값은 이렇다.

status=404 bytes=0

그다음 정상 route를 확인했다.

curl -sS -o /dev/null -w 'status=%{http_code}\n' https://<host>/health
curl -sS -o /dev/null -w 'status=%{http_code}\n' https://<host>/

마지막으로 앱 컨테이너 로그와 Sentry를 봤다.

docker logs --since 5m <app-container> 2>&1 \
  | grep -E '/%2e%65%6e%76|/service-account\.json|/actuator|/wp-config' \
  || true

차단이 제대로 됐다면 Caddy에서 끝난 probe는 앱 로그에 남지 않아야 한다. Sentry에서도 새 profile/span으로 들어오지 않아야 한다.

운영하면서 걸린 함정

이번에 실제로 한 번 걸린 부분은 Caddyfile 배포 방식이었다.

운영 컨테이너가 host의 Caddyfile을 bind mount하고 있었는데, host에서 새 파일을 mv로 교체했다. 파일 내용은 바뀐 것처럼 보였지만, 컨테이너 안에서는 예전 inode를 계속 보고 있었다.

이런 상황에서는 caddy reload만으로 기대한 설정이 안 들어갈 수 있다. 해결은 둘 중 하나다.

  • 기존 파일에 in-place로 덮어쓴 뒤 caddy reload를 한다.
  • 파일 교체가 필요했다면 컨테이너를 재시작해서 mount를 새로 잡게 한다.

설정 파일을 바꿨다고 끝내지 않고 컨테이너 안에서 보이는 Caddyfile, caddy validate, 실제 curl 결과까지 봐야 했다.

오탐 정책

전역 blocklist에서 제일 위험한 이름은 너무 일반적인 파일명이다.

예를 들면 config.json, config.js, app.json은 어떤 서비스에서는 민감한 설정 파일 probe일 수 있지만, 다른 서비스에서는 정상 public asset일 수 있다. 특히 SPA, PWA, 일부 SDK 문서, mobile app link 파일을 제공하는 서비스에서는 이런 이름이 정상일 때가 있다.

그래서 원칙을 이렇게 잡았다.

  • 새 서비스에 필요한 public path가 blocklist와 충돌하면 전체 blocklist를 끄지 않는다.
  • 해당 host와 path만 좁게 예외 처리한다.
  • 예외 추가 뒤에는 "예외 path는 통과"와 "대표 probe는 여전히 차단"을 둘 다 확인한다.

보안 규칙은 강하게 만드는 것보다 운영자가 안전하게 예외를 줄 수 있게 만드는 쪽이 더 오래 간다.

다음에는 이렇게 판단한다

Sentry에 공격성 transaction이 찍혔을 때 바로 "해킹당했다"라고 볼 필요는 없다. 공개 서버에는 늘 자동 scanner가 붙는다.

하지만 "흔한 일이니까 무시한다"도 좋은 운영은 아니다. 특히 profile과 span에 이런 요청이 계속 쌓이면 진짜 성능 문제나 에러를 보는 데 방해가 된다.

비슷한 probe traffic을 다시 보면 세 가지를 같이 본다.

  • 관측: Sentry에서 실제로 들어온 transaction을 보고 blocklist를 만든다.
  • 위치: 앱 코드가 아니라 reverse proxy에서 빠르게 끝낸다.
  • 검증: 차단 응답, 정상 route, 앱 로그, Sentry 재유입을 같이 확인한다.

작은 보안 조치라도 이 세 단계를 거치면 운영 노이즈를 줄이면서 나중에 설명 가능한 변경으로 남길 수 있다.

참고

givemethatsewon profile
@givemethatsewon
프로젝트를 만들고 운영하면서 배운 개발, 제품, 디버깅 기록을 남깁니다.