How it works

동작 원리

라이브 누출 시연
사전 필터링 대 순진한 사후 필터링
지금 실행됨

이 숫자는 이 페이지를 열 때 실제로 계산됐습니다.

질의는 서버가 고른 네 개 중에서만 고를 수 있습니다 — 자유 입력창을 두면 이 비교 자체가 존재 오라클이 됩니다. 임의의 주제를 물어 등급 밖 계정에게 어떤 조항이 잡히는지를 열거할 수 있기 때문입니다. 목록은 backend/demo/compare.py시연_질의 이고, 아래 숫자는 캐시가 아니라 이번 호출에서 실제로 계산된 값입니다.

2단계 누출
필터를 걸어도 플래너가 인라인하면 다시 샌다
측정 기록

라이브가 아닙니다. 아래 출처에서 측정된 값입니다. 출처: backend/adapters/db/chunk_search.py (by_vector 독스트링) · backend/tests/test_leakage.py:30-35 (문서당_청크 상수와 주석)

사전 필터링 SQL 은 WITH 절(CTE)로 권한 통과 청크를 먼저 확정합니다. AS MATERIALIZED 힌트가 빠지면 Postgres 플래너가 이 CTE 를 인라인해 근사 벡터 인덱스(HNSW)를 먼저 타고 권한 필터를 나중에 적용합니다 — 그 순간 사전 필터링이 다시 사후 필터링이 됩니다. 실측(pgvector:pg16, 공개 2,000 + 기밀 2,000 청크): 인라인되면 등급 1 사용자가 k=10 을 요청해도 0건, AS MATERIALIZED 를 붙이면 10건이 옵니다. 지금 chunk_search.py 의 검색 SQL 은 AS MATERIALIZED 를 쓰고 있어 이 경로가 막혀 있습니다.

이 수치는 코퍼스 규모에 좌우됩니다. test_leakage.py 의 고정값 문서당_청크 는 2,000 입니다 — 문서당 300 개(총 600)로 줄이면 인라인해도 여전히 10건이 나와 누출이 드러나지 않고, 2,000 개(총 4,000)여야 인라인 시 0건이 됩니다. 600 규모에서는 플래너가 인라인해도 순차 스캔으로 정확 검색을 해버려 근사 인덱스의 후보 절단이 애초에 일어나지 않기 때문입니다. 지금 작업 코퍼스는 문서 9개 · 청크 338개로, 이 현상이 나타나는 규모가 아닙니다 — 그래서 이 패널은 라이브가 아니라 measured 입니다.

위 패널(①)은 "이렇게 짜면 샌다" 를 보여줍니다. 이 패널은 "고친 줄 알았는데 여전히 샜다" 를 보여줍니다 — AS MATERIALIZED 로 고쳤다고 믿은 뒤에도 코퍼스 규모가 바뀌면 같은 취약점이 다시 드러납니다. 두 번째는 테스트 없이는 알 수 없습니다.

기록이 담지 않는 것
access_records 는 본문도 제목도 담지 않는다
스키마

코드와 스키마에서 그대로 읽은 것입니다.

access_records 의 컬럼 전부입니다(backend/db/schema.sql):

idtspersonadepartmentclearancequeryclause_coderesource_kindresource_idallowedsession_id

청크 본문도 문서 제목도 담는 컬럼이 없습니다 — text, doc_title, title, body, content 다섯 이름 전부 없습니다. 제목만으로도 문서의 존재가 드러나기 때문입니다 — AccessViolation 이 예외 메시지에 식별자만 담고 본문·호스트 이름·문서 제목을 담지 않는 것과 같은 원칙입니다(core/agent/policy.py).

이 문단은 주장을 둘 하고 있어서 테스트도 둘입니다. 다섯 이름이 없다는 좁은 주장은 backend/tests/test_access_log.py::test_기록에_본문_컬럼이_없다 가 금지 목록으로 지킵니다 — 목록에 없는 이름의 컬럼이 새로 생기면 그 테스트는 그대로 통과합니다. 위 태그가 컬럼 전부라는 전수 주장은 그래서 다른 테스트가 필요합니다: test_화면이_인쇄한_컬럼_목록이_스키마와_같다 가 이 화면이 인쇄한 배열과 information_schema 를 집합으로 비교합니다. 실제로 session_id 를 더할 때 앞의 테스트는 녹색이었습니다 — 뒤의 테스트는 그때 생겼습니다.

session_id 는 브라우저를 구분하는 난수입니다. 이메일·이름과 잇지 않으며, 열람 이력 화면이 내 질의와 남의 질의를 가르는 데만 씁니다 — 이 표가 본문도 제목도 담지 않는다는 주장은 그대로입니다.

열람 대상은 청크이거나 로그 이벤트입니다. resource_kind chunk 또는 log_event 만 허용하는 CHECK 제약이 있고 DEFAULT 가 없어, 종류를 빠뜨린 INSERT 가 조용히 청크로 기록되는 일을 막습니다. 이 컬럼이 필요한 이유는 두 id 공간이 겹치기 때문입니다 — 청크 id(1..338)와 로그 이벤트 id(1..33). resource_kind 없이 숫자만 두면 로그 위반 기록이 무관한 문서 청크로 풀립니다 — W4a 최종 리뷰가 실제로 잡은 결함입니다.

같은 불변식, 두 데이터 타입
문서와 로그가 같은 권한 판정을 공유한다
스키마

코드와 스키마에서 그대로 읽은 것입니다.

documents 와 로그가 발생한 장비를 담는 hosts 는 같은 두 권한 컬럼 required_clearance · allowed_departments 를 갖습니다(backend/db/schema.sql). 이름을 맞춘 이유는 core/access/visibility.pyvisible() 하나를 문서와 로그가 그대로 공유하기 위해서입니다 — 판정 함수가 두 벌이면 그 어긋남은 조용히 결과만 줄이고 에러를 내지 않습니다.

문서로그
관측되는 값결과 개수 (k 가 상한)이벤트 개수 (가변)
위험개수 차이로 존재 노출부분 집계를 전체로 오해
장치사전 필터링 + 고정 k사전 필터링 + 무조건적 범위 고지

로그 쪽 장치는 core/agent/policy.py로그_범위_고지 문구입니다 — 숨겨진 이벤트가 1000건이든 0건이든 같은 문구가 붙어, 관측값이 숨은 데이터 유무에 따라 변하지 않습니다. 문서 쪽처럼 개수를 숨기는 것이 아니라 부분 집계라는 사실 자체를 알리는 장치입니다 — 관측되는 값의 성질이 다르기 때문에 장치의 모양도 다릅니다.

visible() 을 부르는 비테스트 호출부는 core/agent/policy.py 안에 둘입니다 — 62행이 청크 히트 재검증, 98행이 로그 이벤트 재검증입니다. 같은 함수가 서로 다른 두 타입을 재검증하는 것이 이 패널의 주장입니다. 이 둘도 backend/tests/test_how_screen_claims.py 가 집합 동일성으로 고정합니다.

권한 SQL 조각도 한 곳에서 옵니다 — adapters/db/permission_sql.py 권한_WHERE(alias) 하나입니다. 이를 직접 import 하는 파일은 여섯입니다 — 문서 검색(chunk_search.py), 로그 검색(log_search.py), 의도적으로 새는 데모 경로(demo/naive_search.py), 위 패널 ①의 조항 코드 조회(demo/compare.py), 그리고 테스트 둘(backend/tests/test_permission_sql.py · backend/tests/test_search_sql.py) 입니다 — grep -rn "from adapters.db.permission_sql import 권한_WHERE" backend --include="*.py" 를 돌리면 이 여섯 줄이 그대로 나옵니다. 비테스트 넷 중 사본을 따로 두는 것은 chunk_search.py 뿐입니다 — 40행이 _권한_WHERE = 권한_WHERE("d") 로 모듈 상수에 저장해 둡니다. log_search.py:34 · naive_search.py:40 · compare.py:57 은 반환값을 호출부 문자열에 바로 끼워 넣을 뿐 저장해두는 사본이 없습니다. test_permission_sql.pytest_chunk_search_가_이_조각을_쓴다 가 그 저장된 사본이 원본과 여전히 같은지를 단언하고, test_search_sql.py 는 권한 조건을 담은 SQL 네 문장이 그 조각을 여전히 포함하는지와 그 위치가 ORDER BY·LIMIT 보다 앞인지를 단언합니다 — 뒤로 밀리는 순간 사후 필터링이기 때문입니다. 이 목록과 행 번호는 backend/tests/test_how_screen_claims.py 가 집합 동일성으로 고정합니다 — 일곱째 파일이 생기면 이 문장이 조용히 거짓이 되는 대신 테스트가 먼저 터집니다.

가장 좋은 사실: 위 패널(①)의 새는 데모 경로도 같은 권한 조각을 씁니다. 그 경로가 새는 이유는 규칙이 달라서가 아니라, naive_search.py 에서는 이 조각이 상위 k개를 먼저 뽑는 ORDER BY · LIMIT 서브쿼리 바깥에 놓여 사후 필터가 되기 때문입니다 — 사전 필터링 버전(chunk_search.py)은 같은 조각을 서브쿼리 에 둡니다.

한 곳인 것은 이 SQL 조각이지 권한 규칙 자체가 아닙니다. permission_sql.py 의 모듈 독스트링이 스스로 적어두듯, 같은 규칙이 SQL(사전 필터링) · core/access/visibility.py(재검증) · frontend/lib/visibility.ts(화면 설명) 세 계층에 의도적으로 산다 — 각자 다른 일을 하기 때문입니다. 문제가 되는 것은 같은 계층 안에서 사본이 늘어나는 것입니다.