
지난 글에서 주문 조회에 소유권 검사를 넣었습니다. 남의 주문이면 404를 돌려줍니다. 이제 질문이 바뀝니다. "방금 거절한 사실을 누가 알고 있나?" 대부분의 앱에서 답은 "아무도"입니다. 액세스 로그에는 404 한 줄만 남고, 그 404가 "없는 주문"이었는지 "남의 주문"이었는지는 사라집니다. 이번 글은 그 차이를 이벤트로 남기는 법입니다.
원문은 이 층을 RASP(Runtime Application Self-Protection)라고 부릅니다. 엄밀히 RASP는 앱 런타임 안에 들어가 위험한 호출(SQL 실행, 파일 열기, 외부 요청, 명령 실행)을 직접 지켜보고 막는 기술을 말합니다. 예를 들어 Datadog의 Exploit Prevention은 추적 라이브러리 안에서 SSRF, 로컬 파일 포함(LFI), SQL 주입, 명령 주입을 막는다고 설명합니다.
그런데 원문이 말하는 "세션 사용자 ID와 요청 객체 ID를 비교한다"는 RASP 제품이 아니어도 할 수 있습니다. 우리 코드의 인가 검사가 결과를 밖으로 알리기만 하면 됩니다. 이 시리즈는 이것을 RASP라고 부르지 않고 "인가 결정 이벤트"라고 부릅니다. 이름이 정확해야 제품 비교도 정확해집니다(11편).

실험실 앱은 네 가지 이벤트를 남깁니다.
| 이벤트 | 언제 | 출처 |
|---|---|---|
authz_fail | 객체가 있지만 이 사용자 것이 아닐 때 | OWASP Logging Vocabulary |
input_validation_fail | 모르는 파라미터 이름, 형식이 틀린 값 | OWASP Logging Vocabulary |
object_not_found | 요청한 객체가 없을 때 | 이 시리즈에서 붙인 이름 |
honeytoken_access | 미끼 객체에 접근했을 때(5편) | 이 시리즈에서 붙인 이름 |
OWASP Logging Vocabulary는 보안 이벤트 이름을 표준처럼 맞춰 쓰자는 문서입니다. 이름을 맞춰 두면 나중에 SIEM(보안 로그를 모아 분석하는 시스템)에서 서비스 여러 개의 이벤트를 한 규칙으로 엮을 수 있습니다. 표준에 없는 이름은 우리 쪽 이름이라고 문서에 적어 둡니다. 참고로 이 어휘집에는 없는 파일 요청이 반복될 때의 malicious_excess_404, 직접 객체 참조 악용을 뜻하는 malicious_direct_reference 같은 탐지 결과 이름도 있습니다. 4편의 규칙이 경보를 낼 때 이런 이름을 쓰면 됩니다.
핸들러는 이벤트를 기록만 하고, 판단은 미들웨어가 합니다. 실험실 lab/app.py의 핵심만 옮깁니다.
def sec_event(request, type_, **kw):
# 주체 ID와 객체 ID만. 이름·주소·토큰은 넣지 않는다.
request.state.events.append({"type": type_, **kw})
@app.get("/api/orders/{order_id}")
async def get_order(order_id: str, request: Request):
user = current_user(request)
if not order_id.isdigit():
sec_event(request, "input_validation_fail", names=["order_id"])
return JSONResponse({"detail": "invalid id"}, status_code=422)
order = ORDERS.get(int(order_id))
if order is None:
sec_event(request, "object_not_found", object=int(order_id))
return JSONResponse({"detail": "not found"}, status_code=404)
if order["owner"] != user:
sec_event(request, "authz_fail", object=int(order_id))
return JSONResponse({"detail": "not found"}, status_code=404) # 응답은 같다
return order
미들웨어는 요청이 끝난 뒤 한 줄짜리 기록을 만듭니다.
{"t": 1032.4, "sub": "u151", "ip": "203.0.113.10",
"method": "GET", "route": "/api/orders/{order_id}", "path_id": "100458",
"status": 404, "events": [{"type": "authz_fail", "object": 100458}]}
몇 가지 설계 선택이 있습니다.
/api/orders/{order_id})로 남깁니다. 그래야 "같은 엔드포인트"로 묶어 셀 수 있습니다.이벤트를 나중에 OpenTelemetry(OTel, 추적·지표·로그를 모으는 공개 표준)로 보낼 생각이라면 처음부터 속성 이름을 맞춥니다. OTel HTTP 서버 규약은 안정(Stable) 상태입니다.
| 실험실 필드 | OTel 속성 | 상태 |
|---|---|---|
method | http.request.method | 필수 |
route | http.route | 있으면 필수 |
status | http.response.status_code | 응답했으면 필수 |
ip | client.address | 권장 |
sub | enduser.id 또는 user.id | 개발 중(Development) |
사용자 ID 속성은 아직 개발 중 단계입니다. enduser.id는 폐기되지 않았고, enduser.role은 user.roles로 옮겨 가는 중입니다. 이름이 바뀔 수 있으니 내보내는 지점 한곳에서 바꿀 수 있게 해 두세요.
보안 이벤트는 오래 보관되고 여러 사람이 봅니다. 그러니 넣지 않을 것부터 정합니다.
OWASP Logging Cheat Sheet도 비밀번호·세션 ID·접근 토큰 같은 값은 로그에서 빼거나 가리라고 권합니다.
이벤트는 많을수록 좋은 게 아닙니다. 실험실에서 쓸모 있었던 원칙입니다.
authz_fail), 지운 주문의 옛 링크(18건, object_not_found), 번호를 잘못 친 요청(8건)입니다.honeytoken_access와 authz_fail이 함께 붙습니다.모든 링크는 2026-10-30에 확인했습니다.
enduser.* 속성: https://opentelemetry.io/docs/specs/semconv/registry/attributes/enduser/user.* 속성: https://opentelemetry.io/docs/specs/semconv/registry/attributes/user/lab/traffic.py가 만든 시나리오(12편)이전 글 방어선 0: 탐지보다 먼저, 코드에서 막기
다음 글 방어선 1-②: 신원별 슬라이딩 윈도우로 열거·퍼징 잡기
시리즈 전체 목록 해킹 시도를 몇 초 안에 잡는 서버 전체 목록