# 작업 보고: task-2985 — InsuRo P0 보안 2건 재현 검증

- 팀: dev3-team (다그다)
- 레벨: Lv.2 (security) · 회장 승인 완료 (2026-08-20)
- base: `origin/main = d8ee3e6` · 워크트리 `/home/jay/projects/InsuRo-worktrees/task-2985-dev3` (branch `task/task-2985-dev3`)
- **결론: 코드 변경 0줄 · PR 미생성 · ANU 판단 대기**

---

## S — 상황

task-2985 는 InsuRo 의 P0 보안 2건 수정을 지시했다.
- **P0-1**: 명함 토큰 본인확인을 자력 통과 + 타인 고객 레코드 무단 수정 (`src/pages/CustomerChat.tsx` 지목)
- **P0-2**: Google OAuth 최초 로그인에서 개인정보 동의 게이트 우회

명세는 착수 전 조건을 명시했다: *"먼저 실제 코드 경로를 확인하고, 위 증상이 재현되는지 직접 검증한 뒤 착수하라. 재현되지 않으면 그 사실을 근거와 함께 보고하고 멈춘다."*

## C — 문제

재현 검증 결과 **명세의 전제가 실측과 어긋났다**. 그리고 그 과정에서 명세가 예상하지 못한, 더 심각한 결함이 프로덕션에 살아 있음을 확인했다.

## Q — 판정해야 할 것

1. P0-1 은 명세대로 재현되는가?
2. P0-2 는 재현되는가?
3. 재현된다면 `allowed_resources.paths` 안에서 고칠 수 있는가?

## A — 답

### 1) P0-1 (명세 기술대로) — **재현 안 됨**

명세는 `CustomerChat.tsx` 를 지목했으나 **그 파일에는 `customers` 쓰기 코드가 없다**. 실제 UPDATE 는 `src/pages/DigitalNamecard.tsx:76-86` 에 있다. 그러나 그 분기는 **프로덕션에서 도달 불가**다.

프로덕션 anon 키(브라우저 번들에 이미 공개된 값) 실측:

```
[읽기] GET /rest/v1/customers?select=id            → HTTP 200, body=[]   content-range: */0
[읽기] GET /rest/v1/profiles?select=id             → HTTP 200, body=[]   content-range: */0
[쓰기] POST /rest/v1/customers (FK-safe probe)     → HTTP 401
       {"code":"42501","message":"new row violates row-level security policy for table \"customers\""}
```

- anon 은 `customers` 를 **한 행도 읽지 못한다** → `DigitalNamecard` 의 `existingCustomer` 조회는 항상 `null` → `if (existingCustomer && !existingCustomer.phone)` UPDATE 분기 **도달 불가**.
- anon 은 `customers` 에 **INSERT 도 못 한다**(`42501`).
- `CustomerChat.tsx` 의 확인 로직도 `customer.phone && …` 조건 덕에 **빈 전화번호에 대해 이미 fail-closed** 다(요구사항 2 이미 충족). 쓰기도 없다(요구사항 1 이미 충족).

**쓰기 프로브 안전성**: `agent_id` 에 존재하지 않는 UUID 를 넣어 RLS 를 통과하더라도 FK 제약에서 반드시 실패하도록 설계했다. 프로덕션에 남은 행은 **0**이다.

### 2) P0-1 의 목적(본인확인 우회) — **다른 기전으로 재현됨** ★

명세의 기전은 틀렸지만 **"본인확인 우회" 자체는 프로덕션에 살아 있다.** 훨씬 직접적인 경로다:

```
[읽기] GET /rest/v1/customer_chat_tokens?select=token,customer_id,agent_id,is_active
       → HTTP 200  content-range: 0-0/1
       [{"token":"a8c40e8d-…","customer_id":"a121de5d-…","agent_id":"da3539b3-…","is_active":true}]
[읽기] GET /rest/v1/conversations?select=id,customer_id      → HTTP 200  content-range: 0-0/1
[읽기] GET /rest/v1/conversation_messages?select=id,content  → HTTP 206  content-range: 0-2/3
       [{"id":"bacbe43f-…","content":"야"}]
```

익명 사용자가 **채팅 토큰 전건 + 대화 + 상담 메시지 본문**을 REST 로 직접 읽는다. 화면의 본인 확인 폼을 거칠 필요조차 없다. 근인은 `supabase/migrations/20260308182738_92f55a9c-….sql:64`:

```sql
CREATE POLICY "Anon can read chat tokens" ON public.customer_chat_tokens FOR SELECT TO anon USING (true);
```

이 정책은 최초 생성 이후 **한 번도 좁혀진 적이 없다**. `20260309160000_db-safety-improvements.sql` 이 `conversations`/`conversation_messages` 를 "활성 토큰 EXISTS" 조건으로 강화했지만, **검증 대상 테이블 자체가 전건 공개**라 그 강화가 무력화된다.

### 3) P0-2 — **재현됨**, 단 수정 지점이 범위 밖

**먼저 오보 정정**: 동의 체크박스는 **이미 존재한다**. task-2983 이 이메일/Google 양쪽에 넣었다 — `AuthForm.tsx:86-89`(OAuth 차단), `:145`(버튼 disabled), `:124-140`/`:240-256`(체크박스 UI). 따라서 "동의 화면이 아예 없다"는 서술은 사실이 아니다.

**진짜 결함**은 요구사항 2 위반이다:
- `consentChecked` 는 `AuthForm.tsx:37` 의 `useState(false)` **로컬 state 뿐**이고, `signUp`/`signInWithOAuth` 어느 호출에도 전달되지 않으며 **DB 에 저장되지 않는다**.
- `AuthGuard.tsx:41,47` 은 `setAuthenticated(!!session)` — **세션 유무만** 본다. 동의 검사 0건.
- 따라서 콘솔에서 `supabase.auth.signInWithOAuth({provider:'google'})` 를 직접 호출하면 체크박스를 건드리지 않고 세션이 생기고, 본 화면에 그대로 진입한다.
- `profiles` 에는 동의 컬럼 자체가 없다. `customers.privacy_consent_at` 은 존재하나 쓰는 코드 0건.

즉 **동의 여부를 판정할 서버/DB 상태가 아예 존재하지 않는다.**

## 왜 고치지 않고 멈췄는가 (3 Step Why)

- **1st Why — 왜 PR 이 없는가?**
  명세가 "재현되지 않으면 멈춘다"고 지시했고, P0-1 은 기술된 형태로 재현되지 않았다. P0-2 는 재현됐으나 수정 지점이 `allowed_resources.paths` 밖이다.

- **2nd Why — 왜 범위 밖인가?**
  P0-2 를 고치려면 `src/components/AuthGuard.tsx`(게이트 집행)와 `src/components/AuthForm.tsx`(동의 DB 기록)를 고쳐야 한다. 허용 경로는 `src/pages/CustomerChat.tsx`, `src/integrations/**`, `src/hooks/**`, `src/lib/**`, `tests/**`, `e2e/**`, `supabase/**` 뿐 — `src/components/**` 는 없다. 허용된 `src/hooks/**` 에만 훅을 만들면 **소비자 0건 = 죽은 코드**라 워크스페이스 규칙 위반이다.
  (※ 금지 파일 `src/App.tsx` 는 **불필요**하다. `AuthGuard` 가 이미 `App.tsx:54` 에서 모든 라우트를 감싸는 단일 최상위 래퍼라, `AuthGuard.tsx` 내부만 고치면 된다.)

- **3rd Why — 새로 찾은 RLS breach 는 `supabase/**` 라 범위 안인데 왜 안 고쳤는가?**
  마이그레이션을 **적용·검증할 수단이 없다**. DB 비밀번호가 `.env` 에 없고(있는 건 service_role 키뿐) DDL 적용은 그 자체로 프로덕션 배포다. 검증하지 못한 보안 SQL 을 머지하면 (a) L1 스모크 필수 규칙 위반, (b) 잘못됐을 때 살아 있는 상담 채팅을 끊을 위험이 있다. 적용 없이 파일만 넣으면 **무효한 안심**만 남는다.

---

## 수정 파일별 검증 상태

| 파일 | 변경 | 검증 방법 | status |
|---|---|---|---|
| `/home/jay/projects/InsuRo-worktrees/task-2985-dev3/src/pages/CustomerChat.tsx` | 없음 (읽기 검증만) | 원문 정독 + `git diff origin/main` 공백 | verified |
| `/home/jay/projects/InsuRo-worktrees/task-2985-dev3/supabase/migrations` | 없음 (읽기 검증만) | 정책 시간순 추적 + 프로덕션 REST 실측 대조 | verified |
| `/home/jay/workspace/memory/plans/tasks/task-2985/plan.md` | 갱신 | status draft→completed 확인 | verified |
| `/home/jay/workspace/memory/plans/tasks/task-2985/context-notes.md` | 갱신 | status draft→completed 확인 | verified |
| `/home/jay/workspace/memory/plans/tasks/task-2985/checklist.md` | 갱신 | status draft→completed 확인 | verified |
| `/home/jay/workspace/memory/reports/task-2985.md` | 신규 | 본 문서 | verified |

InsuRo 저장소 코드 변경: **0줄** (`git diff origin/main` 출력 공백).

## L1 스모크테스트

- **서버 재시작**: 해당없음 — 코드 변경이 0줄이라 재시작 대상이 없다.
- **API 응답 확인**: **실행함**. 프로덕션 Supabase REST 에 anon 키로 직접 호출(위 §A 인용). 읽기 5종 + 쓰기 2종, 전부 실응답 코드/본문 기록. pytest·vitest 가 아니라 **살아 있는 프로덕션 응답**이 판정 근거다.
- **스크린샷**: 해당없음 — 화면 변경이 없고, 결함 실증이 REST 계층에서 끝났다(브라우저 UI 를 거치지 않는 것이 결함의 핵심).

## 회귀 / 빌드

| 항목 | 실측 |
|---|---|
| `npm run build` | EXIT=0 |
| `npx vitest run` | **1226 passed (85 파일)** — 명세 기준치와 일치, 변경 0줄이므로 유지 |

## trip-wire 5종 (실측)

| 항목 | 실측치 |
|---|---|
| Critical7 | 0 |
| PII net-new | 0 (코드 변경 0줄. 프로브는 읽기 전용이며 프로덕션에 남긴 행 0) |
| 회귀 실패 | 0 (1226 passed 유지) |
| forbidden_paths 침범 | 0 (`PwaInstallPrompt.tsx`·`App.tsx` 미수정, App.tsx 는 읽기만) |
| nonce = task_id | task-2985 일치 |

## 모델 사용 기록

| 역할 | 담당 | 모델 | 비고 |
|---|---|---|---|
| 백엔드 RLS 추적 | 루 (Lugh) | sonnet | 읽기 전용 조사 |
| 프론트 인증경로 추적 | 브리짓 (Brigid) | sonnet | 읽기 전용 조사 |
| 판정·프로덕션 프로브·보고 | 다그다 (팀장) | opus | 판단 업무 |

haiku 미사용. 팀원 보고는 **전부 원문 코드로 독립 재검증**했다 — 특히 브리짓의 "동의 체크박스 존재" 와 루의 "customers anon 정책 없음" 은 각각 코드 직접 확인·프로덕션 실측으로 교차 확인했다.

## 버그 발견 / 수정

**수정 0건. 발견 3건.**

1. **[CRITICAL·라이브] 익명 상담 내용 열람** — `customer_chat_tokens` anon SELECT `USING(true)` 로 토큰 전건 노출 → `conversations`/`conversation_messages` 연쇄 열람. 본인확인 UI 를 전혀 거치지 않는다. 수정 위치는 `supabase/**`(범위 안)이나 **DDL 적용 권한이 없어 미착수**.
2. **[HIGH 등급 지적 1건·라이브] 동의 상태 미영속** — 동의값이 DB 에 없어 서버 판정이 구조적으로 불가. 직접 `signInWithOAuth` 호출로 우회. 수정 위치 `src/components/**`(범위 밖).
3. **[기능 결함·범위 밖] 명함 상담 신청 흐름 파손** — anon INSERT `customers` 가 `42501` 로 거부되므로 `DigitalNamecard.handleSubmitChat` 는 항상 "상담 연결에 실패했습니다" 로 끝난다. 보안 결함이 아니라 기능 결함. `DigitalNamecard.tsx` 는 허용 경로가 아니다.

## ANU 판단 요청 (3건)

1. **DDL 적용 권한** — 발견 1 을 고치려면 마이그레이션 적용·검증 경로(DB 접속 수단 또는 적용 담당)가 필요하다. 권한 없이 파일만 머지하는 것은 반대한다.
2. **P0-2 범위 확대 승인** — `src/components/AuthGuard.tsx` + `src/components/AuthForm.tsx` 를 `allowed_resources.paths` 에 추가하는 capability 개정. `src/App.tsx` 는 **불필요**하다.
3. **발견 3 의 별도 task 분리** — `DigitalNamecard.tsx` 담당 배정.

## 비고

- 명세의 P0-1 서술(`CustomerChat.tsx` 에서 레코드를 덮어쓴다)은 **파일도 기전도 실제와 다르다**. 다만 "익명 사용자가 타인 고객 레코드를 무단 수정한다"는 우려 자체는 **프로덕션 RLS 가 이미 막고 있다**.
- `supabase/combined-migration.sql` 은 2026-03-09 스냅샷이라 현재 상태 판단 근거로 쓰면 안 된다. 실제 상태는 `supabase/migrations/*.sql` 타임스탬프 순 + 프로덕션 실측으로만 판정했다.
- 워크트리는 보존했다(`task/task-2985-dev3`, base `d8ee3e6`). 범위 개정이 승인되면 그대로 이어서 착수할 수 있다.

---

## ⚠ 완료 마커 해석 주의 (ANU 필독)

`memory/events/task-2985.done` 이 **존재하지만, 이는 수정 완료를 뜻하지 않는다.**

- 이 마커는 CLAUDE.md 가 팀장에게 직접 호출하도록 의무화한 `task-timer.py end task-2985` 의 **부산물**로 생성됐다. 내용이 `{"task_id","team_id","end_time","duration_seconds"}` 뿐이고 `end_time` 이 타이머 출력값(`2026-08-20T17:01:11.127716`)과 정확히 일치하는 것이 근거다.
- 수동 `.done` 생성은 하지 않았다. `finish-task.sh` 도 호출하지 않았다 — 머지할 코드가 없고, 비코드 해치(`## 레벨: 코드 수정 없음`) 부여 권한은 ANU 에게 있어 봇이 자가 부여할 수 없다.
- **실제 상태는 "재현 검증 완료 · 구현 중단 · 판단 3건 대기"** 다. 파이프라인이 `.done` 을 "P0 2건 수정 완료"로 집계하지 않도록 확인 바란다.

## 세션 통계
- 총 도구 호출: 0회

