haechi 1.3.0 → 1.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +15 -4
- package/README.md +15 -4
- package/docs/current/code-review-risk-register-2026-06-16-round2.ko.md +142 -0
- package/docs/current/code-review-risk-register-2026-06-16-round2.md +142 -0
- package/docs/current/code-review-risk-register-2026-06-16.ko.md +377 -0
- package/docs/current/code-review-risk-register-2026-06-16.md +377 -0
- package/docs/current/configuration.ko.md +2 -1
- package/docs/current/configuration.md +2 -1
- package/docs/current/operations-runbook.ko.md +21 -1
- package/docs/current/operations-runbook.md +22 -1
- package/docs/current/release-process.ko.md +14 -6
- package/docs/current/release-process.md +14 -6
- package/docs/current/risk-register-release-gate.ko.md +48 -5
- package/docs/current/risk-register-release-gate.md +48 -5
- package/docs/current/shared-responsibility.ko.md +10 -1
- package/docs/current/shared-responsibility.md +10 -1
- package/docs/current/threat-model.ko.md +3 -0
- package/docs/current/threat-model.md +3 -0
- package/package.json +2 -1
- package/packages/cli/bin/haechi.mjs +92 -3
- package/packages/cli/runtime.mjs +54 -1
- package/packages/core/index.mjs +15 -0
- package/packages/crypto/index.mjs +42 -20
- package/packages/plugin/process-sandbox.mjs +56 -1
- package/packages/plugin/sandbox.mjs +23 -0
- package/packages/proxy/index.mjs +385 -34
- package/packages/ssrf/index.mjs +60 -4
- package/packages/stream-filter/index.mjs +127 -12
- package/packages/token-vault/index.mjs +46 -5
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
# Haechi 리스크 레지스터 및 릴리스 게이트
|
|
2
2
|
|
|
3
3
|
- 문서 상태: Living document(core 1.3.x 추적)
|
|
4
|
-
- 작성일: 2026-06-
|
|
4
|
+
- 작성일: 2026-06-16
|
|
5
5
|
- 기준 버전: 1.3.x
|
|
6
6
|
- 기준 브랜치: `main`
|
|
7
7
|
|
|
8
8
|
## 1. 현재 판단
|
|
9
9
|
|
|
10
|
-
Haechi는 `1.x` stable 라인을 출시했습니다. developer preview 게이트(G2, `haechi@0.3.2`)부터
|
|
10
|
+
Haechi는 `1.x` stable 라인을 출시했습니다. developer preview 게이트(G2, `haechi@0.3.2`)부터 G8(1.3.0 backend + detection coverage expansion)까지 모든 게이트가 통과되었으며, 아래 게이트 이력은 감사 추적으로 보존합니다. 1.0.0은 strict semver 하의 frozen API 계약을 선언하고(문서화된 deprecation 정책과 freeze 가드 `tests/api-contract.test.mjs` 포함), signed·sandboxed `authProvider` plugin에 한해 dynamic-loading 금지를 좁게 해제했습니다. 1.1.0은 커널 수준 capability 거부를 갖춘 opt-in `process-isolated` plugin 런타임을 추가했습니다. stable 표현을 막던 조건 — 1.0 API 안정성, 외부 `cryptoProvider`/KMS reference adapter(`haechi-crypto-kms`), stream-aware enforcement(`streaming.requestMode: "inspect"`) — 은 모두 갖춰졌습니다. Haechi는 여전히 컴플라이언스를 보장하지 않는 self-hosted 보안 toolkit이며, 운영 배포는 네트워크 접근 통제, upstream 인증, key custody를 직접 책임집니다(threat model §5 참고).
|
|
11
|
+
|
|
12
|
+
**2026-06-16 코드리뷰 보완 — `haechi@1.3.1`로 발행:** 전체 코드리뷰 결과를 `docs/current/code-review-risk-register-2026-06-16.ko.md`에 등록부로 열었습니다. 이 리뷰에서 P0 credential-boundary leak 1건, P1 릴리스 차단 이슈 4건, P2 하드닝/테스트 공백 8건이 확인됐습니다. **13개 `P*-CR-*` 항목이 모두 Resolved이며(§5.7) `haechi@1.3.1` 보완 컷(2026-06-16, attested OIDC publish)으로 발행되었습니다.** G9은 **Pass**입니다. 운영자는 수정 사항(특히 P0-CR-001 프록시 헤더 경계 패치)을 반영하려면 `haechi@1.3.0`에서 `1.3.1`로 업그레이드해야 합니다.
|
|
11
13
|
|
|
12
14
|
| 구분 | 판단 | 이유 |
|
|
13
15
|
|---|---|---|
|
|
14
16
|
| GitHub public | 허용 | 보안 한계, threat model, shared responsibility가 문서화됨 |
|
|
15
|
-
| GitHub release/tag | 허용 |
|
|
16
|
-
| npm stable |
|
|
17
|
-
| production use | 운영자
|
|
17
|
+
| GitHub release/tag | 허용 (`v1.3.2` 릴리스됨) | `v1.3.2` CR2 보완 컷이 태깅·릴리스됨; §5.7 및 §5.8(`CR2-001..008`) 항목이 모두 Resolved이고 G9/G10은 Pass |
|
|
18
|
+
| npm stable | `haechi@1.3.2` publish됨 | CR2 보완이 `haechi@1.3.2` attested OIDC publish(2026-06-16)로 발행됨; 이전 `1.3.1`은 CR2 수정 이전 동작을 담고 있음 |
|
|
19
|
+
| production use | 운영자 게이트; `1.3.2`로 업그레이드 | 운영자 네트워크 통제, 인가/인증, key custody가 있을 때만 지원; `haechi@1.3.1` 운영자는 민감한 제3자 업스트림 트래픽을 프록시로 라우팅하기 전에 CR2 수정(특히 `CR2-001` 프록시 upstream-cancel과 `CR2-002` token-vault audit hygiene)을 반영하도록 `1.3.2`로 업그레이드해야 함 |
|
|
18
20
|
|
|
19
21
|
## 2. 릴리스 게이트
|
|
20
22
|
|
|
@@ -29,6 +31,8 @@ Haechi는 `1.x` stable 라인을 출시했습니다. developer preview 게이트
|
|
|
29
31
|
| G6 | 1.1.0 plugin capability 강제 (`process-isolated`) | P1-SEC-027 / P1-SEC-028 mitigated; `process-isolated` 런타임(`--permission` 하 자식, 부여 0, `data:` URL 로드, stdio 무시, JSON-string IPC) + fail-closed `--allow-net` 기능 탐지(`netEnforcement:"require-permission"`) + 코어 `haechi/ssrf` 가드 + 호스트 중개 키 자료 + spawn-storm 서킷 브레이커; fs/net/stdio 레드팀 + SSRF + config 테스트 통과(행동 스위트는 `--allow-net` Node에서 실행, 아니면 fail-closed로 skip); API freeze 통과 유지(additive `./ssrf` export + additive config 키); core는 zero runtime dependency 유지; core 1.1.0 bump(additive + opt-in 마이너) | Pass |
|
|
30
32
|
| G7 | 1.2.0 신뢰성 강화 트랙 (WS1–WS6) | 탐지 품질 측정+강화(WS2: 라벨 코퍼스 precision/recall `bench:detection` 게이트, 자격증명+국제 PII 커버리지, 하드블록 타입 불변식이 적용된 `filters.minConfidence` / `filters.allowlist`, offset 무결성을 갖춘 NFKC 유니코드 회피 폴딩); WS3 주입 가능한 `rateLimiter` 시임 + bounded fixed-window map; WS4 운영성(`/__haechi/live`+`/ready` 분리, 주입 가능한 `/metrics`, 구조적 로그 + 요청별 `correlationId`, graceful drain, max-in-flight backpressure, env overlay, 하드닝 Dockerfile/compose/runbook, `configVersion`); WS6 proxy TLS / remote-bind 하드닝(`proxy.tls` / `proxy.trustForwardedProto`, fail-closed `assertSafeProxyTransport`) + OWASP-LLM/NIST 컨트롤 매핑 백서 + RFC 9116 `security.txt` + 취약점 공개 경로. 모든 변경은 1.1 동작을 보존하는 기본값 뒤의 additive(`tests/api-contract.test.mjs` 통과); no-plaintext-in-audit 불변식이 텔레메트리까지 확장; core는 zero runtime dependency 유지; core 1.2.0 bump(additive 마이너) | Pass |
|
|
31
33
|
| G8 | 1.3.0 백엔드 + 탐지 커버리지 확장 | **Anthropic Messages API**(`/v1/messages`, content-block + SSE `delta.text`, `event:` 라인 보존 재직렬화)와 **Google Gemini API**(model-in-path `:generateContent`/`:streamGenerateContent`, 기존 정확-매칭 어댑터를 바이트 동일하게 두는 additive `:method`-suffix 라우트 매처) 프로토콜 어댑터 추가; 탐지 커버리지 확장 — 클라우드/SaaS provider 키(OpenAI/Anthropic/Google-OAuth/SendGrid/Twilio/npm/Azure, anchored)와 국제 PII(FR/ES/JP + IT/SG/IN/DE/NL 국가 ID, 체크섬 validator), 각 하드블록-대-dial-eligible 결정은 측정된 충돌률 기반(하드블록은 비숫자 앵커 또는 비현실적으로 드문 형태가 필요; 흔한 길이의 bare-digit run은 allowlist로 정리 가능 유지); `bench:throughput` proxy 부하 벤치; `haechi-ratelimit-redis` 공유 저장소 rate-limiter 위성(WS3 시임의 운영 소비자; proxy가 이제 `rateLimiter.allow`를 `await`); `haechi-dashboard`가 요청별 `correlationId` 노출. 모든 변경은 additive — 새 `target.type`/탐지타입/`privacy.profile` *값*이며 새 config 키가 아님(`configVersion`은 `1` 유지); `tests/api-contract.test.mjs` 통과; core는 zero runtime dependency 유지; core 1.3.0 bump(additive 마이너) | Pass |
|
|
34
|
+
| G9 | 2026-06-16 전체 코드리뷰 보완 게이트 (1.3.1로 발행) | `P0-CR-001` 및 `P1-CR-002`부터 `P1-CR-005`까지 해결 또는 책임자 명시 수용; P2 항목은 해결 또는 명시적 non-blocking 근거와 일정 기록; 연결된 등록부 갱신. **13개 `P*-CR-*` 항목이 모두 Resolved이며(§5.7) `haechi@1.3.1`(2026-06-16, attested OIDC publish)로 발행되었습니다; core가 1.3.0 → 1.3.1로 bump(patch, 보완 전용 — API/config 표면 변경 없음, `configVersion`은 `1` 유지)되었습니다.** | Pass (`haechi@1.3.1`, 2026-06-16) |
|
|
35
|
+
| G10 | 2026-06-16 코드리뷰 round 2 (CR2) 보완 게이트 | CR2 등록부(`code-review-risk-register-2026-06-16-round2.md`, §5.8)는 **P0/P1을 발견하지 못했습니다**; 세 개의 P2(`CR2-001` 프록시 upstream-cancel, `CR2-002` token-vault audit hygiene, `CR2-003` plugin IPC reply 경계)와 P3 묶음(`CR2-004..008`)이 모두 **Resolved이며 `haechi@1.3.2`로 발행되었고**(`CR2-009` won't-fix, `CR2-010` accepted) 연결된 등록부가 갱신되었습니다. | Pass (`haechi@1.3.2`, 2026-06-16) |
|
|
32
36
|
|
|
33
37
|
## 3. P0 배포 차단 리스크 상태
|
|
34
38
|
|
|
@@ -128,6 +132,43 @@ base64/인코딩 값 디코딩 검사, query string 검사, audit tail truncatio
|
|
|
128
132
|
| P1-SEC-027 | Plugin capability *강제*: 1.0 `worker_threads` sandbox는 memory/crash 격리뿐이라 악의적 signed plugin이 `fs`/`net`을 써서 credential을 exfiltrate할 수 있음. **P1-SEC-024의 수용된 worker 잔여를 강화** — 1.1이 새 opt-in 런타임에 실제 강제 추가 | Mitigated | `packages/plugin/process-sandbox.mjs` `createProcessIsolatedAuthProvider`/`…Sync`(PR #54): signed `authProvider`가 `--permission` 하 자식 `node`에서 **부여 0**(fs/child-process/worker/addons/wasi 없음, `--allow-net` 없음)으로, `data:` URL 로드(fs 권한 없음 → TOCTOU/symlink 표면 없음), `stdio:['ignore','ignore','ignore','ipc']`(stdout/stderr/fd 유출 채널 없음), 정화 env, JSON-string 전용 IPC + 공유 null-proto sanitizer + 호스트측 keyed-HMAC identity로 실행됩니다. **Node 26 실측 검증**: plugin의 `fs`/`net`/`fetch`/`dns`/`child_process`/`worker`와 `process.binding('tcp_wrap')` 우회가 모두 `ERR_ACCESS_DENIED`. 네트워크 봉쇄는 **커널 `--allow-net` 거부**(삭제 가능한 JS 하니스가 아님); 기본값 `netEnforcement:"require-permission"`은 강제 못 하는 Node에서 **fail closed**(동작 probe 기능 탐지; PR #54). spawn-storm 서킷 브레이커(PR #56)가 재spawn 제한. lifecycle audit에 호스트 계산/enum 전용 `isolation`/`grants`/`netEnforcement` 추가(PR #56). config: `auth.plugin.isolation:"process"` fail-closed 배선(PR #56). 테스트: fs/net/stdio 레드팀(`--allow-net` 없는 Node에선 fail-closed라 skip) + 상시 실행 fail-closed 계약 + config 매트릭스. **잔여:** `--allow-net` 없는 Node(fail-closed, 미봉쇄); `networkEgress` 부여 plugin; 자식 메모리의 credential/키 자료(core-dump/swap); V8/Node 탈출(런타임 통제일 뿐 OS 샌드박스 아님) |
|
|
129
133
|
| P1-SEC-028 | 호스트 중개 키 자료 + SSRF: 키 자료가 필요한 커스텀 자격증명 plugin이 plugin 주도 SSRF 벡터가 될 수 있고, 코어엔 SSRF 가드가 없었음(위성 복사본은 코어에서 도달 불가) | Mitigated | 새 node:-only, 의존성 0 **`haechi/ssrf`** 코어 모듈(PR #55): `isBlockedAddress`(private/loopback/link-local/metadata), `guardedFetch`(https 전용, DNS 후 재확인, `redirect:"error"`, 본문 제한 + timeout), `createGuardedKeyFetcher`(TTL 캐시 + cooldown). `process-isolated` 런타임의 선택적 `keyMaterial:{url}`은 **호스트**가 **운영자 선언** URL에서 이 가드로 가져와 IPC로 주입하므로, plugin은 URL을 명명하지 않습니다(plugin 주도 SSRF 없음). kid-refetch cooldown이 아웃바운드 비율을 제한하고, blocked-address URL은 fail closed됩니다. 테스트: 표준 `isBlockedAddress` 벡터 테이블 + 코어-대-`auth-jwt` parity 가드, `guardedFetch` SSRF 거부/제한, cooldown fail-closed, 런타임 키 주입 + no-SSRF. **잔여:** 위성은 의도적으로 로컬 복사본을 유지함(crypto/auth 패키지는 core-ssrf에 런타임 의존 금지; `crypto-kms/ssrf-parity.test.mjs`) — 코어 재import는 연기하며, drift는 제거가 아니라 parity로 가드; 가드의 DNS-rebinding 창(resolve-then-connect)은 운영자 선언 URL에 대해 수용 |
|
|
130
134
|
|
|
135
|
+
## 5.7 2026-06-16 전체 코드리뷰 Open 리스크 상태
|
|
136
|
+
|
|
137
|
+
권위 있는 항목별 등록부는 `docs/current/code-review-risk-register-2026-06-16.ko.md`입니다. 이 절은 릴리스 게이트 요약입니다. **13개 항목이 모두 Resolved이며 `haechi@1.3.1`로 발행되었습니다**(2026-06-16): P0 + 네 개의 P1(프록시 헤더 경계 패치, SSRF IPv4-mapped 정규화, response-header/streaming 경계, streaming-inspection 텍스트 수정)과 여덟 개의 P2 모두(CR-006 mcp-wrap stderr filter, CR-007 init key-file 검증, CR-008 satellite `manifest.bin` check, CR-009 auth-throw 회귀 테스트, CR-010 process-sandbox quota 테스트, CR-011 audit middle-tamper 테스트, CR-012 vault IPv6 테스트, CR-013 SSE multi-line `data:`). **G9은 Pass입니다.**
|
|
138
|
+
|
|
139
|
+
| ID | 리스크 | 상태 | 종료에 필요한 증거 |
|
|
140
|
+
|---|---|---|---|
|
|
141
|
+
| P0-CR-001 | 프록시가 클라이언트 `Authorization`, `Cookie`, proxy-auth 등 주변 자격증명을 모델 업스트림으로 전달 | Resolved | `filteredHeaders()`의 기본 차단 업스트림 헤더 허용목록 + `createHaechiProxy`에서 전달되는 `forwardPolicy`(게이트웨이 클라이언트 인증과 업스트림 공급자 인증 분리: `auth.provider !== none`이면 클라이언트 `Authorization` 폐기, `none`이면 전달); cookie/proxy-auth/hop-by-hop 항상 폐기; 추가 fail-closed `target.forwardHeaders`; `tests/proxy-header-allowlist.test.mjs`가 게이트웨이 bearer는 업스트림에 안 보이고 공급자 헤더(`x-api-key`/`anthropic-version`/`x-goog-api-key`)는 보임을 증명; README/threat-model/shared-responsibility/configuration(+ko) 갱신 |
|
|
142
|
+
| P1-CR-002 | SSRF 가드가 `::ffff:7f00:1` 같은 hex IPv4-mapped IPv6 private 주소를 놓침 | Resolved | 각 `isBlockedAddress` 복사본(core `packages/ssrf`, `satellites/auth-jwt`, `satellites/crypto-kms/vault.mjs`)이 IPv4-mapped IPv6 주소를 16바이트로 파싱해 임베드된 IPv4(dotted `::ffff:127.0.0.1` 및 hex `::ffff:7f00:1`, bracketed, leading-zero, 혼합 `::`, 대소문자 무시)를 private/loopback/link-local/metadata 검사 전에 정규화; 공인 mapped 주소(`::ffff:8.8.8.8` == `::ffff:808:808`)는 허용 유지되고 기존 vault 과차단도 제거. 복사본은 의도적으로 독립 유지(어떤 위성도 `haechi/ssrf`를 import하지 않음 — core peer floor가 올라감); drift는 parity 테스트로 보증. 테스트: `tests/ssrf.test.mjs`(hex/dotted/bracketed loopback+RFC1918+metadata+public 벡터, core-vs-auth-jwt parity), `satellites/auth-jwt/auth-jwt.test.mjs`(mapped-IPv6 생성 차단 + public-mapped 미차단), `satellites/crypto-kms/vault.test.mjs`(확장된 range table + P2-CR-012 IPv6 loopback 테스트), `satellites/crypto-kms/ssrf-parity.test.mjs`(dotted+hex mapped parity 벡터) |
|
|
143
|
+
| P1-CR-003 | 자동 압축 해제된 업스트림 본문이 기존 압축 응답 헤더와 함께 반환될 수 있음 | Resolved | 중앙화 `sanitizeResponseHeaders()`(content-encoding/content-length/transfer-encoding/hop-by-hop 제거)를 모든 응답 경로(pass-through, 전달/미보호, 보호, streaming)에 적용; 올바른 content-length는 버퍼링된 바디에만 재설정; `tests/proxy-header-allowlist.test.mjs` gzip pass-through + 미보호 응답 테스트가 잔존 content-encoding 없음과 downstream 읽기 가능을 증명 |
|
|
144
|
+
| P1-CR-004 | `streaming.requestMode: "pass-through"`가 response-size cap 없이 전체 업스트림 본문을 버퍼링 | Resolved | 실행 바이트 한도(`responseProtection.maxBytes`)를 가진 진정한 경계 streaming pass-through(`pipeUpstreamBodyBounded`); 초과 시 업스트림 취소 + 클라이언트 쓰기 종료; 미보호/전달 raw read도 한도 적용(초과 시 502); `tests/proxy-header-allowlist.test.mjs`가 oversize pass-through 스트림이 경계/중단됨을 증명 |
|
|
145
|
+
| P1-CR-005 | streaming inspection이 non-JSON SSE/NDJSON 프레임을 원문 통과시켜 plain-text PII 우회 가능 | Resolved | `parseFrame`(`packages/stream-filter/index.mjs`)이 parse 실패 frame을 CONTROL allowlist(`[DONE]`, comment-only, empty/keepalive → 원문 통과)와 non-JSON CONTENT frame(`data:` 텍스트)으로 구분; `handleFrame`이 CONTENT frame을 새 `protector.protectText`(`packages/core/index.mjs`, single-shot `transformSegment`, delta `push`/`flush` 버퍼와 DISTINCT하여 JSON sliding buffer를 오염시키지 않음)로 텍스트 검사하고 `serializeTextFrame`로 `data: <protected text>` 재방출, block action 시 stream fail-closed; response-direction marker skip + audit tally 보존; JSON delta 경로 불변. 테스트: `tests/stream-filter.test.mjs`(plain-text SSE redact, block action 차단, PII 포함 malformed/partial JSON, NDJSON non-JSON 텍스트, control-frame 통과, marker 미재플래그) + `tests/proxy-streaming.test.mjs` end-to-end plain-text 재현 |
|
|
146
|
+
| P2-CR-006 | `mcp-wrap`이 child `stderr`를 filtering/audit 없이 상속 | Resolved | `haechi mcp-wrap`에 `--stderr filter\|drop\|inherit`(기본 `filter`) 추가: 각 완성된 stderr 라인을 재방출 전에 `createStreamProtector().protectText`로 보호(chunk 경계 버퍼링, block-action drop, audit-silent), `drop`은 폐기, `inherit`은 명시적 opt-in 경계, 알 수 없는 값은 fail closed; `tests/mcp-wrap.test.mjs`가 네 가지 모드를 모두 커버 |
|
|
147
|
+
| P2-CR-007 | 기존 key file을 `initLocalKeyFile()`이 검증하지 않음 | Resolved | `initLocalKeyFile`의 기존 파일 non-force 경로가 이제 공유 `loadKeyFile({ requireActive:true })`로 검증(corrupted JSON, active key 부재, 잘못된 길이의 active/retired key 모두 throw); 유효한 파일은 비파괴 유지; `tests/crypto.test.mjs`가 네 가지 케이스를 커버 |
|
|
148
|
+
| P2-CR-008 | satellite packaging check가 `manifest.bin` target file을 검증하지 않음 | Resolved | `evaluateSatellitePackaging()`이 모든 `manifest.bin` 타깃(string + object-map 형식)을 packed-file 집합과 대조해 검증; `tests/satellite-packaging-gate.test.mjs`가 positive + negative(bin 누락) 케이스를 추가 |
|
|
149
|
+
| P2-CR-009 | `authProvider.authenticate()` 예외 경로 회귀 테스트 부재 | Resolved | `tests/proxy-auth.test.mjs`가 throw하는 provider를 주입해 fail-closed(전달 안 됨, generic client error), audit status `haechi_auth_provider_error`, raw error/subject/issuer 미노출을 단언; mutation으로 검증 |
|
|
150
|
+
| P2-CR-010 | process-isolated sandbox quota 분기 parity 테스트 부족 | Resolved | `tests/plugin-process-sandbox.test.mjs`(+ crash fixture)가 isolated-process parity를 추가: oversized result 거부, over-capacity 거부, timeout 종료, child-crash fail-closed; 실제 `process-sandbox.mjs`에 대해 mutation으로 검증 |
|
|
151
|
+
| P2-CR-011 | audit chain 중간 변조 분기 집중 테스트 부족 | Resolved | `tests/audit-chain-tamper.test.mjs`가 실제 multi-record 로그를 기록하고 `verifyAuditChain`이 middle-record content mutation, `previousHash` 누락/오류, 잘못된 `eventHash`를 거부함을 단언; tail-truncation 한계는 계속 문서화 |
|
|
152
|
+
| P2-CR-012 | KMS vault IPv6 loopback carve-out의 IPv6 테스트 부족 | Resolved | `satellites/crypto-kms/vault.test.mjs`에 전용 IPv6 loopback 정책 테스트("…enforces the IPv6 loopback policy (::1, [::1], dotted + hex mapped) — P2-CR-012")를 추가해 bare `::1`, bracketed `[::1]`, dotted `::ffff:127.0.0.1`, hex `::ffff:7f00:1`/`::ffff:7f00:0001`(및 bracketed 변형)을 검증하고, 공인 mapped 주소(`::ffff:8.8.8.8`/`::ffff:808:808`)가 과차단되지 않음을 단언; 확장된 range table과 `ssrf-parity.test.mjs`가 auth-jwt와의 dotted+hex 일치를 고정 |
|
|
153
|
+
| P2-CR-013 | SSE multi-line `data:` 필드를 newline separator 없이 합침 | Resolved | `parseFrame`이 여러 `data:` line을 `join("\n")`(스펙 separator)으로 합치고 line별 스펙 선행 공백 1개만 제거; multi-line JSON은 여전히 `JSON.parse`되고 multi-line plain text는 newline과 함께 재구성되어 검사되며 `serializeTextFrame`가 multi-line payload를 여러 `data:` line으로 재방출; `tests/stream-filter.test.mjs`가 multi-line JSON event와 PII 포함 multi-line plain-text event를 커버 |
|
|
154
|
+
|
|
155
|
+
## 5.8 2026-06-16 코드리뷰 Round 2 (CR2) 상태 — 게이트 G10
|
|
156
|
+
|
|
157
|
+
권위 있는 항목별 등록부는 `docs/current/code-review-risk-register-2026-06-16-round2.md`입니다; 이 절은 릴리스 게이트 요약입니다. 1.3.1 컷 이후 진행한 2차 심층 리뷰는 **P0도 P1도 발견하지 못했습니다**(외부에서 P1로 보고된 두 항목 모두 검증 결과 P2로 내려갔습니다 — 둘 다 stored-plaintext leak도, auth/SSRF 우회도 아닙니다). 세 개의 P2 + P3 묶음(`CR2-001..008`)은 **Resolved이며 `haechi@1.3.2`로 발행되었습니다**; 보고된 한 항목은 **false positive**(`CR2-009`, won't-fix)였고 한 항목은 **이미 문서화된 수용 잔여 리스크**(`CR2-010`, accepted)였습니다. **G10은 Pass입니다.**
|
|
158
|
+
|
|
159
|
+
| ID | 리스크 | 상태 | 종료에 필요한 증거 |
|
|
160
|
+
|---|---|---|---|
|
|
161
|
+
| CR2-001 | pass-through streaming이 downstream disconnect 시 upstream reader를 절대 취소하지 않음(`pipeUpstreamBodyBounded`가 `drain`에서 영원히 park) — 인증되지 않은 resource leak | Resolved | per-request `AbortController` + upstream reader를 취소하고 fetch를 abort하는 클라이언트 `close`/`aborted` listener; `drain` 대기를 `close`와 race; 스트림 도중 disconnect가 reader를 즉시 취소하는 회귀 테스트 |
|
|
162
|
+
| CR2-002 | token-vault reveal/purge가 호출자 제공 raw `token` + `error.message`(token interpolate됨)를 audit event에 기록; `FORBIDDEN_KEYS`는 key 이름으로만 제거 | Resolved | 일반화된 오류 메시지; 기록 이전에 `token`을 keyed-HMAC하거나 `tok_` 형태로 검증; `error.message` 대신 enum `reasonCode`; raw token이 `reason`/`token`에 도달하지 않는다는 회귀 테스트; 불변식 표현 정합화 |
|
|
163
|
+
| CR2-003 | plugin IPC reply가 `JSON.parse` 이전에 size-bound되지 않음; process child에 heap cap 없음 → 적대적 signed plugin으로 인한 event-loop 정지 + 메모리 급증 | Resolved | 두 sandbox 모두에서 parse 이전 reply byte-length 검사(oversized를 deny로 drop); 새 `resourceLimits` knob을 통한 process child의 `--max-old-space-size` heap cap; oversized-reply fixture 회귀 테스트 |
|
|
164
|
+
| CR2-004 | `sanitizeResponseHeaders`가 변환된 응답에 stale body-coupled validator(`etag`/`content-md5`/`digest`/`last-modified`)를 유지 | Resolved | 모든 body-mutating 경로에서 해당 헤더 drop + `cache-control: no-store`; 변경된 응답이 upstream `ETag`를 drop하는 테스트 |
|
|
165
|
+
| CR2-005 | `maxBytes` 초과 request body가 (유한한) Node `requestTimeout`까지 read-and-discard됨 — socket teardown 없음 | Resolved | 413 경로에서 `request.pause()`/`destroy()`(또는 `Connection: close`); 선택적으로 non-null 기본 timeout |
|
|
166
|
+
| CR2-006 | `mcp-wrap --stderr filter`가 라인 지향이라 newline-split secret이 회피함(본질적; single-line secret은 잡힘, `drop` 사용 가능) | Resolved | `COMMAND_HELP` + 등록부 노트; 고민감 도구에 `--stderr drop` 권장 |
|
|
167
|
+
| CR2-007 | README가 mcp-wrap "stderr ... pass through"라고 하지만 기본값은 이제 `--stderr filter` | Resolved | README + `README.ko.md` 수정 |
|
|
168
|
+
| CR2-008 | README streaming split-match 주장이 범위 한정 없음(cross-frame buffering은 delta 채널만) | Resolved | README 두 구절 + `README.ko.md`를 delta 채널로 한정 |
|
|
169
|
+
| CR2-009 | (보고된 P2) credential `maxMessageBytes` 검사 이후 append된 `keyMaterial` | Won't fix (FALSE POSITIVE) | `keyMaterial`은 운영자 통제 + fetcher `maxBytes`로 hard-bound; 공격자 증폭 없음 — 선택적 cosmetic re-assert만 |
|
|
170
|
+
| CR2-010 | (보고된 P2) 두 NON-JSON SSE frame에 걸쳐 분할된 secret 미포착 | Accepted (documented) | round-1 `P1-CR-005`, `threat-model.md`, in-code comment에 이미 범위 외; JSON delta 채널은 `maxMatchBytes`까지 buffering함 |
|
|
171
|
+
|
|
131
172
|
## 6. P2 제품/문서 리스크 상태
|
|
132
173
|
|
|
133
174
|
| ID | 기존 리스크 | 상태 | 해소 증거 |
|
|
@@ -141,6 +182,8 @@ base64/인코딩 값 디코딩 검사, query string 검사, audit tail truncatio
|
|
|
141
182
|
|
|
142
183
|
이 체크리스트는 `1.x` stable 라인의 모든 릴리스에 대한 상시 배포 전 템플릿이며, `0.3.2` developer preview에서 처음 적용되었습니다. 그 결과를 아래에 참조 기록으로 보존합니다.
|
|
143
184
|
|
|
185
|
+
2026-06-16 현재 상태: G9은 `Pass`입니다(round-1 보완이 `haechi@1.3.1`로 발행됨). 게이트 **G10**(CR2, §5.8)은 이제 `Pass`입니다 — CR2 P2 + P3 묶음(`CR2-001..008`)이 Resolved이며 `haechi@1.3.2`로 발행되었으므로, 그 컷에 대해 이 체크리스트가 해제되었습니다.
|
|
186
|
+
|
|
144
187
|
외부 npm 게이트 확인 결과(`0.3.2` developer preview, 2026-06-10, 배포 후)는 다음과 같습니다.
|
|
145
188
|
|
|
146
189
|
- `npm whoami`: `raeseoklee`
|
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
# Haechi Risk Register and Release Gates
|
|
2
2
|
|
|
3
3
|
- Status: Living document (tracks core 1.3.x)
|
|
4
|
-
- Date: 2026-06-
|
|
4
|
+
- Date: 2026-06-16
|
|
5
5
|
- Target version: 1.3.x
|
|
6
6
|
- Branch: `main`
|
|
7
7
|
|
|
8
8
|
## 1. Current Assessment
|
|
9
9
|
|
|
10
|
-
Haechi has shipped its `1.x` stable line. The developer-preview gate (G2, `haechi@0.3.2`) and every gate through
|
|
10
|
+
Haechi has shipped its `1.x` stable line. The developer-preview gate (G2, `haechi@0.3.2`) and every gate through G8 (1.3.0 backend + detection coverage expansion) are passed; the gate history below is retained as the audit trail. 1.0.0 declared the frozen API contract under strict semver (with a documented deprecation policy and `tests/api-contract.test.mjs` as the freeze guard) and narrowly lifted the dynamic-loading ban for a signed, sandboxed `authProvider` plugin; 1.1.0 added the opt-in `process-isolated` plugin runtime with kernel-enforced capability denial. The previously distribution-blocking conditions for the stable label — 1.0 API stability, the external `cryptoProvider`/KMS reference adapter (`haechi-crypto-kms`), and stream-aware enforcement (`streaming.requestMode: "inspect"`) — are all in place. Haechi remains a self-hosted security toolkit, not a compliance guarantee, and production deployments still own network access control, upstream authentication, and key custody (see §5 of the threat model).
|
|
11
|
+
|
|
12
|
+
**2026-06-16 code-review remediation — shipped in `haechi@1.3.1`:** a full code review opened the risk register at `docs/current/code-review-risk-register-2026-06-16.md`. The review found one P0 credential-boundary leak, four P1 release-blocking issues, and eight P2 hardening/test gaps. **All 13 `P*-CR-*` findings are Resolved (§5.7) and shipped in the `haechi@1.3.1` remediation cut (2026-06-16, attested OIDC publish).** G9 is **Pass**. Operators must upgrade from `haechi@1.3.0` to `1.3.1` to pick up the fixes (notably the P0-CR-001 proxy header-boundary patch).
|
|
11
13
|
|
|
12
14
|
| Category | Judgment | Rationale |
|
|
13
15
|
|---|---|---|
|
|
14
16
|
| GitHub public | Allowed | Security limitations, threat model, and shared responsibility are documented |
|
|
15
|
-
| GitHub release/tag | Allowed |
|
|
16
|
-
| npm stable |
|
|
17
|
-
| Production use | Operator-gated
|
|
17
|
+
| GitHub release/tag | Allowed (`v1.3.2` released) | The `v1.3.2` CR2 remediation cut is tagged and released; all §5.7 and §5.8 (`CR2-001..008`) findings are Resolved and G9/G10 are Pass |
|
|
18
|
+
| npm stable | `haechi@1.3.2` published | The CR2 remediation shipped in the `haechi@1.3.2` attested OIDC publish (2026-06-16); the prior `1.3.1` carries the pre-CR2-fix behavior |
|
|
19
|
+
| Production use | Operator-gated; upgrade to `1.3.2` | Supported only with operator network controls, authz/authn, and key custody; operators on `haechi@1.3.1` should upgrade to `1.3.2` to pick up the CR2 fixes (notably the `CR2-001` proxy upstream-cancel and `CR2-002` token-vault audit hygiene) before routing sensitive third-party upstream traffic through the proxy |
|
|
18
20
|
|
|
19
21
|
## 2. Release Gates
|
|
20
22
|
|
|
@@ -29,6 +31,8 @@ Haechi has shipped its `1.x` stable line. The developer-preview gate (G2, `haech
|
|
|
29
31
|
| G6 | 1.1.0 plugin capability enforcement (`process-isolated`) | P1-SEC-027 / P1-SEC-028 mitigated; the `process-isolated` runtime (child under `--permission`, zero grants, `data:`-URL load, stdio-ignored, JSON-string IPC) + the fail-closed `--allow-net` feature detection (`netEnforcement:"require-permission"`) + the core `haechi/ssrf` guard + host-mediated key material + the spawn-storm circuit breaker; the fs/net/stdio red-team + SSRF + config tests green (the behavioral suite runs on a `--allow-net` Node and skips fail-closed otherwise); the API freeze stays green (additive `./ssrf` export + additive config keys); core stays zero runtime dependency; core bumped to 1.1.0 (additive + opt-in minor) | Pass |
|
|
30
32
|
| G7 | 1.2.0 Reliability Hardening Track (WS1–WS6) | Detection quality measured + tightened (WS2: a labeled-corpus precision/recall `bench:detection` gate, credential + international-PII coverage, `filters.minConfidence` / `filters.allowlist` with the hard-block-types invariant, NFKC unicode-evasion folding with offset-integrity); WS3 injectable `rateLimiter` seam + bounded fixed-window map; WS4 operability (`/__haechi/live`+`/ready` split, injectable `/metrics`, structured logs + per-request `correlationId`, graceful drain, max-in-flight backpressure, env overlay, hardened Dockerfile/compose/runbook, `configVersion`); WS6 proxy TLS / remote-bind hardening (`proxy.tls` / `proxy.trustForwardedProto`, fail-closed `assertSafeProxyTransport`) + OWASP-LLM/NIST control-mapping whitepaper + RFC 9116 `security.txt` + vulnerability-disclosure path. Every change is additive behind 1.1-preserving defaults (`tests/api-contract.test.mjs` green); the no-plaintext-in-audit invariant extends to telemetry; core stays zero runtime dependency; core bumped to 1.2.0 (additive minor) | Pass |
|
|
31
33
|
| G8 | 1.3.0 backend + detection coverage expansion | New protocol adapters for the **Anthropic Messages API** (`/v1/messages`, content-block + SSE `delta.text` with `event:`-line-preserving re-serialize) and the **Google Gemini API** (model-in-path `:generateContent`/`:streamGenerateContent` via an additive `:method`-suffix route matcher that leaves the exact-match adapters byte-identical); detection coverage expansion — cloud/SaaS provider keys (OpenAI/Anthropic/Google-OAuth/SendGrid/Twilio/npm/Azure, anchored) and international PII (FR/ES/JP + IT/SG/IN/DE/NL national IDs with checksum validators), each hard-block-vs-dial-eligible decision driven by measured collision rates (a non-numeric anchor or implausibly-rare shape is required for hard-block; a bare-digit run over a common length stays allowlist-clearable); a `bench:throughput` proxy load benchmark; the `haechi-ratelimit-redis` shared-store rate-limiter satellite (the WS3 seam's production consumer; the proxy now `await`s `rateLimiter.allow`); `haechi-dashboard` surfaces the per-request `correlationId`. Every change is additive — new `target.type`/detection-type/`privacy.profile` *values*, not new config keys (`configVersion` stays `1`); `tests/api-contract.test.mjs` green; core stays zero runtime dependency; core bumped to 1.3.0 (additive minor) | Pass |
|
|
34
|
+
| G9 | 2026-06-16 full code-review remediation gate (shipped in 1.3.1) | `P0-CR-001` and `P1-CR-002` through `P1-CR-005` resolved or formally accepted; P2 items either resolved or scheduled with explicit non-blocking rationale; linked register updated. **All 13 `P*-CR-*` findings are Resolved (§5.7) and shipped in `haechi@1.3.1` (2026-06-16, attested OIDC publish); core bumped 1.3.0 → 1.3.1 (patch, remediation-only — no API/config surface change, `configVersion` stays `1`).** | Pass (`haechi@1.3.1`, 2026-06-16) |
|
|
35
|
+
| G10 | 2026-06-16 code-review round 2 (CR2) remediation gate | The CR2 register (`code-review-risk-register-2026-06-16-round2.md`, §5.8) found **no P0/P1**; its three P2s (`CR2-001` proxy upstream-cancel, `CR2-002` token-vault audit hygiene, `CR2-003` plugin IPC reply bound) plus the P3 cluster (`CR2-004..008`) are all **Resolved and shipped in `haechi@1.3.2`** (`CR2-009` won't-fix, `CR2-010` accepted) and the linked register is updated. | Pass (`haechi@1.3.2`, 2026-06-16) |
|
|
32
36
|
|
|
33
37
|
## 3. P0 Distribution-Blocking Risk Status
|
|
34
38
|
|
|
@@ -136,6 +140,43 @@ Additive, accumulating on `main` toward a later `1.2.0` minor; the seam + honest
|
|
|
136
140
|
|---|---|---|---|
|
|
137
141
|
| P1-OPS-010 | Proxy rate limiter is single-process and **not injectable**, and its fixed-window `Map` is **never pruned** — a one-shot identity's slot lingers forever, so a high-cardinality identity stream is unbounded memory growth keyed by identity; and a multi-replica deployment silently weakens the limit (per-process throughput multiplies by the replica count) with no replaceable seam | Mitigated | The rate limiter is now an **injectable collaborator** mirroring `cryptoProvider`/`auditSink`/`tokenVault`: `createRuntime(config, { rateLimiter })` (`packages/cli/runtime.mjs`) supplies it, `assertProvider("rateLimiter", …, ["allow"])` fails closed at construction if it lacks `allow()`, and it is exposed on the returned runtime object; the proxy consults `runtime.rateLimiter` (`packages/proxy/index.mjs`, with a backward-compatible local-default fallback for a hand-built runtime). The default per-process in-memory fixed-window limiter (the documented default; `allow(key, limit) -> boolean`, 429 semantics unchanged) is **self-bounding**: a lazy, amortized sweep evicts fully-expired window slots once the `Map` crosses a size threshold — **no background timer** (so `node --test` does not hang). A multi-replica operator injects a shared-store implementation (e.g. Redis) satisfying the same contract, or enforces the limit at a shared front door. Docs: `configuration.md`(+ko) "Rate limiter injection" seam, `shared-responsibility.md`(+ko) §4. Tests: `tests/rate-limiter.test.mjs` — an injected limiter is the one consulted (deny→429, allow→pass-through), fail-closed on a missing `allow()`, the default limiter prunes aged-out one-shot identities (bounded `Map` via `_size()`), and the fixed-window limit/isolation semantics are unchanged; the existing `tests/proxy-auth.test.mjs` 429 test stays green. **Residual:** core ships **no** built-in distributed limiter (track non-goal §5) — a shared-store implementation is the operator's injection or a future satellite; the default's per-process scope is the documented honest default |
|
|
138
142
|
|
|
143
|
+
## 5.7 2026-06-16 Full Code Review Open Risk Status
|
|
144
|
+
|
|
145
|
+
The authoritative itemized register is `docs/current/code-review-risk-register-2026-06-16.md`. This section is the release-gate summary. **All 13 findings are Resolved and shipped in `haechi@1.3.1`** (2026-06-16): the P0 + four P1s (proxy header-boundary patch, SSRF IPv4-mapped normalization, response-header/streaming bounds, streaming-inspection text fix) and all eight P2s (CR-006 mcp-wrap stderr filter, CR-007 init key-file validation, CR-008 satellite `manifest.bin` check, CR-009 auth-throw regression test, CR-010 process-sandbox quota tests, CR-011 audit middle-tamper tests, CR-012 vault IPv6 tests, CR-013 SSE multi-line `data:`). **G9 is Pass.**
|
|
146
|
+
|
|
147
|
+
| ID | Risk | Status | Required closure evidence |
|
|
148
|
+
|---|---|---|---|
|
|
149
|
+
| P0-CR-001 | Proxy forwards client `Authorization`, `Cookie`, proxy-auth, and similar ambient credentials to the model upstream | Resolved | Default-drop upstream header allowlist in `filteredHeaders()` with a `forwardPolicy` threaded from `createHaechiProxy` (gateway-client auth separated from upstream-provider auth: client `Authorization` dropped when `auth.provider !== none`, forwarded when `none`); always-drop cookie/proxy-auth/hop-by-hop; additive fail-closed `target.forwardHeaders`; `tests/proxy-header-allowlist.test.mjs` proves the gateway bearer is not seen upstream while provider headers (`x-api-key`/`anthropic-version`/`x-goog-api-key`) are; README/threat-model/shared-responsibility/configuration (+ko) updated |
|
|
150
|
+
| P1-CR-002 | SSRF guard misses hex IPv4-mapped IPv6 private addresses such as `::ffff:7f00:1` | Resolved | Each `isBlockedAddress` copy (core `packages/ssrf`, `satellites/auth-jwt`, `satellites/crypto-kms/vault.mjs`) now parses an IPv4-mapped IPv6 address to its 16 octets and normalizes the embedded IPv4 (dotted `::ffff:127.0.0.1` AND hex `::ffff:7f00:1`, bracketed, leading-zero, mixed `::`, case-insensitive) before the private/loopback/link-local/metadata check; a genuinely public mapped address (`::ffff:8.8.8.8` == `::ffff:808:808`) stays allowed and the old vault over-block is gone. The copies stay DELIBERATELY independent (no satellite imports `haechi/ssrf` — that would raise their core peer floor); drift is guarded by the parity tests. Tests: `tests/ssrf.test.mjs` (hex/dotted/bracketed loopback+RFC1918+metadata+public vectors, core-vs-auth-jwt parity), `satellites/auth-jwt/auth-jwt.test.mjs` (mapped-IPv6 construction blocks + public-mapped not-blocked), `satellites/crypto-kms/vault.test.mjs` (extended range table + P2-CR-012 IPv6 loopback test), `satellites/crypto-kms/ssrf-parity.test.mjs` (dotted+hex mapped parity vectors) |
|
|
151
|
+
| P1-CR-003 | Auto-decompressed upstream body can be returned with original compressed response headers | Resolved | Centralized `sanitizeResponseHeaders()` (strips content-encoding/content-length/transfer-encoding/hop-by-hop) applied on every response path — pass-through, forwarded/unprotected, protected, streaming; correct content-length re-set only for a buffered body; `tests/proxy-header-allowlist.test.mjs` gzip pass-through + unprotected response tests prove no stale content-encoding and a readable downstream body |
|
|
152
|
+
| P1-CR-004 | `streaming.requestMode: "pass-through"` buffers the full upstream body without a response-size cap | Resolved | True bounded streaming pass-through (`pipeUpstreamBodyBounded`) with a running byte cap (`responseProtection.maxBytes`) that cancels upstream + tears down the client write on overrun; the unprotected/forwarded raw read also capped (502 over the cap); `tests/proxy-header-allowlist.test.mjs` proves an oversize pass-through stream is bounded/aborted |
|
|
153
|
+
| P1-CR-005 | Streaming inspection raw-passes non-JSON SSE/NDJSON frames, allowing plain-text PII bypass | Resolved | `parseFrame` (`packages/stream-filter/index.mjs`) splits parse-failed frames into a CONTROL allowlist (`[DONE]`, comment-only, empty/keepalive → pass raw) vs a non-JSON CONTENT frame (its `data:` text); `handleFrame` inspects a CONTENT frame as text via a new `protector.protectText` (`packages/core/index.mjs`, single-shot `transformSegment`, DISTINCT from the delta `push`/`flush` buffer so it never corrupts the JSON sliding buffer), re-emits `data: <protected text>` (`serializeTextFrame`), and fails the stream closed on a block action; response-direction marker skip + audit tally preserved; JSON delta path unchanged. Tests: `tests/stream-filter.test.mjs` (plain-text SSE redacted, block action blocks, malformed/partial JSON with PII, NDJSON non-JSON text, control-frame pass-through, marker not re-flagged) + `tests/proxy-streaming.test.mjs` end-to-end plain-text repro |
|
|
154
|
+
| P2-CR-006 | `mcp-wrap` inherits child `stderr` without filtering or audit | Resolved | `haechi mcp-wrap` gains `--stderr filter\|drop\|inherit` (default `filter`): each complete stderr line is protected via `createStreamProtector().protectText` before re-emit (chunk-boundary buffered, block-action dropped, audit-silent), `drop` discards, `inherit` is an explicit opt-in boundary, unknown value fails closed; `tests/mcp-wrap.test.mjs` covers all four modes |
|
|
155
|
+
| P2-CR-007 | Existing key files are not validated by `initLocalKeyFile()` | Resolved | `initLocalKeyFile` existing-file non-force path now validates via the shared `loadKeyFile({ requireActive:true })` (corrupted JSON, missing active key, wrong-length active/retired key all throw); valid files stay non-destructive; `tests/crypto.test.mjs` covers the four cases |
|
|
156
|
+
| P2-CR-008 | Satellite packaging check does not validate `manifest.bin` target files | Resolved | `evaluateSatellitePackaging()` validates every `manifest.bin` target (string + object-map forms) against the packed-file set; `tests/satellite-packaging-gate.test.mjs` adds positive + negative (missing-bin) cases |
|
|
157
|
+
| P2-CR-009 | `authProvider.authenticate()` exception path lacks regression coverage | Resolved | `tests/proxy-auth.test.mjs` injects a throwing provider and asserts fail-closed (not forwarded, generic client error), audit status `haechi_auth_provider_error`, and no raw error/subject/issuer leak; mutation-verified |
|
|
158
|
+
| P2-CR-010 | Process-isolated sandbox quota branches lack parity tests | Resolved | `tests/plugin-process-sandbox.test.mjs` (+ crash fixture) adds isolated-process parity: oversized result denied, over-capacity rejected, timeout terminated, child-crash fail-closed; mutation-verified against the real `process-sandbox.mjs` |
|
|
159
|
+
| P2-CR-011 | Audit chain middle-tamper branches lack focused tests | Resolved | `tests/audit-chain-tamper.test.mjs` writes a real multi-record log and asserts `verifyAuditChain` rejects middle-record content mutation, missing/wrong `previousHash`, and wrong `eventHash`; the tail-truncation limitation stays documented |
|
|
160
|
+
| P2-CR-012 | KMS vault IPv6 loopback carve-out lacks IPv6-focused tests | Resolved | `satellites/crypto-kms/vault.test.mjs` adds a dedicated IPv6 loopback policy test ("…enforces the IPv6 loopback policy (::1, [::1], dotted + hex mapped) — P2-CR-012") covering bare `::1`, bracketed `[::1]`, dotted `::ffff:127.0.0.1`, and hex `::ffff:7f00:1`/`::ffff:7f00:0001` (plus bracketed variants), and asserts a public mapped address (`::ffff:8.8.8.8`/`::ffff:808:808`) is NOT over-blocked; the extended range table and `ssrf-parity.test.mjs` lock the dotted+hex agreement with auth-jwt |
|
|
161
|
+
| P2-CR-013 | SSE multi-line `data:` fields are joined without newline separators | Resolved | `parseFrame` joins multiple `data:` lines with `join("\n")` (spec separator) and strips only the single spec leading space per line; multi-line JSON still `JSON.parse`s, multi-line plain text is reconstructed with newlines for inspection, and `serializeTextFrame` re-emits a multi-line payload as multiple `data:` lines; `tests/stream-filter.test.mjs` covers a multi-line JSON event and a multi-line plain-text event with PII |
|
|
162
|
+
|
|
163
|
+
## 5.8 2026-06-16 Code Review Round 2 (CR2) Status — gate G10
|
|
164
|
+
|
|
165
|
+
The authoritative itemized register is `docs/current/code-review-risk-register-2026-06-16-round2.md`; this is the release-gate summary. A second deep review after the 1.3.1 cut found **no P0 and no P1** (the two externally-reported P1s both verified down to P2 — neither is a stored-plaintext leak or an auth/SSRF bypass). The three P2s + the P3 cluster (`CR2-001..008`) are **Resolved and shipped in `haechi@1.3.2`**; one reported item was a **false positive** (`CR2-009`, won't-fix) and one is an **already-documented accepted residual** (`CR2-010`, accepted). **G10 is Pass.**
|
|
166
|
+
|
|
167
|
+
| ID | Risk | Status | Required closure evidence |
|
|
168
|
+
|---|---|---|---|
|
|
169
|
+
| CR2-001 | Pass-through streaming never cancels the upstream reader on downstream disconnect (`pipeUpstreamBodyBounded` parks on `drain` forever) — unauthenticated resource leak | Resolved | Per-request `AbortController` + client `close`/`aborted` listener that cancels the upstream reader and aborts the fetch; `drain` wait raced against `close`; regression test that a mid-stream disconnect cancels the reader promptly |
|
|
170
|
+
| CR2-002 | Token-vault reveal/purge writes the raw caller-supplied `token` + `error.message` (token-interpolated) into the audit event; `FORBIDDEN_KEYS` strips by key name only | Resolved | Generic error messages; keyed-HMAC or `tok_`-shape-validate the `token` before recording; enum `reasonCode` instead of `error.message`; regression test that no raw token reaches `reason`/`token`; reconcile the invariant wording |
|
|
171
|
+
| CR2-003 | Plugin IPC reply not size-bounded before `JSON.parse`; process child has no heap cap → event-loop stall + memory spike from a hostile signed plugin | Resolved | Reply byte-length check before parse in both sandboxes (drop oversized as deny); `--max-old-space-size` heap cap on the process child via a new `resourceLimits` knob; regression test with an oversized-reply fixture |
|
|
172
|
+
| CR2-004 | `sanitizeResponseHeaders` keeps stale body-coupled validators (`etag`/`content-md5`/`digest`/`last-modified`) on a transformed response | Resolved | Drop those headers on every body-mutating path + `cache-control: no-store`; test that a mutated response drops the upstream `ETag` |
|
|
173
|
+
| CR2-005 | Over-`maxBytes` request body is read-and-discarded until the (finite) Node `requestTimeout` — no socket teardown | Resolved | `request.pause()`/`destroy()` (or `Connection: close`) on the 413 path; optionally non-null default timeouts |
|
|
174
|
+
| CR2-006 | `mcp-wrap --stderr filter` is line-oriented, so a newline-split secret evades it (inherent; single-line secrets caught, `drop` available) | Resolved | `COMMAND_HELP` + register note; recommend `--stderr drop` for high-sensitivity tools |
|
|
175
|
+
| CR2-007 | README says mcp-wrap "stderr ... pass through" but the default is now `--stderr filter` | Resolved | Correct README + `README.ko.md` |
|
|
176
|
+
| CR2-008 | README streaming split-match claim is unscoped (cross-frame buffering is delta-channel only) | Resolved | Scope both README passages + `README.ko.md` to the delta channel |
|
|
177
|
+
| CR2-009 | (reported P2) `keyMaterial` appended after the credential `maxMessageBytes` check | Won't fix (FALSE POSITIVE) | `keyMaterial` is operator-controlled + hard-bounded by the fetcher `maxBytes`; no attacker amplification — optional cosmetic re-assert only |
|
|
178
|
+
| CR2-010 | (reported P2) secret split across two NON-JSON SSE frames not caught | Accepted (documented) | Already out-of-scope in round-1 `P1-CR-005`, `threat-model.md`, and an in-code comment; the JSON delta channel does buffer up to `maxMatchBytes` |
|
|
179
|
+
|
|
139
180
|
## 6. P2 Product/Documentation Risk Status
|
|
140
181
|
|
|
141
182
|
| ID | Risk | Status | Resolution evidence |
|
|
@@ -149,6 +190,8 @@ Additive, accumulating on `main` toward a later `1.2.0` minor; the seam + honest
|
|
|
149
190
|
|
|
150
191
|
This checklist is the standing pre-distribution template for every release on the `1.x` stable line; it was first exercised for the `0.3.2` developer preview, whose results are retained below as the reference record.
|
|
151
192
|
|
|
193
|
+
Current 2026-06-16 status: G9 is `Pass` (round-1 remediation shipped in `haechi@1.3.1`). Gate **G10** (CR2, §5.8) is now `Pass` — the CR2 P2s + P3 cluster (`CR2-001..008`) are Resolved and shipped in `haechi@1.3.2`, so the checklist is cleared for that cut.
|
|
194
|
+
|
|
152
195
|
External npm gate check results (`0.3.2` developer preview, 2026-06-10, post-publish):
|
|
153
196
|
|
|
154
197
|
- `npm whoami`: `raeseoklee`
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|---|---|---|
|
|
10
10
|
| 로컬 개발 | CLI, default config, dev key 생성 | dev key를 운영 환경이나 공유 환경에 재사용하지 않습니다 |
|
|
11
11
|
| 정책 집행 | redact/mask/tokenize/encrypt/block pipeline | 규제 정책과 조직 정책에 맞는 action을 선택합니다 |
|
|
12
|
-
| HTTP proxy | loopback 기본값, remote bind guard, body/response limit | 인증, TLS termination, firewall, upstream auth를
|
|
12
|
+
| HTTP proxy | loopback 기본값, remote bind guard, body/response limit, 기본 차단 upstream 헤더 허용목록(gateway-클라이언트 인증과 upstream-제공자 인증 분리) | 인증, TLS termination, firewall, upstream auth를 담당합니다. upstream 제공자 키는 의도적으로 공급합니다(클라이언트 `Authorization`은 `auth.provider: none`일 때만 전달되며, 그 외에는 `x-api-key` 같은 제공자 키 헤더를 설정하거나 추가 헤더를 `target.forwardHeaders`에 나열합니다) |
|
|
13
13
|
| Streaming | 기본 차단 | pass-through를 사용할 때 보호가 적용되지 않는 위험을 감수합니다 |
|
|
14
14
|
| TokenVault | 암호화 저장, reveal 기본 차단, purge | reveal 승인 절차와 DSAR/retention 운영을 담당합니다 |
|
|
15
15
|
| Audit | 평문 제거, hash chain | append-only storage, backup, 보존 기간, 외부 서명을 담당합니다 |
|
|
@@ -45,3 +45,12 @@ Haechi의 상태 보유 통제는 설계상 단일 프로세스입니다. 로드
|
|
|
45
45
|
- **Audit hash chain + anchor**는 단일 작성자입니다. 각 복제본에 **고유한** `audit.path`(및 anchor 경로)를 주세요. 하나의 audit 파일을 복제본 간에 공유하면 체인이 분기되어 검증 불가 상태가 됩니다.
|
|
46
46
|
- **TokenVault와 auth store**는 whole-file 로컬 저장소입니다 — 단일 호스트에서는 올바르지만 공유 다중 작성자 저장소는 아닙니다. 다중 복제 토큰화에는 공유 `tokenVault`를 주입하세요.
|
|
47
47
|
- 파일 락은 `O_EXCL` + atomic rename에 의존하며 NFS/공유 파일시스템에서는 보장되지 않습니다 — 이 저장소들은 로컬 디스크에 두세요.
|
|
48
|
+
|
|
49
|
+
## 5. Gateway 인증과 upstream 인증 (헤더 전달)
|
|
50
|
+
|
|
51
|
+
Haechi는 **gateway-클라이언트 인증**과 **upstream-제공자 인증**을 분리합니다. proxy는 임의의 클라이언트 헤더를 모델 upstream으로 전달하지 않고 기본 차단 허용목록을 적용합니다(P0-CR-001):
|
|
52
|
+
|
|
53
|
+
- `auth.provider`가 `bearer`/`external`/`plugin`이면 클라이언트의 `Authorization`은 Haechi가 소비한 **gateway credential**이므로 upstream으로 **절대 전달되지 않습니다**. upstream 제공자 키는 별도로 공급하세요 — 클라이언트 요청에 제공자 키 헤더(`x-api-key`, `x-goog-api-key` 등, 모두 허용목록에 포함)를 설정하거나, 자체 credential 주입으로 upstream을 감싸십시오.
|
|
54
|
+
- `auth.provider`가 `none`이면 클라이언트의 `Authorization`은 **upstream 제공자 키**로 간주되어 전달됩니다(OpenAI 호환 pass-through 패턴).
|
|
55
|
+
- `Cookie`, `Set-Cookie`, `Proxy-Authorization`, hop-by-hop 헤더는 항상 폐기되고, 허용목록에 없는 헤더는 기본 폐기됩니다. 특이한 upstream에는 `target.forwardHeaders`(소문자 이름)로 허용목록을 넓히세요 — 항상 폐기되는 credential/hop-by-hop 헤더는 다시 켤 수 없습니다.
|
|
56
|
+
- **운영자 책임:** upstream이 필요한 credential 헤더를 실제로 받는지 확인하고(gateway 인증에서는 gateway가 더 이상 클라이언트 `Authorization`을 중계하지 않습니다), `target.forwardHeaders`는 무분별한 통과 목록이 아니라 검토된 허용목록으로 다루세요.
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|---|---|---|
|
|
10
10
|
| Local development | CLI, default config, dev key generation | Do not reuse dev keys in production or shared environments |
|
|
11
11
|
| Policy enforcement | redact/mask/tokenize/encrypt/block pipeline | Select actions appropriate to regulatory and organizational policy |
|
|
12
|
-
| HTTP proxy | Loopback default, remote bind guard, body/response limits | Authentication, TLS termination, firewall, upstream auth |
|
|
12
|
+
| HTTP proxy | Loopback default, remote bind guard, body/response limits, default-drop upstream header allowlist (gateway-client auth separated from upstream-provider auth) | Authentication, TLS termination, firewall, upstream auth; supply the upstream provider key intentionally (client `Authorization` is forwarded only with `auth.provider: none`; otherwise set provider key headers like `x-api-key` or list extras in `target.forwardHeaders`) |
|
|
13
13
|
| Streaming | Blocked by default | Accept the risk of no protection when using pass-through |
|
|
14
14
|
| TokenVault | Encrypted storage, reveal blocked by default, purge | Reveal approval workflow, DSAR/retention operations |
|
|
15
15
|
| Audit | Plaintext removal, hash chain | Append-only storage, backup, retention period, external signing |
|
|
@@ -45,3 +45,12 @@ Haechi's stateful controls are single-process by design. Running 2+ replicas beh
|
|
|
45
45
|
- **Audit hash chain + anchor** are single-writer. Give each replica its **own** `audit.path` (and anchor path); never share one audit file across replicas, or the chain forks into an unverifiable state.
|
|
46
46
|
- **TokenVault and the auth store** are whole-file local stores — correct for one host, but not a shared multi-writer store. For multi-replica tokenization, inject a shared `tokenVault`.
|
|
47
47
|
- File locking relies on `O_EXCL` + atomic rename, which do not hold on NFS / shared filesystems — keep these stores on local disk.
|
|
48
|
+
|
|
49
|
+
## 5. Gateway auth vs upstream auth (header forwarding)
|
|
50
|
+
|
|
51
|
+
Haechi keeps **gateway-client authentication** and **upstream-provider authentication** separate. The proxy does NOT forward arbitrary client headers to the model upstream; it applies a default-drop allowlist (P0-CR-001):
|
|
52
|
+
|
|
53
|
+
- When `auth.provider` is `bearer`/`external`/`plugin`, the client's `Authorization` is the **gateway credential** Haechi consumed and is **never forwarded** upstream. Supply the upstream provider key out-of-band — set the provider key header (`x-api-key`, `x-goog-api-key`, etc., all on the allowlist) on the client request, or front the upstream with your own credential injection.
|
|
54
|
+
- When `auth.provider` is `none`, the client's `Authorization` is treated as the **upstream provider key** and is forwarded (the OpenAI-compatible pass-through pattern).
|
|
55
|
+
- `Cookie`, `Set-Cookie`, `Proxy-Authorization`, and hop-by-hop headers are always dropped; any non-allowlisted header is dropped by default. Use `target.forwardHeaders` (lowercase names) to widen the allowlist for an unusual upstream — it cannot re-enable an always-dropped credential/hop-by-hop header.
|
|
56
|
+
- **Operator responsibility:** confirm your upstream actually receives the credential header it needs (the gateway no longer relays the client `Authorization` under gateway auth), and treat `target.forwardHeaders` as a reviewed allowlist, not a catch-all.
|
|
@@ -33,6 +33,9 @@ Haechi가 보호하려는 주요 자산은 다음과 같습니다.
|
|
|
33
33
|
| 위협 | 영향 | 현재 통제 |
|
|
34
34
|
|---|---|---|
|
|
35
35
|
| 인터넷 노출 proxy | 인증 없는 LLM gateway | non-loopback bind 기본 실패 |
|
|
36
|
+
| gateway credential의 upstream 전달 | Haechi가 소비한 gateway 토큰인 클라이언트 `Authorization`, `Cookie`, `Proxy-Authorization`가 모델 제공자로 전달되어 gateway 비밀이 신뢰 경계를 넘어 유출됩니다 (P0-CR-001) | **기본 차단 upstream 헤더 허용목록.** proxy는 명시적인 제공자/어댑터 헤더 집합(`x-api-key`, `anthropic-version`, `anthropic-beta`, `x-goog-api-key`, `openai-organization`, `openai-beta`, `accept`, `accept-language`, `user-agent`, `content-type`)만 전달합니다. `Cookie`/`Set-Cookie`/`Proxy-Authorization`와 hop-by-hop 헤더는 항상 폐기됩니다. `Authorization`은 `auth.provider !== none`이면(gateway credential이므로) 폐기되고, `auth.provider: none`일 때만(upstream 제공자 키이므로) 전달됩니다. `target.forwardHeaders`는 허용목록을 추가로 넓히지만 항상 폐기되는 헤더를 다시 켤 수는 없습니다(설정 시점 fail-closed) |
|
|
37
|
+
| 압축 해제된 바디에 잔존하는 압축 헤더 | Node `fetch()`가 gzip/br/deflate를 자동 해제하지만, upstream의 `content-encoding`/`content-length`를 유지한 채 전달하면 downstream 클라이언트가 평문 바이트에 `content-encoding: gzip`을 보고 "incorrect header check"로 실패합니다 (P1-CR-003) | **모든 응답 경로에서 중앙화된 `sanitizeResponseHeaders`**(pass-through, 전달/미보호, 보호, streaming): `content-encoding`, `content-length`, `transfer-encoding`, hop-by-hop 헤더를 제거하고, 완전 버퍼링된 바디에 한해 올바른 `content-length`만 다시 설정합니다 |
|
|
38
|
+
| 무제한 streaming pass-through | `streaming.requestMode: "pass-through"`가 크기 제한 없이 전체 upstream 바디를 버퍼링해, 장수명·악의적 스트림이 메모리/연결 자원을 무한정 점유할 수 있었습니다 (P1-CR-004) | **진정한 경계 streaming pass-through**: upstream 바디를 도착하는 대로 실행 바이트 카운트(`responseProtection.maxBytes`)와 함께 클라이언트로 파이핑하며, 한도를 초과하면 upstream 읽기를 취소하고 클라이언트 쓰기를 종료합니다(크기 기준 fail-closed). 동일한 한도가 미보호/전달 버퍼링 읽기에도 적용됩니다(한도 초과 시 502) |
|
|
36
39
|
| streaming 우회 | SSE/NDJSON 평문 유출 | `inspect` 모드는 SSE/NDJSON을 stream-filter합니다. `block`(기본값)은 거부하고, `pass-through`는 명시적으로 감사된 opt-out입니다 |
|
|
37
40
|
| Ollama 암묵 streaming 우회 | `stream` 생략 시 NDJSON 평문 유출 | `/api/chat`·`/api/generate`는 `stream: false`를 명시하지 않으면 streaming으로 간주해 기본 차단합니다 |
|
|
38
41
|
| 비JSON/압축/대용량 응답 | responseProtection 우회 | fail-closed response policy |
|
|
@@ -33,6 +33,9 @@ The primary assets Haechi protects are:
|
|
|
33
33
|
| Threat | Impact | Current Control |
|
|
34
34
|
|---|---|---|
|
|
35
35
|
| Internet-exposed proxy | Unauthenticated LLM gateway | Non-loopback bind fails by default |
|
|
36
|
+
| Gateway credential forwarded upstream | The client `Authorization` (the gateway token Haechi consumed), `Cookie`, or `Proxy-Authorization` is forwarded to the model provider, leaking a gateway secret across the trust boundary (P0-CR-001) | **Default-drop upstream header allowlist.** The proxy forwards only an explicit provider/adapter header set (`x-api-key`, `anthropic-version`, `anthropic-beta`, `x-goog-api-key`, `openai-organization`, `openai-beta`, `accept`, `accept-language`, `user-agent`, `content-type`). `Cookie`/`Set-Cookie`/`Proxy-Authorization` and hop-by-hop headers are always dropped. `Authorization` is dropped when `auth.provider !== none` (it is the gateway credential), and forwarded only when `auth.provider: none` (it is the upstream provider key). `target.forwardHeaders` widens the allowlist additively but cannot re-enable an always-dropped header (fail-closed at config time) |
|
|
37
|
+
| Decompressed body with stale compression headers | Node `fetch()` auto-decompresses gzip/br/deflate, but a forwarded response that keeps the upstream `content-encoding`/`content-length` makes a downstream client see e.g. `content-encoding: gzip` on plain bytes and fail with "incorrect header check" (P1-CR-003) | **Centralized `sanitizeResponseHeaders` on every response path** (pass-through, forwarded/unprotected, protected, streaming): strips `content-encoding`, `content-length`, `transfer-encoding`, and hop-by-hop headers; a correct `content-length` is re-set only for a fully-buffered body |
|
|
38
|
+
| Unbounded streaming pass-through | `streaming.requestMode: "pass-through"` buffered the full upstream body with no size cap, so a long-lived or malicious stream could hold memory/connection resources indefinitely (P1-CR-004) | **True bounded streaming pass-through**: the upstream body is piped to the client as it arrives with a running byte cap (`responseProtection.maxBytes`); exceeding the cap cancels the upstream read and tears down the client write (fail-closed on size). The same cap applies to the unprotected/forwarded buffered-body read (502 over the cap) |
|
|
36
39
|
| Streaming bypass | SSE/NDJSON plaintext leak | `inspect` mode stream-filters SSE/NDJSON; `block` (default) refuses; `pass-through` is an explicit audited opt-out |
|
|
37
40
|
| Ollama implicit streaming bypass | NDJSON plaintext leak when `stream` is omitted | `/api/chat` and `/api/generate` are treated as streaming unless `stream: false` is explicit; blocked by default |
|
|
38
41
|
| Non-JSON / compressed / oversized response | responseProtection bypass | Fail-closed response policy |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "haechi",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.2",
|
|
4
4
|
"description": "Self-hosted AI context enforcement across LLM, MCP, vLLM, Ollama, and agent traffic — a stable, zero-dependency security gateway.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
],
|
|
67
67
|
"scripts": {
|
|
68
68
|
"test": "node --test",
|
|
69
|
+
"test:inference:live": "node --test tests/local-inference.integration.test.mjs",
|
|
69
70
|
"check:types": "tsc -p jsconfig.json --noEmit",
|
|
70
71
|
"pack:dry": "npm pack --dry-run",
|
|
71
72
|
"scan:stale-names": "node scripts/stale-name-scan.mjs",
|
|
@@ -549,6 +549,8 @@ async function mcpStdioCommand(argv) {
|
|
|
549
549
|
await runMcpStdioFilter({ runtime });
|
|
550
550
|
}
|
|
551
551
|
|
|
552
|
+
const STDERR_MODES = new Set(["filter", "drop", "inherit"]);
|
|
553
|
+
|
|
552
554
|
async function mcpWrapCommand(argv) {
|
|
553
555
|
const separator = argv.indexOf("--");
|
|
554
556
|
if (separator === -1 || !argv[separator + 1]) {
|
|
@@ -558,21 +560,108 @@ async function mcpWrapCommand(argv) {
|
|
|
558
560
|
const command = argv[separator + 1];
|
|
559
561
|
const commandArgs = argv.slice(separator + 2);
|
|
560
562
|
|
|
563
|
+
// --stderr controls how the child's stderr crosses the local-process boundary.
|
|
564
|
+
// filter (default) runs each line through the same Haechi protection as MCP
|
|
565
|
+
// traffic before re-emitting; drop discards it; inherit is the raw passthrough
|
|
566
|
+
// (an explicit, opt-in local-process boundary). Unknown values fail closed.
|
|
567
|
+
const stderrMode = options.stderr === undefined ? "filter" : options.stderr;
|
|
568
|
+
if (!STDERR_MODES.has(stderrMode)) {
|
|
569
|
+
throw new Error(`mcp-wrap --stderr must be one of: filter | drop | inherit (got ${JSON.stringify(stderrMode)})`);
|
|
570
|
+
}
|
|
571
|
+
|
|
561
572
|
const config = await loadConfig(options.config ?? DEFAULT_CONFIG_PATH);
|
|
562
573
|
const runtime = createRuntime(config);
|
|
563
574
|
|
|
575
|
+
// "inherit" hands the child's stderr straight to the terminal (raw, unfiltered);
|
|
576
|
+
// filter/drop pipe it so the wrapper can inspect or discard each line.
|
|
564
577
|
const child = spawn(command, commandArgs, {
|
|
565
|
-
stdio: ["pipe", "pipe", "inherit"]
|
|
578
|
+
stdio: ["pipe", "pipe", stderrMode === "inherit" ? "inherit" : "pipe"]
|
|
566
579
|
});
|
|
567
580
|
|
|
568
581
|
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
569
582
|
process.on(signal, () => child.kill(signal));
|
|
570
583
|
}
|
|
571
584
|
|
|
585
|
+
if (stderrMode === "filter") {
|
|
586
|
+
pipeFilteredStderr({ runtime, child });
|
|
587
|
+
} else if (stderrMode === "drop") {
|
|
588
|
+
// Consume so the child's stderr pipe never fills and stalls the child, but
|
|
589
|
+
// re-emit nothing.
|
|
590
|
+
child.stderr?.resume();
|
|
591
|
+
}
|
|
592
|
+
|
|
572
593
|
const { code } = await wrapMcpChild({ runtime, child });
|
|
573
594
|
process.exitCode = code;
|
|
574
595
|
}
|
|
575
596
|
|
|
597
|
+
// Filter the child's stderr through the SAME protection the wrapper applies to
|
|
598
|
+
// MCP traffic, then re-emit each safe line to the parent process.stderr. Each
|
|
599
|
+
// complete line is protected as text via the runtime's haechi instance (redact/
|
|
600
|
+
// mask rewrite detected secrets/PII in place); a block-action detection drops the
|
|
601
|
+
// line entirely. Partial lines are buffered across chunk boundaries (split on \n;
|
|
602
|
+
// hold the trailing partial, flushed on stream end).
|
|
603
|
+
function pipeFilteredStderr({ runtime, child, stderr = process.stderr }) {
|
|
604
|
+
const source = child.stderr;
|
|
605
|
+
if (!source) {
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
source.setEncoding("utf8");
|
|
609
|
+
let buffer = "";
|
|
610
|
+
// Serialize async protection so lines re-emit in source order even though
|
|
611
|
+
// protectStderrLine is async.
|
|
612
|
+
let queue = Promise.resolve();
|
|
613
|
+
|
|
614
|
+
function enqueue(line) {
|
|
615
|
+
queue = queue.then(async () => {
|
|
616
|
+
const safe = await protectStderrLine(runtime, line);
|
|
617
|
+
if (safe !== null) {
|
|
618
|
+
stderr.write(`${safe}\n`);
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
source.on("data", (chunk) => {
|
|
624
|
+
buffer += chunk;
|
|
625
|
+
let index;
|
|
626
|
+
while ((index = buffer.indexOf("\n")) !== -1) {
|
|
627
|
+
const line = buffer.slice(0, index);
|
|
628
|
+
buffer = buffer.slice(index + 1);
|
|
629
|
+
enqueue(line);
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
source.on("end", () => {
|
|
633
|
+
// Flush any trailing partial line (no terminating newline).
|
|
634
|
+
if (buffer.length > 0) {
|
|
635
|
+
enqueue(buffer);
|
|
636
|
+
buffer = "";
|
|
637
|
+
}
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Protect one stderr line as text. Returns the protected line (detected secrets/
|
|
642
|
+
// PII redacted/masked in place), or null when a block-action detection means the
|
|
643
|
+
// line must be dropped (not emitted). Uses the runtime's haechi stream/text
|
|
644
|
+
// protector — the clean single-shot text entrypoint (protectText) that detects,
|
|
645
|
+
// decides, and transforms a complete, self-contained text segment by offset, the
|
|
646
|
+
// same logic the streaming delta channel commits with. A fresh protector per line
|
|
647
|
+
// keeps no cross-line state (we already split on \n and buffer partials above).
|
|
648
|
+
async function protectStderrLine(runtime, line) {
|
|
649
|
+
if (line.length === 0) {
|
|
650
|
+
return line;
|
|
651
|
+
}
|
|
652
|
+
const protector = runtime.haechi.createStreamProtector({
|
|
653
|
+
protocol: "mcp-stdio",
|
|
654
|
+
operation: "stderr",
|
|
655
|
+
direction: "response",
|
|
656
|
+
mode: runtime.config.policy.mode ?? runtime.config.mode
|
|
657
|
+
});
|
|
658
|
+
const result = await protector.protectText(line);
|
|
659
|
+
if (result.blocked) {
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
return result.text;
|
|
663
|
+
}
|
|
664
|
+
|
|
576
665
|
function parseOptions(argv) {
|
|
577
666
|
const options = {};
|
|
578
667
|
for (let index = 0; index < argv.length; index += 1) {
|
|
@@ -675,9 +764,9 @@ const COMMAND_HELP = {
|
|
|
675
764
|
summary: "Filter MCP JSON-RPC traffic on stdin/stdout (one direction)."
|
|
676
765
|
},
|
|
677
766
|
"mcp-wrap": {
|
|
678
|
-
usage: "haechi mcp-wrap [--config haechi.config.json] -- <command> [args...]",
|
|
767
|
+
usage: "haechi mcp-wrap [--config haechi.config.json] [--stderr filter|drop|inherit] -- <command> [args...]",
|
|
679
768
|
summary: "Wrap an MCP server with bidirectional stdio protection.",
|
|
680
|
-
detail: "Spawns <command>, applies the method allowlist + params protection client→server, and result protection + injection heuristics server→client. Drop-in for MCP client configs."
|
|
769
|
+
detail: "Spawns <command>, applies the method allowlist + params protection client→server, and result protection + injection heuristics server→client. Drop-in for MCP client configs. --stderr controls the child's stderr: filter (default) protects each line with the same policy before re-emitting, drop discards it, inherit passes it through raw (an explicit, opt-in local-process boundary). filter follows the configured policy mode — in dry-run/report-only it detects but does not transform (like the rest of the pipeline), so set policy.mode=enforce for stderr redaction to take effect. filter protects each COMPLETE line independently, so it cannot catch a secret a child deliberately splits across a newline; use drop for high-sensitivity tools."
|
|
681
770
|
},
|
|
682
771
|
auth: {
|
|
683
772
|
usage: "haechi auth add --type user|service|agent [--scope k:v ...] [--label k=v ...]\n haechi auth list [--config haechi.config.json]\n haechi auth revoke <id> [--config haechi.config.json]",
|
package/packages/cli/runtime.mjs
CHANGED
|
@@ -598,6 +598,7 @@ export function normalizeConfig(config) {
|
|
|
598
598
|
if (merged.auth.provider === "plugin") {
|
|
599
599
|
validatePluginAuthConfig(merged);
|
|
600
600
|
}
|
|
601
|
+
validateForwardHeaders(merged.target);
|
|
601
602
|
createProtocolAdapter(merged.target);
|
|
602
603
|
return merged;
|
|
603
604
|
}
|
|
@@ -936,6 +937,54 @@ function validatePluginAuthConfig(merged) {
|
|
|
936
937
|
}
|
|
937
938
|
}
|
|
938
939
|
|
|
940
|
+
// P0-CR-001 — additive escape hatch for an unusual upstream that needs a header
|
|
941
|
+
// the built-in allowlist does not cover. `target.forwardHeaders` is an OPTIONAL
|
|
942
|
+
// array of extra lowercase header NAMES to forward to the upstream. Fail-closed:
|
|
943
|
+
// it must be an array of non-empty strings, and it may NOT name a header that the
|
|
944
|
+
// proxy always drops (ambient client credentials + hop-by-hop control headers) —
|
|
945
|
+
// an operator cannot re-enable a gateway-credential leak through it. Absent =
|
|
946
|
+
// the built-in default-drop allowlist alone (byte-identical to prior behavior).
|
|
947
|
+
const FORWARD_HEADERS_FORBIDDEN = new Set([
|
|
948
|
+
"host",
|
|
949
|
+
"content-length",
|
|
950
|
+
"content-type",
|
|
951
|
+
"authorization",
|
|
952
|
+
"cookie",
|
|
953
|
+
"set-cookie",
|
|
954
|
+
"proxy-authorization",
|
|
955
|
+
"connection",
|
|
956
|
+
"keep-alive",
|
|
957
|
+
"te",
|
|
958
|
+
"trailer",
|
|
959
|
+
"transfer-encoding",
|
|
960
|
+
"upgrade"
|
|
961
|
+
]);
|
|
962
|
+
|
|
963
|
+
function validateForwardHeaders(target) {
|
|
964
|
+
if (target.forwardHeaders === undefined || target.forwardHeaders === null) {
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
if (!Array.isArray(target.forwardHeaders)) {
|
|
968
|
+
throw new Error("target.forwardHeaders must be an array of lowercase header names");
|
|
969
|
+
}
|
|
970
|
+
const normalized = [];
|
|
971
|
+
for (const name of target.forwardHeaders) {
|
|
972
|
+
if (typeof name !== "string" || !name.trim()) {
|
|
973
|
+
throw new Error("target.forwardHeaders entries must be non-empty strings");
|
|
974
|
+
}
|
|
975
|
+
const lower = name.trim().toLowerCase();
|
|
976
|
+
if (lower !== name) {
|
|
977
|
+
throw new Error(`target.forwardHeaders entries must be lowercase header names (got: ${JSON.stringify(name)})`);
|
|
978
|
+
}
|
|
979
|
+
if (FORWARD_HEADERS_FORBIDDEN.has(lower)) {
|
|
980
|
+
throw new Error(`target.forwardHeaders may not include the always-dropped header ${JSON.stringify(lower)} (ambient credentials and hop-by-hop headers are never forwarded)`);
|
|
981
|
+
}
|
|
982
|
+
normalized.push(lower);
|
|
983
|
+
}
|
|
984
|
+
// Persist the validated, de-duplicated list back onto the normalized target.
|
|
985
|
+
target.forwardHeaders = [...new Set(normalized)];
|
|
986
|
+
}
|
|
987
|
+
|
|
939
988
|
function resolveAuthProvider(config, providers, cryptoProvider, auditSink) {
|
|
940
989
|
if (config.auth.provider === "external") {
|
|
941
990
|
if (typeof providers.authProvider?.authenticate !== "function") {
|
|
@@ -973,7 +1022,11 @@ function resolveAuthProvider(config, providers, cryptoProvider, auditSink) {
|
|
|
973
1022
|
return createProcessIsolatedAuthProviderSync({
|
|
974
1023
|
...common,
|
|
975
1024
|
netEnforcement: plugin.netEnforcement ?? "require-permission",
|
|
976
|
-
keyMaterial: plugin.keyMaterial ?? null
|
|
1025
|
+
keyMaterial: plugin.keyMaterial ?? null,
|
|
1026
|
+
// CR2-003: reuse the worker's resourceLimits.maxOldGenerationSizeMb knob to
|
|
1027
|
+
// cap the child heap. Optional for the process runtime (the sandbox defaults
|
|
1028
|
+
// when absent), so pass it through whether or not the config supplied it.
|
|
1029
|
+
resourceLimits: plugin.resourceLimits ?? null
|
|
977
1030
|
});
|
|
978
1031
|
}
|
|
979
1032
|
return createSandboxedAuthProviderSync({ ...common, resourceLimits: plugin.resourceLimits });
|
package/packages/core/index.mjs
CHANGED
|
@@ -148,6 +148,21 @@ export function createHaechi({ filterEngine, policyEngine, cryptoProvider, audit
|
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
return {
|
|
151
|
+
// Single-shot text protection for a complete, self-contained text payload
|
|
152
|
+
// (P1-CR-005): a parse-failed CONTENT frame whose data: text is NOT JSON
|
|
153
|
+
// (plain text, malformed/partial JSON, provider-specific text). It detects,
|
|
154
|
+
// decides, tallies, and either returns { text } or { blocked: true } — the
|
|
155
|
+
// SAME transformSegment logic the delta channel commits with. CRITICALLY it
|
|
156
|
+
// does NOT touch the cross-frame `pending` buffer, so inspecting a non-JSON
|
|
157
|
+
// frame's text cannot corrupt the JSON delta channel's sliding-buffer state.
|
|
158
|
+
// Per-frame inspection only: cross-frame buffering of arbitrary non-JSON
|
|
159
|
+
// frames is out of scope (the delta channel keeps its own buffer).
|
|
160
|
+
async protectText(text) {
|
|
161
|
+
if (typeof text !== "string" || text.length === 0) {
|
|
162
|
+
return { text: text ?? "", blocked: false };
|
|
163
|
+
}
|
|
164
|
+
return transformSegment(text);
|
|
165
|
+
},
|
|
151
166
|
// Protect string leaves of a parsed frame OTHER than the incremental
|
|
152
167
|
// delta text (e.g. tool-call arguments). Returns the mutated object.
|
|
153
168
|
async protectFrameExtras(value) {
|