가이드

프록시 & History

프록시는 클라이언트와 업스트림 서버 사이에 자리 잡아 오가는 요청과 응답을 플로우로 기록하고 현재 프로젝트에 저장합니다. History는 그 플로우를 되짚어 읽는 곳입니다.

트래픽 캡처

gori를 실행하고 클라이언트를 127.0.0.1:8070으로 향하게 하세요(자세한 내용은 Quick Start 참고). c로 언제든 캡처를 토글할 수 있습니다. 꺼두면 트래픽이 기록 없이 통과하므로 환경을 설정하는 동안 편리합니다.

클라이언트가 프록시를 바라보고 있다면 http://gori.proxy/에서 gori의 안내 페이지와 CA 다운로드를 받을 수 있습니다. gori가 자체적으로 응답하는 예약된 이름이라 네트워크로 나가지 않습니다. 인증서보다 프록시를 먼저 설정하게 되는 휴대폰에서 특히 유용합니다. 프록시를 설정하지 않은 클라이언트는 리슨 주소로 직접 접속하면 같은 페이지를 받습니다.

각 플로우는 요청과 응답 전체를 기록합니다. 시작 줄, 헤더, 본문(저장되는 본문은 2 MiB로 제한되지만, 더 큰 본문도 바이트 단위 그대로 전달되며 실제 크기를 보고하고, TUI의 플로우 상세 뷰에서 캡처된 바이트 수와 실제 전송 크기 및 Settings → Network / capture_max_mib에서 설정값을 늘리는 방법을 배너로 표시합니다)까지 담습니다. gzip, deflate, Brotli, Zstd로 압축된 본문은 화면에 표시할 때 디코드됩니다.

HTTPS와 업스트림 검증. HTTPS의 경우 gori는 오리진 서버 인증서를 시스템 CA 트러스트 스토어로 검증합니다(표준 위치에서 자동 탐색하며 SSL_CERT_FILE / SSL_CERT_DIR를 존중). 최소 컨테이너 등에서 스토어를 찾지 못하면 검증이 실패해 해당 플로우는 오류로 기록됩니다. 이때는 SSL_CERT_FILE=/path/to/ca-bundle.crt를 지정하거나 --insecure-upstream으로 실행하세요(설정 → Network → verify upstream). 이는 gori가 트래픽을 복호화하기 위해 클라이언트에서 gori 루트 CA를 신뢰하는 것과는 별개입니다.

RESPONSE 서브탭의 gori 플로우 상세 뷰. HTTP/2 200 상태 줄과 구문 강조된 응답 헤더를 보여준다
Enter로 아무 플로우나 열어 전체 요청과 응답을 읽습니다. 헤더, HTTP/2 프레임, 원시 바이트를 볼 수 있는 서브탭이 함께 제공됩니다.

인터셉트

i를 눌러 Intercept를 켭니다. 켜져 있으면 매칭되는 요청(그리고 선택적으로 응답)이 붙잡혀, 계속 진행하기 전에 포워드, 드롭, 편집할 수 있습니다. Intercept 탭 상단의 필터 바에서 붙잡을 방향을 고르고 쿼리 언어 표현식으로 붙잡을 대상을 좁힐 수 있어, 관심 있는 트래픽에서만 멈춥니다.

붙잡을 방향과 쿼리 조건을 위한 필터 바, 그리고 catch가 꺼졌을 때의 forward/drop을 설명하는 카드를 갖춘 gori Intercept 탭
Intercept 탭: i로 catch를 토글하고, 방향을 고르고, 매칭되는 트래픽만 붙잡아 흘러가기 전에 포워드, 드롭, 편집합니다.

붙잡힌 큐에서도 History 목록과 똑같은 다중 선택을 쓸 수 있습니다(아래 플로우 표시하기). t는 커서 위의 메시지를 표시하고 한 칸 내려가며, Shift-↑ / Shift-↓는 연속 범위를 확장하고, Shift-T는 큐 전체를 표시하며, Esc는 모두 해제합니다. 그러면 포워드(f)와 드롭(d)은 표시가 있으면 표시된 전부, 없으면 커서 행을 대상으로 하므로, 한꺼번에 몰려든 홀드를 키 한 번으로 흘려보내거나 끊을 수 있습니다 — Shift-F는 표시와 무관하게 여전히 큐 전체를 포워드합니다. 표시된 행은 왼쪽 여백에 굵은 막대가 붙고 필터 줄에 3 marked 카운트가 실시간으로 표시되며, 해당 메시지가 큐를 떠나는 순간 표시도 함께 사라져 카운트가 화면보다 커지는 일이 없습니다.

붙잡힌 메시지를 읽는 데는 표시도 편집기도 필요 없습니다. Shift-← / Shift-→로 미리보기를 가로로, PgUp / PgDn / Home / End로 세로로 스크롤하면 붙잡힌 바이트는 그대로 둔 채 전체를 훑어볼 수 있습니다.

무엇이 붙잡히는가

요청은 HTTP/1.1과 HTTP/2에서 붙잡히며 gRPC도 포함됩니다. 응답도 마찬가지지만, 마지막 바이트를 기다릴 수 없는 응답은 예외입니다. WebSocket 업그레이드(101), Server-Sent Events 스트림, close로 구분되는 응답은 붙잡지 않고 도착하는 대로 흘려보냅니다. WebSocket 메시지도 붙잡을 수 있지만, 명시적으로 요청했을 때만입니다 — 아래 WebSocket에서의 인터셉트를 보세요. 그 소켓을 연 요청은 다른 요청과 똑같이 붙잡을 수 있습니다.

WebSocket에서의 인터셉트

WebSocket 메시지는 catch 조건에 proto:ws가 들어 있을 때만 붙잡힙니다. 그 외에는 아무것도 이 경로를 켜지 않습니다. 빈 조건도, host:acme.test도, 어떤 방향 설정도 마찬가지입니다. 빈 조건이 모든 것을 붙잡는 HTTP의 규칙과 정확히 반대이며, 의도된 것입니다. 브라우저는 요청 하나가 멈춰 있는 것을 견디지만, 초당 수십 개의 메시지가 흐르는 거래·채팅 소켓이 호스트 필터를 하나 입력했다는 이유로 통째로 얼어붙으면 빠져나올 방법이 없습니다.

그래서 입력할 조건은 proto:ws이고, 필요하면 좁힙니다.

proto:ws body:subscribe          "subscribe" 가 들어간 메시지만 붙잡기
proto:ws host:acme.test          이 소켓의 메시지만 붙잡기

body:는 메시지 페이로드의 부분 문자열이며 WebSocket 메시지에만 매칭됩니다. HTTP 홀드 지점에서는 아직 바이트가 존재하지 않기 때문입니다. c:REQ / c:RES 칩은 평소와 같습니다. REQ가 클라이언트 → 서버, RES가 서버 → 클라이언트입니다.

붙잡힌 메시지는 붙잡힌 요청과 같은 큐에 WS↑ 또는 WS↓ 뱃지, 그 메시지를 실어 나르는 소켓, 페이로드 앞부분과 함께 올라옵니다. forward, drop, 편집, 마크 모두 동일하게 동작합니다. 다른 점은 이렇습니다.

Match & Replace는 홀드보다 먼저 돌기 때문에, 편집기에서 보는 것이 규칙이 만들어 낸 결과이고 전달하는 것이 실제로 나가는 바이트입니다.

HTTP/2에서의 인터셉트

인터셉트는 연결을 강등하지 않고 HTTP/2에 적용되므로 캐치를 켠 채로도 gRPC 클라이언트가 계속 동작하며, 스트림을 개별적으로 붙잡습니다 — 요청 하나를 붙잡아도 탭 전체가 멈추지 않습니다. HTTP/1.1과 다른 점이 셋 있습니다.

헤드 규칙이 HTTP/2에서 표현할 수 없는 것들(HTTP/2에서의 헤드 규칙, 아래)은 손으로 편집한 헤드에도 똑같이 적용됩니다.

스코프

스코프는 큰 세션을 대상에 집중시킵니다. Project 탭에서 호스트, 문자열, 정규식으로 include/exclude 규칙을 정의합니다. sscope 렌즈를 토글하면 뷰가 스코프 내 트래픽으로 좁혀집니다. Intercept와 스캐너가 작동할 대상도 스코프로 제한할 수 있습니다.

샌드박스

샌드박스는 테스트를 허용된 범위 안으로만 강하게 가두는 차단 게이트입니다. Project 탭의 Project settings 패널에서 토글하거나, 어느 탭에서든 커맨드 팔레트(Ctrl-PToggle sandbox)로 토글합니다(기본값: off). on인 동안 캡처 프록시는 스코프가 허용하는 요청만 전달하고, 나머지는 오리진에 닿기 전에 차단합니다. 차단된 시도도 aborted 플로우로 기록되어 History에서 확인할 수 있습니다. HTTP/1.1에서는 클라이언트가 X-Gori-Sandbox: blocked 헤더가 붙은 403을 받고, HTTP/2에서는 차단된 스트림만 취소되며(RST_STREAM, CANCEL) 같은 연결의 나머지 스트림은 그대로 동작합니다. 여기서 "허용"이란 스코프를 허용 목록(allowlist)으로 평가한 것으로, include 규칙이 하나 이상 매칭되고 exclude 규칙에는 매칭되지 않아야 합니다.

허용 목록이므로 include 규칙이 하나도 없으면 모든 트래픽이 차단됩니다. 먼저 대상에 대한 include를 추가하세요(스코프가 빈 상태에서 샌드박스를 켜면 바로 이 점을 확인하도록 물어봅니다). 켜져 있는 동안에는 상단 바에 빨간 sandbox 칩이 계속 표시되고, Project settings 행의 토글 옆에 현재 효과가 안내됩니다.

샌드박스는 프록시로 캡처되는 트래픽에만 적용됩니다. Repeater, Fuzzer, Miner, MCP send_request 도구는 각자 스코프를 강제합니다(범위를 벗어난 대상은 SCOPE_BLOCKED로 거부). HTTPS는 요청 URL을 확인하기 위해 TLS 인터셉트에 의존합니다. 스코프에 들 수 없는 호스트는 CONNECT 단계에서 거부되고, 통과한 호스트의 요청은 하나씩 개별 검사합니다. 이 검사는 HTTP/2에서도 스트림 단위로 동작하므로, 샌드박스 때문에 연결이 HTTP/1.1로 내려가지 않고 gRPC 클라이언트도 계속 동작합니다. CONNECT 안에 들어오는 평문 HTTP/2(h2c, 드묾)도 다른 HTTP/2 연결과 똑같이 스트림 단위로 검사합니다. 터널은 열리고, 그 위에서 스코프를 벗어난 스트림만 개별적으로 취소됩니다.

Sitemap

Sitemap 탭은 History를 중복 제거된 host → path 엔드포인트 트리로 접어, 메서드 칩과 스코프 마커를 함께 보여줍니다. 대상의 공격 표면을 한눈에 파악하기 좋습니다. g를 누르면 경로 파라미터의 id를 접습니다. /user/1/user/2가 한 노드를 공유하고, /user/<uuid>{uuid} 하나로 모입니다. 쿼리 문자열은 별개의 축으로 접힙니다. /search?q=widgets/search?q=<payload>/search 한 줄이 되고(펼치면 원래 변형이 보입니다), ⇧G로 끌 수 있습니다.

캡처된 호스트들이 경로 트리로 펼쳐지고 메서드 칩과 호스트별 경로 개수가 표시된 gori Sitemap 탭
Sitemap은 History를 메서드 칩이 달린 host → path 트리로 접어, 대상의 표면을 한눈에 보여줍니다.

경로 표시하기 (다중 선택)

트리에서도 History 목록과 같은 방식으로 표시(mark)할 수 있습니다(제스처 전체는 플로우 표시하기 참고). t를 누르면 커서 위의 경로를 표시하고 아래로 한 칸 이동하므로, t를 연달아 누르면 연속된 행이 표시됩니다. Shift-↑ / Shift-↓는 시작한 지점부터 연속 범위를 확장하고, Esc는 표시를 모두 해제합니다. 표시된 행은 왼쪽 여백에 굵은 막대가 붙고, 필터 줄에 3 marked 카운트가 실시간으로 표시됩니다(접힌 하위 트리에 있어 화면에 없는 개수도 함께). Shift에서 손을 떼고 그냥 / 를 누르면 GUI 목록이 강조를 접듯 그 범위가 해제됩니다. t로 직접 찍어 둔 표시는 남고, 마우스 휠은 스크롤일 뿐이라 표시를 지우지 않습니다.

표시는 어떤 동작이 있는지를 바꾸지 않고, 액션 메뉴가 무엇을 대상으로 하는지만 바꿉니다. 실제 대상 = 표시된 것이 있으면 그것들, 없으면 커서 행입니다.

동작 표시된 경로에 대해
경로 태그 Shift-T 편집기 하나, 메모 하나를 표시된 모든 경로에 적용(비우면 전체 해제)
Repeater로 보내기 r 표시된 엔드포인트마다 서브탭 하나, 캡처된 플로우 기준으로 중복 제거(최대 20)

따라서 / status:5xx → 필요한 경로를 표시 → Shift-Tauth면 전체에 한 번에 태그가 붙고, 이후 tag:auth로 다시 찾을 수 있습니다. 메뉴 제목이 SPACE · 3 MARKED로 바뀌고 항목 이름도 Tag 3 paths, Send 3 paths to Repeater처럼 바뀝니다. Discover와 Sequencer는 단일 대상으로 남습니다(하위 트리 하나를 스캔하고, 엔드포인트 하나의 토큰을 수집하므로) — 표시가 있는 동안 메뉴 항목에 (cursor)가 붙습니다.

여기서 t는 표시, Shift-T는 태그입니다. 두 목록에서 t의 의미를 맞추기 위해 태그가 Shift-T로 옮겨졌습니다. 합성 노드인 {uuid} / [1, 2, 3 …] 폴드는 실제 경로가 아니므로 표시도 태그도 되지 않습니다 — 범위 선택은 그 행을 건너뛰고, 그 위에서 t를 누르면 그렇다고 알려줍니다. History와 달리 "전체 표시"는 없습니다. 트리에서는 호스트와 폴더까지 그 아래 엔드포인트와 같은 묶음에 들어가 버리기 때문입니다.

프로토콜 지원

캡처 / 인터셉트 / 재전송 / 퍼징의 단일 기준표는 기능 매트릭스에 있습니다. 아래 내용은 그 경계를 만든 프록시 쪽 트레이드오프를 설명합니다.

gori는 HTTP/3을 직접 가로채지 않습니다. QUIC은 UDP이고 gori의 리스너는 전부 TCP 소켓이므로, Alt-Svc: h3=":443"으로 응답하는 원 서버는 클라이언트에게 프록시 밖으로 나가는 길을 제안하는 셈입니다. 기본값에서 gori는 그 제안을 그대로 두되 그 사실을 알립니다 — 무엇이 통과했는지 밝히는 안내가 플로우에 남습니다. 클라이언트가 QUIC으로 떠나도 History에 설명 없는 구멍이 아니라 보고된 사각지대로 남습니다. network.strip_alt_svc를 켜면 h3를 광고하는 Alt-Svc 필드를 클라이언트가 받는 응답에서 제거하므로(HTTP/1.1과 HTTP/2 모두), 클라이언트가 읽는 응답에는 옮겨 갈 곳이 남지 않습니다. (Alt-Svc 말고 다른 경로로 h3를 알아내는 클라이언트 — 예를 들어 DNS HTTPS 레코드 — 는 응답 쪽 제거가 닿는 범위 밖입니다.) 제거 대상은 딱 그 필드들뿐입니다. Alt-Svc: clear는 그대로 둡니다 — 이미 캐시한 대체 경로를 잊으라는 지시이기 때문입니다. h2=":8443" 같은 h3가 아닌 대체 경로도 그대로 둡니다 — 그것도 TCP 포트이고 여전히 gori를 지나가기 때문입니다.

기본적으로 gori는 WebSocket 압축을 끕니다. gori가 중계하는 핸드셰이크에서 Sec-WebSocket-Extensions를 제거하므로 permessage-deflate는 협상되지 않고, 캡처된 프레임은 실제로 오간 메시지 그대로입니다. 이 제거가 없으면 양쪽 피어는 gori가 해독하지 않는 압축에 합의하게 되고, History와 상세 뷰, gori run history show, MCP 도구, export가 전부 deflate 스트림을 페이로드인 양 보여 주게 됩니다. 믿을 수 있는 캡처의 대가로, 원래라면 압축을 쓸 앱이 gori를 지나는 동안에는 압축을 쓰지 못합니다. 특정 호스트의 소켓을 있는 그대로 중계해야 한다면, 요청에 head Match & Replace 규칙을 걸어 제거된 제안을 되돌려 놓으세요. 제거는 Match & Replace보다 먼저 실행되며, 바로 규칙이 이를 되돌릴 수 있게 하려는 순서입니다. Sec-WebSocket-Extensions를 복원하면 오리진은 실제로 확장을 제안받은 것이 되므로, gori는 오리진의 수락 응답에 손대지 않고 그대로 중계하고 두 피어는 프록시가 없을 때와 똑같이 압축을 협상합니다. 이 경우에도 플로우는 그대로 캡처되며, 지금 보고 있는 프레임이 메시지가 아니라 해당 확장으로 인코딩된 바이트라는 [gori] 알림이 함께 남습니다. TLS passthrough도 연결에 손대지 않지만 그 호스트는 아무것도 캡처되지 않습니다. 규칙을 먼저 고려하고, 호스트를 gori에서 아예 빼고 싶을 때 passthrough를 쓰세요.

핸드셰이크와 트랜스크립트는 별개의 패널입니다. 캡처된 소켓을 열면 상세 뷰의 REQUEST·RESPONSE 옆에 MESSAGES 칩이 붙습니다(플로우가 더 갖고 있으면 그것도 함께 — RFC 8441 소켓이면 FRAMES (h2), 서브스크립션이면 GRAPHQL). RESPONSE는 서버가 답한 업그레이드 — 101(또는 RFC 8441의 200), 서버가 고른 서브프로토콜, 수락한 확장, 그 자리에서 내려준 Set-Cookie — 이고, MESSAGES는 프레임 로그입니다. 둘은 서로 다른 질문에 답하며 어느 쪽도 다른 쪽을 대신하지 못하므로 분리되어 있습니다. 위 규칙으로 Sec-WebSocket-Extensions를 되돌려 놓았다면, 원 서버가 실제로 그것을 받아들였는지는 RESPONSE에서 읽습니다. ^X(hex)와 b(공백 표시)는 캡처된 다른 헤드와 마찬가지로 RESPONSE에서 동작하고, MESSAGES에서는 꺼집니다 — 그 바이트는 response_body가 아니라 메시지 로그에 있기 때문입니다.

HTTP/2 위의 WebSocket

오리진이 SETTINGS_ENABLE_CONNECT_PROTOCOL을 광고하면 요즘 브라우저는 HTTP/1.1 Upgrade: 핸드셰이크 대신 RFC 8441의 확장 CONNECT로 WebSocket을 엽니다. gori는 그 광고를 그대로 중계하므로 클라이언트는 이 경로를 쓸 자격이 있고, 실제로 쓰면 gori가 그 소켓을 읽습니다. RFC 8441은 핸드셰이크만 대체하고 나머지는 그대로이므로 프레임은 동일한 RFC 6455 프레임이며 동일한 코덱이 읽습니다. 어떤 핸드셰이크로 열린 소켓인지에 따라 분석 결과가 달라지지 않습니다.

캡처와 재전송은 같습니다. 실시간 편집은 다릅니다. 트랜스크립트는 History의 MESSAGES 패널, gori run history show, MCP get_flow 도구, HAR export에 HTTP/1.1 소켓과 똑같이 나타나며, Repeater의 ^R·gori run repeater send·MCP send_websocket·Fuzzer 스윕 모두 소켓을 다시 열고 프레임을 주고받습니다. 다음 두 가지는 HTTP/1.1 전용입니다.

재전송은 캡처와 같은 방식으로 소켓을 다시 엽니다. RFC 8441 플로우로 Repeater를 시드하면 HTTP 탭이 아니라 WebSocket 탭이 열립니다. gori는 HTTP/2로 다이얼하고(ALPN h2, 평문 대상이면 h2c prior knowledge), 오리진의 SETTINGS_ENABLE_CONNECT_PROTOCOL을 기다린 뒤, :protocol websocket과 캡처 자신의 :path·:authority·헤더를 실은 :method CONNECT를 보내고, HTTP/2에는 존재하지 않는 101이 아니라 2xx를 소켓이 열린 신호로 판정합니다. 그 뒤부터는 HTTP/1.1 경로와 완전히 같은 엔진입니다 — 같은 메시지 스크립트, 마스킹, 프레임 모양, 상한, 트랜스크립트. 핸드셰이크는 캡처 그대로이며 지어낸 것은 없습니다. 요청 패널에 보이는 X-Gori-Protocol: websocket 줄이 바로 그 :protocol 유사 헤더이고, 다른 헤더처럼 편집할 수 있습니다.

네 가지는 감추지 않고 보고합니다. SETTINGS_ENABLE_CONNECT_PROTOCOL을 광고하지 않는 오리진은 빈 트랜스크립트가 아니라 그 설정을 지목하는 거부로 끝납니다 — RFC 8441 §3이 그 광고 없이 확장 CONNECT를 보내는 것을 금지하기 때문입니다. 2xx가 아닌 응답은 오리진의 헤드를 그대로 실은 거부입니다. 세션 중간의 RST_STREAM이나 GOAWAY는 이미 주고받은 프레임을 유지하고 피어가 밝힌 에러 코드를 결과 note에 덧붙입니다. 그리고 이런 탭의 --http/^V 전환은 정지점이 셋이 아니라 둘입니다 — WebSocket, 또는 그 CONNECT를 평범한 HTTP/2 요청으로 — 이 바이트에는 HTTP/1.1 형태가 없기 때문입니다. keep_sec_websocket_key는 여기서 보존할 대상이 없으며(RFC 8441에는 Sec-WebSocket-Key가 없습니다), 플래그를 무시하는 대신 결과가 그 사실을 알려 줍니다.

필터로 찾을 수 있습니다. PROTO 열은 WSS로 표시되고, proto:ws는 RFC 8441 소켓을 HTTP/1.1 소켓과 함께 돌려줍니다. h2 핸드셰이크에 존재하지도 않는 101로 판정하는 대신, 확장 CONNECT:protocol 토큰을 캡처 시점에 흐름에 기록하기 때문입니다. proto:wss는 여전히 TLS 쪽만을 뜻합니다. 의도적으로 하지 않는 것이 둘 있습니다. connect-udp(RFC 9298)나 connect-ip(RFC 9484)를 실은 확장 CONNECT는 RFC 6455 프레이밍이 아니므로 잡히지 않고, 오리진이 거절한 핸드셰이크는 소켓이 열린 적이 없으므로 거절된 HTTP/1.1 핸드셰이크와 똑같이 평범한 실패 요청입니다. 이 변경 이전의 gori가 캡처한 흐름은 소급해서 추측하지 않고 분류되지 않은 채로 남습니다. 라벨이 필요하면 다시 캡처하세요.

.proto 없는 protobuf

gori는 gRPC 페이로드를 protobuf 와이어 포맷 자체에서 디코드합니다. 스키마는 사용하지 않습니다. 필드 이름과 선언된 타입은 .proto에 있고 gori는 그것을 읽지 않으므로, 얻을 수 있는 것은 번호가 붙은 필드와 그 와이어 타입입니다 — varint, fixed32/fixed64, 그리고 length-delimited 페이로드가 바이트가 중첩된 깊이만큼 중첩되어 나옵니다.

모호함은 해소하지 않고 그대로 보고합니다. 스키마가 없으면 length-delimited 필드는 실제로 동시에 여러 가지입니다. 항상 원시 바이트이고, 페이로드가 올바른 UTF-8이면 문자열이며, 페이로드가 깨끗하게 파싱되면 중첩 메시지입니다. gori는 성립하는 해석을 전부 나열하고 각각을 함께 보여 주면서, 그중 어느 것도 확정이 아니라는 한 줄 안내를 붙입니다. 하나를 골라 버리는 것이야말로 이 디코더가 피하려고 만들어진 실패입니다. 사실처럼 읽히는 추측은 바이트보다 나쁩니다.

다음 두 페이로드는 모든 표면에서 의도적으로 hex로 남습니다.

트리는 모든 표면에서 제공됩니다. TUI의 History와 Repeater 패널(p로 트리와 바이트 미리보기를 전환하며, ^X는 여전히 바이트 그대로의 덤프를 보여 줍니다), gori run history show --format json, 그리고 MCP get_flow 도구(뒤 둘은 grpc_messages[].protobuf 필드)입니다. 잘렸거나 악의적인 메시지는 파싱되는 데까지 디코드된 뒤 거부되는 대신 complete: false로 표시됩니다. 어느 쪽이든 원본 옥텟에는 계속 접근할 수 있습니다.

.proto가 있을 때

스키마를 가지고 있다면 gori는 그것을 씁니다. 프로젝트에 디스크립터 셋(protoc이 내보내는 바이너리 산출물)을 지정하면, 같은 패널이 번호 대신 이름과 타입이 붙은 필드를 그립니다.

protoc --descriptor_set_out=api.desc --include_imports -I. api.proto

경로는 Project → Project settings → Proto schema에서 지정합니다. .desc 파일 하나여도 되고, 그런 파일들이 든 디렉터리(.desc, .pb, .protoset, .fds, .bin)여도 됩니다. 비워 두면 ~/.gori/protos/에 있는 디스크립터 셋을 전부 읽으므로, 파일 하나를 거기 두는 것만으로 경로를 따로 지정하지 않은 모든 프로젝트에 적용됩니다. 그 행은 실제로 무엇이 로드됐는지(2 files · 41 messages · 12 rpcs)를, 아무것도 로드되지 않았다면 그 이유를 보여 줍니다 — 조용히 아무것도 안 가리키는 경로야말로 이름을 붙여 줘야 할 실패이기 때문입니다. 프로젝트 설정인 이유는 .proto가 한 대상의 API를 서술하는 것이고 다음 engagement까지 따라가서는 안 되기 때문입니다.

디스크립터 셋 자체가 protobuf라서 gori는 자기 디코더로 파싱하며, 런타임에 protoc이 필요 없습니다. .proto 소스 파일은 읽지 않습니다 — 소스를 가리키면 위 명령과 함께 그렇게 말해 줍니다.

경로가 곧 바인딩입니다. gRPC 요청은 /package.Service/Method로 가고, 디스크립터 셋은 그 경로를 rpc의 입력·출력 메시지 타입으로 바로 매핑합니다. 그래서 요청 패널은 입력 메시지로, 응답 패널은 출력 메시지로 읽히며, 추측도 없고 flow마다 설정할 것도 없습니다. 트리 위 한 줄 안내가 어떤 rpc와 어떤 메시지로 해석됐는지 이름을 밝히므로, gori가 고른 바인딩을 직접 확인할 수 있습니다.

스키마는 바이트 위의 렌즈일 뿐, 바이트를 대체하지 않습니다(P7). 여기서 세 가지가 따라오고, 셋 다 화면에 드러납니다.

스키마의 공백과 스키마와의 충돌은 구분됩니다. 이름이 없는 enum 값이나 셋에 없는 메시지 타입은 "스키마가 부족하다"는 안내이지, 바이트가 틀렸다는 주장이 아닙니다. 디스크립터 셋을 로드하지 않았다면 모든 표면은 이전과 완전히 동일하게 렌더됩니다.

바이트가 아니라 필드를 편집합니다. 스키마가 로드되면 Repeater의 gRPC 탭에 같은 페이로드를 보는 두 번째 편집기가 생깁니다. ␣E(요청 카드의 ␣E:FIELDS 칩)가 메시지를 이름과 타입이 붙은 행으로 나열하고, 행에서 를 누르면 값 입력 칸이 열립니다. 값을 입력해 적용하면 gori는 그 필드만 다시 인코딩하고 메시지의 나머지 바이트는 캡처에서 그대로 복사합니다. 이걸 가능하게 하는 게 선언입니다 — 와이어는 어떤 필드가 varint라는 것만 말할 뿐, 그것이 int32(부호 확장)인지 bool인지 enum인지 zigzag sint32인지는 말하지 않고, -3은 넷 각각에서 서로 다른 옥텟이기 때문입니다. bytes는 hex로, enum은 자기 값 이름으로, packed 런은 쉼표로 구분한 목록으로 편집합니다. 값을 바꾸지 않고 적용하면 캡처가 바이트 그대로 돌아옵니다. ^X와 같은 이유로 unary 호출에만 적용됩니다 — 메시지가 0개거나 여러 개인 본문에는 편집할 단일 페이로드가 없습니다. 중첩 메시지는 그 안의 필드 단위로 편집할 수 있고, 메시지 자체의 행은 편집 대상이 아닙니다.

입력할 수 없는 행은 그 이유를 말합니다. (undeclared) 필드 번호와 스키마가 부정하는 와이어 타입은 둘 다 읽기 전용으로 남고 원본 해석을 유지합니다 — 무엇으로 입력해야 할지가 없고, 거기에 타입 편집기를 내미는 것이야말로 이 렌즈가 피하려고 존재하는 추측이기 때문입니다. 그 옥텟을 바꾸는 방법은 여전히 ^X이고, 스키마가 불가능하다고 말하는 것을 보내는 방법도 여전히 ^X입니다. 편집된 메시지 앞의 5바이트 길이 접두사는 hex 편집 때와 똑같이 ␣F:FRAME 토글이 관장합니다.

대상에게 직접 물어볼 수도 있습니다. gRPC 서버 리플렉션에 응답하는 서버는 이미 디스크립터를 갖고 있습니다. gori run grpc reflect https://api.test:443이 그것을 받아와 프로젝트에 캐시하고, 에이전트는 MCP grpc_reflect로 같은 일을 합니다. TUI에서는 캡처된 flow 위에서 실행합니다 — 스페이스 메뉴의 **gRPC: fetch schema (reflection)**이며, 그 행의 호스트를 대상으로 합니다. grpc.reflection.v1을 먼저, v1alpha를 그다음으로 시도하고(실제 배포된 서버 대부분은 아직 v1alpha입니다), 둘 다 응답하지 않으면 조용히 실패하지 않고 그렇다고 말합니다. 서비스 목록 → 각 서비스를 선언한 파일 → 그 파일들의 import 순으로, 그래프가 닫힐 때까지 따라갑니다.

결과는 같은 렌즈입니다. 하나의 Schema, 같은 /package.Service/Method 바인딩, 같은 렌더러와 같은 필드 편집기. Proto schema 행은 각각이 어디서 왔는지 말합니다 — 1 file · reflection https://api.test:443 · 41 messages · 12 rpcs — 그리고 두 출처가 어긋나는 선언은 조용히 병합되지 않고 redefined로 집계되며, 대상 자신의 말이 우선합니다.

이것은 아웃바운드 요청이고, 그렇게 다뤄집니다. 물었을 때만 실행되고 캡처·flow 열기·프로젝트 열기에서는 절대 실행되지 않습니다(P4). 다른 모든 능동 전송과 같은 scope chokepoint를 지나므로, scope 밖이거나 scope가 없는 대상은 연결이 열리기 전에 거절됩니다(--allow-unscoped / allow_unscoped:true는 사전 검사만 면제하고, Sandbox는 면제하지 않습니다). 스스로 다시 가져오는 일도 없습니다. gori run grpc schema가 캐시된 것을 보여주고, gori run grpc forget <target>이 하나를 지웁니다. 그리고 이렇게 얻은 스키마도 여전히 렌즈입니다 — 무엇을 받는지에 대한 대상의 말일 뿐, 진실은 여전히 바이트입니다.

MessagePack과 CBOR

Content-Typeapplication/msgpack 또는 application/cbor(그리고 +msgpack / +cbor 접미사)인 본문은 상세 패널에서 JSON으로 렌더됩니다. 다른 본문을 정렬할 때 쓰는 그 p 토글 그대로입니다. 이게 없으면 이런 본문은 바이너리 자리표시자와 hex 뷰로 떨어집니다 — 두 형식 모두 정수 0을 NUL 바이트로 인코딩하기 때문에, 실제 본문은 거의 전부 바이너리 감지에 먼저 걸립니다.

여기서 JSON은 투영이지 재인코딩이 아닙니다. 두 형식은 JSON에 담을 자리가 없는 것들을 실어 나르는데, 그것들은 접어 없애지 않고 전부 이름을 달아 돌려줍니다. 바이트 문자열은 {"$bin": "…"}, MessagePack 확장은 {"$ext": "…", "$ext_type": n}, CBOR 태그는 {"$tag": n, "value": …}($bignum$time은 원래 값을 대체하지 않고 그 옆에 놓입니다), 유효하지 않은 UTF-8 텍스트는 {"$str_invalid_utf8": "…"}, JSON 숫자로는 정확하지 않은 정수는 10진 문자열입니다. 이 중 무엇이든 조용히 강제 변환하는 리더는 증거를 지어내는 셈입니다.

한 가지 모호함은 가리지 않고 그대로 받아들입니다. 문서 자신의 맵 키가 문자 그대로 $bin이나 $tag이면 래퍼와 같은 모양으로 렌더됩니다. 그런 본문 하나를 막자고 모든 본문의 모든 키를 이스케이프하면 흔한 본문이 오히려 읽기 어려워지고, 정말 문제가 될 때 바이트는 ^X 한 번 거리에 있습니다.

투영이기 때문에, 당신이 곧 보낼 무언가를 덮어쓰는 일은 없습니다. Repeater 에디터에서 msgpack 요청 본문을 정렬하려 하면, 다시 되돌릴 수 없는 것으로 당신의 바이트를 바꿔치기하는 대신 거절합니다. 값 도중에 끝난 문서는 읽은 데까지 렌더하고 패널 노트에 그렇다고 밝힙니다 — 캡처 상한에 잘린 본문에서 흔한 경우입니다. 그동안에도 바이트는 ^X 한 번 거리에 있고, 같은 렌더링이 gori run show --format json과 MCP get_flowbinary_documents[]로 들어갑니다.

디스패치는 오직 content type으로만 합니다 — 스니핑은 하지 않습니다. application/octet-stream으로 표시된 본문은 어느 리더에도 넘기지 않습니다. 스키마 없는 리더는 어떤 바이트로도 무언가를 만들어 내고, 틀린 렌더링은 맞는 hex 덤프보다 나쁘기 때문입니다. 라벨 없는 본문이 갈 곳은 Decoder 탭(msgpack-decode, cbor-decode)입니다. 거기서는 그게 무엇인지 정한 사람이 오퍼레이터 자신이니까요.

와이어 프로토콜 위에서, gori는 흔히 쓰이는 페이로드를 인라인으로 디코드합니다.

이 플로우는 어디서 왔나

History에는 브라우저가 한 일만 담기지 않습니다. gori 자신의 도구도 여기에 씁니다 — MCP send_request는 기본으로 기록하고, Discover 크롤은 가져온 것을 저장하며, Repeater 전송은 TUI에서 기록되고(gori run에서는 요청 시), 퍼즈 스윕도 기록할 수 있으며, import는 다른 도구의 캡처를 읽어 들입니다. 이 전부가 예전에는 캡처 트래픽과 구별되지 않았습니다. History를 증거로 읽는 순간 이 차이가 중요해집니다 — "대상이 이렇게 답했다"와 "내가 그렇게 만들었다"는 서로 다른 주장입니다.

SRC 열이 그것을 말해 줍니다. PROXY는 클라이언트가 gori를 거쳐 보낸 트래픽이고, RPTR· FUZZ·CRAWL 등은 gori가 만든 요청이며, IMPRT는 파일에서 읽어 들인 것입니다. 상세 패널은 요청 아래에 sent by gori — repeater (tui) #4처럼 풀어 적어, 어느 표면에서 왔고 어느 세션이 만든 것인지까지 되짚을 수 있게 합니다.

src:로 필터링합니다.

src:proxy        실제로 일어난 트래픽만 History로 읽기
src:gori         gori가 전송한 것만
-src:repeater    내가 다시 보낸 것을 뺀 전부

컬러 룰도 같은 항을 받으므로, src:gori + strip 마커를 걸어 두면 스크롤하는 동안 내 트래픽이 시각적으로 계속 분리됩니다. 이 컬럼이 생기기 전에 캡처된 플로우는 출처가 없어 로 표시되며 어느 쪽에도 매칭되지 않습니다. 어떤 캡처도 기록하지 않은 출처를 gori가 추측하지는 않기 때문입니다.

Repeater 전송은 기본으로 기록됩니다. Settings → General → Record Repeater sends에서 끌 수 있습니다. 이 설정은 TUI만 지배합니다 — gori run repeater send는 여전히 --record-history가 필요하고(기본 off), MCP send_request는 여전히 record_history를 받으므로(기본 on) 어떤 스크립트의 동작도 이 설정 때문에 바뀌지 않습니다. WebSocket 전송과 send-group은 기록되지 않으며, 상태줄이 한 번 그렇게 알려 줍니다.

History 필터링

History는 gori의 쿼리 언어로 검색할 수 있습니다. 몇 가지 예시입니다.

status:5xx                  flows that errored
host:api.example.com        a single host
method:POST body:password   POST requests mentioning "password"
dur:>500                    responses slower than 500 ms
path~/admin/                path matching a regex

History 필터 바에 쿼리를 입력하거나, 헤드리스로 실행하세요.

gori run history -q 'status:5xx host:api.example.com'

뷰 (v)

뷰(view) 는 목록을 좁히는 이름 붙은 쿼리이며, 무엇보다 모드입니다. v를 눌러 하나를 고르면, 그 뒤로 다른 필터를 입력하는 동안에도 계속 좁혀 줍니다. 이것이 뷰와 필터 바의 차이입니다 — 뷰는 입력한 내용을 대체하지 않고 그 위에 AND로 얹힙니다. ⇧S 스코프 렌즈와 같은 방식이라, / status:5xx는 뷰를 지우는 대신 뷰 안에서 다시 좁힙니다. 필터 줄의 v:name 칩이 지금 무엇을 보고 있는지 항상 알려 줍니다 — 옆의 f:follow·⇧S scope 칩과 같이 소문자로 그려지고, 줄에 비해 이름이 긴 뷰는 줄여서 씁니다(History + Repeaterv:history+rptr). 이름이 이름으로 쓰이는 곳 — 피커, gori run views, --view, MCP — 에서는 그대로이고, --view는 대소문자를 가리지 않아 --view historyHistory를 찾습니다.

모든 프로젝트에 일곱 개가 기본으로 들어 있고, 축은 두 가지입니다. 출처 뷰는 이게 대상에 대한 증거인가, gori가 한 일인가에, 프로토콜 뷰는 어떤 대화를 읽고 있는가에 답합니다.

의미
All 전부
History src:proxy — 클라이언트가 실제로 gori를 통해 보낸 트래픽만
History + Repeater src:proxy OR src:repeater — 거기에 내가 다시 보낸 요청까지. 기본값
WebSocket proto:ws — 평문과 TLS 소켓 모두
gRPC proto:grpc
SSE proto:sse — 서버 전송 이벤트 스트림
Errors status:>=400

두 축을 미리 합쳐 두지 않은 것은 의도적입니다. WebSocket이라는 이름의 뷰가 몰래 import된 소켓까지 제외한다면 자기 이름에 대해 거짓말을 하는 셈입니다. 대신 렌즈로 조합됩니다 — History + Repeater를 고르고 proto:ws를 입력하면 교집합이 되며, 어느 기본 뷰도 다른 쪽을 미리 알 필요가 없습니다.

새 프로젝트는 All이 아니라 History + Repeater로 열립니다. gori 자신의 크롤러·퍼저·임포터가 쓴 flow는 대상에 대한 증거가 아니고, 그것들이 섞인 목록이야말로 src:가 고치려던 결함입니다 — 기본값은 그 term을 한 번도 입력하지 않는 사람에게도 그걸 고쳐 줄 뿐입니다. Repeater가 포함된 이유는, 재전송은 실제 엔드포인트를 향한 테스터 자신의 의도적 행위이고 그 응답을 캡처된 응답 옆에서 읽는 것이 그 탭의 목적이기 때문입니다. 예외가 하나 있습니다. gori가 출처를 기록하기 전에 캡처된 프로젝트는 All로 열립니다. src:가 그 행들을 양쪽 어디에도 걸지 않아 평소 기본값이면 빈 목록이 되기 때문입니다.

계속 쓰고 싶은 필터를 입력했다면 피커의 + Save current filter as a view… 행에서 이름을 붙이면 됩니다. 어디에 둘지 묻습니다.

피커 안에서 ^E는 뷰의 쿼리를 필터 바로 불러와 편집하게 하고(같은 이름으로 저장하면 그 뷰가 갱신됩니다), ^X는 뷰를 지웁니다. 기본 제공 뷰는 편집도 삭제도 되지 않습니다. 다른 스코프에 이미 있는 이름으로 저장하면 뷰를 새로 만들지 않고 그쪽으로 옮깁니다.

활성 뷰는 스코프 렌즈처럼 프로젝트별로 재시작 후에도 유지됩니다. 켜 둔 뷰를 다른 gori가 지웠다면, 존재하지 않는 뷰로 계속 거르는 대신 History가 All로 돌아가고 그 사실을 알려 줍니다.

뷰는 TUI 편의 기능이 아니라 프로젝트 객체이며, 세 표면 모두가 읽습니다.

gori run views                                   # 활성 뷰가 표시된 목록
gori run views add 'acme errors' -q 'host:api.acme.test status:5xx'
gori run views add 'proxied' -q 'src:proxy' --scope global
gori run views set 'acme errors' -q 'status:>=500'
gori run views scope 'acme errors' --to global   # 스코프 옮기기
gori run views rm 'acme errors'
gori run history --view 'History' -q 'status:5xx'

MCP에도 같은 세트가 있습니다. list_views, create_view, update_view, delete_view, 그리고 list_historyview 인자입니다.

목록이 고장 난 것처럼 보이기 전에 알아 둘 것이 하나 있습니다. gori가 출처를 기록하기 전에 캡처된 플로우에는 source가 없고, src:는 그런 행을 양쪽 어디에도 걸지 않습니다. 그래서 오래된 프로젝트에서는 트래픽이 아무리 많아도 History 뷰가 비어 있을 수 있습니다 — 빈 화면이 그렇게 말해 주며, All은 전부 보여 줍니다.

뷰의 쿼리는 실행할 때가 아니라 저장할 때 검사합니다. 모든 항이 버려질 쿼리는 아예 거절합니다. v: 칩은 좁히고 있다고 주장하는데 실제로는 아무것도 좁히지 않는 상태가 되기 때문입니다.

컬럼 (Space C)

쿼리는 어떤 플로우가 걸리는가에 답합니다. 컬럼(column) 은 나머지 절반, 즉 각 행에서 X의 값은 무엇인가에 답합니다. Space 다음 C를 누르면 컬럼을 추가할 수 있습니다 — X-Request-Id, JWT의 sub, rate-limit 헤더, JSON 본문 속 필드 — 그리고 그 값이 목록의 모든 플로우 옆에 그려집니다.

컬럼은 추출 디스크립터입니다. 세션 바인딩이 이미 쓰는, 메시지에서 값을 찾는 그 다섯 가지 방식 그대로이므로 새로 배울 문법은 없습니다.

종류 읽는 곳
header 이름으로 지정한 헤더
cookie 이름으로 지정한 쿠키 — 응답에서는 Set-Cookie, 요청에서는 Cookie 항아리
jsonpath JSON 본문의 리프 값 (data.id, $.items[0].name)
regex 디코드된 본문에서 캡처 그룹 1, 없으면 매치 전체
position 디코드된 본문의 고정 바이트 범위

컬럼은 어느 쪽을 읽을지도 함께 정합니다. 응답(기본값)이거나 요청입니다. 이건 들리는 것보다 중요합니다 — 목록에서 보고 싶은 id는 오리진이 되돌려 준 것만큼이나 자주 내 클라이언트가 보낸 것이고, 양쪽을 나란히 놓은 컬럼 두 개가 프록시가 그 값을 바꿔치기하는 장면을 보여 줍니다.

편집기는 목록 카드입니다. a 추가, e 편집, d 삭제, Shift-← / Shift-→로 컬럼을 왼쪽/오른쪽으로 옮깁니다. 이 카드의 존재 이유가 순서이므로, 화살표가 하는 일이 그것입니다. 누르는 즉시 프로젝트에 저장되고 카드 뒤의 목록이 바로 다시 그려집니다 — 디스크립터를 입력하는 동안 폼 하단 띠에 커서 아래 플로우에서 뽑히는 값이 표시되므로, 기억이 아니라 실제 메시지를 보고 판단하게 됩니다.

컬럼은 오른쪽 클러스터에서 자리를 가져가며 TYPE / SIZE / DUR보다 우선합니다. 좁은 터미널에서는 그쪽이 먼저 떨어집니다 — 요청하지도 않은 칸이니까요. 아무것도 매치하지 않은 디스크립터는 빈 칸을 그립니다. 셀렉터를 되비추지도 않고, SRC가 "gori도 모른다"는 뜻으로 쓰는 도 아닙니다. 값은 실제로 화면에 있는 행에 대해서만 추출되고 플로우별로 기억되므로, 5,000행짜리 창을 스크롤해도 보이는 열 몇 개 값만큼만 듭니다.

이 집합은 프로젝트 객체이고, 헤드리스 표면들도 같은 값을 읽습니다.

gori run ls                                     # 이 프로젝트의 컬럼을 함께 출력
gori run ls --no-columns                        # 컬럼 없는 기본 목록
gori run ls --column header:x-request-id        # 즉석 지정 — 프로젝트 설정을 대체
gori run ls --column 'RID=req:header:x-request-id' --column jsonpath:data.id
gori run ls --format json --column 'T=regex:tok=(\w+)'

--column 스펙은 [LABEL=][req|res:]kind:selector입니다. 라벨의 기본값은 셀렉터, 쪽의 기본값은 응답입니다. =는 첫 :보다 앞에 올 때만 라벨 구분자이므로, regex:token=(\w+)regex:token이라는 컬럼이 아니라 여러분이 쓴 패턴 그대로입니다. MCP의 list_historycolumns 인자로 같은 스펙을 받아 각 행에 값을 실어 보냅니다 — 이쪽은 명시적으로 요청할 때만 동작합니다. 에이전트가 요청하지 않은 행별 블록은 페이지의 모든 행에서 비용이 되기 때문입니다.

플로우 표시하기 (다중 선택)

t를 누르면 커서 위의 플로우를 표시(mark) 하고 바로 다음(더 오래된) 플로우로 이동하므로, t를 연달아 누르면 정렬 순서와 무관하게 연속된 행이 표시됩니다. Shift-↑ / Shift-↓는 시작한 지점부터 연속 범위를 확장하고, Shift-T는 현재 필터에 보이는 전체를 표시하며, Esc는 표시를 모두 해제합니다. 표시된 행은 왼쪽 여백에 굵은 막대가 붙고, 필터 줄에 3 marked 카운트가 실시간으로 표시됩니다.

Shift에서 손을 떼면 범위 선택이 끝납니다. 이어서 누른 / (또는 PgUp / PgDn, 다른 행 클릭)는 GUI 목록이 강조를 접듯 그 범위를 해제하고 커서만 옮깁니다. tShift-T로 직접 찍어 둔 표시는 남습니다 — 그 사이를 Shift 없이 이동해야 떨어진 행들을 골라 표시할 수 있기 때문입니다. 마우스 휠은 스크롤일 뿐이라 표시를 지우지 않습니다.

표시는 어떤 동작이 있는지를 바꾸지 않고, 스페이스 메뉴가 무엇을 대상으로 하는지만 바꿉니다.

실제 대상 = 표시된 것이 있으면 그것들, 없으면 커서 행

따라서 / status:5xxShift-TSpaceX면 모든 오류 플로우를 확인 한 번으로 삭제하고, SpaceY면 그 URL들을 한꺼번에 복사합니다. 메뉴 제목이 SPACE · 3 MARKED로 바뀌고 항목 이름도 Delete 3 flows, Mine 3 flows처럼 바뀌므로, 일괄 처리가 예상 밖에 일어나는 일은 없습니다.

동작 표시된 플로우에 대해
복사 y URL 목록(한 줄에 하나)
형식 지정 복사 Space Y urls / host 목록 / cURL / 원본 요청 / 원본 응답 / 요청+응답 쌍
삭제 Space X 전체를 확인 한 번으로
연결(Link…) Space k 한 카드에 모든 이슈와 노트(그리고 + New issue… / + New note…)가 함께 뜹니다. 대상은 한 번만 고르거나 새로 만들고, 전체를 첨부
이슈 추가 Shift-F 표시된 전체를 근거로 가진 이슈 하나
Repeater / Fuzzer Ctrl-R / Shift-I 플로우마다 서브탭 하나(최대 20)
파라미터 마이닝 Space m 설정 팝업 한 번, 플로우마다 세션 하나(최대 20)
액티브 스캔 실행 Space A 요청 수 추정치를 전체에 대해 합산
스코프에 호스트 추가 Space h 호스트 중복 제거 — 2개 호스트의 12개 플로우면 규칙 2개
Comparer로 보내기 Space c 정확히 2개면 A(이전)/B(이후)에 바로 채움

표시는 필터 변경, 정렬 변경, 다른 탭에 갔다 오는 것에도 유지됩니다. 카운트 칩이 현재 화면에 없는 개수를 알려줍니다. 트래픽을 보내는 동작은 여전히 먼저 확인을 받고 요청마다 스코프를 검사합니다 — 표시는 요청 수를 바꾸지, 게이트를 바꾸지 않습니다. 플로우 하나에만 의미가 있는 몇 가지(상세 보기 열기, Sequencer, 브라우저로 응답 열기)는 단일 대상으로 남으며, 표시가 있는 동안 메뉴 항목에 (cursor)가 붙습니다.

요청을 코드로 복사하기

플로우 하나 위에서 Space Y — History에서, 상세 뷰의 REQUEST 패널에서, Repeater 탭에서 — 를 누르면 Copy as… 가 열립니다. 같은 요청을 다른 도구가 읽는 모양으로 내줍니다.

항목 클립보드에 담기는 것
URL / Headers / Body / Cookies 각 부분만. 요청에 담을 내용이 없는 항목은 아예 나타나지 않습니다
cURL 실행 가능한 curl 명령
Python requests 스크립트
fetch JavaScript fetch() 호출
Go net/http 프로그램
httpie http 명령줄
CSRF PoC 요청을 교차 출처로 재현하는 자동 제출 HTML 폼
wscat WebSocket Repeater 탭 전용. 핸드셰이크와 out-프레임을 wscat 세션으로
Raw request / Raw response / Req + Res pair 와이어 바이트 그대로

언어 항목은 모두 요청 입니다. 응답 패널에는 더 짧은 자체 메뉴(status + headers / body / raw)가 있습니다. 메뉴와 CLI가 직렬화기 하나를 공유하므로 gori run show --format curl|python|fetch|go|httpie|csrf(run show)와 바이트 단위로 같은 텍스트가 나옵니다. Repeater 탭에서는 이 메뉴가 ¦chainexec:실행 합니다. 요청을 그리기만 하는 다른 경로와 다른 점인데, 훅을 건너뛴 명령줄은 스스로 재현한다고 주장하는 그 전송을 재현하지 못하기 때문입니다.

여러 플로우를 마크하면 메뉴가 집합용 형식으로 바뀝니다 — URL 목록, 호스트 목록, 그리고 (20개까지) cURL, raw request, raw response, req+res 쌍. 언어별 스니펫은 단일 플로우 전용입니다. Go 프로그램 스무 개가 든 파일은 아무도 붙여넣지 않습니다.

브라우저로 응답 열기

터미널은 페이지를 레이아웃하지도, PNG를 보여주지도, PDF를 페이지 단위로 넘기지도 못합니다. Space Shift-B는 그걸 할 수 있는 곳으로 응답을 넘깁니다. gori가 디코딩된 본문을 ~/.gori/preview/ 아래 파일로 쓰고, 데스크톱의 오프너(macOS는 open, Linux는 xdg-open)로 엽니다.

같은 동작이 세 곳에 있습니다.

위치 여는 대상
History 목록 Space Shift-B 커서 행의 응답
History 상세 Space Shift-B 열려 있는 플로우의 응답
Repeater Space Shift-B 활성 서브탭의 마지막 응답

누르기 전에 알아둘 것이 네 가지 있습니다.

본문은 디코딩되고, 헤더는 들어가지 않습니다. gori는 와이어 바이트를 저장하므로 실제 응답 대부분은 디스크에서 gzip/br/zstd 상태입니다. file:// URL은 Content-Encoding을 실어 나르지 못하므로, 미리보기에는 압축을 푼 문서가 들어갑니다. 파일에는 본문만 담깁니다 — 헤더는 이 동작을 실행한 그 패널에 이미 떠 있습니다.

확장자는 gori의 것이지, 타깃의 것이 아닙니다. 응답의 미디어 타입을 키로 하는 고정된 허용 목록에서 나옵니다(text/html.html, image/png.png, application/vnd.api+json.json). 알 수 없는 타입은 전부 .txt 또는 .bin이 됩니다. 악의적인 Content-Type이 데스크톱의 실행 대상을 고를 수 없습니다. 파일 이름은 플로우 id와 타임스탬프로 만들어지므로, 캡처된 바이트는 여기에도 닿지 않습니다.

상대 경로 리소스는 로드되지 않습니다. gori는 <base href>를 주입하지 않습니다. 주입하면 페이지가 실제 CSS와 JS를 라이브 오리진에서 가져오게 되고, 그건 요청한 적 없는 트래픽입니다. 그래서 리소스가 상대 경로인 페이지는 스타일 없이 렌더링됩니다. 이 기능은 응답이 무엇이라 말했는지를 보여줍니다. 사이트가 어떻게 생겼는지 보고 싶다면 프록시된 브라우저를 그 URL로 보내세요.

HTML은 실행됩니다. 타깃의 페이지를 브라우저로 열면 그 자바스크립트가 실행됩니다. 타깃의 쿠키에는 닿을 수 없는 file:// 오리진에서 실행되며, Burp의 "Show response in browser"와 같은 거래입니다. gori는 스크립트를 제거하지 않습니다 — 무력화된 렌더링은 테스트 대상과 다른 문서이기 때문입니다. 브라우저가 실행할 문서일 때는 상태 줄이 그렇게 말해줍니다. 키가 맨 글자가 아니라 Shift-B인 이유이기도 합니다.

미리보기 디렉터리는 gori의 것이고, 모드는 0700이며, 쓸 때마다 최신 32개만 남기고 정리됩니다. ~/.gori를 지우면 함께 사라집니다.

Match & Replace (Rewriter 탭)

Rewriter 탭이 Match & Replace 편집기입니다. 오가는 요청/응답을 그 자리에서 재작성하는 규칙을 관리합니다. 탭 바에서 Comparer 오른쪽에 있고, 커맨드 팔레트(Ctrl-PMatch & Replace 또는 Go to Rewriter)로도 열 수 있습니다.

각 규칙에는 동작이 있습니다.

동작 하는 일
Replace 헤드나 본문의 텍스트를 리터럴 부분 문자열 또는 정규식으로 찾아 치환
Add header Name: value 헤더를 추가
Set header 이름으로 헤더 값을 교체(없으면 추가)
Remove header 이름으로 헤더를 제거
Short circuit 원본에 접속하지 않고 규칙에 적힌 응답으로 답함

Replace 규칙은 요청이나 응답의 헤드(요청/상태 줄 + 헤더), 본문(엔티티), 또는 ws(WebSocket 메시지 — 아래 WebSocket에서의 Match & Replace)를 대상으로 합니다. 리터럴과 정규식 중에서 고르며, 정규식 치환은 $1/$2 캡처 그룹 삽입을 지원합니다(리터럴 $$$). 헤더 동작은 항상 헤드에 적용되고, 헤더 이름을 대소문자 구분 없이 매칭합니다. 값이 비어 있으면 매칭된 텍스트를 삭제하거나 헤더를 제거합니다.

어떤 규칙이든 호스트 글롭으로 범위를 좁혀 매칭되는 트래픽에만 적용할 수 있습니다. 일반 문자열은 부분 문자열로 매칭되고(example.comapi.example.com에 매칭), *는 와일드카드입니다(*.example.com). 비워 두면 모든 호스트에 적용됩니다.

목록은 a 추가, e/Enter 편집, x 켜기/끄기, d 삭제, s 전역/프로젝트 전환, Shift-J/Shift-K 순서 변경(규칙은 위에서 아래로 적용), space로 전체 메뉴를 다룹니다. 편집기는 규칙이 최근 몇 개의 플로우에 영향을 줄지 실시간 미리보기로 보여 줍니다. 규칙은 저장 즉시 적용되고 재시작은 필요 없습니다.

목록 아래에는 편집 가능한 샘플 메시지와, 그 옆에 켜져 있는 규칙을 통과시킨 결과가 나란히 놓입니다. 실제로 캡처한 요청을 붙여 넣어 규칙을 풀어놓기 전에 무엇이 바뀌는지 확인하는 자리입니다. 샘플은 그것이 미리 보여 주는 규칙과 마찬가지로 프로젝트에 저장됩니다.

프리셋 — 능력이 아니라 출발점

거의 모든 엔게이지먼트에서 반복되는 응답 재작성이 일곱 가지 있고, 하나같이 다시 타이핑하기 싫은 정규식입니다. Rewriter에서 p(Add from preset…)를 눌러 고르세요.

프리셋 설치하는 것
unhide-hidden-fields <input type="hidden">type="text"로 바꿔 필드를 보이고 편집 가능하게
enable-disabled-fields 폼 컨트롤에서 disabled / readonly 제거
remove-length-limits maxlength= 제거
strip-validation 제출을 막는 required, pattern=, on{submit,change,input}= 핸들러 제거
remove-csp Content-Security-Policy 헤더 제거
remove-security-headers 브라우저 측 보호 헤더 제거
disable-sri integrity=를 벗겨 subresource-integrity 검사가 수정된 자산을 막지 않게

프리셋은 능력이 아니라 출발점입니다 — 일곱 가지 모두 Match & Replace가 이미 표현할 수 있습니다. 설치하면 a와 같은 경로로 평범한 규칙이 쓰이므로, 목록에 나타나고, 어느 프리셋에서 왔는지 이름을 달고 있으며, 직접 타이핑한 규칙과 똑같이 편집·순서변경·비활성화·삭제됩니다. 같은 프리셋을 두 번 설치하면 조용히 병합되지 않고 눈에 보이게 중복됩니다.

헤드리스에서는 gori run rewriter preset listgori run rewriter preset add <name>(--scope=global, 트래픽에 닿기 전에 검토하려면 --disabled)이고, 에이전트는 list_rule_presets로 읽고 create_rule_from_preset으로 설치합니다.

전역 규칙과 프로젝트 규칙

모든 규칙은 두 곳 중 하나에 저장되며, 목록의 G/P 열이 어느 쪽인지 보여 줍니다.

규칙을 만들 때는 편집기의 scope: 행에서 고르고, 이미 있는 규칙은 목록에서 s를 눌러 두 곳 사이로 옮깁니다. 필드와 지금 이 프로젝트에서의 켜짐/꺼짐 상태는 그대로 따라가고, 달라지는 것은 누가 이 규칙을 보느냐입니다.

전역 규칙이 먼저 자기들 순서대로 적용되고, 그다음이 프로젝트 규칙입니다. 상시 계층 위에 로컬 계층이 얹히는 순서입니다. Shift-J/Shift-K는 같은 범위 안에서만 순서를 바꿉니다. 범위의 경계는 위치가 아니기 때문입니다.

전역 규칙은 기본 켜짐/꺼짐 상태를 갖고, 프로젝트는 거기에 다른 답을 낼 수 있습니다.

전역 규칙을 삭제하면 모든 프로젝트에서 사라집니다. 다른 창에서 돌고 있는 gori는 규칙을 다시 읽을 때(Rewriter 탭 재진입) 전역 변경을 가져오고, 별도의 gori 프로세스는 재시작해야 반영됩니다.

헤드리스에서는 모든 하위 명령이 --scope=global로 라이브러리를 가리킵니다. gori run rewriter add --scope=global …, gori run rewriter disable 3 --scope=global(이 프로젝트의 오버라이드), 여기에 --everywhere를 더하면 기본값을 바꿉니다. MCP 규칙 도구도 같은 scope 인자를 받습니다.

예전의 저장된 규칙 라이브러리(s/o)에서 올라온 경우: 항목들은 gori가 파일을 처음 읽을 때 꺼진 상태의 전역 규칙으로 흡수됩니다. 프리셋은 불러오기 전까지 아무 일도 하지 않았으므로 스스로 트래픽을 재작성하기 시작하지 않습니다. 필요한 것만 x로 켜면 됩니다.

본문 규칙은 메시지를 버퍼링해 재작성하고 Content-Length를 자동으로 다시 맞춥니다(청크 본문은 de-chunk 후 재프레이밍됩니다). 헤드 규칙은 본문을 손대지 않고 계속 스트리밍합니다. 압축된 본문은 재작성되지 않고 거부됩니다. gori는 전달 경로에서 압축을 풀지 않는데, 압축된 바이트 위에 패턴을 돌리면 우연히 압축 스트림 내부에 매칭되어 본문을 망가뜨릴 수 있습니다. 흔한 바이트 하나면 충분하고, 오류도 없이 Content-Length까지 다시 계산되어 멀쩡해 보입니다. 그래서 규칙은 아예 발동하지 않고 응답은 바이트 그대로 지나갑니다. 이는 압축이 어느 쪽으로 선언되었든 적용됩니다 — Content-Encoding: gzip/br/… 그리고 Transfer-Encoding에 실린 압축 계층 모두. 다만 순수한 Transfer-Encoding: chunked는 압축이 아니라 프레이밍이므로 해당하지 않으며, 규칙이 보기 전에 de-chunk되어 엔티티로 전달됩니다. 스트리밍 응답(SSE, close로 구분되는 응답, WebSocket 업그레이드)은 그대로 흘려보냅니다. 본문 규칙은 여전히 매칭되는 호스트를 HTTP/1.1로 내립니다. 이 강등은 연결을 맺을 때 한 번 결정되므로, HTTP/2 연결이 이미 열린 뒤에 켠 규칙은 클라이언트가 새 연결을 열기 전까지 그 연결이 실어 나르는 어떤 요청에도 적용되지 않습니다. HTTP/2에서 Match & Replace는 헤드에 적용됩니다. 본문 재작성은 구현되어 있지 않고 앞으로도 만들지 않습니다. HTTP/2 흐름 제어 때문에 본문 길이를 바꾸는 재작성은 그대로 실패하거나 스트림을 교착시키기 때문입니다. 그래서 본문 규칙은 해당 호스트를 HTTP/1.1로 내리고, 그 강등을 받아들이지 못하는 h2 클라이언트(gRPC)는 본문 규칙이 켜져 있는 동안 연결되지 않습니다. gori.log에 호스트당 한 번, 호스트와 이유가 남습니다.

WebSocket에서의 Match & Replace

partws로 두면 규칙이 HTTP 헤드나 본문 대신 WebSocket 메시지를 재작성합니다. 방향은 target이 정합니다. request는 클라이언트 → 서버, response는 서버 → 클라이언트입니다. 나머지는 그대로입니다. 리터럴이나 정규식, 캡처 그룹, $NAME 바인딩, 그리고 호스트 글롭이 똑같이 동작하며, 글롭은 그 소켓을 연 호스트와 매칭됩니다.

gori run rewriter add --target=request --part=ws --find='"role":"user"' --value='"role":"admin"'

규칙은 조각(fragment)을 다시 합친 메시지 전체에 적용되므로, 조각 경계를 가로지르는 패턴도 매칭됩니다. body의 한 갈래가 아니라 별도의 part 인 것은 의도된 것입니다. WebSocket을 켰다는 이유로 기존 본문 규칙이 프레임을 재작성하기 시작하는 일은 없습니다.

믿고 쓰기 전에 알아 둘 것이 여섯 가지 있습니다.

규칙은 흐르는 중에 재작성할 뿐 아무것도 멈추지 않습니다. 메시지를 멈춰 세우고 직접 판단하려면 WebSocket에서의 인터셉트로 붙잡으세요. 둘은 함께 동작합니다. 규칙이 먼저 돌고, 편집기는 그 결과를 보여 줍니다.

Short circuit — 원본 없이 답하기

나머지 네 동작은 이미 존재하는 메시지를 재작성합니다. Short circuit 은 대신 답합니다. 요청이 매칭되면 gori가 직접 작성한 응답을 돌려주고, 원본에는 접속하지 않습니다. Replace 규칙으로는 구조적으로 불가능한 경우 — 엔드포인트가 404·500을 내거나, 원본이 꺼져 있거나 인증 벽 뒤에 있거나, 본문을 유도하는 게 아니라 만들어 내야 하는 경우 — 를 덮습니다.

"이 검사가 클라이언트 말고 다른 곳에서도 강제되는가?" 를 묻는 방법입니다. 인가 확인 요청이 {"isAdmin": true}를 반환하게 하거나, 클라이언트가 믿고 따르는 권한 값을 뒤집거나, JSON 필드에 페이로드를 넣어 DOM 싱크에 닿게 하거나, 잘못된 형식의 본문으로 클라이언트 파싱을 시험합니다.

규칙은 요청 헤드를 매칭하고(리터럴 또는 정규식, 호스트 글롭도 평소대로), 원하는 응답을 담습니다. 원시 HTTP 응답으로 작성하며, response: 행에서 Enter를 누르면 편집기가 열립니다.

200 OK
Content-Type: application/json

{"isAdmin": true}

첫 줄이 상태 줄입니다. 200, 200 OK, HTTP/1.1 200 OK 모두 됩니다. reason phrase를 생략하면 채워 줍니다. 빈 줄까지가 헤더이고, 그 뒤가 본문이며 입력한 바이트 그대로 나갑니다. 크거나 바이너리인 스텁은 body file 에 경로를 지정하세요. gori가 그 파일의 바이트를 본문으로 내보내고, 디스크에서 바뀌면 다시 읽습니다. 스텁을 gori 밖에서 편집해도 다음 요청부터 반영됩니다.

Content-Length는 gori가 실제로 보내는 바이트에서 항상 다시 유도합니다. 규칙에 적은 Content-LengthTransfer-Encoding은 버려집니다. 본문과 어긋난 길이는 keep-alive 커넥션에서 다음 요청을 어긋나게 하기 때문입니다. 그 외에는 적은 그대로 나갑니다. gori가 헤더를 덧붙이지 않습니다.

규칙을 따를 수 없으면(응답이 파싱되지 않거나 본문 파일이 없어졌으면) gori가 X-Gori-Short-Circuit: error와 함께 502로 답하고 이유를 플로우에 기록합니다. 원본으로 넘기지 않습니다. 그 요청은 나가지 않는다고 선언한 것이고, 스텁 파일이 지워졌다는 이유로 페이로드가 새는 쪽이 더 나쁜 실패입니다.

알아둘 결과가 둘 있습니다.

Short circuit 규칙은 본문 규칙과 마찬가지로 매칭되는 호스트를 HTTP/1.1로 내립니다. h2 릴레이는 요청에 로컬로 답할 방법이 없어서, 스텁 규칙을 h2 커넥션에 그대로 두면 요청이 조용히 원본까지 나갑니다. 그 규칙이 막으려던 바로 그 일입니다. gori.log에 호스트당 한 번, 호스트와 이유가 남습니다. h2 전용 클라이언트(gRPC)는 스텁 규칙이 켜져 있는 동안 연결되지 않습니다.

HTTP/2에서의 헤드 규칙

헤드 규칙은 연결을 강등하지 않고 HTTP/2에 적용되므로 gRPC가 계속 동작합니다. 규칙은 플로우 상세 뷰가 보여 주는 것과 같은 헤드(GET /path HTTP/2, :authority를 대신하는 Host: 줄, 소문자 필드 이름)를 대상으로 쓰며, 실제 전송에도 그대로 그 헤드가 쓰입니다. HTTP/2에 자리가 없어서 HTTP/1.1과 다르게 동작하는 것들이 있습니다.

헤드 규칙은 저장 이후에 열리는 연결부터 적용됩니다. 이미 열려 있는 장수명 HTTP/2 연결에는 그 연결의 다음 요청 헤드부터 적용됩니다. 본문 규칙, short-circuit 규칙, 본문 스코프 extract 규칙은 그렇지 않습니다. 이들은 호스트를 HTTP/1.1로 내려서 동작하는데 그 강등은 연결을 맺을 때 한 번 결정되고 이미 열린 HTTP/2 연결은 되돌리지 않으므로, 연결 도중에 켠 규칙은 클라이언트가 다시 연결하기 전까지 그 연결로 오가는 어떤 요청에도 적용되지 않습니다. gori.log에 연결당 한 번 그 사실이 남습니다.

같은 규칙을 헤드리스에서도 다룰 수 있습니다. gori run rewriter(list / add / rm / enable / disable / preview)와 MCP의 create_rule / update_rule / list_rules / preview_rule 도구입니다. 둘 다 scope 인자를 받으므로 TUI를 열지 않고도 전역 규칙을 만들고 켜고 끌 수 있습니다.

행 색칠하기 (Colormarker 탭)

마킹은 지금 당장 다루는 집합을 위한 것입니다. 색상 규칙은 상시적입니다. "이 engagement의 모든 5xx는 빨강"이라고 한 번만 말해 두면, 트래픽이 도착할 때마다 매칭되는 행이 계속 빨갛게 남습니다. ZAP의 neonmarker와 같은 발상이되, 태그가 아니라 조건으로 동작합니다.

탭은 기본적으로 숨겨져 있습니다. settings:tabs에서 표시할 수 있고, Rewriter 옆에 자리합니다.

각 규칙은 조건, 색상, 스타일을 갖습니다.

스타일 그리는 방식
full History 한 줄 전체의 배경을 칠합니다
strip TIME 앞 좁은 컬럼에 색 셀 하나를 칠합니다. 목록 왼쪽 가장자리에 세로 줄이 생기는 모양입니다

두 스타일은 공존합니다. 건너뛰고 싶은 노이즈에는 strip을, 절대 놓치면 안 되는 행에는 full을 쓰면 됩니다. 색상은 6가지(red, orange, yellow, green, blue, purple)이며 활성 테마 팔레트로 해석되므로 밝은 테마와 어두운 테마 양쪽에서 제대로 읽힙니다.

색조는 커서/마크 밴드를 대체하지 않고 섞입니다. 그래서 선택된 색칠 행도 여전히 선택된 것으로 읽히고, 표시된 행은 왼쪽 여백의 굵은 막대를 그대로 유지합니다. 스와치 컬럼은 strip 규칙이 활성화되어 있을 때만 예약되므로, 색상 규칙이 없는 프로젝트의 렌더링은 이전과 완전히 동일합니다.

첫 번째로 매칭되는 활성 규칙이 이깁니다. 바로 옆 Rewriter와 Colormarker가 갈라지는 지점입니다. 고쳐쓰기 규칙은 합성되어 활성화된 모든 규칙이 순서대로 실행되지만, 색상 규칙은 해석되어 첫 매칭이 행을 칠하고 나머지는 조회조차 되지 않습니다. 따라서 순서는 tiebreak이 아니라 실제 결정입니다. Shift-J / Shift-K로 재정렬하며, 전역 규칙이 프로젝트 규칙보다 먼저 해석되므로 상시 정책이 로컬 레이어보다 우선합니다.

조건은 조건부 인터셉트 바가 쓰는 것과 같은 불리언 문법입니다. host:, path:, method:, scheme:, status:, proto:AND / OR / NOT, -부정, (그룹)을 더한 형태이며, when: 행에서 Tab을 누르면 캐럿 아래 토큰이 자동완성됩니다. History 검색 바와 다르게 동작하는 세 가지가 있고, gori는 빈 목록을 보고 나서야 알아채는 대신 거부하거나 경고합니다.

규칙은 Match & Replace 규칙과 똑같이 이 프로젝트 또는 모든 프로젝트가 읽는 전역 라이브러리 중 한쪽에 삽니다. s로 둘 사이를 옮기고, x로 여기서 켜고 끄고, Shift-X로 전역 규칙의 기본값을 모든 곳에서 뒤집습니다. 라이브러리와 의견이 다른 프로젝트는 그 불일치만 저장하며, 다시 의견이 같아지는 순간 불일치는 삭제됩니다. 껐다 켠 규칙이 오늘의 답에 고정되는 대신 라이브러리를 다시 따라가게 되는 이유입니다.

동작
규칙 추가 / 편집 a / Enter 또는 e
여기서 켜기·끄기 x
전역 규칙의 기본값을 모든 곳에서 뒤집기 Shift-X
프로젝트 ⇄ 전역 이동 s
재정렬 (어떤 규칙이 이길지 바뀜) Shift-J / Shift-K
삭제 d

규칙 폼은 입력하는 동안 미리보기를 보여줍니다. 조건이 최근 플로우 중 몇 개에 매칭되는지와 실제로 몇 개를 칠하게 되는지를 함께 알려주며, 앞선 규칙이 이미 그 행을 차지했다면 두 숫자가 달라집니다.

헤드리스에서도 스크립트로 다룰 수 있습니다. gori run colormarker (list / add / rm / enable / disable / move / preview)와 MCP create_color_rule / list_color_rules / move_color_rule / preview_color_rule 도구를 쓰면 됩니다.

세션 바인딩

회전하는 토큰(세션 쿠키, CSRF 필드, 베어러)은 미리 값을 적어 두어야 하는 규칙에게는 아무 쓸모가 없습니다. 바인딩은 gori가 응답에서 본 값을 전송 시점에 채워 넣는 이름이며, 두 개의 서로 다른 행으로 나뉩니다.

한 이름은 정확히 하나의 extract 규칙이 씁니다. 같은 이름을 주장하는 두 번째 규칙은 저장 시점에 이유와 함께 거부됩니다. 선언되었지만 아직 바인딩되지 않은 이름은 빈 값으로도, 리터럴 $SESSION으로도 나가지 않습니다. 규칙이 적용되지 않고 그 이유가 이벤트 피드에 남습니다.

추출은 프록시를 지나는 트래픽직접 보낸 요청(Repeater 탭)에서 동작합니다. 스윕(Fuzzer, Miner, Discover, 액티브 Probe)에서는 의도적으로 동작하지 않습니다. 스윕은 공격자 모양의 페이로드를 보내는데, 그것을 되돌려주는 응답이 세션을 페이로드에서 유도된 값으로 다시 묶으면 이후 모든 요청에 그 값이 실려 나가기 때문입니다.

또한 전달된 바이트에서 동작합니다. Match & Replace 이후, 그리고 인터셉트 게이트에서 내린 결정 이후입니다. 편집한 응답은 편집한 값을 묶고, 드롭한 응답은 아무것도 묶지 않습니다. 브라우저가 그것을 받은 적이 없기 때문입니다.

값이 사는 곳. 프로젝트가 열려 있는 동안 메모리에만 있습니다. 규칙은 저장되지만 값은 저장되지 않습니다. settings.json에도, 프로젝트 DB에도 없습니다. 다시 열었을 때 복원된 토큰은 이미 낡은 값이고, 다시 추출하는 비용은 요청 하나입니다. 바인딩된 값은 이벤트 피드·이슈·노트·로그 줄에 나타나지 않습니다. 캡처된 트래픽에는 나타납니다. 거기서 온 값이기 때문이고, 캡처를 가리는 것은 전송된 바이트에 대한 거짓말입니다.

본문 디스크립터의 비용. 쿠키나 헤더 디스크립터는 어차피 파싱되는 응답 헤드를 읽으므로 비용이 없고 HTTP/2 에서도 동작합니다. 정규식·JSON 경로·바이트 범위 디스크립터는 응답 본문이 필요하므로 gori가 응답을 스트리밍하지 않고 버퍼링하며(Match & Replace 본문 규칙과 같은 거래), 본문 규칙과 같은 이유로 매칭되는 호스트를 HTTP/1.1로 강등시킵니다. HTTP/2의 DATA 프레임은 손대지 않고 중계되기 때문입니다. 강등되는 것은 규칙 자신의 glob이 매칭하는 호스트뿐이며, gori.log가 호스트당 한 번 이유와 함께 기록합니다. 스트리밍 응답(SSE, close-delimited, WebSocket 업그레이드)과 버퍼링 한도를 넘는 본문은 버퍼링되지 않으므로 본문 디스크립터가 읽을 수 없습니다. 그런 응답이 조건에 걸리면 셀렉터가 못 찾았다고 하지 않고 이벤트 피드가 그 사실을 말합니다.

압축된 본문은 본문 디스크립터가 돌기 전에 디코드됩니다. 그래서 gzip 된 HTML 페이지 안의 CSRF 토큰에 닿을 수 있습니다. 도착한 그대로의 엔티티를 매칭하는 Match & Replace 본문 패턴과 다른 점입니다. 같은 디스크립터는 프록시가 본 응답이든 Repeater 전송이 본 응답이든 같은 것을 뜻합니다.

bindings 서브탭은 모든 이름과 바인딩 여부, 어떤 규칙이 썼는지, 마스킹된 미리보기를 보여 줍니다. 헤드리스에서는 gori run rewriter extract / gori run rewriter bindings와 MCP의 create_extract_rule / update_extract_rule / list_extract_rules 도구가 있습니다.

임포트

모든 것을 실시간으로 캡처할 필요는 없습니다. 커맨드 팔레트(Ctrl-P)에서:

동작 소스
Import: HAR 브라우저 또는 프록시 HAR 익스포트 → 전체 요청/응답 플로우
Import: URLs 한 줄에 URL 하나씩 담긴 텍스트 파일 → 골격 요청 플로우
Import: OpenAPI OpenAPI/Swagger JSON 또는 YAML → 오퍼레이션마다 요청 템플릿 하나
Import: Postman Postman Collection v2 익스포트 → 저장된 요청마다 요청 템플릿 하나
Import: Insomnia Insomnia v4 JSON 익스포트 → 저장된 요청마다 요청 템플릿 하나
Import: Burp Burp Suite 저장 항목(XML) → 전체 요청/응답 플로우, 바이트 단위 그대로
Import: WSDL WSDL 1.1 서비스 설명서(XML) → 오퍼레이션마다 SOAP 요청 템플릿 하나

형식이 잘못된 항목은 전체 임포트를 중단시키지 않고 건너뜁니다. 임포트된 플로우는 캡처된 트래픽처럼 History에 들어오므로, 똑같이 필터링하고 Repeater로 재전송하거나 퍼징·스캔할 수 있습니다.

HAR은 WebSocket 메시지를 양방향으로 실어 나릅니다. 캡처된 소켓은 실제 101 핸드셰이크로 익스포트되고, 메시지 로그는 Chrome DevTools의 _webSocketMessages 필드에 나란히 담깁니다 — 가짜 HTTP 교환으로 꾸며내지 않습니다. 그 HAR을 임포트하면 메시지가 플로우에 복원되므로, 전달 과정을 거친 뒤에도 WebSocket 창, gori run show, 그리고 재익스포트에서 그대로 보입니다. 각 메시지는 방향, opcode(제어 프레임 — 코드와 사유가 담긴 CLOSE, PING, PONG — 포함), 바이트(유효한 UTF-8이 아니면 base64로 담기므로 바이너리 프레임이나 의도적으로 잘못된 UTF-8 프레임도 정확히 왕복합니다), 밀리초 단위 타임스탬프를 유지합니다. gori가 남긴 [gori] … 알림 행도 함께 이동합니다 — 스트림에서의 위치가 그 행이 가리키는 프레임을 지목하기 때문입니다. HAR이 담지 못하는 것은 두 가지입니다: 프레임별 형태(FIN/RSV/마스크 키/프래그먼트 수)는 --format json 또는 raw를 쓰고, 전송 기록이 비어 있는 소켓은 여전히 건너뛰고 개수를 보고합니다 — 핸드셰이크만으로는 교환이 아니기 때문입니다.

Postman과 Insomnia는 컬렉션 자체의 variable 목록(Insomnia는 익스포트된 환경)에서 {{변수}}를 치환합니다. 치환되지 않은 변수가 URL에 남은 요청은 {{baseUrl}}을 호스트로 그대로 저장하지 않고 건너뜁니다. 모든 요청이 그렇게 건너뛰어지면, 어떤 변수를 채워야 하는지 에러 메시지가 이름을 알려 줍니다. 폴더 중첩은 끝까지 따라가고, 인증은 bearer·basic·헤더 API 키를 채워 줍니다. 토큰 교환이 필요한 방식(OAuth, AWS SigV4, NTLM 등)은 직접 채워야 합니다.

Burp 항목은 저장된 그대로의 wire 바이트를 유지합니다. 이상한 공백, 중복 헤더, 일부러 틀린 Content-Length, 요청 타깃 안의 CRLF까지 그대로입니다. 손으로 만든 요청이 Repeater에서 바이트 단위로 똑같이 재전송된다는 점이, 요청을 다시 기술하는 대신 Burp에서 가져오는 이유입니다.

WSDL은 서비스가 게시한 SOAP 포트마다 오퍼레이션당 SOAP 요청 템플릿 하나를 만듭니다. SOAP 1.1(SOAPAction, text/xml)과 SOAP 1.2(action 미디어 타입 파라미터, application/soap+xml)를 모두 다루므로, 두 버전을 함께 게시하는 엔드포인트는 요청 하나가 아니라 둘로 들어옵니다. 본문은 <wsdl:types> 안의 XSD에서 뽑아낸 스켈레톤이며, 내장 타입마다 유효한 플레이스홀더를 채워 스키마를 검증하는 게이트웨이도 시드 요청을 통과시킵니다. 재귀 타입은 첫 반복에서 멈추고 직접 중첩하라는 주석을 남깁니다. WSDL 1.1만 읽고, http:binding(GET/POST) 포트와 HTTP가 아닌 트랜스포트는 이유와 함께 건너뛸 뿐 손상으로 세지 않으며, 외부 xsd:import 파일은 가져오지 않고, <!DOCTYPE>는 아예 거부합니다 — 서비스 설명서는 문서이지, 재전송해 달라고 건넨 wire 바이트가 아니기 때문입니다.

같은 소스를 헤드리스에서도 다룰 수 있습니다. gori run import --postman PATH(그리고 --har / --urls / --oas / --insomnia / --burp / --wsdl)와 MCP의 import_flows 도구입니다.

호스트 오버라이드

호스트 오버라이드는 /etc/hosts 스타일 맵입니다. DNS를 바꾸지 않고 호스트명에 대해 특정 IP로 접속합니다. 두 개의 레이어가 있습니다.

레이어 위치 우선순위
Project Project 탭 → HOST OVERRIDES 패널 (a / e / d) 충돌 시 우선
Global Preferences(Ctrl-,) → Network & TabsNetworkHostname overrides, Ctrl-PSettings: Hostnames, 또는 settings.jsonhostname_overrides 폴백

스테이징 호스트, IP 기반 가상 호스트, 또는 Host 헤더를 그대로 유지하면서 프로덕션 호스트명을 랩 박스로 향하게 할 때 유용합니다.

프록시를 설정할 수 없는 클라이언트

프록시 설정을 아예 무시하는 클라이언트가 있습니다 — 임베디드 장비, 정적 링크된 바이너리, HTTP_PROXY를 읽지 않는 모든 것. 이런 경우 투명 리스너를 띄우고 방화벽으로 트래픽을 밀어 넣으세요. 클라이언트 쪽 설정은 전혀 필요 없습니다.

gori는 가능한 곳에서는 커널에서 목적지를 복구합니다(Linux는 SO_ORIGINAL_DST, macOS는 root가 필요한 pf 조회). 그 답이 어느 기계에 연결될지를 정하고, 이름은 여전히 클라이언트의 Host 헤더나 TLS SNI에서 옵니다 — 인증서가 발급되는 이름, 스코프가 매칭하는 이름, History에 보이는 이름. 커널이 답하지 못하는 곳에서는 그 이름이 목적지 역할까지 합니다. 어느 출처가 정했는지는 로그에 남으므로, 목적지가 이상해 보이면 따라갈 수 있습니다.

settings.jsonlisteners에 추가하고 iptables / pf를 그쪽으로 향하게 하면 됩니다 — 설정 키와 리다이렉트 규칙은 listeners 레퍼런스를 참고하세요. 캡처되는 플로우, 스코프, 샌드박스, passthrough 목록 모두 일반 프록시 경로와 똑같이 동작합니다.

인증서는 여전히 클라이언트에서 신뢰해야 합니다. 투명 모드가 없애 주는 것은 프록시 설정이지 gori CA의 필요성이 아닙니다.

대상에 reverse 리스너도 쓸 수 있다면 그쪽이 낫습니다. 목적지를 그냥 선언하므로 방화벽 규칙이 필요 없고, 목적지의 어느 부분도 클라이언트가 보낸 것에서 가져오지 않습니다.

프록시를 지정할 수는 있는데 HTTP 프록시는 지정할 수 없는 클라이언트라면 — ALL_PROXY=socks5://127.0.0.1:1080, 프록시 설정이 SOCKS뿐인 런타임 — socks5 리스너를 쓰세요. 클라이언트가 SOCKS 핸드셰이크에서 목적지를 직접 말하므로 방화벽 규칙도, SNI나 Host 헤더에서 복구할 것도 없고, 핸드셰이크 이후는 투명 경로와 똑같이 가로챕니다. 인증 없이 CONNECT만 처리하며, 거부(샌드박스가 배제한 목적지, UDP ASSOCIATE를 요청한 클라이언트)는 RFC 1928이 정한 응답 코드로 답하고 플로우로도 기록하므로, 이유 없이 닫힌 연결이 아니라 History에서 읽힙니다. SOCKS5 모드를 참고하세요.

피닝된 앱이 방해할 때

gori는 모든 HTTPS 연결을 가로채므로, 인증서를 피닝하는 클라이언트(모바일 앱, 자동 업데이터, 백그라운드 에이전트)는 깨집니다. 휴대폰이나 공용 머신에서는 그런 트래픽이 원하든 원치 않든 함께 들어오고, 다른 것을 테스트하는 동안 계속 실패합니다.

그 호스트들을 TLS passthrough에 등록하세요(Preferences → Network & TabsNetwork, 쉼표로 구분. 또는 settings.jsonnetwork.tls_passthrough). 등록된 호스트는 불투명한 터널로 중계되어, 클라이언트가 원 서버의 실제 인증서를 보고 정상 동작합니다. 대신 그 호스트는 아무것도 캡처되지 않습니다.

스코프로는 안 됩니다. 스코프는 무엇을 기록하고 개입할지를 결정할 뿐이고, 스코프 밖 호스트도 복호화됩니다. TLS 자체에 손대지 않게 하는 설정은 passthrough뿐입니다. 패턴 문법은 tls_passthrough 레퍼런스를 참고하세요.

우회된 호스트는 어디에도 플로우를 남기지 않으므로, 처음 중계되는 순간 상단 바에 노란 bypass:N 칩이 생깁니다. 칩을 클릭하거나 커맨드 팔레트에서 TLS passthrough hosts를 실행하면 목록이 열립니다. 호스트마다 매칭된 규칙, 처음 본 시각, 그동안의 연결 수가 함께 나옵니다. 설정이 전역이므로 이 목록도 프로젝트별이 아니라 세션 전체 기준입니다.

origin이 프록시를 막을 때

피닝된 클라이언트와는 다른 종류의 실패이고, 겉모습도 전혀 다릅니다. 요청은 나가는데 origin이 gori를 물리기 전에는 본 적 없는 챌린지 페이지나 403을 돌려줍니다. HTTP 쪽에는 단서가 없습니다 — 헤더도 브라우저의 것, 쿠키도 브라우저의 것입니다.

증거는 한 층 아래에 있습니다. origin이 보는 ClientHello는 브라우저가 아니라 gori의 OpenSSL 핸드셰이크이고, 안티봇 스택(Cloudflare, Akamai, DataDome, PerimeterX)은 그것을 JA3/JA4로 지문화합니다. 순정 OpenSSL의 hello는 Chrome처럼 보이지 않으므로, 헤더를 한 줄도 읽기 전에 판정이 끝납니다.

outbound_tls는 그 핸드셰이크를 목적지별로 다시 빚을 수 있습니다. 가장 빠른 형태는 프리셋입니다:

{
  "outbound_tls": [
    { "host": "shop.example.com", "preset": "chrome" }
  ]
}

이름은 chrome, firefox, safari, curl 네 가지입니다. 각각 암호군 목록과 순서, TLS 1.3 스위트, 지원 그룹, 서명 알고리즘, 브라우저가 제안하는 h2, http/1.1 ALPN 쌍(gori의 기본값은 프로토콜 하나만 제안하는데, 이것부터가 이미 티가 납니다), 그리고 session_ticket·status_request 확장이 아예 나타나는지 여부를 채웁니다. 규칙에 직접 적은 값은 프리셋을 이기고, 각 필드는 프리셋 없이 단독으로도 쓸 수 있습니다 — outbound_tls 참고.

그리고 확인하세요. 와이어에 닿지 못한 손잡이는 닿은 손잡이와 겉보기가 똑같습니다:

gori settings tls-fingerprint shop.example.com

실제 dial이 만드는 것과 같은 TLS 컨텍스트에서, gori가 그 목적지로 정말 보내는 hello의 JA3/JA4와 각 다이제스트 뒤의 원본 목록을 출력합니다.

프리셋은 근사치로 읽으세요. 분류기가 읽는 값 수준 필드는 전부 맞추지만, 브라우저의 JA3를 바이트 단위로 재현하지는 못합니다 — 확장 순서와 GREASE 배치는 OpenSSL의 것이고 거기서 설정할 수 없습니다. 그래도 "순정 OpenSSL 클라이언트로 보이는 것"은 대개 면할 수 있고, 탐지되는 실체가 바로 그것입니다. 다만 비교는 다이제스트가 아니라 JA4_r 목록으로 하고, 다이제스트는 다를 것으로 예상하세요.

질문을 반대로 던지기

목적지 규칙은 "이 origin엔 항상 Chrome처럼"에 답합니다. 정작 거기까지 오게 만든 질문은 보통 그 반대 — 이 엔드포인트가 chrome일 때와 curl일 때 다르게 답하나? — 이고, 그건 같은 호스트에 대한 A/B입니다. 전송 사이에 규칙을 고쳐서는 답할 수 없습니다. 두 전송이 서로 다른 설정 상태에서 나가고, 어느 쪽이 뭐였는지 기록도 없으며, 그 호스트로 가는 다른 모든 탭과 백그라운드 캡처의 핸드셰이크까지 함께 바뀝니다.

그래서 전송 하나 또는 실행 하나가 자기 지문을 지정할 수 있고, dial 시점에 해석되며, 목적지 테이블은 그대로 둡니다. Repeater에서 플로우를 열고 TARGET 밴드가 ␣T:chrome이 될 때까지 ␣T를 눌러 전송하세요. 그 다음 탭을 복제해 curl로 돌린 뒤 다시 전송합니다. 탭 둘, 호스트 하나, 진짜 핸드셰이크 둘, 응답 둘이 화면에 나란히 남습니다. 헤드리스로는 gori run repeater 42 --tls-preset chrome이고, 스윕 전체는 gori run fuzz --tls-preset chrome으로 지정합니다.

오버라이드는 목적지 정책을 갈아치우는 게 아니라 좁힙니다. ClientHello 모양만 가져가고 목적지의 클라이언트 인증서·프로토콜 범위·permissive는 그대로 둡니다 — 지문 비교가 조용히 "인증됨 vs 익명" 비교로 바뀌면 안 되니까요. 필드별 표 전체는 전송 단위 TLS 지문에 있고, gori settings tls-fingerprint HOST --preset curl은 오버라이드가 실제로 무엇을 보낼지 보내기 전에 출력해 줍니다.

Project 탭

Project 홈 탭은 단순한 요약 이상입니다. 개요 아래에 서브탭 스트립이 있습니다. /로 카드를 바꾸고, /Enter로 열려 있는 카드 안으로 들어가며, Esc(또는 맨 위에서 )로 다시 스트립으로 올라옵니다.

개요 밴드는 프로젝트 자체의 정보를 담습니다. 이름, 디렉터리, 레지스트리 short id와 바인딩된 워크스페이스, 캡처 여부가 함께 표시되는 프록시 주소, 플로우/바이트 수, 확인된 이슈(미검토 Probe 발견 수 병기), DB 크기, 생성 시각과 마지막 활동, 그리고 Probe가 식별한 기술 스택입니다. 넓은 터미널에서는 2열로 배치되고, 좁으면 그룹별 요약 한 줄로 접힙니다. 창이 좁아질 때 사라지는 것은 정보 자체가 아니라 세부 표현입니다.

개요, 한눈에 보는 상태 바, 스코프, 호스트 오버라이드, 환경 변수, 설명, 네트워크 패널을 갖춘 gori Project 탭
Project 홈: 개요와 한눈에 보는 상태, 그리고 스코프, 호스트 오버라이드, 환경 변수, 프로젝트별 네트워크 설정 패널.
서브탭 용도
DESCRIPTION 자유 형식 프로젝트 노트
SCOPE include/exclude 규칙 (호스트, 문자열, 정규식)
HOST OVERRIDES 프로젝트별 접속 맵
ENV 아웃바운드 요청을 위한 프로젝트별 $KEY 변수. Repeater & Fuzzer 참고
PROJECT SETTINGS scope 렌즈 + 샌드박스 토글, 전역 Settings 기본값을 재정의하는 프로젝트별 네트워크 고정(bind / upstream), 그리고 gRPC .proto 스키마 경로
ACTIVITY 이 프로젝트에 누가 무엇을 바꿨는지 — 추가 전용 이벤트 피드를 최신순으로. 설정 변경(스코프 규칙, 샌드박스, host override, $KEY 변수, rewrite 규칙, 네트워크 고정)은 어디서 바꾸든 기록되고, 각 행이 그것을 만든 주체(tui · cli · agent)를 밝힙니다. 백그라운드 작업 결과와 에이전트 툴 호출도 함께 들어옵니다. s source, l level, a actor, / 텍스트로 필터하고 로 이벤트가 가리키는 플로우·세션을 열며, ⇧X로 피드를 비웁니다(에이전트 감사 기록도 함께 사라지므로 먼저 확인을 묻습니다). ⇧X는 History·Probe 이슈·Issues 목록·Authorize 큐를 비우는 키와 같으며, 각각 자기 탭 안에서만 동작합니다. 소문자 c는 다른 곳과 똑같이 캡처 토글로 남습니다. 알림을 띄우지 않고 실패한 훅이나 세션 바인딩이 보이는 곳입니다

스코프 규칙과 호스트 오버라이드는 스크립트로도 다룰 수 있습니다: gori run project scope add --kind=include --type=host --pattern=api.example.com, gori run project host-override add --host=api.example.com --ip=10.0.0.1. 전체 플래그는 CLI Reference에 있습니다.

다음 단계