로그의 민감 필드 가리기
60분 안팎
학습 목표
구조화 로그에서 필요한 정보만 남깁니다.
개념
왜 로그가 새로운 노출 경로인가요
자료 보관 앱에서 요청 처리는 안전하게 끝났는데 디버그 로그에 비밀번호와 자료 본문이 남을 수 있습니다. 로그는 운영자·개발자·수집 시스템·백업 담당자에게 전달되며 자료 자체와 다른 보관 기간을 갖습니다. 자료 화면의 소유자 검사가 로그 조회 권한을 대신하지 않습니다. 이번 목표는 사건을 연결할 정보는 보존하면서 인증 수단과 개인 자료를 기록 경계 밖으로 보내지 않는 것입니다.
마스킹은 표시 모양을 바꾸는 일이고 필드 제한은 기록할 대상을 줄이는 일입니다. 이미 적재한 로그를 화면에서만 가리면 원본 저장소에는 값이 남습니다. 이 과제에서는 로그로 쓰기 전에 새 객체를 만들며, 원본 요청 객체는 바꾸지 않습니다. 인증 함수가 읽어야 할 토큰을 마스킹 함수가 원본에서 덮어쓰면 기능이 망가지기 때문에 관찰 객체와 처리 객체를 분리합니다.
무엇을 남겨야 조사할 수 있나요
요청 ID는 한 요청의 응답과 관찰 행을 연결합니다. 상태 코드는 처리가 거절됐는지 성공했는지 알려 주며 메서드와 경로 형태는 어떤 기능이 호출됐는지 설명합니다. 비밀번호·세션 ID·Authorization·쿠키·CSRF 원문·자료 제목과 내용은 그 연결에 필요하지 않습니다. 토큰의 앞부분 몇 글자도 남기지 않습니다. 여러 시스템의 기록을 조합하면 짧은 조각도 계정을 추적하거나 인증값을 유추할 단서가 될 수 있습니다.
허용 필드라고 해서 값을 검증하지 않아도 되는 것은 아닙니다. 공격자가 requestId에 개인 자료를 넣어 보내면 필드 이름만으로 보호할 수 없습니다. 미션의 서버는 요청 ID를 자체 생성하고 클라이언트 값을 사용하지 않습니다. 브라우저 과제에서는 r- 뒤에 숫자가 있는 합성 식별자만 유지합니다. 다른 값은 invalid-id로 바꿉니다. status는 100부터 599까지의 정수만 남기고 나머지는 null로 표시합니다.
브라우저 과제의 정확한 계약
유효한 JSON 값 하나를 입력받습니다. 객체이면 requestId와 status를 새 객체의 앞 두 필드로 만듭니다. 배열·null·문자열 같은 입력은 빈 객체처럼 처리합니다. password·token·content가 입력 객체의 직접 필드로 존재하면 그 값의 유형과 관계없이 [REDACTED]로 바꾸어 추가합니다. 이 세 필드의 출력 순서는 password, token, content입니다. 없는 민감 필드를 새로 만들지는 않습니다.
허용하지 않은 필드는 전부 버립니다. 예를 들어 metadata 안의 토큰을 찾아 재귀적으로 수정하는 대신 metadata 자체를 내보내지 않습니다. 따라서 이번 과제는 임의 JSON의 모든 키를 유지하는 범용 마스킹 도구가 아닙니다. 지정된 진단 이벤트를 만드는 함수입니다. requestId와 status가 누락되어도 항상 두 필드가 존재하므로 분석기는 일정한 구조를 받습니다. 출력 JSON 뒤에 줄바꿈 하나만 붙입니다.
{"requestId":"r-7","status":401,"password":"demo"}
// 출력에서 password의 값만 [REDACTED]로 바뀝니다.
// 입력에 없는 token과 content는 추가하지 않습니다.
Python에서는 isinstance(value, int)만으로 상태를 검사하면 True가 정수처럼 통과합니다. bool은 int의 하위 유형이기 때문입니다. 이번 계약은 type(value) is int로 정확한 정수를 요구합니다. 문자열 401이나 소수 401.0도 정수가 아니므로 null입니다. 정규식은 re.fullmatch로 전체 요청 ID를 검사합니다. r-7 뒤에 다른 글자나 줄바꿈을 붙여도 허용되지 않습니다.
기록은 어디서 만들까요
미션에는 별도의 JavaScript safeEvent가 있습니다. 이 함수는 표시용 민감 필드 자체를 생략하고 requestId·method·path·status 네 필드만 만듭니다. 자료 ID가 든 경로는 /documents/:id 형태로 줄이고 알 수 없는 경로는 /unknown으로 바꿉니다. 요청 URL의 쿼리나 알 수 없는 경로 구간에 비밀이 들어갈 수 있기 때문입니다. 브라우저 과제의 null 계약과 미션의 status 오류 대체값 500은 서로 다른 기록 스키마입니다.
서버의 send 경로는 safeEvent로 만든 객체만 log 콜백에 전달합니다. 예외 때도 요청 본문이나 전체 Error 객체를 추가하지 않습니다. 정상·거절·크기 초과·잘못된 JSON 등 여러 응답이 같은 기록 경계를 거치는지 살펴봅니다. 마스킹 함수를 별도로 시험해 통과했어도 실제 logger가 원본 객체를 받으면 보호 효과가 없습니다. 호출 위치 확인과 최종 기록 검사를 함께 수행해야 합니다.
원문 부재와 관찰 보존을 같이 검사합니다
합성 이벤트에 비밀번호·토큰·본문·중첩 비밀을 넣고 출력에서 그 원문이 없음을 확인합니다. 동시에 요청 ID와 401이 남는지도 확인합니다. 모든 필드를 지우면 비밀 검사만 통과하고 조사에 필요한 근거를 잃습니다. 입력 이벤트 객체의 값이 그대로인지 비교하면 처리와 관찰을 섞은 결함도 잡습니다. 없는 필드·빈 객체·배열·잘못된 유형은 경계값으로 다룹니다.
테스트가 expected와 actual JSON의 차이를 보여 주면 키 순서·null과 문자열 null의 차이·누락 필드를 먼저 읽습니다. 디버깅 print를 정답 출력에 넣으면 채점 계약을 깨고 민감 정보가 다시 노출될 수 있습니다. 로컬 시험의 AssertionError에는 합성값만 들어가며 실제 서비스 로그를 fixture로 복사하지 않습니다. 오류 재현에 실제 개인 자료가 필요하면 범위와 최소화 기준부터 다시 검토해야 합니다.
마스킹을 완성했다고 주장할 수 있는 범위
이 실습이 검사하는 것은 제공된 입력 계약과 이 서버의 이벤트 생성 경로입니다. 시스템 전체의 과거 로그·외부 APM·프록시 접근 로그·셸 기록까지 검사한 것은 아닙니다. 새 예외 처리기나 계측 도구가 추가되면 새로운 기록 경계가 생깁니다. 토큰 유출이 의심될 때는 이후 로그를 가리는 것 외에 기존 사본의 접근 제한과 자격 증명 폐기가 필요합니다. 기록 삭제만으로 접근 권한이 사라지지는 않습니다.
보고서에는 남긴 필드의 목적과 제거한 필드의 범위를 함께 적습니다. requestId는 사용자 신원 증명이 아니며 status 200도 자료 접근의 적법성을 보장하지 않습니다. 인가 회귀 결과와 로그 관찰을 연결하되 서로 다른 증거로 구분합니다. 더 읽기의 로그 분석 장은 기록을 집계하는 방법을 다룹니다. 여기서는 집계 이전에 어떤 정보가 기록 경계를 통과하는지 직접 통제하는 데 집중합니다.
관련 원칙과 도구의 범위는 OWASP 로그 기록에서 확인할 수 있습니다. 이 레슨의 판정은 제공된 교재 코드와 고정 fixture 범위에 한정합니다.
따라하기
필요한 관찰값을 남깁니다
진단 연결은 유지하고 비밀번호 원문은 내보내지 않습니다.
import json, re
def safe(event):
event = event if isinstance(event, dict) else {}
rid = event.get('requestId')
status = event.get('status')
result = {'requestId': rid if isinstance(rid, str) and re.fullmatch(r'r-[0-9]+', rid) else 'invalid-id',
'status': status if type(status) is int and 100 <= status <= 599 else None}
for name in ('password', 'token', 'content'):
if name in event:
result[name] = '[REDACTED]'
return result
print(json.dumps(safe({'requestId':'r-7','status':401,'password':'demo'}),ensure_ascii=False,separators=(',',':')))실행 결과
{"requestId":"r-7","status":401,"password":"[REDACTED]"}
불리언 상태와 알 수 없는 필드를 처리합니다
True는 이 계약에서 정수가 아닙니다. metadata는 전체 생략합니다.
import json, re
def safe(event):
event = event if isinstance(event, dict) else {}
rid = event.get('requestId')
status = event.get('status')
result = {'requestId': rid if isinstance(rid, str) and re.fullmatch(r'r-[0-9]+', rid) else 'invalid-id',
'status': status if type(status) is int and 100 <= status <= 599 else None}
for name in ('password', 'token', 'content'):
if name in event:
result[name] = '[REDACTED]'
return result
print(json.dumps(safe({'requestId':'private-text','status':True,'metadata':{'token':'demo'}}),separators=(',',':')))실행 결과
{"requestId":"invalid-id","status":null}
원본 처리 객체를 보존합니다
새 로그 객체를 만들고 원본 token은 변경하지 않습니다.
import json, re
def safe(event):
event = event if isinstance(event, dict) else {}
rid = event.get('requestId')
status = event.get('status')
result = {'requestId': rid if isinstance(rid, str) and re.fullmatch(r'r-[0-9]+', rid) else 'invalid-id',
'status': status if type(status) is int and 100 <= status <= 599 else None}
for name in ('password', 'token', 'content'):
if name in event:
result[name] = '[REDACTED]'
return result
source={'requestId':'r-1','status':200,'token':'synthetic'}
result=safe(source)
print(result['token'])
print(source['token']=='synthetic')실행 결과
[REDACTED] True
브라우저에서 경계 입력을 제출합니다
표준 입력은 JSON 값 하나입니다. 빈 객체·배열·문자열 상태·빈 민감 필드도 계약대로 처리합니다. 채점 출력에 디버그 문장을 붙이지 않습니다.
확인 문제
실습
유효한 JSON 값 하나를 받습니다. 객체가 아니면 빈 객체로 처리합니다. requestId는 r- 다음 숫자 한 개 이상의 전체 일치 문자열만 유지하고 아니면 invalid-id입니다. status는 정확한 정수 100~599만 유지하고 아니면 null입니다. 새 객체에 이 두 필드를 먼저 넣고, 원본에 password·token·content가 있으면 그 순서로 [REDACTED]를 넣습니다. 다른 필드는 중첩 객체까지 전부 생략합니다. JSON은 ensure_ascii=False, separators=(',', ':')로 출력하며 줄바꿈 하나를 붙입니다.
모범 답안
import json, re
def safe(event):
event = event if isinstance(event, dict) else {}
rid = event.get('requestId')
status = event.get('status')
result = {'requestId': rid if isinstance(rid, str) and re.fullmatch(r'r-[0-9]+', rid) else 'invalid-id',
'status': status if type(status) is int and 100 <= status <= 599 else None}
for name in ('password', 'token', 'content'):
if name in event:
result[name] = '[REDACTED]'
return result
import sys
print(json.dumps(safe(json.load(sys.stdin)), ensure_ascii=False, separators=(',', ':')))
더 읽기
면접 질문
- 비밀값을 코드와 로그에서 보호하는 방법을 설명합니다.