Devin.KR

증거 보존과 계정 격리

80분 안팎

학습 목표

증거 보존 뒤 해당 실습 계정의 접근을 차단합니다.

개념

격리 전에 남겨야 할 것

의심 계정을 차단하면 다음 접근을 줄일 수 있지만 훈련 로그나 자료 상태가 바뀔 수 있습니다. 원본 사건 파일을 덮어쓰지 않고 먼저 증거 사본을 보존합니다. 사고 대응자는 무엇을 복사했고 누가 어떤 허가로 조치했는지 설명할 수 있어야 합니다. 이 실습은 변경이 멈춘 합성 파일과 메모리 세션을 대상으로 합니다. 실제 긴급 대응에서는 보존과 차단의 순서를 상황에 맞게 결정하고 그 이유를 기록하며 이 교재의 순서를 모든 사고에 적용하지 않습니다.

바이트 사본과 요약은 다릅니다

JSON을 읽어서 보기 좋게 다시 저장하면 공백과 키 순서가 바뀔 수 있습니다. 내용이 비슷해 보여도 원본과 바이트가 같지 않습니다. preserve는 readFileSync가 반환한 Buffer를 그대로 새 파일에 씁니다. 보고서용 필드 요약은 별도 산출물입니다. 복사 뒤 원본과 사본의 SHA-256을 비교하고 원본 이름·사본 이름·해시를 기록합니다. 해시가 같다는 말은 복사 시점의 바이트가 일치한다는 뜻이며 원본 사건이 진실임을 보증하지 않습니다.

기존 사본을 덮어쓰지 않습니다

writeFileSync의 flag wx는 대상이 있으면 실패합니다. 이미 보존한 파일을 덮어쓰는 실수를 줄이는 장치입니다. EEXIST가 나오면 이전 보존 기록이 있는지 읽고 새 사건 묶음 이름을 선택합니다. 실패를 피하려고 기존 사본을 삭제하지 않습니다. mode 0o600은 이 실습에서 파일 소유자 접근으로 제한하려는 설정이며 기존 디렉터리 정책과 플랫폼도 별도로 검토해야 합니다. 해시만 남기고 사본을 지우면 다시 내용을 분석할 수 없습니다.

계정 차단과 세션 폐기

로그인 화면을 막는 것만으로 이미 발급된 세션이 사라지지는 않습니다. createAuth에는 blocked 집합을 두고 login과 resolve 양쪽에서 확인합니다. suspend는 서버 내부 계정 id를 받아 그 계정을 차단하고 기존 세션을 제거합니다. 같은 사용자에게 두 세션을 발급해 둘 다 거절되는지 검사합니다. 새 로그인도 실패해야 합니다. 클라이언트가 보낸 owner나 role을 격리 대상을 정하는 권위 있는 값으로 사용하지 않습니다.

키가 있는 세션도 다룹니다

앞 모듈은 더미 서명키가 있으면 sessions.cjs의 세션 저장소를 사용합니다. 기본 Map만 비우면 그 분기의 세션은 살아 있습니다. revokeAccount는 내부 user.id가 일치하는 세션만 제거합니다. resolve에서도 차단 상태를 확인해 누락된 세션 제거가 접근 허용으로 이어지는 것을 막습니다. 이것은 방어를 두 위치에 둔 것이며 다른 프로세스의 세션 저장소까지 갱신하는 구현은 아닙니다. 다중 인스턴스 반영은 잔여 위험으로 인계합니다.

정상 계정을 함께 검사합니다

Alice를 격리한 직후 Bob의 기존 세션은 계속 유효해야 합니다. 모두 삭제하는 코드는 계정별 격리 과제에서 과한 영향입니다. 테스트는 Alice 두 세션과 새 로그인 거절, Bob 세션 유지, 존재하지 않는 id 오류, 같은 계정 반복 격리를 확인합니다. 정상 업무에 미친 영향을 자동 검사에 넣어야 보안 조치가 무엇을 희생했는지 드러납니다. Bob의 접근이 유지된다고 Bob이 안전한 사용자임을 판정한 것은 아닙니다.

전역 키 교체는 별도 결정

더미 키 노출을 가정하면 rotateKey는 이전 전체 세션과 CSRF 상태를 폐기합니다. 이 동작은 계정별 격리와 달리 Bob에게도 재로그인을 요구합니다. 키가 바뀌면 정상 계정도 로그아웃되는 범위를 보고서에 적습니다. 새 키 형식 검사가 실패할 때는 기존 상태가 유지되는 회귀도 확인합니다. 유출 가능성이 있는 옛 키로 되돌리는 조치를 복구라고 쓰지 않습니다. 새 키 전달과 다른 인스턴스 반영은 이 메모리 훈련 밖의 작업입니다.

권한 있는 내부 동작으로 제한합니다

suspend와 release는 HTTP 공개 경로가 아니라 훈련에서 호출하는 내부 함수입니다. 외부 사용자가 계정 id를 보내면 차단하는 API를 만들지 않습니다. 실제 서비스에서는 운영자 인증·인가와 감사 기록 및 승인 절차가 추가로 필요합니다. unknown_account 오류는 대상 식별 실패를 뜻합니다. 다른 계정을 임의로 골라 계속하지 않고 시나리오 매핑을 확인합니다. fixture a와 demo-alice의 연결은 교재에 적힌 훈련 가정입니다.

starter 실패를 읽습니다

containment-test.cjs는 증거 복사 검사 후 Alice 세션이 null인지 비교합니다. starter의 suspend는 빈 함수이므로 실제 값은 사용자 객체로 남아 AssertionError가 발생합니다. 오류에서 비교한 값과 해당 줄을 보고 로그인 함수가 아니라 격리 함수의 누락을 찾습니다. 테스트의 expected null을 객체로 바꾸면 접근 차단 계약을 없애는 것입니다. 원인을 고친 뒤 Bob 세션 검사까지 통과하는지 확인해 전체 삭제로 우회하지 않았는지 점검합니다.

실패한 보존을 해석합니다

ENOENT는 원본 경로가 없거나 선택한 사본 폴더가 없다는 의미일 수 있습니다. 경로와 제출 ZIP 루트를 확인합니다. evidence_copy_mismatch는 복사 검증 중 바이트가 다르다는 신호이므로 격리를 계속한 것으로 보고하지 않습니다. 실습 테스트는 임시 파일을 사용하고 finally에서 정리하지만 실제 사건 사본은 인계 보관 기준에 따라 유지합니다. 이 차이를 놓치면 시험 정리 코드를 실제 보존 절차에 잘못 적용할 수 있습니다.

현재 구현의 지속성 한계

blocked와 세션은 메모리에 있어서 프로세스를 새로 만들면 차단 상태가 사라집니다. 이번 검증은 같은 런타임에서 동작하는 범위입니다. 재시작해도 안전하다는 표현은 쓰지 않습니다. 실제 계정 상태 저장소, 재로그인 차단 정책, 만료·폐기 정보 전파가 후속 개발 요청입니다. 최종 보고서는 기능 시험의 성공과 지속성 미구현을 동시에 보여주며 운영 적용의 완료 조건을 app-team에 전달합니다.

격리 기록을 인계합니다

대상 계정과 근거 사건 id, 보존 사본 참조, 실행자, 조치 이유, 정상 계정 영향, 해제 조건을 적습니다. 실습에서는 의심 상태를 해소했다고 주장하지 않고 조사 필요를 유지합니다. 비밀값은 보고서에 넣지 않고 교체 여부와 검증 결과만 기록합니다. 세션 폐기의 기술은 서재 보안 장에서 더 읽을 수 있으며 이 레슨 제출물은 어떤 접근이 언제부터 거절되는지를 검증한 기록입니다. 다음 단계는 격리 상태를 유지하면서 자료를 복구하는 작업입니다.

파일 생성 플래그의 의미는 Node.js 파일 플래그 공식 문서를 참조합니다.

따라하기

바이트 해시 비교

같은 바이트와 줄바꿈이 빠진 바이트를 각각 비교합니다. 두 번째 차이가 자료 의미의 참·거짓을 나타내지는 않습니다.

const {createHash}=require('node:crypto');
const a=Buffer.from('synthetic-event\n'),b=Buffer.from(a);
const hash=x=>createHash('sha256').update(x).digest('hex');
console.log(hash(a)===hash(b));
console.log(hash(a)===hash(Buffer.from('synthetic-event')));

실행 결과

true
false

신규 쓰기의 거절

임시 파일에 wx로 두 번 쓰고 첫 내용이 유지되는지 확인합니다. 실제 사건 사본은 이 예제의 정리 코드를 사용해 삭제하지 않습니다.

const fs=require('node:fs'),os=require('node:os'),path=require('node:path');
const d=fs.mkdtempSync(path.join(os.tmpdir(),'proof-'));
try{const p=path.join(d,'copy');fs.writeFileSync(p,'one',{flag:'wx'});
try{fs.writeFileSync(p,'two',{flag:'wx'});}catch(e){console.log(e.code);}
console.log(fs.readFileSync(p,'utf8'));}finally{fs.rmSync(d,{recursive:true,force:true});}

실행 결과

EEXIST
one

격리 대상 식별 비교

세션 id 대신 서버 계정 id로 모든 대상 세션을 찾는 작은 예입니다. 신규 로그인 금지는 내려받은 앱의 blocked 검사에서 구현합니다.

const sessions=new Map([['a1',{id:'demo-alice'}],['a2',{id:'demo-alice'}],['b1',{id:'demo-bob'}]]);
for(const [sid,user] of sessions)if(user.id==='demo-alice')sessions.delete(sid);
console.log([...sessions.keys()].join(','));

실행 결과

b1

앱에서 회귀 검사 실행

해당 실습 ZIP 루트에서 다음 명령을 실행합니다. starter는 지정 TODO의 AssertionError로 실패합니다. 수정 뒤 각 PASS 행과 종료 코드 0을 확인합니다. 외부 Docker 검사는 별도 대기입니다.

bash check.sh

확인 문제

실습

ZIP 루트에서 bash check.sh를 실행합니다. auth.cjs의 suspend와 release를 완성합니다. 증거 복사·중복 사본 거절, 대상의 모든 세션과 신규 로그인 거절, 정상 Bob 세션 유지, 잘못된 대상 오류를 확인합니다. 테스트 기준은 바꾸지 않습니다.

시작 코드·테스트 내려받기

실행 명령

bash check.sh

기대 결과

종료 코드 0, 증거·격리 검사 통과

모범 답안모범 답안 내려받기

더 읽기

면접 질문

  • 비밀값을 코드와 로그에서 보호하는 방법을 설명합니다.