haechi 1.1.2 → 1.2.0
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/SECURITY.md +7 -1
- package/docs/README.md +2 -0
- package/docs/current/compliance-mapping.ko.md +53 -0
- package/docs/current/compliance-mapping.md +53 -0
- package/docs/current/config-version.ko.md +30 -0
- package/docs/current/config-version.md +51 -0
- package/docs/current/configuration.ko.md +147 -7
- package/docs/current/configuration.md +147 -7
- package/docs/current/operations-runbook.ko.md +121 -0
- package/docs/current/operations-runbook.md +204 -0
- package/docs/current/release-process.ko.md +1 -1
- package/docs/current/release-process.md +1 -1
- package/docs/current/risk-register-release-gate.ko.md +3 -2
- package/docs/current/risk-register-release-gate.md +11 -2
- package/docs/current/security-whitepaper.ko.md +102 -0
- package/docs/current/security-whitepaper.md +102 -0
- package/docs/current/shared-responsibility.ko.md +2 -2
- package/docs/current/shared-responsibility.md +2 -2
- package/docs/current/threat-model.ko.md +3 -2
- package/docs/current/threat-model.md +3 -2
- package/haechi.config.example.json +19 -3
- package/package.json +5 -2
- package/packages/audit/index.mjs +26 -2
- package/packages/cli/bin/haechi.mjs +54 -8
- package/packages/cli/runtime.mjs +391 -10
- package/packages/core/index.mjs +143 -8
- package/packages/filter/index.mjs +299 -9
- package/packages/metrics/index.mjs +181 -0
- package/packages/proxy/index.mjs +518 -39
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Haechi Risk Register and Release Gates
|
|
2
2
|
|
|
3
|
-
- Status: Living document (tracks core 1.
|
|
3
|
+
- Status: Living document (tracks core 1.2.x)
|
|
4
4
|
- Date: 2026-06-11
|
|
5
|
-
- Target version: 1.
|
|
5
|
+
- Target version: 1.2.x
|
|
6
6
|
- Branch: `main`
|
|
7
7
|
|
|
8
8
|
## 1. Current Assessment
|
|
@@ -27,6 +27,7 @@ Haechi has shipped its `1.x` stable line. The developer-preview gate (G2, `haech
|
|
|
27
27
|
| G4 | 0.9.0 observability + interactive-auth satellite cut | P1-SEC-026 / P1-OPS-009 mitigated and P2-CRYPTO-001 accepted; `haechi-dashboard` + `haechi-auth-oidc` + `haechi-crypto-kms@0.2.0` tests green; satellite tarballs zero-dep; core bumped to 0.9.0 (only an additive FORBIDDEN_KEYS audit hardening) | Pass |
|
|
28
28
|
| G5 | 1.0.0 stable API contract + signed-plugin sandbox | P1-SEC-024 / P1-SEC-025 mitigated, P2-API-001 / P2-OPS-006 resolved; the API freeze + deprecation policy + `tests/api-contract.test.mjs` green; the Ed25519 signed-plugin contract + `assertAuthProviderConformance` + the worker-isolated `authProvider` sandbox tests green; PR0 satellite peer-ranges widened to `>=0.8.0 <2.0.0` and the `check-satellite-peer-ranges.mjs` preflight gate green; core stays zero runtime dependency; core bumped to 1.0.0 | Pass |
|
|
29
29
|
| 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
|
+
| 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 |
|
|
30
31
|
|
|
31
32
|
## 3. P0 Distribution-Blocking Risk Status
|
|
32
33
|
|
|
@@ -126,6 +127,14 @@ These IDs are scoped to the 1.0.0 stable cut (the API freeze + the Ed25519 signe
|
|
|
126
127
|
| P1-SEC-027 | Plugin capability *enforcement*: the 1.0 `worker_threads` sandbox is memory/crash isolation only, so a malicious signed plugin can use `fs`/`net` and exfiltrate the credential. **Strengthens P1-SEC-024's accepted worker residual** — 1.1 adds real enforcement for a new opt-in runtime | Mitigated | `packages/plugin/process-sandbox.mjs` `createProcessIsolatedAuthProvider`/`…Sync` (PR #54): a signed `authProvider` runs in a child `node` under `--permission` with **zero grants** (no fs/child-process/worker/addons/wasi, no `--allow-net`), loaded from a `data:` URL (no fs grant → no TOCTOU/symlink surface), `stdio:['ignore','ignore','ignore','ipc']` (no stdout/stderr/fd leak channel), scrubbed env, JSON-string-only IPC + the shared null-proto sanitizer + host-side keyed-HMAC identity. **Empirically validated on Node 26**: the plugin's `fs`/`net`/`fetch`/`dns`/`child_process`/`worker` and the `process.binding('tcp_wrap')` bypass are all `ERR_ACCESS_DENIED`. Network containment is the **kernel `--allow-net` denial**, not a deletable JS harness; the default `netEnforcement:"require-permission"` **fails closed** (behavior-probed feature detection; PR #54) on a Node that cannot enforce it. A spawn-storm circuit breaker (PR #56) bounds respawns. Lifecycle audit gains host-computed/enum-only `isolation`/`grants`/`netEnforcement` (PR #56). Config: `auth.plugin.isolation:"process"` wired fail-closed (PR #56). Tests: the fs/net/stdio red-team (skipped on a Node without `--allow-net`, where the runtime fails closed instead) + the always-run fail-closed contract + the config matrix. **Residual:** a Node without `--allow-net` (fail-closed, not contained); a `networkEgress`-granted plugin; credential/key material in child memory (core-dump/swap); a V8/Node escape (a runtime control, not an OS sandbox) |
|
|
127
128
|
| P1-SEC-028 | Host-mediated key material + SSRF: a custom-credential plugin needing key material could be a plugin-driven SSRF vector, and core had no SSRF guard (the satellites' copies are unreachable from core) | Mitigated | A new node:-only, zero-dependency **`haechi/ssrf`** core module (PR #55): `isBlockedAddress` (private/loopback/link-local/metadata), `guardedFetch` (https-only, post-DNS re-check, `redirect:"error"`, bounded body + timeout), `createGuardedKeyFetcher` (TTL cache + cooldown). The `process-isolated` runtime's optional `keyMaterial:{url}` is fetched by the **host** from the **operator-declared** URL through this guard and injected over the IPC — the plugin never names a URL (no plugin-driven SSRF), and the kid-refetch cooldown bounds the outbound rate; a blocked-address URL fails closed. Tests: the canonical `isBlockedAddress` vector table + a core-vs-`auth-jwt` parity guard, `guardedFetch` SSRF refusal/bounding, the cooldown fail-closed, and the runtime key-injection + no-SSRF tests. **Residual:** the satellites keep their DELIBERATE local copies (a crypto/auth package must not runtime-depend on core-ssrf; `crypto-kms/ssrf-parity.test.mjs`) — the core re-import is deferred and the drift is guarded by parity, not eliminated; the guard's DNS-rebinding window (resolve-then-connect) is accepted for an operator-declared URL |
|
|
128
129
|
|
|
130
|
+
## 5.6 Reliability Hardening Track — Horizontal-scale & State Safety (WS3)
|
|
131
|
+
|
|
132
|
+
Additive, accumulating on `main` toward a later `1.2.0` minor; the seam + honest docs, never a built-in distributed store (track §3 non-goal).
|
|
133
|
+
|
|
134
|
+
| ID | Risk | Status | Resolution evidence |
|
|
135
|
+
|---|---|---|---|
|
|
136
|
+
| 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 |
|
|
137
|
+
|
|
129
138
|
## 6. P2 Product/Documentation Risk Status
|
|
130
139
|
|
|
131
140
|
| ID | Risk | Status | Resolution evidence |
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Haechi 보안 백서
|
|
2
|
+
|
|
3
|
+
- 문서 상태: Living document (WS6 — reliability-hardening-track §WS6)
|
|
4
|
+
- 이 문서의 성격: **통제 매핑 + 구조화된 자체 평가**이며, 인증이나 독립 감사가 아닙니다.
|
|
5
|
+
- 출처: `packages/*` 코드, `docs/current/threat-model.md`, `docs/current/risk-register-release-gate.md`. 이 백서는 그 내용을 *매핑*할 뿐 그대로 재서술하지 않으며, 내용을 복제하는 대신 저장소 경로와 위험 ID를 인용합니다.
|
|
6
|
+
|
|
7
|
+
## 0. 이 문서가 무엇이고 — 무엇이 아닌가
|
|
8
|
+
|
|
9
|
+
Haechi는 **AI 컨텍스트 집행 계층**입니다. OpenAI 호환 / MCP / vLLM / Ollama / llama.cpp JSON payload를 검사하여 PII와 비밀 정보를 탐지하고, 모델·도구·로그에 도달하기 전에 redact / mask / tokenize / encrypt / block 합니다.
|
|
10
|
+
|
|
11
|
+
이 문서는 Haechi가 **실제로 출시한** 통제를, 저장소 전반에서 이미 인용하는 두 프레임워크 — **OWASP Top 10 for LLM Applications (2025)**와 **NIST AI Risk Management Framework (AI RMF 1.0)** — 에 매핑하고, reliability-hardening track이 잡아내고 수정한 적대적 발견을 **구조화된 자체 모의해킹(self-pentest)**으로 기록합니다.
|
|
12
|
+
|
|
13
|
+
이 문서는 명시적으로 다음이 **아닙니다**:
|
|
14
|
+
- 컴플라이언스 인증·확약·보증 보고서(reliability-hardening-track §5 비목표 및 `SECURITY.md` Scope 참고)
|
|
15
|
+
- 독립 제3자 침투 시험
|
|
16
|
+
- 모든 OWASP-LLM / NIST-AI-RMF 항목이 *완전히* 완화되었다는 주장. 여러 항목은 **책임 공유**(`docs/current/shared-responsibility.md`)이거나 범위 밖(`docs/current/threat-model.md` §4)입니다. 아래에는 출시 코드에 실제로 존재하는 통제만 매핑하며, 희망 사항은 담지 않습니다.
|
|
17
|
+
|
|
18
|
+
## 1. 통제 목록 (출시된 표면)
|
|
19
|
+
|
|
20
|
+
아래 매핑된 모든 통제는 load-bearing하며 테스트로 뒷받침되는 동작입니다. 각 통제의 근거는 이 표가 아니라 코드 경로와 threat-model / risk-register 행입니다.
|
|
21
|
+
|
|
22
|
+
| # | 통제 | 위치 | 근거 |
|
|
23
|
+
|---|---|---|---|
|
|
24
|
+
| C1 | 탐지 + redact/mask/tokenize/encrypt/block 파이프라인 | `packages/core` (`protectJson`), `packages/filter`, `packages/policy` | threat-model §3 |
|
|
25
|
+
| C2 | fail-closed 집행(알 수 없는 policy/config/`target.type`은 throw; non-JSON/무효/압축/초과 응답은 fail-closed) | `packages/cli/runtime.mjs` (`normalizeConfig`), `packages/proxy` (`maybeProtectResponse`) | CLAUDE.md 불변식; P1-SEC-010 |
|
|
26
|
+
| C3 | audit SHA-256 hash chain + head anchoring(변조 + tail-truncation 증거) | `packages/audit` (`verifyAuditChain`, `audit.anchor`) | P1-SEC-003 |
|
|
27
|
+
| C4 | audit에 평문/PII 없음(`FORBIDDEN_KEYS`); keyed-HMAC identity 해시; 구조화 경로 키 해싱 | `packages/audit`, `packages/auth` (`buildIdentity`) | P1-SEC-012 |
|
|
28
|
+
| C5 | streaming 기본 차단; bounded·opt-in 검사(cross-frame sliding buffer) | `packages/proxy`, `packages/stream-filter`, `core` (`createStreamProtector`) | P1-SEC-007 |
|
|
29
|
+
| C6 | body 읽기 전 클라이언트 auth gate; named policy-profile 해석; identity별 rate limit | `packages/proxy` (`authorizeRequest`), `packages/auth` | SECURITY.md; threat-model §3 |
|
|
30
|
+
| C7 | 서명·sandbox된 `authProvider` 플러그인(Ed25519 + trust anchor + pin/floor/revocation + worker/process 격리) | `packages/plugin`, `packages/cli/runtime.mjs` | P1-SEC-024 / P1-SEC-027 |
|
|
31
|
+
| C8 | host-mediated fetch의 SSRF 가드; absolute-form proxy target 거부 | `packages/ssrf`, `packages/proxy` (`assertRelativeProxyTarget`) | P1-SEC-009 / P1-SEC-028 |
|
|
32
|
+
| C9 | token-vault reveal 거버넌스(`revealPolicy`), retention, token id 기준 audit된 reveal/purge | `packages/token-vault` | P1-SEC-002 |
|
|
33
|
+
| C10 | 정책은 강해지기만 함(`ACTION_STRENGTH`); privacy profile은 강화만 가능, 약화 불가 | `packages/policy`, `packages/privacy-profiles` | P1-SEC-005 |
|
|
34
|
+
| C11 | 탐지 정밀도 통제(`filters.minConfidence`, `filters.allowlist`); hard-block 타입은 **억제 불가** | `packages/filter`, `packages/core` | WS2c |
|
|
35
|
+
| C12 | 매칭 전 string leaf의 NFKC 정규화(full-width/혼동 문자 회피) | `packages/core` / `packages/filter` | WS2d |
|
|
36
|
+
| C13 | **proxy TLS / remote-bind 강화(이 WS):** remote bind는 Haechi가 직접 TLS 종단(`proxy.tls`)하거나, 신뢰 hop을 명시적으로 확인(`proxy.trustForwardedProto`, `X-Forwarded-Proto: https` 강제)해야 하며, 그렇지 않으면 기동 시 throw | `packages/proxy` (`assertSafeProxyTransport`, 서버 선택, forwarded-proto gate), `packages/cli/runtime.mjs` (`proxy.tls` 해석) | 본 문서 §3 |
|
|
37
|
+
| C14 | 기본 loopback 바인드; `--allow-remote-bind` 없이는 non-loopback 거부 | `packages/proxy` (`assertSafeProxyBind`) | SECURITY.md |
|
|
38
|
+
| C15 | bounded recursion-depth + byte/encoding 가드(fail-closed 4xx; non-UTF-8 거부) | `packages/core`, `packages/proxy` (`readBody`) | WS5 |
|
|
39
|
+
| C16 | 운영 fail-closed 신호(audit 쓰기 불가 시 `/__haechi/ready` 503; metrics에 PII 없음) | `packages/proxy` (`handleReady`), `packages/metrics` | WS4 |
|
|
40
|
+
|
|
41
|
+
## 2. OWASP Top 10 for LLM Applications (2025) 매핑
|
|
42
|
+
|
|
43
|
+
각 행은 OWASP-LLM 위험을 그 위험을 다루는 Haechi 통제에 매핑하고 정직한 잔여 위험을 기록합니다. 주로 운영자/모델의 책임인 위험은 완화로 주장하지 않고 **책임 공유 / 범위 밖**으로 표기합니다.
|
|
44
|
+
|
|
45
|
+
| OWASP-LLM (2025) | Haechi 통제 | 커버리지 & 정직한 잔여 위험 |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| **LLM01 Prompt Injection** | C5(응답/도구 결과 injection 탐지는 기본 report-only), C1 | **부분 / 설계상.** injection 탐지는 응답/도구 결과 방향에서 동작하며 명시적 격상 전에는 report-only입니다(CLAUDE.md 불변식). Haechi는 prompt injection을 "해결"하지 않습니다 — `threat-model.md` §4는 prompt-injection *예방*을 비목표로 둡니다. 도구 결과가 컨텍스트로 재유입되기 전 비밀/PII를 탐지해 폭발 반경을 줄입니다. |
|
|
48
|
+
| **LLM02 Sensitive Information Disclosure** | C1, C2, C9, C4 | **주 사용 사례.** detect→redact/mask/tokenize/encrypt/block 파이프라인과 응답 보호가 바로 이 통제입니다. 잔여: 탐지는 regex+validator이며 ML이 아닙니다(비목표); 문서화된 제외(base64/URL-query)는 유효합니다(threat-model §4). |
|
|
49
|
+
| **LLM03 Supply Chain** | C7, SBOM + SHA-pinned CI(P1-OPS-002/006) | **플러그인 경로에 대해 대응.** 동적 코드는 서명·trust-anchor·pin/floor/revocable한 `authProvider` 플러그인으로만 허용됩니다. 코어는 zero-runtime-dependency를 유지해 의존성 공격 표면을 줄입니다. |
|
|
50
|
+
| **LLM04 Data & Model Poisoning** | — | **범위 밖.** Haechi는 인라인 컨텍스트 필터이며 학습/데이터 파이프라인 통제가 아닙니다. 완화가 아니라 범위 밖으로 표기합니다. |
|
|
51
|
+
| **LLM05 Improper Output Handling** | C5, C2, C1 | **대응.** 응답/stream 보호가 모델 출력을 호출자/도구 도달 전에 검사하며, non-JSON/무효/압축/초과 응답은 fail-closed입니다. 잔여: block 전에 이미 방출된 streaming 바이트는 회수 불가(문서화). |
|
|
52
|
+
| **LLM06 Excessive Agency** | C6, C7, C9 | **부분.** auth gate + named policy profile + model allowlist가 누가 게이트웨이를 구동하고 어떤 모델/연산이 도달 가능한지 제약하며, token reveal은 거버넌스됩니다. 게이트웨이 너머의 에이전트 수준 인가는 운영자의 몫입니다. |
|
|
53
|
+
| **LLM07 System Prompt Leakage** | C1, C5, C4 | **부분.** prompt/응답에 박힌 비밀/PII는 탐지·보호되며, audit는 원문 prompt를 저장하지 않습니다(C4). 모델이 스스로 system prompt 텍스트를 방출하는 것은 막지 않습니다. |
|
|
54
|
+
| **LLM08 Vector & Embedding Weaknesses** | — | **범위 밖.** RAG/vector-store 구성요소 없음. |
|
|
55
|
+
| **LLM09 Misinformation** | — | **범위 밖.** 사실성 통제가 아닙니다. |
|
|
56
|
+
| **LLM10 Unbounded Consumption** | C6(rate limit), C15(byte/depth 한계), C16(backpressure/timeout) | **게이트웨이에서 대응.** identity별 rate limit, request byte + nesting-depth 상한, max-in-flight backpressure(503 + `Retry-After`), 튜닝된 timeout. 잔여: 내장 rate limiter는 단일 프로세스(다중 replica는 주입된 공유 limiter 필요 — shared-responsibility.md). |
|
|
57
|
+
|
|
58
|
+
## 3. NIST AI RMF (AI RMF 1.0) 매핑
|
|
59
|
+
|
|
60
|
+
AI RMF 네 기능에 매핑합니다. Haechi는 운영자가 자신의 AI-RMF 프로그램 안에서 사용하는 *기술 통제 표면*이며, 운영자를 위한 거버넌스를 대신 구현하지 않습니다.
|
|
61
|
+
|
|
62
|
+
| AI RMF 기능 | Haechi 통제 | Haechi의 기여(그리고 경계) |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| **GOVERN** | C2, C10, C14, 문서화된 threat-model + 책임 공유 구분 | fail-closed 기본값, 강화-전용 정책 격자, 기본 loopback, Haechi가 하는 일과 운영자가 소유하는 일의 명시적 문서화. 거버넌스 *정책*은 운영자의 몫입니다. |
|
|
65
|
+
| **MAP** | C1, 탐지 타입 행렬(`configuration.md`), threat-model §1–2 | 민감 데이터가 prompt/응답/도구 호출을 통해 *어디로* 흐르고 *어떤* 범주가 탐지되는지 드러내, 운영자가 게이트웨이 경계에서 AI-시스템 데이터 위험을 매핑하도록 돕습니다. |
|
|
66
|
+
| **MEASURE** | C3, C4, C11, `bench:detection` precision/recall gate, `/__haechi/metrics` | 변조 증거 audit, CI gate로 묶인 precision/recall 측정 하니스, PII 없는 운영 metrics가 측정 가능한 신호를 제공합니다. 잔여: 탐지 metrics는 regex/validator 규칙만 측정합니다. |
|
|
67
|
+
| **MANAGE** | C2, C5, C6, C9, C13, C16, operations-runbook | 인라인 집행, streaming 봉쇄, auth gate, token-vault 거버넌스, TLS 강화 remote bind, readiness/backpressure, Day-2 runbook(rotation/retention)이 운영자가 프로덕션에서 위험을 관리하게 합니다. |
|
|
68
|
+
|
|
69
|
+
## 4. 구조화된 자체 모의해킹(self-pentest)
|
|
70
|
+
|
|
71
|
+
이것은 **자체 평가**입니다 — Haechi가 자신의 통제를 스스로 적대적으로 시험한 것이며, 독립 침투 시험이 아닙니다. 아래 각 발견은 테스트로 재현·수정되었고 지금은 회귀로 보호됩니다.
|
|
72
|
+
|
|
73
|
+
### 4.1 방법론
|
|
74
|
+
- **실환경 검증.** proxy + 보호 파이프라인은 env-gated 통합 스위트(`tests/local-inference.integration.test.mjs`; P1-OPS-003)를 통해 실제 OpenAI 호환(vLLM) 및 Ollama 네이티브 엔드포인트에 대해 동작합니다. 모델 서버가 없으면 CI가 이를 건너뛰므로 mock-only가 아니라 opt-in입니다.
|
|
75
|
+
- **적대적 회귀 코퍼스.** 라벨링된 positive/hard-negative 탐지 코퍼스가 `npm run bench:detection`을 구동하며 CI gate(`scan:detection`)로 묶여, precision/recall 회귀가 빌드를 실패시킵니다(WS2a).
|
|
76
|
+
- **테스트 규율로서의 fail-closed 단언.** 모든 신규 경로(depth guard, readiness, backpressure, env overlay, §3의 TLS remote-bind guard)는 happy path뿐 아니라 *throw/deny* 방향을 단언하는 테스트와 함께 출시됩니다.
|
|
77
|
+
|
|
78
|
+
### 4.2 잡아내고 수정한 발견 (선별)
|
|
79
|
+
|
|
80
|
+
| 발견 | 분류 | 무엇이 깨졌나 | 수정 + 가드 |
|
|
81
|
+
|---|---|---|---|
|
|
82
|
+
| **WS2d — Unicode 회피** | 탐지 우회 | full-width / 혼동 코드포인트가 비밀/PII 값을 모든 regex 규칙 앞에서 통과시켰습니다(string leaf를 정규화 전에 매칭). | 매칭 전 string leaf를 NFKC 정규화(C12); 탐지 코퍼스의 회피 fixture로 커버. |
|
|
83
|
+
| **WS2c — bearer-recall 회귀** | 탐지 정밀도 | "Bearer …" 산문 false positive를 줄이려 추가한 context anchor가 *실제* bearer-token 비밀까지 억제했습니다(코퍼스가 잡은 recall 회귀). | anchor를 재범위화해 hard-block `secret` 타입이 allowlist/anchor로 절대 억제되지 않도록 했습니다(C11 불변식); 정밀도 통제 테스트가 FP 절감과 recall 하한을 모두 고정. |
|
|
84
|
+
| **WS5 — deep-nesting 스택 오버플로** | 가용성(DoS) | `maxRequestBytes` 안의 깊게 중첩된 JSON이 재귀 스택을 넘쳐 uncaught crash가 났습니다. | 설정 가능한 `limits.maxNestingDepth` fail-closed 4xx(C15); deep-nesting 테스트. |
|
|
85
|
+
| **P1-SEC-009 — proxy SSRF / absolute target** | SSRF | absolute/protocol-relative request target이 upstream을 우회시킬 수 있었습니다. | origin-form 전용 target; upstream URL은 고정 upstream에 path+search만 결합(C8). |
|
|
86
|
+
| **WS6 — 평문 remote bind(이 WS)** | 기밀성 | non-loopback 바인드가 plain HTTP를 제공해 bearer token + payload를 평문 노출했습니다. | 이제 remote bind는 Haechi 종단 TLS 또는 `X-Forwarded-Proto: https`를 강제하는 명시적 `trustForwardedProto` hop을 요구하며, 그렇지 않으면 기동 시 throw(C13). `tests/proxy-tls.test.mjs`로 가드(TLS 없을 때 throw, https-over-TLS smoke, forwarded-proto 거부, fail-closed config). |
|
|
87
|
+
|
|
88
|
+
### 4.3 수용된 잔여 위험 (정직하게)
|
|
89
|
+
- 서명된 플러그인 자신의 `fs`/`fetch`는 1.0 `worker_threads` 모드에서 차단되지 않습니다(메모리/크래시 격리만); opt-in 1.1 `process-isolated` 런타임에서만 집행됩니다(threat-model §3, P1-SEC-027).
|
|
90
|
+
- 단일 프로세스 rate limiter / audit chain / token vault: 다중 replica 안전성은 주입된 공유 저장소가 필요합니다(shared-responsibility.md; reliability-hardening-track §3).
|
|
91
|
+
- 탐지는 regex + validator로 유지됩니다(ML 없음); 문서화된 base64 / URL-query 제외는 유효합니다(threat-model §4).
|
|
92
|
+
|
|
93
|
+
## 5. 공개(Disclosure)
|
|
94
|
+
|
|
95
|
+
의심되는 취약점은 `SECURITY.md`와 `/.well-known/security.txt`에 따라 GitHub **private vulnerability reporting**(Security Advisories) <https://github.com/raeseoklee/haechi/security/advisories> 으로 신고하십시오. 신고에 실제 비밀, 프로덕션 prompt, 고객 데이터, 개인정보를 포함하지 마십시오.
|
|
96
|
+
|
|
97
|
+
## 6. 상호 참조
|
|
98
|
+
- `docs/current/threat-model.md` — 권위 있는 위협 모델과 제외 항목.
|
|
99
|
+
- `docs/current/risk-register-release-gate.md` — 위험별 해결 상태와 release gate.
|
|
100
|
+
- `docs/current/compliance-mapping.md` — 통제-의무 매핑 + DSAR/retention 워크플로.
|
|
101
|
+
- `docs/current/shared-responsibility.md` — Haechi가 소유하는 것 vs. 운영자.
|
|
102
|
+
- `docs/current/operations-runbook.md` — Day-2 운영, rotation, retention.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Haechi Security Whitepaper
|
|
2
|
+
|
|
3
|
+
- Status: Living document (WS6 — reliability-hardening-track §WS6)
|
|
4
|
+
- Scope of this document: a **control mapping + structured self-assessment**, not a certification or an independent audit.
|
|
5
|
+
- Source of truth: the code under `packages/*`, `docs/current/threat-model.md`, and `docs/current/risk-register-release-gate.md`. This whitepaper *maps* those; it does not restate them wholesale, and it cites repo paths and risk IDs rather than duplicating their content.
|
|
6
|
+
|
|
7
|
+
## 0. What this is — and is not
|
|
8
|
+
|
|
9
|
+
Haechi is an **AI context enforcement layer**: it inspects and protects OpenAI-compatible / MCP / vLLM / Ollama / llama.cpp JSON payloads (detecting PII and secrets, then redacting / masking / tokenizing / encrypting / blocking them) before they reach models, tools, or logs.
|
|
10
|
+
|
|
11
|
+
This document maps the controls Haechi **actually ships** to two frameworks already cited across the repo — the **OWASP Top 10 for LLM Applications (2025)** and the **NIST AI Risk Management Framework (AI RMF 1.0)** — and records a **structured self-pentest** of the adversarial findings the reliability-hardening track caught and fixed.
|
|
12
|
+
|
|
13
|
+
It is explicitly **NOT**:
|
|
14
|
+
- a compliance certification, attestation, or assurance report (see the reliability-hardening-track §5 non-goal and `SECURITY.md` Scope);
|
|
15
|
+
- an independent third-party penetration test;
|
|
16
|
+
- a claim that every OWASP-LLM / NIST-AI-RMF item is *fully* mitigated. Several are **shared responsibility** (`docs/current/shared-responsibility.md`) or out of scope (`docs/current/threat-model.md` §4). Only controls that exist in the shipped code are mapped below; nothing here is aspirational.
|
|
17
|
+
|
|
18
|
+
## 1. Control inventory (the shipped surface)
|
|
19
|
+
|
|
20
|
+
Every control mapped below is load-bearing, test-backed behavior. The authority for each is the code path and the threat-model / risk-register row, not this table.
|
|
21
|
+
|
|
22
|
+
| # | Control | Where it lives | Authority |
|
|
23
|
+
|---|---|---|---|
|
|
24
|
+
| C1 | Detection + redaction/mask/tokenize/encrypt/block pipeline | `packages/core` (`protectJson`), `packages/filter`, `packages/policy` | threat-model §3 |
|
|
25
|
+
| C2 | Fail-closed enforcement (unknown policy/config/`target.type` throws; non-JSON/invalid/compressed/oversized responses fail closed) | `packages/cli/runtime.mjs` (`normalizeConfig`), `packages/proxy` (`maybeProtectResponse`) | CLAUDE.md invariants; P1-SEC-010 |
|
|
26
|
+
| C3 | Audit SHA-256 hash chain + head anchoring (tamper + tail-truncation evidence) | `packages/audit` (`verifyAuditChain`, `audit.anchor`) | P1-SEC-003 |
|
|
27
|
+
| C4 | No plaintext/PII in audit (`FORBIDDEN_KEYS`); keyed-HMAC identity hashes; structured-path key hashing | `packages/audit`, `packages/auth` (`buildIdentity`) | P1-SEC-012 |
|
|
28
|
+
| C5 | Streaming blocked by default; bounded, opt-in inspection with a cross-frame sliding buffer | `packages/proxy`, `packages/stream-filter`, `core` (`createStreamProtector`) | P1-SEC-007 |
|
|
29
|
+
| C6 | Client auth gate before body-read; named policy-profile resolution; per-identity rate limit | `packages/proxy` (`authorizeRequest`), `packages/auth` | SECURITY.md; threat-model §3 |
|
|
30
|
+
| C7 | Signed, sandboxed `authProvider` plugin (Ed25519 + trust anchors + pin/floor/revocation + worker/process isolation) | `packages/plugin`, `packages/cli/runtime.mjs` | P1-SEC-024 / P1-SEC-027 |
|
|
31
|
+
| C8 | SSRF guard on host-mediated fetches; absolute-form proxy target rejection | `packages/ssrf`, `packages/proxy` (`assertRelativeProxyTarget`) | P1-SEC-009 / P1-SEC-028 |
|
|
32
|
+
| C9 | Token-vault reveal governance (`revealPolicy`), retention, audited reveal/purge by token id | `packages/token-vault` | P1-SEC-002 |
|
|
33
|
+
| C10 | Policies only get stronger (`ACTION_STRENGTH`); privacy profiles may strengthen, never weaken | `packages/policy`, `packages/privacy-profiles` | P1-SEC-005 |
|
|
34
|
+
| C11 | Detection precision controls (`filters.minConfidence`, `filters.allowlist`) that **cannot** suppress hard-block types | `packages/filter`, `packages/core` | WS2c |
|
|
35
|
+
| C12 | NFKC normalization of string leaves before matching (full-width/confusable evasion) | `packages/core` / `packages/filter` | WS2d |
|
|
36
|
+
| C13 | **Proxy TLS / remote-bind hardening (this WS):** a remote bind requires Haechi to terminate TLS (`proxy.tls`) or an explicit trusted-hop acknowledgement (`proxy.trustForwardedProto`, enforcing `X-Forwarded-Proto: https`); otherwise it throws at startup | `packages/proxy` (`assertSafeProxyTransport`, server selection, forwarded-proto gate), `packages/cli/runtime.mjs` (`proxy.tls` resolution) | this document §3 |
|
|
37
|
+
| C14 | Loopback bind by default; non-loopback refused without `--allow-remote-bind` | `packages/proxy` (`assertSafeProxyBind`) | SECURITY.md |
|
|
38
|
+
| C15 | Bounded recursion-depth + byte/encoding guards (fail-closed 4xx; non-UTF-8 rejected) | `packages/core`, `packages/proxy` (`readBody`) | WS5 |
|
|
39
|
+
| C16 | Operability fail-closed signals (`/__haechi/ready` 503 when audit not writable; metrics carry no PII) | `packages/proxy` (`handleReady`), `packages/metrics` | WS4 |
|
|
40
|
+
|
|
41
|
+
## 2. OWASP Top 10 for LLM Applications (2025) mapping
|
|
42
|
+
|
|
43
|
+
Each row maps an OWASP-LLM risk to the Haechi control(s) that address it, with the honest residual. Where a risk is primarily the operator's / model's responsibility, it is marked **Shared / Out of scope** rather than claimed as mitigated.
|
|
44
|
+
|
|
45
|
+
| OWASP-LLM (2025) | Haechi control(s) | Coverage & honest residual |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| **LLM01 Prompt Injection** | C5 (response/tool-result injection detection is report-only by default), C1 | **Partial / by design.** Injection detection runs on the response/tool-result direction and is report-only unless explicitly escalated (CLAUDE.md invariant). Haechi does not "solve" prompt injection — `threat-model.md` §4 lists prompt-injection *prevention* as a non-goal. It reduces blast radius by detecting secrets/PII in tool results before they re-enter context. |
|
|
48
|
+
| **LLM02 Sensitive Information Disclosure** | C1, C2, C9, C4 | **Primary use case.** The detect→redact/mask/tokenize/encrypt/block pipeline plus response protection is exactly this control. Residual: detection is regex+validator, not ML (non-goal); documented exclusions (base64/URL-query) stand (threat-model §4). |
|
|
49
|
+
| **LLM03 Supply Chain** | C7, plus SBOM + SHA-pinned CI (P1-OPS-002/006) | **Addressed for the plugin path.** Dynamic code is admitted only as a signed, trust-anchored, pinned/floored/revocable `authProvider` plugin. Core stays zero-runtime-dependency, shrinking the dependency attack surface. |
|
|
50
|
+
| **LLM04 Data & Model Poisoning** | — | **Out of scope.** Haechi is an inline context filter, not a training/data-pipeline control. Marked out of scope, not mitigated. |
|
|
51
|
+
| **LLM05 Improper Output Handling** | C5, C2, C1 | **Addressed.** Response/stream protection inspects model output before it reaches the caller/tools; fail-closed for non-JSON/invalid/compressed/oversized responses. Residual: streaming bytes already emitted before a block cannot be retracted (documented). |
|
|
52
|
+
| **LLM06 Excessive Agency** | C6, C7, C9 | **Partial.** The auth gate + named policy profiles + model allowlist constrain who can drive the gateway and which models/operations are reachable; token reveal is governed. Agent-level authorization beyond the gateway is the operator's. |
|
|
53
|
+
| **LLM07 System Prompt Leakage** | C1, C5, C4 | **Partial.** Secrets/PII embedded in prompts/responses are detected and protected; audit never stores raw prompt text (C4). Haechi does not prevent a model from emitting its own system prompt text. |
|
|
54
|
+
| **LLM08 Vector & Embedding Weaknesses** | — | **Out of scope.** No RAG/vector-store component. |
|
|
55
|
+
| **LLM09 Misinformation** | — | **Out of scope.** Not a factuality control. |
|
|
56
|
+
| **LLM10 Unbounded Consumption** | C6 (rate limit), C15 (byte/depth limits), C16 (backpressure/timeouts) | **Addressed at the gateway.** Per-identity rate limit, request byte + nesting-depth caps, max-in-flight backpressure (503 + `Retry-After`), tuned timeouts. Residual: the built-in rate limiter is single-process (multi-replica needs an injected shared limiter — shared-responsibility.md). |
|
|
57
|
+
|
|
58
|
+
## 3. NIST AI RMF (AI RMF 1.0) mapping
|
|
59
|
+
|
|
60
|
+
Mapped to the four AI RMF functions. Haechi is a *technical control surface* an operator uses inside their own AI-RMF program; it does not implement governance for them.
|
|
61
|
+
|
|
62
|
+
| AI RMF function | Haechi control(s) | What Haechi contributes (and the boundary) |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| **GOVERN** | C2, C10, C14, the documented threat-model + shared-responsibility split | Fail-closed defaults, a stronger-only policy lattice, loopback-by-default, and explicit documentation of what Haechi does vs. what the operator owns. Governance *policy* remains the operator's. |
|
|
65
|
+
| **MAP** | C1, the detection-type matrix (`configuration.md`), threat-model §1–2 | Surfaces *where* sensitive data flows through prompts/responses/tool calls and *which* categories are detected, helping an operator map AI-system data risks at the gateway boundary. |
|
|
66
|
+
| **MEASURE** | C3, C4, C11, the `bench:detection` precision/recall gate, `/__haechi/metrics` | Tamper-evident audit, a precision/recall measurement harness wired as a CI gate, and PII-free operational metrics give an operator measurable signals. Residual: detection metrics measure regex/validator rules only. |
|
|
67
|
+
| **MANAGE** | C2, C5, C6, C9, C13, C16, the operations-runbook | Inline enforcement, streaming containment, the auth gate, token-vault governance, TLS-hardened remote binding, readiness/backpressure, and a Day-2 runbook (rotation/retention) let an operator manage risk in production. |
|
|
68
|
+
|
|
69
|
+
## 4. Structured self-pentest
|
|
70
|
+
|
|
71
|
+
This is a **self-assessment** — Haechi's own adversarial testing of its own controls — not an independent pentest. Each finding below was reproduced as a test, fixed, and is now regression-guarded.
|
|
72
|
+
|
|
73
|
+
### 4.1 Methodology
|
|
74
|
+
- **Real-environment validation.** The proxy + protection pipeline is exercised against real OpenAI-compatible (vLLM) and Ollama native endpoints via the env-gated integration suite (`tests/local-inference.integration.test.mjs`; P1-OPS-003). CI skips these when no model server is present, so they are opt-in rather than mocked-only.
|
|
75
|
+
- **Adversarial regression corpus.** A labeled positive/hard-negative detection corpus drives `npm run bench:detection`, wired as a CI gate (`scan:detection`) so a precision/recall regression fails the build (WS2a).
|
|
76
|
+
- **Fail-closed assertion as a test discipline.** Every new path (depth guard, readiness, backpressure, env overlay, and the TLS remote-bind guard in §3) ships a test that asserts the *throw/deny* direction, not only the happy path.
|
|
77
|
+
|
|
78
|
+
### 4.2 Findings caught and fixed (selected)
|
|
79
|
+
|
|
80
|
+
| Finding | Class | What broke | Fix + guard |
|
|
81
|
+
|---|---|---|---|
|
|
82
|
+
| **WS2d — Unicode evasion** | Detection bypass | Full-width / confusable code points slipped a secret/PII value past every regex rule (string leaves were matched pre-normalization). | NFKC-normalize string leaves before matching (C12); covered by the evasion fixtures in the detection corpus. |
|
|
83
|
+
| **WS2c — bearer-recall regression** | Detection precision | A context anchor added to cut "Bearer …"-in-prose false positives also suppressed *real* bearer-token secrets (a recall regression the corpus caught). | The anchor was re-scoped so the hard-block `secret` type is never suppressed by an allowlist/anchor (C11 invariant); the precision-control tests pin both the FP cut **and** the recall floor. |
|
|
84
|
+
| **WS5 — deep-nesting stack overflow** | Availability (DoS) | A deeply nested JSON within `maxRequestBytes` overflowed the recursion stack → uncaught crash. | Configurable `limits.maxNestingDepth` fail-closed 4xx (C15); a deep-nesting test. |
|
|
85
|
+
| **P1-SEC-009 — proxy SSRF / absolute target** | SSRF | An absolute/protocol-relative request target could redirect the upstream. | Origin-form-only targets; the upstream URL combines only path+search with the fixed upstream (C8). |
|
|
86
|
+
| **WS6 — remote bind in plaintext (this WS)** | Confidentiality | A non-loopback bind served plain HTTP, exposing bearer tokens + payloads in cleartext. | A remote bind now requires Haechi-terminated TLS or an explicit `trustForwardedProto` hop enforcing `X-Forwarded-Proto: https`; otherwise it throws at startup (C13). Guarded by `tests/proxy-tls.test.mjs` (throw-without-TLS, https-over-TLS smoke, forwarded-proto rejection, fail-closed config). |
|
|
87
|
+
|
|
88
|
+
### 4.3 Accepted residuals (honest)
|
|
89
|
+
- A signed plugin's own `fs`/`fetch` is not blocked in the 1.0 `worker_threads` mode (memory/crash isolation only); enforced only in the opt-in 1.1 `process-isolated` runtime (threat-model §3, P1-SEC-027).
|
|
90
|
+
- Single-process rate limiter / audit chain / token vault: multi-replica safety needs an injected shared store (shared-responsibility.md; reliability-hardening-track §3).
|
|
91
|
+
- Detection stays regex + validators (no ML); documented base64 / URL-query exclusions stand (threat-model §4).
|
|
92
|
+
|
|
93
|
+
## 5. Disclosure
|
|
94
|
+
|
|
95
|
+
Report suspected vulnerabilities via GitHub **private vulnerability reporting** (Security Advisories) at <https://github.com/raeseoklee/haechi/security/advisories>, per `SECURITY.md` and `/.well-known/security.txt`. Do not include real secrets, production prompts, customer data, or personal information in a report.
|
|
96
|
+
|
|
97
|
+
## 6. Cross-references
|
|
98
|
+
- `docs/current/threat-model.md` — the authoritative threat model and exclusions.
|
|
99
|
+
- `docs/current/risk-register-release-gate.md` — the per-risk resolution status and release gates.
|
|
100
|
+
- `docs/current/compliance-mapping.md` — the control-to-obligation mapping + DSAR/retention workflow.
|
|
101
|
+
- `docs/current/shared-responsibility.md` — what Haechi owns vs. the operator.
|
|
102
|
+
- `docs/current/operations-runbook.md` — Day-2 operations, rotation, and retention.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Haechi Shared Responsibility
|
|
2
2
|
|
|
3
|
-
- 문서 상태: Living document (core 1.
|
|
3
|
+
- 문서 상태: Living document (core 1.2.x 추적)
|
|
4
4
|
- 작성일: 2026-06-10
|
|
5
5
|
|
|
6
6
|
## 1. 책임 매트릭스
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
|
|
42
42
|
Haechi의 상태 보유 통제는 설계상 단일 프로세스입니다. 로드밸런서 뒤에서 복제본을 2개 이상 실행하면, 운영자가 공유 인프라를 제공하지 않는 한 이들이 **무음으로 약화**됩니다.
|
|
43
43
|
|
|
44
|
-
- **Rate limit**은 프로세스별·인메모리이므로 전체 처리량이 복제본 수만큼 배가됩니다. identity별 한도를 공유 front door에서 강제하거나, 공유 저장소 기반 `rateLimiter`를
|
|
44
|
+
- **Rate limit**은 프로세스별·인메모리이므로 전체 처리량이 복제본 수만큼 배가됩니다. identity별 한도를 공유 front door에서 강제하거나, `createRuntime(config, { rateLimiter })`를 통해 공유 저장소 기반 `rateLimiter`를 주입하세요(이 시임은 `allow(key, limit)` 계약을 만족합니다. [`configuration.md` → Rate limiter 주입](./configuration.ko.md#rate-limiter-주입) 참고). 기본 프로세스별 limiter는 window map도 bounding하므로 identity 기준 무한 메모리 증가가 없습니다.
|
|
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/공유 파일시스템에서는 보장되지 않습니다 — 이 저장소들은 로컬 디스크에 두세요.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Haechi Shared Responsibility
|
|
2
2
|
|
|
3
|
-
- Status: Living document (tracks core 1.
|
|
3
|
+
- Status: Living document (tracks core 1.2.x)
|
|
4
4
|
- Date: 2026-06-10
|
|
5
5
|
|
|
6
6
|
## 1. Responsibility Matrix
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
|
|
42
42
|
Haechi's stateful controls are single-process by design. Running 2+ replicas behind a load balancer **silently weakens** them unless the operator supplies shared infrastructure:
|
|
43
43
|
|
|
44
|
-
- **Rate limit** is per-process and in-memory — total throughput multiplies by the replica count. Enforce a per-identity limit at a shared front door, or inject a shared-store `rateLimiter
|
|
44
|
+
- **Rate limit** is per-process and in-memory — total throughput multiplies by the replica count. Enforce a per-identity limit at a shared front door, or inject a shared-store `rateLimiter` via `createRuntime(config, { rateLimiter })` (the seam satisfies the `allow(key, limit)` contract; see [`configuration.md` → Rate limiter injection](./configuration.md#rate-limiter-injection)). The default per-process limiter also bounds its window map (no unbounded memory growth keyed by identity).
|
|
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.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Haechi Threat Model
|
|
2
2
|
|
|
3
|
-
- 문서 상태: Living document(core 1.
|
|
3
|
+
- 문서 상태: Living document(core 1.2.x 추적)
|
|
4
4
|
- 작성일: 2026-06-10
|
|
5
5
|
|
|
6
6
|
## 1. 보호 대상
|
|
@@ -46,6 +46,7 @@ Haechi가 보호하려는 주요 자산은 다음과 같습니다.
|
|
|
46
46
|
| 행 걸린 upstream | proxy 연결 고갈 | `limits.upstreamTimeoutMs` 기본 120s, 초과 시 504 fail |
|
|
47
47
|
| signing/encryption 키 혼용 | key separation 위반 | policy bundle 서명 키를 domain-separated 파생 키로 분리 |
|
|
48
48
|
| JSON number/object key 은닉 | 카드번호 등 비문자열 leaf 미탐지 | number leaf와 object key도 detection/transform 대상 |
|
|
49
|
+
| 모든 정규식 규칙을 우회하는 유니코드 난독화 | card/RRN/phone/email/secret을 시각·의미상 동등한 비ASCII 유니코드 형태(전각 숫자 `4242…`, 전각 `@`, 수학·원문자 영숫자)로 보내 모든 탐지 규칙을 무력화 | **매칭 전 각 string leaf의 NFKC 정규화**(WS2d)입니다. 정규화가 무변환인 경우(leaf의 약 99%) 탐지는 이전과 바이트 단위로 동일합니다. 접힘이 **위치 안정적**인 경우(모든 코드포인트가 같은 UTF-16 길이로 접히고 코드포인트별 접힘이 전체 정규화를 그대로 재구성) 정규화 사본에서 탐지하고 원본의 정확한 구간을 redact/block하며, 기록되는 값은 접힌 형태가 아니라 원본 바이트입니다. 그 외 — 길이가 달라지거나(수학 숫자·합자) 총 길이는 같지만 내부 offset을 이동시키는 수축+확장 보상 — 의 경우 offset을 원본에 매핑할 수 없으므로 탐지가 **fail closed**되어 leaf 전체를 덮는 단일 탐지로 처리합니다(leaf 전체 redact/block — 우회 시도를 과도 redact하는 것이 안전한 실패입니다). `String.prototype.normalize` 빌트인을 사용하므로 새 의존성은 없습니다. **수용된 잔여:** base64/percent-encoded 페이로드는 여전히 디코딩 후 재검사하지 않습니다(§4 참조) |
|
|
49
50
|
| 인증 없는 멀티 클라이언트 접근 | 로컬 프로세스가 upstream / token round-trip 경로를 무단 사용 | 선택적 bearer auth (`auth.provider: bearer`); 없거나 잘못된 경우 → 바디 읽기 전 401; identity별 rate limit 및 model allowlist |
|
|
50
51
|
| Audit tail truncation | 꼬리 audit 레코드의 무음 삭제 | 추가 전용/별도 미디어의 `audit.anchor` head-hash anchoring으로 마지막 anchor까지의 절단 탐지 (0.7) |
|
|
51
52
|
| Local dev key in production | 소프트웨어 키의 운영 custody 오용 | `assertCryptoProviderConformance`를 통한 외부 `cryptoProvider` 주입; reference KMS adapter (envelope 암호화) |
|
|
@@ -86,7 +87,7 @@ Haechi는 다음을 보장하지 않습니다.
|
|
|
86
87
|
- 법적 컴플라이언스 인증
|
|
87
88
|
- 모델 hallucination, prompt injection 완전 방어
|
|
88
89
|
- 외부 MCP server의 OAuth/resource binding 검증
|
|
89
|
-
- base64/
|
|
90
|
+
- base64/percent-encoded 값의 **디코딩 후** 검사 — Haechi는 NFKC 정규화 텍스트에서 매칭하지만(§3의 유니코드 난독화 행 참조) base64/URL 디코딩 후 재검사는 하지 **않습니다**. 전송 전 base64·percent로 인코딩된 값은 검사되지 않습니다. (WS2d는 디코딩-후-재검사 패스를 보류했습니다. 상시 디코딩은 오탐이 많고, recall-safe한 opt-in을 범위 내에서 precision-neutral하게 만들 수 없어 문서화된 제외로 남깁니다.)
|
|
90
91
|
- URL query string 내 민감값 검사 (JSON body만 검사)
|
|
91
92
|
- 마지막 anchor 이후의 audit tail truncation — `audit.anchor`(0.7)는 anchor가 추가 전용/별도 미디어에 있을 때 마지막 anchor까지의 레코드 삭제를 탐지합니다. 마지막 anchor 이후 기록된 레코드와 동일 파일시스템 anchor는 대상에서 제외됩니다
|
|
92
93
|
- JSON-RPC batch 메시지 처리 (MCP stdio filter는 batch를 fail-closed로 거부)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Haechi Threat Model
|
|
2
2
|
|
|
3
|
-
- Status: Living document (tracks core 1.
|
|
3
|
+
- Status: Living document (tracks core 1.2.x)
|
|
4
4
|
- Date: 2026-06-10
|
|
5
5
|
|
|
6
6
|
## 1. Assets Under Protection
|
|
@@ -46,6 +46,7 @@ The primary assets Haechi protects are:
|
|
|
46
46
|
| Hung upstream | Proxy connection exhaustion | `limits.upstreamTimeoutMs` default 120 s; 504 fail on timeout |
|
|
47
47
|
| Signing/encryption key conflation | Key separation violation | Policy bundle signing key isolated as a domain-separated derived key |
|
|
48
48
|
| JSON number / object key concealment | Undetected non-string leaves such as card numbers | Number leaves and object keys included in detection/transform scope |
|
|
49
|
+
| Unicode-obfuscation evasion of every regex rule | A card/RRN/phone/email/secret sent in a visually/semantically equivalent non-ASCII Unicode form (full-width digits `4242…`, full-width `@`, mathematical/enclosed alphanumerics) defeats every detection rule | **NFKC normalization of each string leaf before matching** (WS2d). When the normalization is a no-op (~99% of leaves) detection is byte-identical to before. When the fold is **position-stable** (every codepoint folds to the same UTF-16 length and the per-codepoint folds reconstruct the whole normalization), detection runs on the normalized copy and the exact original span is redacted/blocked (the recorded value is the original bytes, never the fold). Otherwise — a length change (mathematical digits/ligatures) **or** a compensating contraction+expansion that keeps the total length equal while shifting interior offsets — offsets cannot map back, so detection **fails closed** to a single whole-leaf detection (the entire leaf is redacted/blocked — over-redacting an evasion attempt is the safe failure). Uses the `String.prototype.normalize` builtin (no new dependency). **Accepted residual:** base64/percent-encoded payloads are still not decoded-and-rescanned (see §4) |
|
|
49
50
|
| Unauthenticated multi-client access | Any local process uses the upstream / token round-trip | Optional bearer auth (`auth.provider: bearer`); missing/invalid → 401 before body read; per-identity rate limit and model allowlist |
|
|
50
51
|
| Audit tail truncation | Silent deletion of trailing audit records | `audit.anchor` head-hash anchoring on append-only/separate media detects truncation back to the last anchor (0.7) |
|
|
51
52
|
| Local dev key in production | Software key misused as production custody | External `cryptoProvider` injection with `assertCryptoProviderConformance`; reference KMS adapter (envelope encryption) |
|
|
@@ -86,7 +87,7 @@ Haechi does not guarantee:
|
|
|
86
87
|
- Legal compliance certification
|
|
87
88
|
- Complete defense against model hallucination or prompt injection
|
|
88
89
|
- OAuth/resource binding validation for external MCP servers
|
|
89
|
-
- Inspection of base64/
|
|
90
|
+
- Inspection of base64/percent-encoded values **after decoding** — Haechi matches on the NFKC-normalized text (see the Unicode-evasion row in §3) but does **not** base64/URL-decode-and-rescan. A value that is base64- or percent-encoded before sending is not inspected. (WS2d deferred a decode-and-rescan pass: an always-on decode is false-positive-prone, and a recall-safe opt-in could not be made precision-neutral within scope; it remains a documented exclusion.)
|
|
90
91
|
- Detection of sensitive values in URL query strings (JSON body only)
|
|
91
92
|
- Audit tail truncation beyond the last anchor — `audit.anchor` (0.7) detects deletion of records back to the last anchor when the anchor is on append-only/separate media; records written after the last anchor, and same-filesystem anchors, are not covered
|
|
92
93
|
- JSON-RPC batch message processing (the MCP stdio filter rejects batches fail-closed)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
+
"configVersion": 1,
|
|
2
3
|
"mode": "dry-run",
|
|
3
4
|
"target": {
|
|
4
5
|
"type": "llm-http",
|
|
@@ -7,7 +8,9 @@
|
|
|
7
8
|
},
|
|
8
9
|
"proxy": {
|
|
9
10
|
"host": "127.0.0.1",
|
|
10
|
-
"port": 11016
|
|
11
|
+
"port": 11016,
|
|
12
|
+
"tls": null,
|
|
13
|
+
"trustForwardedProto": false
|
|
11
14
|
},
|
|
12
15
|
"responseProtection": {
|
|
13
16
|
"enabled": false,
|
|
@@ -25,7 +28,12 @@
|
|
|
25
28
|
},
|
|
26
29
|
"limits": {
|
|
27
30
|
"maxRequestBytes": 1048576,
|
|
28
|
-
"
|
|
31
|
+
"maxNestingDepth": 256,
|
|
32
|
+
"upstreamTimeoutMs": 120000,
|
|
33
|
+
"maxInFlight": 0,
|
|
34
|
+
"shutdownGraceMs": 10000,
|
|
35
|
+
"requestTimeoutMs": null,
|
|
36
|
+
"headersTimeoutMs": null
|
|
29
37
|
},
|
|
30
38
|
"policy": {
|
|
31
39
|
"mode": "dry-run",
|
|
@@ -40,7 +48,9 @@
|
|
|
40
48
|
}
|
|
41
49
|
},
|
|
42
50
|
"filters": {
|
|
43
|
-
"customRules": []
|
|
51
|
+
"customRules": [],
|
|
52
|
+
"minConfidence": 0,
|
|
53
|
+
"allowlist": []
|
|
44
54
|
},
|
|
45
55
|
"keys": {
|
|
46
56
|
"provider": "local",
|
|
@@ -67,6 +77,12 @@
|
|
|
67
77
|
"privacy": {
|
|
68
78
|
"profile": null
|
|
69
79
|
},
|
|
80
|
+
"logging": {
|
|
81
|
+
"format": "text"
|
|
82
|
+
},
|
|
83
|
+
"metrics": {
|
|
84
|
+
"enabled": true
|
|
85
|
+
},
|
|
70
86
|
"auth": {
|
|
71
87
|
"provider": "none",
|
|
72
88
|
"store": ".haechi/auth.json",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "haechi",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
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",
|
|
@@ -51,7 +51,8 @@
|
|
|
51
51
|
"./token-vault": "./packages/token-vault/index.mjs",
|
|
52
52
|
"./stream-filter": "./packages/stream-filter/index.mjs",
|
|
53
53
|
"./auth": "./packages/auth/index.mjs",
|
|
54
|
-
"./ssrf": "./packages/ssrf/index.mjs"
|
|
54
|
+
"./ssrf": "./packages/ssrf/index.mjs",
|
|
55
|
+
"./metrics": "./packages/metrics/index.mjs"
|
|
55
56
|
},
|
|
56
57
|
"files": [
|
|
57
58
|
"README.md",
|
|
@@ -74,6 +75,8 @@
|
|
|
74
75
|
"sbom": "node scripts/generate-sbom.mjs",
|
|
75
76
|
"checksums": "node scripts/release-checksums.mjs",
|
|
76
77
|
"bench:payload": "node scripts/bench-payload.mjs",
|
|
78
|
+
"bench:detection": "node scripts/bench-detection.mjs",
|
|
79
|
+
"scan:detection": "node scripts/bench-detection.mjs --gate",
|
|
77
80
|
"check:peer-ranges": "node scripts/check-satellite-peer-ranges.mjs",
|
|
78
81
|
"release:preflight": "node scripts/release-preflight.mjs && node scripts/check-satellite-peer-ranges.mjs",
|
|
79
82
|
"release:preflight:npm": "node scripts/release-preflight.mjs --require-npm-auth && node scripts/check-satellite-peer-ranges.mjs",
|
package/packages/audit/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { createReadStream } from "node:fs";
|
|
2
|
-
import { appendFile, mkdir, open, stat, unlink } from "node:fs/promises";
|
|
1
|
+
import { createReadStream, constants as fsConstants } from "node:fs";
|
|
2
|
+
import { access, appendFile, mkdir, open, stat, unlink } from "node:fs/promises";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { dirname } from "node:path";
|
|
5
5
|
import { createInterface } from "node:readline";
|
|
@@ -91,6 +91,30 @@ export function createJsonlAuditSink({ path, anchor = null }) {
|
|
|
91
91
|
});
|
|
92
92
|
writeQueue = write.catch(() => {});
|
|
93
93
|
await write;
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
// WS4-A readiness probe: a CHEAP writability check used by /__haechi/ready.
|
|
97
|
+
// A security gateway that cannot append to its audit log is NOT ready
|
|
98
|
+
// (fail-closed), so this confirms the audit directory exists and is writable
|
|
99
|
+
// WITHOUT writing an event (no audit-chain side effect). It returns the bare
|
|
100
|
+
// boolean and an enum reason — never a path value or any payload/PII.
|
|
101
|
+
async ready() {
|
|
102
|
+
try {
|
|
103
|
+
const dir = dirname(path);
|
|
104
|
+
await mkdir(dir, { recursive: true });
|
|
105
|
+
await access(dir, fsConstants.W_OK);
|
|
106
|
+
// If the audit file already exists, confirm it is writable too.
|
|
107
|
+
try {
|
|
108
|
+
await access(path, fsConstants.W_OK);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (error.code !== "ENOENT") {
|
|
111
|
+
return { ok: false, reason: "audit_file_not_writable" };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { ok: true };
|
|
115
|
+
} catch {
|
|
116
|
+
return { ok: false, reason: "audit_dir_not_writable" };
|
|
117
|
+
}
|
|
94
118
|
}
|
|
95
119
|
};
|
|
96
120
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFile, stat } from "node:fs/promises";
|
|
3
3
|
import { readAuditSummary, verifyAuditChain } from "../../audit/index.mjs";
|
|
4
|
-
import { DEFAULT_PROXY_PORT, createHaechiProxy } from "../../proxy/index.mjs";
|
|
4
|
+
import { DEFAULT_PROXY_PORT, HAECHI_VERSION, createHaechiProxy } from "../../proxy/index.mjs";
|
|
5
5
|
import { signPolicyBundleFile, verifyPolicyBundleFile } from "../../policy-bundle/index.mjs";
|
|
6
6
|
import { validatePluginManifestFile } from "../../plugin/index.mjs";
|
|
7
7
|
import { runMcpStdioFilter, wrapMcpChild } from "../../mcp-stdio/index.mjs";
|
|
@@ -283,25 +283,69 @@ async function proxyCommand(argv) {
|
|
|
283
283
|
const port = parsePort(options.port ?? config.proxy.port);
|
|
284
284
|
const host = options.host ?? config.proxy.host;
|
|
285
285
|
const allowRemoteBind = Boolean(options["allow-remote-bind"]);
|
|
286
|
+
// proxy.tls / proxy.trustForwardedProto come from the normalized config (the
|
|
287
|
+
// TLS material is loaded from file paths at load time); createHaechiProxy reads
|
|
288
|
+
// them from runtime.config.proxy, so the CLI does not re-pass them. The bind
|
|
289
|
+
// guard inside createHaechiProxy throws fail-closed for a remote bind without
|
|
290
|
+
// TLS and without trustForwardedProto.
|
|
286
291
|
const proxy = createHaechiProxy({ runtime, port, host, allowRemoteBind });
|
|
287
292
|
const address = await proxy.listen();
|
|
293
|
+
const scheme = address.tls ? "https" : "http";
|
|
288
294
|
|
|
289
295
|
const effectiveMode = config.policy.mode ?? config.mode;
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
296
|
+
const jsonLogs = config.logging?.format === "json";
|
|
297
|
+
// Structured startup/shutdown logs honor logging.format. JSON mode emits one
|
|
298
|
+
// line per event carrying only non-secret operational fields (host/port/mode/
|
|
299
|
+
// version/warning codes) — never a payload, token, or PII value.
|
|
300
|
+
const logEvent = (level, event, fields = {}) => {
|
|
301
|
+
if (jsonLogs) {
|
|
302
|
+
const stream = level === "warn" ? process.stderr : process.stdout;
|
|
303
|
+
stream.write(`${JSON.stringify({ level, event, ...fields })}\n`);
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
if (jsonLogs) {
|
|
308
|
+
logEvent("info", "proxy_listening", {
|
|
309
|
+
host: address.host,
|
|
310
|
+
port: address.port,
|
|
311
|
+
scheme,
|
|
312
|
+
tls: Boolean(address.tls),
|
|
313
|
+
upstream: config.target.upstream,
|
|
314
|
+
mode: effectiveMode,
|
|
315
|
+
version: HAECHI_VERSION
|
|
316
|
+
});
|
|
317
|
+
} else {
|
|
318
|
+
console.log(`Haechi proxy listening on ${scheme}://${address.host}:${address.port}`);
|
|
319
|
+
console.log(`Upstream: ${config.target.upstream}`);
|
|
320
|
+
console.log(`Mode: ${effectiveMode}`);
|
|
321
|
+
}
|
|
293
322
|
if (allowRemoteBind) {
|
|
294
|
-
|
|
323
|
+
if (jsonLogs) {
|
|
324
|
+
logEvent("warn", "remote_bind_enabled", { tls: Boolean(address.tls), trustForwardedProto: Boolean(config.proxy?.trustForwardedProto) });
|
|
325
|
+
} else if (address.tls) {
|
|
326
|
+
console.error("warning: --allow-remote-bind exposes the proxy beyond loopback (TLS terminated by Haechi). Put Haechi behind explicit network access controls.");
|
|
327
|
+
} else {
|
|
328
|
+
console.error("warning: --allow-remote-bind exposes the proxy beyond loopback behind a trusted TLS-terminating reverse proxy (proxy.trustForwardedProto). Requests without X-Forwarded-Proto: https are refused. Put Haechi behind explicit network access controls.");
|
|
329
|
+
}
|
|
295
330
|
}
|
|
296
331
|
if (effectiveMode !== "enforce") {
|
|
297
|
-
|
|
332
|
+
if (jsonLogs) {
|
|
333
|
+
logEvent("warn", "non_enforce_mode", { mode: effectiveMode });
|
|
334
|
+
} else {
|
|
335
|
+
console.error(`warning: policy mode is ${effectiveMode}. Payloads are inspected and audited but NOT modified or blocked. Set policy.mode to "enforce" to protect traffic.`);
|
|
336
|
+
}
|
|
298
337
|
}
|
|
299
338
|
if (!config.responseProtection.enabled) {
|
|
300
|
-
|
|
339
|
+
if (jsonLogs) {
|
|
340
|
+
logEvent("warn", "response_protection_disabled");
|
|
341
|
+
} else {
|
|
342
|
+
console.error("warning: responseProtection.enabled is false. Upstream responses are forwarded without inspection.");
|
|
343
|
+
}
|
|
301
344
|
}
|
|
302
345
|
|
|
303
346
|
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
304
347
|
process.once(signal, async () => {
|
|
348
|
+
logEvent("info", "proxy_shutdown", { signal });
|
|
305
349
|
await proxy.close();
|
|
306
350
|
process.exit(0);
|
|
307
351
|
});
|
|
@@ -599,7 +643,7 @@ const COMMAND_HELP = {
|
|
|
599
643
|
proxy: {
|
|
600
644
|
usage: `haechi proxy [--config haechi.config.json] [--host 127.0.0.1] [--port ${DEFAULT_PROXY_PORT}] [--allow-remote-bind]`,
|
|
601
645
|
summary: "Run the local HTTP JSON proxy in front of an upstream LLM.",
|
|
602
|
-
detail: "Binds loopback by default; --allow-remote-bind is required (and must be a CLI flag, not config) to bind non-loopback hosts.
|
|
646
|
+
detail: "Binds loopback (plain http) by default; --allow-remote-bind is required (and must be a CLI flag, not config) to bind non-loopback hosts. A remote bind additionally requires TLS: set proxy.tls ({ keyFile, certFile } or { pfxFile, passphrase? }) so Haechi serves https, OR set proxy.trustForwardedProto: true when a trusted reverse proxy terminates TLS in front of Haechi (Haechi then refuses any request without X-Forwarded-Proto: https). Configure client auth via auth.provider — see 'haechi config'."
|
|
603
647
|
},
|
|
604
648
|
"policy-sign": {
|
|
605
649
|
usage: "haechi policy-sign <policy.json> [--config haechi.config.json] [--out policy.bundle.json]",
|
|
@@ -712,6 +756,8 @@ Detection policy
|
|
|
712
756
|
policy.defaultAction allow | redact | mask | tokenize | encrypt | block
|
|
713
757
|
policy.actions per-type overrides; merges may strengthen, not weaken
|
|
714
758
|
filters.customRules extra regex rules (ReDoS-screened)
|
|
759
|
+
filters.minConfidence [0,1] drop soft detections below this (not hard-block)
|
|
760
|
+
filters.allowlist FP exceptions [value|{value?,path?}] (not hard-block)
|
|
715
761
|
|
|
716
762
|
Tokenization (model sees token, caller sees plaintext)
|
|
717
763
|
tokenVault.revealPolicy disabled | local-dev (manual reveal gate)
|