haechi 1.1.2 → 1.3.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/README.ko.md +46 -11
- package/README.md +46 -11
- 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 +165 -9
- package/docs/current/configuration.md +165 -9
- package/docs/current/operations-runbook.ko.md +155 -0
- package/docs/current/operations-runbook.md +241 -0
- package/docs/current/release-process.ko.md +5 -1
- package/docs/current/release-process.md +5 -1
- package/docs/current/risk-register-release-gate.ko.md +5 -3
- package/docs/current/risk-register-release-gate.md +13 -3
- 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 +4 -2
- package/docs/current/threat-model.md +4 -2
- package/examples/local-proxy-demo/README.md +51 -0
- package/examples/local-proxy-demo/demo.mjs +144 -0
- package/examples/local-proxy-demo/demo.tape +19 -0
- package/examples/local-proxy-demo/live-demo.mjs +121 -0
- package/examples/local-proxy-demo/live-demo.tape +25 -0
- package/haechi.config.example.json +20 -3
- package/package.json +7 -2
- package/packages/audit/index.mjs +26 -2
- package/packages/cli/bin/haechi.mjs +57 -10
- package/packages/cli/runtime.mjs +402 -10
- package/packages/core/index.mjs +143 -8
- package/packages/filter/index.mjs +975 -12
- package/packages/metrics/index.mjs +181 -0
- package/packages/privacy-profiles/index.mjs +72 -3
- package/packages/protocol-adapters/index.mjs +99 -1
- package/packages/proxy/index.mjs +525 -40
- package/packages/stream-filter/index.mjs +69 -7
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Haechi 설정 레퍼런스
|
|
2
2
|
|
|
3
|
-
- 문서 상태: Living document(core 1.
|
|
3
|
+
- 문서 상태: Living document(core 1.3.x 추적)
|
|
4
4
|
|
|
5
5
|
`haechi init`은 `haechi.config.json`을 생성하며, 비밀 정보를 포함하지 않는 템플릿은 `haechi.config.example.json`에 있습니다. 모든 커맨드는 `--config <path>`로 설정 파일을 읽습니다(기본값: `haechi.config.json`). 설정은 **fail-closed 방식으로 검증**됩니다. 알 수 없는 provider, 범위를 벗어난 숫자, 잘못된 형식의 값은 자동으로 무시되지 않고 로드 시점에 오류를 발생시킵니다. `haechi config`는 이 레퍼런스를 출력하며, `haechi status`는 특정 설정 파일의 *실제 적용* 상태를 출력합니다.
|
|
6
6
|
|
|
@@ -8,18 +8,21 @@
|
|
|
8
8
|
|
|
9
9
|
```json
|
|
10
10
|
{
|
|
11
|
+
"configVersion": 1,
|
|
11
12
|
"mode": "dry-run",
|
|
12
13
|
"target": { "type": "llm-http", "adapter": "openai-compatible", "upstream": "http://127.0.0.1:9999" },
|
|
13
|
-
"proxy": { "host": "127.0.0.1", "port": 11016 },
|
|
14
|
+
"proxy": { "host": "127.0.0.1", "port": 11016, "tls": null, "trustForwardedProto": false },
|
|
14
15
|
"responseProtection": { "enabled": false, "mode": "enforce", "failureMode": "fail-closed", "allowNonJson": false, "allowCompressed": false, "maxBytes": 1048576 },
|
|
15
16
|
"streaming": { "requestMode": "block" },
|
|
16
|
-
"limits": { "maxRequestBytes": 1048576, "upstreamTimeoutMs": 120000, "maxNestingDepth": 256 },
|
|
17
|
+
"limits": { "maxRequestBytes": 1048576, "upstreamTimeoutMs": 120000, "maxNestingDepth": 256, "maxInFlight": 0, "shutdownGraceMs": 10000, "requestTimeoutMs": null, "headersTimeoutMs": null },
|
|
17
18
|
"policy": { "mode": "dry-run", "presets": ["korean-pii", "secrets-only", "llm-redact"], "defaultAction": "redact", "actions": { "card": "block" } },
|
|
18
19
|
"filters": { "customRules": [] },
|
|
19
20
|
"keys": { "provider": "local", "keyFile": ".haechi/dev.keys.json" },
|
|
20
21
|
"audit": { "sink": "jsonl", "path": ".haechi/audit.jsonl" },
|
|
21
22
|
"tokenVault": { "provider": "local", "path": ".haechi/token-vault.json", "revealPolicy": "disabled", "retentionDays": 30, "deterministic": false, "deterministicTypes": null, "detokenizeResponses": false },
|
|
22
23
|
"privacy": { "profile": null },
|
|
24
|
+
"logging": { "format": "text" },
|
|
25
|
+
"metrics": { "enabled": true },
|
|
23
26
|
"mcp": { "allowedMethods": ["initialize", "tools/call", "resources/read", "prompts/get"], "protectParams": true, "protectResults": true, "requireJsonRpc": true }
|
|
24
27
|
}
|
|
25
28
|
```
|
|
@@ -28,13 +31,14 @@
|
|
|
28
31
|
|
|
29
32
|
| 키 | 타입 / 값 | 기본값 | 설명 |
|
|
30
33
|
|---|---|---|---|
|
|
34
|
+
| `configVersion` | 양의 정수 | `1` | 설정 스키마 버전 스탬프입니다. 값이 없으면 현재 버전으로 간주합니다. 이 빌드가 이해하는 값보다 **더 높은** 값은 로드 시 **fail-closed**로 실패하며, 양수 정수가 아닌 값은 오류를 발생시킵니다. [`config-version.md`](./config-version.md)를 참고하십시오. |
|
|
31
35
|
| `mode` | `dry-run` \| `report-only` \| `enforce` | `dry-run` | 전역 집행 모드입니다. `dry-run`/`report-only`는 탐지와 audit만 수행하며, `enforce`는 변환/차단을 적용합니다. `policy.mode`가 설정된 경우 해당 값이 우선합니다. |
|
|
32
36
|
|
|
33
37
|
## `target`
|
|
34
38
|
|
|
35
39
|
| 키 | 타입 / 값 | 기본값 | 설명 |
|
|
36
40
|
|---|---|---|---|
|
|
37
|
-
| `target.type` | `llm-http` \| `openai-compatible` \| `vllm-openai` \| `ollama` \| `llama-cpp` | `llm-http` | 프로토콜 adapter를 선택합니다. `llm-http`는 `openai-compatible`의 별칭입니다. 알 수 없는 값은 로드 시 **fail-closed**로 처리됩니다. |
|
|
41
|
+
| `target.type` | `llm-http` \| `openai-compatible` \| `vllm-openai` \| `ollama` \| `llama-cpp` \| `anthropic` \| `gemini` | `llm-http` | 프로토콜 adapter를 선택합니다. `llm-http`는 `openai-compatible`의 별칭입니다. `anthropic`은 Anthropic Messages API(`/v1/messages`, `/v1/messages/count_tokens`, `/v1/complete`)를 대상으로 합니다. 클라이언트가 Anthropic의 `x-api-key`/`anthropic-version` 헤더를 제공하면 프록시가 이를 그대로 전달합니다. `gemini`는 Google Gemini API를 대상으로 합니다. 엔드포인트는 모델명이 경로에 포함되고 `:method` 접미사를 사용하는 형태입니다(`/v1beta/models/{model}:generateContent`, `:streamGenerateContent`(SSE), `:countTokens`, `:embedContent`, `:batchEmbedContents`; `/v1` 또는 `/v1beta` 접두사, 임의의 `{model}`). 클라이언트가 Gemini의 `x-goog-api-key`(또는 `?key=`)를 제공하면 프록시가 이를 그대로 전달합니다. 알 수 없는 값은 로드 시 **fail-closed**로 처리됩니다. |
|
|
38
42
|
| `target.adapter` | 동일한 값 집합 | `openai-compatible` | adapter를 명시적으로 지정합니다. 보통은 설정하지 않고 `type`이 결정하도록 두면 됩니다. |
|
|
39
43
|
| `target.upstream` | URL 문자열 | `http://127.0.0.1:9999` | proxy가 요청을 전달하는 유일한 upstream입니다. 요청 대상은 origin-form 경로여야 하며, 절대 URL 대상은 거부됩니다(SSRF 방어). |
|
|
40
44
|
|
|
@@ -44,6 +48,8 @@
|
|
|
44
48
|
|---|---|---|---|
|
|
45
49
|
| `proxy.host` | 비어 있지 않은 문자열 | `127.0.0.1` | 바인드 주소입니다. loopback이 아닌 host를 사용하려면 `--allow-remote-bind` CLI 플래그가 필요합니다. 설정 파일만으로는 시작되지 않습니다([loopback 밖으로 바인딩](#binding-beyond-loopback) 참고). |
|
|
46
50
|
| `proxy.port` | 정수 0–65535 | `11016` | 리슨 포트입니다(`0` = 임시 포트). `--port`로 실행할 때마다 덮어쓸 수 있습니다. |
|
|
51
|
+
| `proxy.tls` | `null` 또는 `{ keyFile, certFile }` / `{ pfxFile, passphrase? }` | `null` | 기동 시 **파일 경로**에서 읽어들이는 TLS 자료입니다. 설정되면 Haechi가 직접 TLS를 종단합니다(`https` 제공). remote bind에는 `trustForwardedProto`와 함께 둘 중 하나가 필요합니다([loopback 밖으로 바인딩](#binding-beyond-loopback) 참고). fail-closed: non-null이지만 사용 가능한 자료 `((key && cert) 또는 pfx)`로 해석되지 않거나, `pfxFile`을 `keyFile`/`certFile`과 함께 쓰거나, 읽을 수 없는 파일을 지정하면 로드 시 throw합니다. |
|
|
52
|
+
| `proxy.trustForwardedProto` | boolean | `false` | **신뢰하는 reverse proxy가 Haechi 앞단에서 TLS를 종단함**을 운영자가 명시적으로 확인하는 값입니다. `true`이면 remote bind가 plain `http`로 유지될 수 있으나, Haechi는 **`X-Forwarded-Proto`가 `https`가 아닌 모든 요청을 거부**합니다(auth/body 이전에 검사하며, `/__haechi/*` liveness 라우트는 예외입니다). Haechi 자체가 인터넷에 직접 노출될 때는 실제 TLS를 대체하지 못합니다. |
|
|
47
53
|
|
|
48
54
|
## `responseProtection`
|
|
49
55
|
|
|
@@ -74,6 +80,10 @@ upstream JSON 응답을 검사합니다(기본적으로 꺼져 있습니다 —
|
|
|
74
80
|
| `limits.maxRequestBytes` | 양의 정수 | `1048576` | 요청 바디 크기 상한입니다. 초과 시 `413`을 반환합니다. 바디를 전부 버퍼링하지 않고 증분 방식으로 적용됩니다. |
|
|
75
81
|
| `limits.upstreamTimeoutMs` | 양의 정수 | `120000` | upstream 요청 타임아웃입니다. 만료 시 `504 haechi_upstream_timeout`을 반환합니다. 연결 실패 시에는 `502 haechi_upstream_unreachable`을 반환합니다. |
|
|
76
82
|
| `limits.maxNestingDepth` | 양의 정수 | `256` | 탐지 시 walk하는 JSON 최대 중첩 깊이입니다. 이보다 깊게 중첩된 바디는 `413 haechi_request_too_deeply_nested`로 거부되어(upstream 이전, fail-closed), 재귀적 payload walk를 스택 오버플로로부터 보호합니다. 컨테이너 하강을 제한하며, 한도에 있는 leaf는 여전히 검사됩니다. (별도로, 비UTF-8 요청 바디는 fail-closed로 거부됩니다: `400 haechi_request_body_not_utf8`.) |
|
|
83
|
+
| `limits.maxInFlight` | 음이 아닌 정수 | `0` | 전역 max-in-flight 백프레셔 상한입니다. `0`은 비활성화이며(상한 없음 — 1.1 동작), `> 0`이고 현재 in-flight 수가 상한에 도달하면 **새** 요청은 인증/바디 읽기 **이전에** `Retry-After` 헤더와 `{ "error": "haechi_overloaded" }`와 함께 `503`으로 거부됩니다. `/__haechi/*` 관측 라우트는 **예외**입니다(포화 상태에서도 liveness·metrics 스크레이프 가능). 거부마다 `haechi_overloaded_total`이 증가합니다. [운영 런북](./operations-runbook.md#5-backpressure-tuning)을 참고하십시오. |
|
|
84
|
+
| `limits.shutdownGraceMs` | 음이 아닌 정수(ms) | `10000` | 우아한 종료(graceful shutdown) 유예 기간입니다. `SIGINT`/`SIGTERM` 시 프록시는 새 연결 수락을 멈추고, idle keep-alive 소켓을 즉시 닫고, in-flight 요청이 빠질 때까지 기다린 뒤, 이 유예가 지나면 남은 소켓을 강제 종료하여 멈춘 keep-alive가 종료를 무한정 붙잡지 못하게 합니다. 백프레셔 `Retry-After` 초 값의 기준이기도 합니다. 오케스트레이터의 종료 유예를 이 값보다 **크게** 설정하십시오. |
|
|
85
|
+
| `limits.requestTimeoutMs` | `null` \| 음이 아닌 정수(ms) | `null` | Node HTTP 서버의 `requestTimeout`에 매핑됩니다. `null`은 Node 기본값을 그대로 둡니다(동작 불변). 느린 전체 요청 전달을 제한하려면 숫자를 설정하고, `0`은 타임아웃 비활성화입니다(Node 의미). |
|
|
86
|
+
| `limits.headersTimeoutMs` | `null` \| 음이 아닌 정수(ms) | `null` | Node HTTP 서버의 `headersTimeout`에 매핑됩니다. `null`은 Node 기본값을 그대로 둡니다. 느린 헤더 전달(slow-loris)을 제한하려면 숫자를 설정하고, `0`은 비활성화입니다. |
|
|
77
87
|
|
|
78
88
|
## `policy`
|
|
79
89
|
|
|
@@ -93,6 +103,20 @@ upstream JSON 응답을 검사합니다(기본적으로 꺼져 있습니다 —
|
|
|
93
103
|
| 키 | 타입 / 값 | 기본값 | 설명 |
|
|
94
104
|
|---|---|---|---|
|
|
95
105
|
| `filters.customRules` | 규칙 객체 배열 | `[]` | 추가 탐지 규칙입니다: `{ id, type, pattern, flags?, confidence? }`. 패턴은 ReDoS 검사를 통과해야 하며(≤500자, 중첩 한정자 없음, 역참조 없음), 안전하지 않으면 로드 시 거부됩니다. |
|
|
106
|
+
| `filters.minConfidence` | `[0, 1]` 범위의 숫자 | `0` | 정밀도 다이얼입니다. 각 규칙은 `confidence`(0.6~0.95)를 가지며, confidence가 이 임계값 **미만**인 탐지는 policy 결정 전에 버려집니다. 기본값 `0`은 아무것도 게이트하지 않아 기존 동작을 보존합니다. **하드 블록 예외:** 하드 블록 타입(`secret`, `api_key`, `kr_rrn`, `card`, 그리고 강한 앵커 국가-ID `fr_nir`, `es_dni`, `it_codice_fiscale`, `sg_nric`)은 confidence만으로는 **절대** 버려지지 않습니다 — `minConfidence`는 정밀도 위험이 큰 소프트/다이얼 가능 타입(예: `phone`, `email`, `jp_mynumber`, `uk_nino`, `in_aadhaar`, `de_steuer_id`, `nl_bsn`, `injection`)만 다듬으므로, confidence가 낮은 자격증명/PII 누출도 여전히 조치됩니다(fail-closed). |
|
|
107
|
+
| `filters.allowlist` | 문자열 및/또는 `{ value?, path? }` 의 배열 | `[]` | 운영자 false-positive 예외입니다. 매칭된 **value**가 문자열/`value` 항목과 같거나, PII-safe JSON **path**(audit에 표시되는 해시된 `pathText`)가 `path` 항목과 같은 탐지는 policy 결정 전에 억제됩니다(항목이 `value`와 `path`를 모두 설정하면 **둘 다** 일치해야 합니다). **하드 블록 예외:** 하드 블록 타입(`secret`/`api_key`/`kr_rrn`/`card`/`fr_nir`/`es_dni`/`it_codice_fiscale`/`sg_nric`)을 억제하려는 항목은 **무시되며** 탐지는 그대로 발생합니다 — allowlist는 양성(benign) **소프트/다이얼 가능** 타입 FP(예: `jp_mynumber`/`in_aadhaar` 12자리 ID 오탐, `de_steuer_id` 11자리 ID 오탐, `nl_bsn` 9자리 ID 오탐, format-only `uk_nino`)만 정리할 수 있고, 자격증명/강한-앵커-PII 누출은 절대 침묵시킬 수 없습니다. 모든 억제와 모든 `minConfidence` 드롭은 개수와 타입으로 **감사 로그에 기록됩니다**(`summary.suppressedByType` / `summary.droppedByType` / `suppressedCount` / `droppedCount`) — 원시 값은 절대 기록하지 않습니다. 규칙 전체를 삭제하지 않고 양성 FP 하나만 정리할 때 사용하십시오. |
|
|
108
|
+
| `filters.decodeAndRescan` | boolean | `false` | opt-in base64/percent **디코딩 후 재검사**입니다(WS2d 잔여). 기본값 `false`에서는 탐지가 이전과 바이트 단위로 동일합니다 — base64·percent로 인코딩된 값을 디코딩하지 **않습니다**. `true`일 때, 일반 NFKC 스캔 이후 base64/base64url로 **보이는** string leaf(고정 알파벳, 유효한 길이, `16…8192` 바이트, 같은 leaf로 round-trip, **유효한 UTF-8** 디코딩)이거나 `%XX` 이스케이프를 포함하는 leaf(`decodeURIComponent`)를 디코딩하여 같은 규칙·validator로 재검사합니다. 디코딩된 매칭은 인코딩된 leaf에 offset이 없으므로, **WHOLE-LEAF** 탐지(`start:0,end:leaf.length`, value = 인코딩된 leaf 전체)로 fail closed됩니다 — transform이 leaf 전체를 redact/block합니다. **정밀도 가드:** 디코딩된 매칭은 validator 기반이거나 하드 블록 타입일 때만 발생합니다(Luhn 통과 `card`, 체크섬 `kr_rrn`/`us_ssn`, IBAN mod-97, 또는 앵커된 규칙의 `secret`/`api_key`). validator 없는 디코딩된 소프트 타입 매칭(맨 전화번호 형태)은 발생하지 않으므로 무작위 base64는 오탐하지 않습니다. 새 런타임 의존성은 없습니다(`node:buffer` Buffer + `decodeURIComponent` 빌트인). 다른 인코딩(gzip/hex/중첩/커스텀 알파벳)은 범위 밖입니다. |
|
|
109
|
+
|
|
110
|
+
### 탐지 벤치마크
|
|
111
|
+
|
|
112
|
+
탐지 정밀도(precision)/재현율(recall)은 가정하지 않고 측정합니다. 합성 테스트 픽스처로 구성된 라벨링 코퍼스(`tests/fixtures/detection-corpus.json` — type별 양성 샘플과 양성처럼 보이는 hard-negative)를 기반으로 type별 채점기를 돌립니다.
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
npm run bench:detection # type별 TP/FP/FN + precision/recall 표를 출력합니다
|
|
116
|
+
npm run scan:detection # CI 회귀 게이트: 어떤 type이라도 baseline 아래로 떨어지면 실패합니다
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`bench:detection`(`scripts/bench-detection.mjs`)은 기본 필터 엔진을 각 코퍼스 케이스에 적용하여 type별 true/false positive와 false negative를 보고합니다. `scan:detection`은 실측 점수를 고정된 baseline(`scripts/detection-baseline.json`)과 비교하며 **회귀일 때만 실패합니다** — 즉 precision 또는 recall이 기록된 수치 아래로 떨어진 경우입니다. baseline에는 현재의 불완전한 상태(`phone`/`card`/`secret`에서 audit이 재현한 오탐, 그리고 AWS/GitHub/Google/Slack 키·JWT·PEM 헤더에 대한 알려진 커버리지 공백 누락)가 의도적으로 포함되어 있으므로, 게이트는 오늘은 통과하고 변경이 탐지를 악화시킬 때만 실패합니다. 이 게이트는 `release:preflight`에서 doc-freshness 게이트 다음에 실행됩니다. 의도적인 규칙 변경 후에는 `node scripts/bench-detection.mjs --write-baseline`으로 baseline을 재생성하고 diff를 검토하십시오. 기록된 공백과 오탐을 닫는 작업은 reliability-hardening 트랙의 WS2b/WS2c입니다.
|
|
96
120
|
|
|
97
121
|
## `keys`
|
|
98
122
|
|
|
@@ -124,7 +148,70 @@ upstream JSON 응답을 검사합니다(기본적으로 꺼져 있습니다 —
|
|
|
124
148
|
|
|
125
149
|
| 키 | 타입 / 값 | 기본값 | 설명 |
|
|
126
150
|
|---|---|---|---|
|
|
127
|
-
| `privacy.profile` | `null` \| `kr-pipa` \| `eu-gdpr` \| `us-general` | `null` | 집행 전에 지역별 기준 action 집합을 적용합니다. 프로필은 명시적 action을 **강화**할 수는 있지만 약화할 수는 없습니다. 엔지니어링 기본값이며, 법적 자문이 아닙니다. |
|
|
151
|
+
| `privacy.profile` | `null` \| `kr-pipa` \| `eu-gdpr` \| `asia-pdpa` \| `us-general` \| `jp-appi` | `null` | 집행 전에 지역별 기준 action 집합을 적용합니다. 프로필은 명시적 action을 **강화**할 수는 있지만 약화할 수는 없습니다. `eu-gdpr`는 EU 국가 ID(`fr_nir`/`es_dni`/`uk_nino`/`it_codice_fiscale`/`de_steuer_id`/`nl_bsn`)를 block하고, `asia-pdpa`(싱가포르 PDPA / 인도 DPDP)는 `sg_nric`/`in_aadhaar`를 block하며(혼합 지역 페이로드를 위해 다른 체크섬 국가 ID도 함께 block), `jp-appi`는 `jp_mynumber`를 block하고, 모든 프로필이 `jp_mynumber`를 block합니다(체크섬 국가-ID 누출). 엔지니어링 기본값이며, 법적 자문이 아닙니다. |
|
|
152
|
+
|
|
153
|
+
## `logging`
|
|
154
|
+
|
|
155
|
+
| 키 | 타입 / 값 | 기본값 | 설명 |
|
|
156
|
+
|---|---|---|---|
|
|
157
|
+
| `logging.format` | `text` \| `json` | `text` | `text`는 사람이 읽는 기동/종료/오류 로그 줄을 그대로 유지합니다(변경 없음). `json`은 이벤트마다 한 줄짜리 JSON 객체를 출력합니다. fail-closed이며, 다른 값은 예외를 던집니다. |
|
|
158
|
+
|
|
159
|
+
`json` 모드에서 프록시 내부 오류 로그는 `{ "level": "error", "event": "proxy_internal_error", "correlationId", "errorName", "statusCode" }` 한 줄이며, 기동/종료는 `proxy_listening` / `proxy_shutdown`을 출력합니다(원격 바인드/비-enforce 모드/응답 보호 비활성화에 대한 `*_warn` 이벤트도 함께). **어떤 로그 필드도 요청/응답 페이로드, 헤더, 토큰, PII를 절대 담지 않습니다.** 오류 로그는 오류 *클래스 이름*과 요청 `correlationId`만 담습니다.
|
|
160
|
+
|
|
161
|
+
## `metrics`
|
|
162
|
+
|
|
163
|
+
| 키 | 타입 / 값 | 기본값 | 설명 |
|
|
164
|
+
|---|---|---|---|
|
|
165
|
+
| `metrics.enabled` | boolean | `true` | `GET /__haechi/metrics` 라우트를 제어합니다. `false`이면 해당 라우트는 `404`를 반환합니다. fail-closed이며, boolean이 아니면 예외를 던집니다. |
|
|
166
|
+
|
|
167
|
+
메트릭 수집기는 **주입 가능한 협력 객체**이기도 합니다(`createRuntime(config, { metrics })`). 계약과 no-PII 보장은 [운영 엔드포인트](#운영-엔드포인트)를 참고하십시오.
|
|
168
|
+
|
|
169
|
+
## 운영 엔드포인트
|
|
170
|
+
|
|
171
|
+
프록시는 예약된 `/__haechi/*` 접두어 아래에 네 개의 인증 없는 엔드포인트를 제공하며, 이들은 인증과 본문 읽기 **이전**에 처리되고 업스트림으로 프록시되지 않습니다.
|
|
172
|
+
|
|
173
|
+
| 엔드포인트 | 상태 코드 | 본문 | 용도 |
|
|
174
|
+
|---|---|---|---|
|
|
175
|
+
| `GET /__haechi/live` | `200` | `{ ok: true, version }` | 저비용 프로세스 liveness. |
|
|
176
|
+
| `GET /__haechi/ready` | `200` / `503` | `{ ready, version, checks }` | readiness. **fail-closed**: audit 로그에 append할 수 없는 게이트웨이는 ready가 **아닙니다**(`503`). 기본 JSONL sink의 `checks.auditWritable`는 이벤트를 쓰지 않고 audit 디렉터리/파일의 쓰기 가능 여부를 확인하며, `ready()`/`healthCheck()` 메서드가 없는 sink는 ready로 간주합니다. |
|
|
177
|
+
| `GET /__haechi/health` | `200` | `{ ok: true, mode, version }` | back-compat(기존 health 엔드포인트이며 이제 `version`을 포함). |
|
|
178
|
+
| `GET /__haechi/metrics` | `200` / `404` | Prometheus 텍스트 | 텔레메트리(아래 참고). `metrics.enabled: false`이면 `404`. |
|
|
179
|
+
|
|
180
|
+
`version`은 실행 중인 패키지 버전(`package.json`)입니다.
|
|
181
|
+
|
|
182
|
+
### 텔레메트리 (`/__haechi/metrics`)
|
|
183
|
+
|
|
184
|
+
이 엔드포인트는 **Prometheus 텍스트 노출 형식**(`# HELP` / `# TYPE` + `name{label="..."} value`)을 `Content-Type: text/plain`으로 렌더링합니다. 카운터: `haechi_requests_total{route,mode,decision}`와 `haechi_blocks_total`, `haechi_auth_denied_total`, `haechi_rate_limited_total`, `haechi_upstream_timeout_total`, `haechi_upstream_error_total`, `haechi_response_unprotected_total`, `haechi_internal_error_total`. 히스토그램 하나: `haechi_request_duration_seconds{route}`.
|
|
185
|
+
|
|
186
|
+
**텔레메트리 no-PII 불변식.** 모든 메트릭 이름과 **모든 라벨 값**은 경계가 정해진 enum입니다 — 라우트 id, 정책 모드, 고정된 decision 클래스(`forwarded` / `blocked` / `auth_denied` / `rate_limited` / `model_not_allowed` / …)입니다. 메트릭 라벨은 identity id/subject, 토큰, 탐지된 값을 **절대** 담지 않습니다. identity별·값별 라벨 카디널리티가 존재하지 않습니다. 이는 audit에 평문을 남기지 않는 불변식을 텔레메트리로 확장한 것이며, 메트릭 모듈은 방어적으로 라벨 값의 길이를 제한하고 문자셋을 정제합니다.
|
|
187
|
+
|
|
188
|
+
### `providers.metrics` 주입 seam
|
|
189
|
+
|
|
190
|
+
메트릭 수집기는 `createRuntime(config, providers)`를 통해 프로그램적으로 공급됩니다 — `cryptoProvider`/`authProvider`/`rateLimiter`와 동일한 seam이며, JSON 설정 키가 **아닙니다**.
|
|
191
|
+
|
|
192
|
+
```js
|
|
193
|
+
const runtime = createRuntime(config, { metrics });
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
주입된 `metrics`는 `increment(name, labels?, amount?)`, `observe(name, value, labels?)`, `render() -> string`을 구현해야 하며, 그렇지 않으면 `createRuntime`은 생성 시점에 fail-closed로 실패합니다. **기본값**은 위 Prometheus 텍스트를 렌더링하는 무의존성 인메모리 수집기입니다. 다중 레플리카 운영자는 동일한 계약을 만족하는 공유/원격 수집기를 주입합니다.
|
|
197
|
+
|
|
198
|
+
### `correlationId` (audit + 로그)
|
|
199
|
+
|
|
200
|
+
프록시는 요청마다 `correlationId`(UUID)를 생성합니다. 이 값은 protect 컨텍스트로 전달되어 한 요청의 request·response 방향 audit 이벤트가 동일한 추가(additive) 최상위 `correlationId` 필드를 갖게 하며, 프록시 내부 오류 로그 줄에도 전달되어 운영자가 기록된 오류를 그 audit 추적과 연결할 수 있게 합니다. 프록시가 아닌 `protectJson()` 호출에서는 `null`입니다(기존 동작 보존). 이 id는 UUID이며 페이로드/identity/PII 값을 **절대** 담지 않습니다.
|
|
201
|
+
|
|
202
|
+
## 환경변수 설정 오버레이 (배포)
|
|
203
|
+
|
|
204
|
+
컨테이너 / 12-factor 배포를 위해, **비밀이 아닌 운영 키의 고정 allowlist**를 환경변수로 덮어쓸 수 있습니다. 환경변수 값은 **설정 파일보다 우선**하며 **fail-closed**로 검증됩니다 — 잘못된 값은 프로세스를 기동 실패시킵니다. `loadConfig()`에서 파일을 읽은 뒤 검증 이전에 적용됩니다.
|
|
205
|
+
|
|
206
|
+
| 환경변수 | 설정 키 | 타입 / 값 |
|
|
207
|
+
|---|---|---|
|
|
208
|
+
| `HAECHI_PROXY_PORT` | `proxy.port` | 정수 0–65535 |
|
|
209
|
+
| `HAECHI_PROXY_HOST` | `proxy.host` | 비어 있지 않은 문자열 |
|
|
210
|
+
| `HAECHI_UPSTREAM` | `target.upstream` | URL 문자열 |
|
|
211
|
+
| `HAECHI_MODE` | `mode` | `dry-run` \| `report-only` \| `enforce` |
|
|
212
|
+
| `HAECHI_LOG_FORMAT` | `logging.format` | `text` \| `json` |
|
|
213
|
+
|
|
214
|
+
**비밀은 설계상 오버레이 대상이 아닙니다.** `keys.*`, auth 토큰 저장소, 토큰/비밀에 대한 `HAECHI_*` 변수는 **없습니다**. 비밀은 설정 파일에 두거나 주입된 provider(`createRuntime(config, { cryptoProvider, authProvider, … })`)로 공급합니다. 비밀을 프로세스 환경에 두면 `/proc`, 크래시 덤프, 오케스트레이터 inspect 출력으로 누출될 위험이 있으므로 오버레이 allowlist에서 제외합니다. [운영 런북](./operations-runbook.md#2-configuration-via-the-env-var-overlay)을 참고하십시오.
|
|
128
215
|
|
|
129
216
|
## `mcp`
|
|
130
217
|
|
|
@@ -173,15 +260,69 @@ upstream JSON 응답을 검사합니다(기본적으로 꺼져 있습니다 —
|
|
|
173
260
|
| `policy.profiles` | `{ <name>: { presets?, actions?, modelAllowlist?, rate? } }` | `{}` | Named profile입니다. 각각 기본 policy를 재정의합니다. |
|
|
174
261
|
| `policy.profileBinding` | `{ byScope?, byLabel?, default }` | 미설정 | identity scope/label(`"k=v"` 형태)을 profile 이름으로 매핑합니다. `profiles`가 설정된 경우 `default`는 **필수**이며 가장 엄격한 profile이어야 합니다(fail-closed). |
|
|
175
262
|
| `policy.modelAllowlist` | 문자열 배열 | 미설정 | 허용된 `model` 값입니다(기본 레벨; profile별로도 설정 가능). 허용되지 않은 모델 → `403`. 비어 있거나 없으면 모두 허용합니다. |
|
|
176
|
-
| `policy.rate` | `{ requestsPerMinute }` | 미설정 | identity별 요청 rate limit입니다(기본 레벨 또는 profile별). 초과 시 → `429`. 인메모리, 프로세스별입니다. |
|
|
263
|
+
| `policy.rate` | `{ requestsPerMinute }` | 미설정 | identity별 요청 rate limit입니다(기본 레벨 또는 profile별). 초과 시 → `429`. 인메모리, 프로세스별입니다. 다중 replica 시임은 [Rate limiter 주입](#rate-limiter-주입)을 참고하십시오. |
|
|
177
264
|
|
|
178
265
|
### Named profiles
|
|
179
266
|
|
|
180
267
|
identity가 인증되면 **scope → label → `default`** 순으로 profile이 resolve됩니다. scope가 label보다 우선하며 첫 번째 매칭이 적용됩니다. `profiles`가 없거나 `auth.provider: none`인 경우 기본 policy가 적용됩니다. Resolve된 profile의 policy 엔진, `modelAllowlist`, `rate`가 해당 요청을 처리합니다.
|
|
181
268
|
|
|
269
|
+
### Rate limiter 주입
|
|
270
|
+
|
|
271
|
+
rate limiter는 **주입 가능한 collaborator**이며, `createRuntime(config, providers)`의 `providers` 인자를 통해 프로그래밍 방식으로 공급됩니다 — 외부 `cryptoProvider`/`authProvider`와 동일한 시임입니다. JSON config 키가 **아닙니다**.
|
|
272
|
+
|
|
273
|
+
```js
|
|
274
|
+
const runtime = createRuntime(config, { rateLimiter });
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
주입된 `rateLimiter`는 `allow(key, limit)`을 구현해야 하며, `boolean` **또는** `Promise<boolean>`을 반환합니다(`key`는 identity별 버킷, `limit`은 resolve된 `requestsPerMinute`입니다). 구현하지 않으면 `createRuntime`이 construction 시점에 fail-closed로 throw합니다. proxy는 결과를 `await`하므로 동기 boolean과 비동기 공유 저장소 limiter가 동일하게 동작합니다 — 내장 기본값은 동기로 유지되고, 비동기로 resolve되는 Redis 기반 limiter도 올바르게 gate합니다. proxy는 rate 통제 대상 요청마다 `runtime.rateLimiter`를 참조합니다.
|
|
278
|
+
|
|
279
|
+
**기본값**은 프로세스별 인메모리 fixed-window 카운터입니다. 재시작 시 초기화되며 **replica 간에 공유되지 않으므로**, load balancer 뒤에서 총 처리량은 replica 수만큼 곱해집니다. window map은 self-bounding입니다(lazy, amortized sweep로 만료된 one-shot identity를 제거합니다 — 백그라운드 timer 없음). 다중 replica 배포에서는 공유 front door에서 identity별 limit을 강제하거나, 동일한 `allow(key, limit)` 계약을 만족하는 공유 저장소 구현(예: Redis 기반)을 주입하십시오 — [`haechi-ratelimit-redis`](./shared-responsibility.ko.md#4-수평-확장--다중-복제) satellite가 레퍼런스 구현입니다. [Shared responsibility §4](./shared-responsibility.ko.md#4-수평-확장--다중-복제)를 참고하십시오.
|
|
280
|
+
|
|
182
281
|
## Detection type과 action
|
|
183
282
|
|
|
184
|
-
내장 탐지 `type` 값은 다음과 같습니다: `email`, `phone`, `kr_rrn`, `card`, `api_key`, `secret`, `injection`(응답 방향 휴리스틱, 기본 report-only). 커스텀 규칙으로 새로운 type을 추가할 수 있습니다.
|
|
283
|
+
내장 탐지 `type` 값은 다음과 같습니다: `email`, `phone`, `kr_rrn`, `card`, `api_key`, `secret`, `us_ssn`, `iban`, `jp_mynumber`, `fr_nir`, `es_dni`, `uk_nino`, `it_codice_fiscale`, `sg_nric`, `in_aadhaar`, `de_steuer_id`, `nl_bsn`, `injection`(응답 방향 휴리스틱, 기본 report-only). 커스텀 규칙으로 새로운 type을 추가할 수 있습니다.
|
|
284
|
+
|
|
285
|
+
### 지원하는 자격증명·PII 매트릭스
|
|
286
|
+
|
|
287
|
+
탐지는 정규식 + 선택적 validator로 동작합니다(ML 미사용). 모든 규칙은 정밀도를 높게 유지하기 위해 **단단히 anchoring**되어 있으며, recall보다 precision을 우선합니다. 코퍼스(`tests/fixtures/detection-corpus.json`)에는 규칙마다 hard-negative가 포함됩니다. KR phone 규칙과 US SSN/IBAN validator는 유사 형태의 id·timestamp를 거부합니다.
|
|
288
|
+
|
|
289
|
+
| Type | 탐지 대상 | Anchor / validator | 비고 |
|
|
290
|
+
|---|---|---|---|
|
|
291
|
+
| `email` | RFC 형식 주소 | local + domain + TLD | — |
|
|
292
|
+
| `phone` | KR 휴대폰(`01[016789]`, `+82`) | 구분자 없는 bare run은 `0`으로 시작해야 함 | KR 유선번호는 범위 외입니다. |
|
|
293
|
+
| `phone` | E.164 국제번호 | **선행 `+` 필수**(`+[1-9]` + 6–14자리) | bare 숫자열은 절대 매칭하지 않습니다(id·timestamp와 충돌). |
|
|
294
|
+
| `phone` | US/NANP 국내번호 | **구분자 필수**(`(NXX) NXX-XXXX` 또는 `NXX-NXX-XXXX`) | 구분자 없는 10자리 숫자열은 매칭하지 않습니다. |
|
|
295
|
+
| `kr_rrn` | 주민등록번호 | 검증 숫자 validator | 형식은 맞으나 checksum 불일치 → 거부. |
|
|
296
|
+
| `card` | 결제 카드(PAN) | Luhn validator, 13–19자리 | — |
|
|
297
|
+
| `us_ssn` | 미국 사회보장번호 | `AAA-GG-SSSS` + SSA 범위 validator(area `000`/`666`/`900-999`, group `00`, serial `0000` 거부) | 구분자 필수이며, bare 9자리 id는 SSN이 아닙니다. |
|
|
298
|
+
| `iban` | 국제 은행계좌번호 | **mod-97 checksum** validator | checksum이 정밀도 가드입니다 — IBAN 형태이지만 97 비검증 문자열은 거부됩니다. |
|
|
299
|
+
| `jp_mynumber` | 일본 마이넘버(個人番号) | 12자리 + **mod-11 가중 check digit** | check digit이 정밀도 가드입니다; check 불일치 12자리 run은 거부됩니다. **하드 블록.** |
|
|
300
|
+
| `fr_nir` | 프랑스 NIR / INSEE 사회보장번호 | 15자 + **`97 - (앞13 mod 97)` 제어키**(코르시카 `2A`→19, `2B`→18) | 제어키 불일치는 거부됩니다. **하드 블록.** |
|
|
301
|
+
| `es_dni` | 스페인 DNI / NIE | 8자리(DNI) 또는 `X/Y/Z`+7자리(NIE) + **mod-23 check letter**(NIE `X/Y/Z`→`0/1/2`) | check letter 불일치는 거부됩니다. **하드 블록.** |
|
|
302
|
+
| `uk_nino` | 영국 국민보험번호 | `[A-CEGHJ-PR-TW-Z][A-CEGHJ-NPR-TW-Z]\d{6}[A-D]` + 문서화된 무효 prefix 제외(`BG`/`GB`/`NK`/`KN`/`TN`/`NT`/`ZZ`, 2번째 글자 `O`) | **format-only — checksum이 없으므로** 하드 블록 타입이 아닙니다(dial 가능: 운영자가 양성 FP를 allowlist 할 수 있음). |
|
|
303
|
+
| `it_codice_fiscale` | 이탈리아 codice fiscale | `[A-Z]{6}\d{2}[A-Z]\d{2}[A-Z]\d{3}[A-Z]` + **mod-26 check character**(앞 15자에 대한 홀수/짝수 위치 테이블) | check character 불일치는 거부됩니다. **하드 블록** — 희귀한 16자 알파+숫자 혼합 형태에 비숫자 구조 앵커가 있습니다(형태에 대한 측정 충돌률 ~3.8%). |
|
|
304
|
+
| `sg_nric` | 싱가포르 NRIC / FIN | `[STFGM]\d{7}[A-Z]` + **가중합 check letter**(가중치 2,7,6,5,4,3,2; prefix별 offset; series별 letter 테이블) | check letter 불일치는 거부됩니다. **하드 블록** — 희귀한 형태에 비숫자 앵커가 둘(prefix letter + check letter)입니다(측정 충돌률 ~3.9%). |
|
|
305
|
+
| `in_aadhaar` | 인도 Aadhaar | 12자리(`0`/`1`로 시작 불가) + **Verhoeff 체크섬** | Verhoeff check digit 불일치는 거부됩니다. **하드 블록 타입이 아닙니다(dial 가능)** — 흔한 12자리 형태에 대한 Verhoeff는 무작위 run의 ~1/10이 통과하므로(측정 ~9.9%, `jp_mynumber` footgun), 운영자가 양성 12자리 ID FP를 allowlist 할 수 있습니다. |
|
|
306
|
+
| `de_steuer_id` | 독일 세금 ID(Steuer-ID) | 11자리 + **ISO 7064 MOD 11,10** check digit + 앞 10자리에 "정확히 한 숫자만 반복" 구조 테스트 | check digit 또는 반복 구조 불일치는 거부됩니다. **하드 블록 타입이 아닙니다(dial 가능)** — 흔한 길이의 비숫자 앵커 없는 11자리 run이므로(측정 충돌률 ~0.37%이지만 `jp_mynumber` 원칙상 bare-digit 형태는 allowlist로 정리 가능하게 둡니다). |
|
|
307
|
+
| `nl_bsn` | 네덜란드 BSN | 9자리 + **"11-proef"** 가중 mod-11 | 11-proef를 통과하지 못하는 run은 거부됩니다. **하드 블록 타입이 아닙니다(dial 가능)** — 9 bare-digit은 매우 흔하고 11-proef는 무작위 run의 ~1/11이 통과하므로(측정 ~9.1%), 가장 명확한 dial 가능 사례입니다. |
|
|
308
|
+
| `api_key` | OpenAI 형식 / Stripe(`sk_`/`rk_`/`pk_`) | prefix + 24자 이상 | 언더스코어 형식 — Stripe `sk_live_`/`rk_live_`/`sk_test_`/`rk_test_`를 포함합니다. |
|
|
309
|
+
| `api_key` | AWS access key id | `AKIA`/`ASIA` + 정확히 16자 대문자-alnum | — |
|
|
310
|
+
| `api_key` | Google API key | `AIza` + 35자 URL-safe 문자 | — |
|
|
311
|
+
| `api_key` | SendGrid API key | `SG.` + 22자 URL-safe + `.` + 43자 URL-safe | 고정 길이 두 개의 점-구분 세그먼트가 anchor입니다. |
|
|
312
|
+
| `api_key` | Twilio Account/API SID | `AC`/`SK` + 정확히 32자 **hex** | hex 전용 본문이 무작위 base62를 거부합니다; bare 32-hex AUTH TOKEN은 할당식(`auth_token`)으로 포착합니다. |
|
|
313
|
+
| `secret` | OpenAI API key | `sk-`(및 `sk-proj-`) + 20자 이상 base62 유사 문자 | **하이픈** 형식으로 언더스코어 Stripe `sk_`와 구분되며, 두 prefix는 절대 겹치지 않습니다. |
|
|
314
|
+
| `secret` | Anthropic API key | `sk-ant-` + 16자 이상 | OpenAI `sk-` 규칙의 더 엄격한 형제 규칙입니다(attribution을 위해 먼저 실행). |
|
|
315
|
+
| `secret` | Google OAuth client secret | `GOCSPX-` + 정확히 28자 URL-safe 문자 | `AIza` API key와 구분됩니다. |
|
|
316
|
+
| `secret` | npm token | `npm_` + 정확히 36자 base62 문자 | — |
|
|
317
|
+
| `secret` | `Bearer <token>` | `Bearer` + 16자 이상 | — |
|
|
318
|
+
| `secret` | 할당식 `<key> = <value>` | 키 어휘: `api_key`, `api_secret`, `secret`, `secret_key`, `aws_secret_access_key`, `client_secret`, `private_key`, `access_token`, `refresh_token`, `auth_token`, `accountkey`, `token`, `password` | bare-base64 시크릿(AWS secret access key, **Azure Storage `AccountKey=`**, **Twilio auth token**)을 할당식 형태로 포착합니다 — 앵커 없는 88자 base64 Azure 규칙은 임의의 blob에 오탐하므로 `AccountKey=` 컨텍스트가 anchor입니다. |
|
|
319
|
+
| `secret` | GitHub token | `gh[pousr]_` + 36자 이상 base64 유사 문자 | pat/oauth/user/server/refresh 변형. |
|
|
320
|
+
| `secret` | Slack token | `xox[baprs]-` + 10자 이상 본문 | bot/user/refresh/legacy 변형. |
|
|
321
|
+
| `secret` | JWT | 점으로 구분된 3개 base64url 세그먼트, 첫 세그먼트가 `eyJ`(즉 `{"`의 base64)로 시작 | `eyJ` anchor가 임의의 점-구분 토큰을 거부합니다. |
|
|
322
|
+
| `secret` | PEM private key | `-----BEGIN … PRIVATE KEY-----` armor 헤더 | 헤더 존재가 신호이며, "private key"를 언급한 산문은 매칭하지 않습니다. |
|
|
323
|
+
| `injection` | 프롬프트 인젝션 휴리스틱 | 응답 방향 전용, 기본 `allow` | [Action strength](#action-strength) 참고; report-only. |
|
|
324
|
+
|
|
325
|
+
탐지는 문자열 값, JSON number leaf(요청 방향), object key를 대상으로 합니다. 각 **string leaf는 매칭 전 NFKC 정규화**되므로, 유니코드 난독화 형태(전각 숫자 `4242…`, 전각 `@`, 수학·원문자 영숫자)도 ASCII 호환 형태로 접혀 탐지됩니다. 접힘이 UTF-16 길이를 보존하면 우회된 정확한 구간을 redact/block하고, 길이가 달라지면(예: 수학 숫자·합자) 탐지가 fail closed되어 leaf 전체를 redact/block합니다. base64/percent-encoded 값(디코딩 후)과 URL query 문자열은 문서화된 제외 항목으로 남습니다(`docs/current/threat-model.md` 참고). 응답 방향에서는 Haechi 자체 transform marker와 bare JSON number leaf를 건너뜁니다(요청 방향은 항상 전체 스캔).
|
|
185
326
|
|
|
186
327
|
Action(약한 것 → 강한 것 순):
|
|
187
328
|
|
|
@@ -240,17 +381,32 @@ preset과 override(또는 privacy profile)가 충돌할 경우 **강한** action
|
|
|
240
381
|
|
|
241
382
|
## loopback 밖으로 바인딩
|
|
242
383
|
|
|
243
|
-
proxy는 CLI 플래그를 명시적으로 전달하지 않으면 loopback이 아닌 host를 거부합니다 — 설정 파일에 `proxy.host: "0.0.0.0"`을 지정해도 의도적으로 시작되지
|
|
384
|
+
proxy는 CLI 플래그를 명시적으로 전달하지 않으면 loopback이 아닌 host를 거부합니다 — 설정 파일에 `proxy.host: "0.0.0.0"`을 지정해도 의도적으로 시작되지 않습니다. remote bind에는 **TLS가 추가로 필요합니다**: Haechi가 직접 TLS를 종단하거나(`proxy.tls`), 앞단의 TLS 종단기를 명시적으로 확인해야 합니다(`proxy.trustForwardedProto`). 둘 다 없는 remote bind는 **기동 시 throw**합니다 — Haechi는 loopback이 아닌 리스너에서 bearer token과 payload를 평문으로 제공하지 않습니다.
|
|
385
|
+
|
|
386
|
+
**옵션 A — Haechi가 직접 TLS를 종단**(`https` 제공):
|
|
244
387
|
|
|
388
|
+
```jsonc
|
|
389
|
+
// haechi.config.json
|
|
390
|
+
"proxy": { "host": "0.0.0.0", "tls": { "keyFile": "/etc/haechi/tls/key.pem", "certFile": "/etc/haechi/tls/cert.pem" } }
|
|
391
|
+
// 또는 PKCS#12: "tls": { "pfxFile": "/etc/haechi/tls/server.pfx", "passphrase": "…" }
|
|
392
|
+
```
|
|
245
393
|
```bash
|
|
246
394
|
haechi proxy --config haechi.config.json --host 0.0.0.0 --allow-remote-bind
|
|
395
|
+
# → Haechi proxy listening on https://0.0.0.0:11016
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
**옵션 B — 신뢰하는 reverse proxy가 앞단에서 TLS를 종단**(Haechi는 그 뒤 사설망에서 plain `http`로 유지):
|
|
399
|
+
|
|
400
|
+
```jsonc
|
|
401
|
+
"proxy": { "host": "0.0.0.0", "trustForwardedProto": true }
|
|
247
402
|
```
|
|
403
|
+
`trustForwardedProto: true`이면 Haechi는 **`X-Forwarded-Proto`가 `https`가 아닌 모든 요청을**(TLS hop을 우회한 평문 요청을) auth/body 이전에 fail-closed `403`으로 거부합니다. `/__haechi/*` liveness/metrics 라우트는 loopback sidecar가 스크레이프할 수 있도록 예외입니다. 오직 신뢰하는 종단기만 `X-Forwarded-Proto`를 설정해야 합니다 — 신뢰할 수 없는 클라이언트가 Haechi 포트에 직접 도달할 수 있다면 이 옵션을 켜지 마십시오.
|
|
248
404
|
|
|
249
405
|
**proxy는 bearer 클라이언트 인증을 제공합니다**(`auth.provider: bearer`, 0.6에서 출시). 해시 기반 token 저장소, identity별 policy profile, model allowlist, identity별 rate limit을 함께 제공합니다([`auth`](#auth)와 [Named profiles](#named-profiles) 참고). 기본값 `auth.provider: none`은 proxy를 인증 없이 둡니다 — `none`에서는 포트에 접근할 수 있는 누구든 upstream과 token round-trip 경로를 사용할 수 있습니다. 내장 rate limit은 단일 프로세스(인메모리, 프로세스별)이므로, 여러 replica는 공유 limiter를 앞에 두어야 합니다. `--allow-remote-bind`는 어느 경우에도 명시적인 네트워크 통제 하에서만 사용해야 합니다 — 컨테이너 안에서 `0.0.0.0`으로 바인드하고 host 포트 매핑을 제한하거나(`-p 127.0.0.1:11016:11016`), 방화벽/VPN/인증 reverse proxy 뒤에 두어야 합니다.
|
|
250
406
|
|
|
251
407
|
## 검증 요약
|
|
252
408
|
|
|
253
|
-
다음은 로드 시 오류(fail-closed)를 발생시킵니다: 알 수 없는 `keys.provider`; 빈 `proxy.host`; 범위를 벗어난 `proxy.port`; `jsonl`이 아닌 `audit.sink`; `local`이 아닌 `tokenVault.provider`; 잘못된 `revealPolicy`; 양수가 아닌 `retentionDays`; boolean이 아닌 `deterministic`/`detokenizeResponses`; 비어 있거나 문자열이 아닌 `deterministicTypes`; 비어 있거나 문자열이 아닌 `mcp.allowedMethods`; boolean이 아닌 `mcp.*` 플래그; 알 수 없는 `privacy.profile`; 잘못된 `responseProtection.failureMode`; 양수가 아닌 `responseProtection.maxBytes`; boolean이 아닌 `responseProtection.scanNumbers`; 잘못된 `streaming.requestMode`; 잘못된 `streaming.responseMode`; 양수가 아닌 `streaming.maxMatchBytes`; 잘못된 `auth.provider`; 빈 `auth.store`; 문자열이 아닌 `auth.allowedLabelKeys`; 객체가 아닌 `policy.profiles`; 유효한 `default` 없는 `policy.profileBinding`; 문자열이 아닌 `policy.modelAllowlist`; 양수가 아닌 `policy.rate.requestsPerMinute`; 양수가 아닌 `limits
|
|
409
|
+
다음은 로드 시 오류(fail-closed)를 발생시킵니다: 알 수 없는 `keys.provider`; 빈 `proxy.host`; 범위를 벗어난 `proxy.port`; boolean이 아닌 `proxy.trustForwardedProto`; non-`null`이지만 object가 아니거나, `keyFile`만 있고 `certFile`이 없거나(또는 그 반대), `pfxFile`을 `keyFile`/`certFile`과 함께 쓰거나, 읽을 수 없는 파일을 지정하거나, 사용 가능한 자료 `((key && cert) 또는 pfx)`로 해석되지 않는 `proxy.tls`; `jsonl`이 아닌 `audit.sink`; `local`이 아닌 `tokenVault.provider`; 잘못된 `revealPolicy`; 양수가 아닌 `retentionDays`; boolean이 아닌 `deterministic`/`detokenizeResponses`; 비어 있거나 문자열이 아닌 `deterministicTypes`; 비어 있거나 문자열이 아닌 `mcp.allowedMethods`; boolean이 아닌 `mcp.*` 플래그; 알 수 없는 `privacy.profile`; 잘못된 `responseProtection.failureMode`; 양수가 아닌 `responseProtection.maxBytes`; boolean이 아닌 `responseProtection.scanNumbers`; 잘못된 `streaming.requestMode`; 잘못된 `streaming.responseMode`; 양수가 아닌 `streaming.maxMatchBytes`; 잘못된 `auth.provider`; 빈 `auth.store`; 문자열이 아닌 `auth.allowedLabelKeys`; 객체가 아닌 `policy.profiles`; 유효한 `default` 없는 `policy.profileBinding`; 문자열이 아닌 `policy.modelAllowlist`; 양수가 아닌 `policy.rate.requestsPerMinute`; 양수가 아닌 `limits.maxRequestBytes`/`limits.upstreamTimeoutMs`/`limits.maxNestingDepth`; 음수이거나 정수가 아닌 `limits.maxInFlight`/`limits.shutdownGraceMs`; `null`이 아니면서 음수이거나 정수가 아닌 `limits.requestTimeoutMs`/`limits.headersTimeoutMs`; 양수 정수가 아니거나 **지원 범위를 넘는** `configVersion`; 알 수 없는 `target.type`/`adapter`; 안전하지 않은 커스텀 정규식; `allowUnsafeOverrides` 없이 action을 약화하려는 시도; `text`/`json`이 아닌 `logging.format`; boolean이 아닌 `metrics.enabled`; 잘못된 `HAECHI_*` 환경변수 오버레이 값(잘못된 `HAECHI_PROXY_PORT`, 알 수 없는 `HAECHI_MODE`, 형식이 잘못된 `HAECHI_UPSTREAM` 등).
|
|
254
410
|
|
|
255
411
|
# Satellite 운영자 설정 (0.9)
|
|
256
412
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Haechi Configuration Reference
|
|
2
2
|
|
|
3
|
-
- Status: Living document (tracks core 1.
|
|
3
|
+
- Status: Living document (tracks core 1.3.x)
|
|
4
4
|
|
|
5
5
|
`haechi init` writes `haechi.config.json`; a non-secret template is at `haechi.config.example.json`. Every command reads it with `--config <path>` (default `haechi.config.json`). Configuration is **validated fail-closed**: unknown providers, out-of-range numbers, and malformed values throw at load time rather than degrading silently. `haechi config` prints this reference; `haechi status` prints the *effective* state of a given config.
|
|
6
6
|
|
|
@@ -8,18 +8,21 @@
|
|
|
8
8
|
|
|
9
9
|
```json
|
|
10
10
|
{
|
|
11
|
+
"configVersion": 1,
|
|
11
12
|
"mode": "dry-run",
|
|
12
13
|
"target": { "type": "llm-http", "adapter": "openai-compatible", "upstream": "http://127.0.0.1:9999" },
|
|
13
|
-
"proxy": { "host": "127.0.0.1", "port": 11016 },
|
|
14
|
+
"proxy": { "host": "127.0.0.1", "port": 11016, "tls": null, "trustForwardedProto": false },
|
|
14
15
|
"responseProtection": { "enabled": false, "mode": "enforce", "failureMode": "fail-closed", "allowNonJson": false, "allowCompressed": false, "maxBytes": 1048576 },
|
|
15
16
|
"streaming": { "requestMode": "block" },
|
|
16
|
-
"limits": { "maxRequestBytes": 1048576, "upstreamTimeoutMs": 120000, "maxNestingDepth": 256 },
|
|
17
|
+
"limits": { "maxRequestBytes": 1048576, "upstreamTimeoutMs": 120000, "maxNestingDepth": 256, "maxInFlight": 0, "shutdownGraceMs": 10000, "requestTimeoutMs": null, "headersTimeoutMs": null },
|
|
17
18
|
"policy": { "mode": "dry-run", "presets": ["korean-pii", "secrets-only", "llm-redact"], "defaultAction": "redact", "actions": { "card": "block" } },
|
|
18
19
|
"filters": { "customRules": [] },
|
|
19
20
|
"keys": { "provider": "local", "keyFile": ".haechi/dev.keys.json" },
|
|
20
21
|
"audit": { "sink": "jsonl", "path": ".haechi/audit.jsonl" },
|
|
21
22
|
"tokenVault": { "provider": "local", "path": ".haechi/token-vault.json", "revealPolicy": "disabled", "retentionDays": 30, "deterministic": false, "deterministicTypes": null, "detokenizeResponses": false },
|
|
22
23
|
"privacy": { "profile": null },
|
|
24
|
+
"logging": { "format": "text" },
|
|
25
|
+
"metrics": { "enabled": true },
|
|
23
26
|
"mcp": { "allowedMethods": ["initialize", "tools/call", "resources/read", "prompts/get"], "protectParams": true, "protectResults": true, "requireJsonRpc": true }
|
|
24
27
|
}
|
|
25
28
|
```
|
|
@@ -28,13 +31,14 @@
|
|
|
28
31
|
|
|
29
32
|
| Key | Type / values | Default | Notes |
|
|
30
33
|
|---|---|---|---|
|
|
34
|
+
| `configVersion` | positive integer | `1` | Config schema version stamp. Absent = treated as the current version. A value **newer** than this build understands **fails closed** at load; a non-positive/non-integer value throws. See [`config-version.md`](./config-version.md). |
|
|
31
35
|
| `mode` | `dry-run` \| `report-only` \| `enforce` | `dry-run` | Global enforcement mode. `dry-run`/`report-only` detect + audit only; `enforce` transforms/blocks. Overridden by `policy.mode` when set. |
|
|
32
36
|
|
|
33
37
|
## `target`
|
|
34
38
|
|
|
35
39
|
| Key | Type / values | Default | Notes |
|
|
36
40
|
|---|---|---|---|
|
|
37
|
-
| `target.type` | `llm-http` \| `openai-compatible` \| `vllm-openai` \| `ollama` \| `llama-cpp` | `llm-http` | Selects the protocol adapter. `llm-http` aliases `openai-compatible`. Unknown values **fail closed** at load. |
|
|
41
|
+
| `target.type` | `llm-http` \| `openai-compatible` \| `vllm-openai` \| `ollama` \| `llama-cpp` \| `anthropic` \| `gemini` | `llm-http` | Selects the protocol adapter. `llm-http` aliases `openai-compatible`. `anthropic` targets the Anthropic Messages API (`/v1/messages`, `/v1/messages/count_tokens`, `/v1/complete`); the client supplies Anthropic's `x-api-key`/`anthropic-version` headers and the proxy forwards them. `gemini` targets the Google Gemini API, whose endpoints are model-in-path with a `:method` suffix (`/v1beta/models/{model}:generateContent`, `:streamGenerateContent` (SSE), `:countTokens`, `:embedContent`, `:batchEmbedContents`; `/v1` or `/v1beta` prefix, arbitrary `{model}`); the client supplies Gemini's `x-goog-api-key` (or `?key=`) and the proxy forwards it. Unknown values **fail closed** at load. |
|
|
38
42
|
| `target.adapter` | same set | `openai-compatible` | Explicit adapter override; usually leave unset and let `type` decide. |
|
|
39
43
|
| `target.upstream` | URL string | `http://127.0.0.1:9999` | The only upstream the proxy forwards to. Request targets must be origin-form paths; absolute-URL targets are rejected (SSRF guard). |
|
|
40
44
|
|
|
@@ -44,6 +48,8 @@
|
|
|
44
48
|
|---|---|---|---|
|
|
45
49
|
| `proxy.host` | non-empty string | `127.0.0.1` | Bind address. Non-loopback hosts require the `--allow-remote-bind` CLI flag — config alone will not start (see [Binding beyond loopback](#binding-beyond-loopback)). |
|
|
46
50
|
| `proxy.port` | integer 0–65535 | `11016` | Listen port (`0` = ephemeral). Override per-run with `--port`. |
|
|
51
|
+
| `proxy.tls` | `null` or `{ keyFile, certFile }` / `{ pfxFile, passphrase? }` | `null` | TLS material loaded from **file paths** at startup into a TLS context. When present, Haechi terminates TLS itself (serves `https`). Required (or `trustForwardedProto`) for a remote bind — see [Binding beyond loopback](#binding-beyond-loopback). Fail-closed: a non-null value that does not resolve to usable material `((key && cert) or pfx)`, mixes `pfxFile` with `keyFile`/`certFile`, or names an unreadable file throws at load. |
|
|
52
|
+
| `proxy.trustForwardedProto` | boolean | `false` | Operator acknowledgement that a **trusted reverse proxy terminates TLS** in front of Haechi. When `true`, a remote bind may stay plain `http`, but Haechi then **refuses any request whose `X-Forwarded-Proto` is not `https`** (checked before auth/body; the `/__haechi/*` liveness routes are exempt). Never a substitute for real TLS when Haechi is itself internet-facing. |
|
|
47
53
|
|
|
48
54
|
## `responseProtection`
|
|
49
55
|
|
|
@@ -74,6 +80,10 @@ Inspects upstream JSON responses (off by default — turn on to protect what com
|
|
|
74
80
|
| `limits.maxRequestBytes` | positive integer | `1048576` | Request body cap; over the limit returns `413`. Enforced incrementally (the body is not fully buffered first). |
|
|
75
81
|
| `limits.upstreamTimeoutMs` | positive integer | `120000` | Upstream request timeout; on expiry returns `504 haechi_upstream_timeout`. Connection failure returns `502 haechi_upstream_unreachable`. |
|
|
76
82
|
| `limits.maxNestingDepth` | positive integer | `256` | Max JSON nesting depth walked during detection. A more deeply nested body is rejected `413 haechi_request_too_deeply_nested` (fail-closed, before upstream), guarding the recursive payload walk against a stack overflow. Bounds container descent; leaves at the limit are still inspected. (Separately, a non-UTF-8 request body is rejected fail-closed: `400 haechi_request_body_not_utf8`.) |
|
|
83
|
+
| `limits.maxInFlight` | non-negative integer | `0` | Global max-in-flight backpressure ceiling. `0` disables it (no ceiling — 1.1 behavior). When `> 0` and the live in-flight count is at the ceiling, a **new** request is rejected `503` with a `Retry-After` header and `{ "error": "haechi_overloaded" }`, **before** auth/body-read. The `/__haechi/*` observability routes are **exempt** (liveness + metrics stay scrapable under saturation). Each rejection increments `haechi_overloaded_total`. See the [operations runbook](./operations-runbook.md#5-backpressure-tuning). |
|
|
84
|
+
| `limits.shutdownGraceMs` | non-negative integer (ms) | `10000` | Graceful-shutdown grace period. On `SIGINT`/`SIGTERM` the proxy stops accepting connections, closes idle keep-alive sockets immediately, waits for in-flight requests to drain, then after this grace force-closes any lingering socket so a stuck keep-alive cannot hold shutdown open forever. Also seeds the backpressure `Retry-After` seconds. Set your orchestrator's termination grace **above** this value. |
|
|
85
|
+
| `limits.requestTimeoutMs` | `null` \| non-negative integer (ms) | `null` | Maps to the Node HTTP server `requestTimeout`. `null` leaves Node's default untouched (behavior unchanged). Set a number to cap slow whole-request delivery; `0` disables the timeout (Node semantics). |
|
|
86
|
+
| `limits.headersTimeoutMs` | `null` \| non-negative integer (ms) | `null` | Maps to the Node HTTP server `headersTimeout`. `null` leaves Node's default untouched. Set a number to cap slow header delivery (slow-loris); `0` disables it. |
|
|
77
87
|
|
|
78
88
|
## `policy`
|
|
79
89
|
|
|
@@ -93,6 +103,20 @@ The detect→decide core. See [Detection types & actions](#detection-types--acti
|
|
|
93
103
|
| Key | Type / values | Default | Notes |
|
|
94
104
|
|---|---|---|---|
|
|
95
105
|
| `filters.customRules` | array of rule objects | `[]` | Extra detection rules: `{ id, type, pattern, flags?, confidence? }`. Patterns are ReDoS-screened (≤500 chars, no nested quantifiers, no backreferences) and rejected at load if unsafe. |
|
|
106
|
+
| `filters.minConfidence` | number in `[0, 1]` | `0` | Precision dial. Each rule carries a `confidence` (0.6–0.95); a detection whose confidence is **below** this threshold is dropped before the policy decides. `0` (the default) gates nothing, preserving prior behavior. **Hard-block exemption:** a hard-block type (`secret`, `api_key`, `kr_rrn`, `card`, and the strong-anchored national IDs `fr_nir`, `es_dni`, `it_codice_fiscale`, `sg_nric`) is **never** dropped on confidence — `minConfidence` trims only the precision-risky soft/dial-eligible types (e.g. `phone`, `email`, `jp_mynumber`, `uk_nino`, `in_aadhaar`, `de_steuer_id`, `nl_bsn`, `injection`), so a low-confidence credential/PII leak is still acted on (fail-closed). |
|
|
107
|
+
| `filters.allowlist` | array of strings and/or `{ value?, path? }` | `[]` | Operator false-positive exceptions. A detection whose matched **value** equals a string/`value` entry, or whose PII-safe JSON **path** (the hashed `pathText`, as shown in the audit) equals a `path` entry, is suppressed before the policy decides (when an entry sets both `value` and `path`, **both** must match). **Hard-block exemption:** an entry that would suppress a hard-block type (`secret`/`api_key`/`kr_rrn`/`card`/`fr_nir`/`es_dni`/`it_codice_fiscale`/`sg_nric`) is **ignored** and the detection still fires — the allowlist can only clear a benign **soft / dial-eligible** FP (e.g. a `jp_mynumber`/`in_aadhaar` 12-digit-id FP, a `de_steuer_id` 11-digit-id FP, a `nl_bsn` 9-digit-id FP, or a format-only `uk_nino`), never silence a credential/strong-anchored-PII leak. Every suppression and every `minConfidence` drop is **audited** by count and type (`summary.suppressedByType` / `summary.droppedByType` / `suppressedCount` / `droppedCount`) — never the raw value. Use this to clear one benign FP without deleting a whole rule. |
|
|
108
|
+
| `filters.decodeAndRescan` | boolean | `false` | Opt-in base64/percent **decode-and-rescan** (the WS2d residual). With the default `false`, detection is byte-identical to before — a base64- or percent-encoded value is **not** decoded. When `true`, after the normal NFKC scan a string leaf that **looks** base64/base64url (anchored alphabet, valid length, `16…8192` bytes, round-trips to the same leaf, decodes to **valid UTF-8**) or contains a `%XX` escape (`decodeURIComponent`) is decoded and rescanned with the same rules + validators. A decoded hit has no offset in the encoded leaf, so it fails closed to a **whole-leaf** detection (`start:0,end:leaf.length`, value = the whole encoded leaf) — the transform redacts/blocks the entire leaf. **Precision guard:** a decoded hit fires **only** when it is validator-backed or a hard-block type (a Luhn-passing `card`, a checksum `kr_rrn`/`us_ssn`, an IBAN mod-97, or a `secret`/`api_key` on its anchored rule); a decoded soft-type-without-validator match (a bare phone-shaped run) does not fire, so random base64 does not false-positive. No new runtime dependency (`node:buffer` Buffer + the `decodeURIComponent` builtin). Other encodings (gzip/hex/nested/custom-alphabet) stay out of scope. |
|
|
109
|
+
|
|
110
|
+
### Detection benchmark
|
|
111
|
+
|
|
112
|
+
Detection precision/recall is measured, not assumed. A labeled corpus of synthetic test fixtures (`tests/fixtures/detection-corpus.json` — positive samples per type plus benign hard-negatives) drives a per-type scorer:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
npm run bench:detection # print the per-type TP/FP/FN + precision/recall table
|
|
116
|
+
npm run scan:detection # CI regression gate: fail if any type regresses below baseline
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`bench:detection` (`scripts/bench-detection.mjs`) runs the default filter engine over each corpus case and reports true/false positives and false negatives per type. `scan:detection` compares the live scores against the pinned baseline (`scripts/detection-baseline.json`) and **fails only on a regression** — a precision or recall drop below the recorded numbers. The baseline deliberately bakes in the current imperfect state (the audit-reproduced false positives on `phone`/`card`/`secret`, and the known coverage-gap misses for AWS/GitHub/Google/Slack keys, JWT, and PEM headers), so the gate passes today and trips only when a change makes detection worse. It runs in `release:preflight` after the doc-freshness gate. Regenerate the baseline after an intentional rule change with `node scripts/bench-detection.mjs --write-baseline` and review the diff. Closing the recorded gaps and false positives is WS2b/WS2c of the reliability-hardening track.
|
|
96
120
|
|
|
97
121
|
## `keys`
|
|
98
122
|
|
|
@@ -124,7 +148,70 @@ The detect→decide core. See [Detection types & actions](#detection-types--acti
|
|
|
124
148
|
|
|
125
149
|
| Key | Type / values | Default | Notes |
|
|
126
150
|
|---|---|---|---|
|
|
127
|
-
| `privacy.profile` | `null` \| `kr-pipa` \| `eu-gdpr` \| `us-general` | `null` | Applies a regional baseline action set before enforcement. Profiles may **strengthen** but never weaken your explicit actions. Engineering defaults, not legal advice. |
|
|
151
|
+
| `privacy.profile` | `null` \| `kr-pipa` \| `eu-gdpr` \| `asia-pdpa` \| `us-general` \| `jp-appi` | `null` | Applies a regional baseline action set before enforcement. Profiles may **strengthen** but never weaken your explicit actions. `eu-gdpr` blocks the EU national IDs (`fr_nir`/`es_dni`/`uk_nino`/`it_codice_fiscale`/`de_steuer_id`/`nl_bsn`); `asia-pdpa` (Singapore PDPA / India DPDP) blocks `sg_nric`/`in_aadhaar` (plus the other checksummed national IDs for mixed-region payloads); `jp-appi` blocks `jp_mynumber`; every profile blocks `jp_mynumber` (a checksummed national-ID leak). Engineering defaults, not legal advice. |
|
|
152
|
+
|
|
153
|
+
## `logging`
|
|
154
|
+
|
|
155
|
+
| Key | Type / values | Default | Notes |
|
|
156
|
+
|---|---|---|---|
|
|
157
|
+
| `logging.format` | `text` \| `json` | `text` | `text` keeps the human-readable startup/shutdown/error lines (unchanged). `json` emits one single-line JSON object per event. Fail-closed: any other value throws. |
|
|
158
|
+
|
|
159
|
+
In `json` mode the proxy's internal-error log is a single line `{ "level": "error", "event": "proxy_internal_error", "correlationId", "errorName", "statusCode" }`, and startup/shutdown emit `proxy_listening` / `proxy_shutdown` (plus `*_warn` events for remote-bind / non-enforce-mode / response-protection-disabled). **No log field ever carries a request/response payload, header, token, or any PII** — error logs carry the error *class name* and the request `correlationId` only.
|
|
160
|
+
|
|
161
|
+
## `metrics`
|
|
162
|
+
|
|
163
|
+
| Key | Type / values | Default | Notes |
|
|
164
|
+
|---|---|---|---|
|
|
165
|
+
| `metrics.enabled` | boolean | `true` | Gates the `GET /__haechi/metrics` route. When `false`, that route returns `404`. Fail-closed: a non-boolean throws. |
|
|
166
|
+
|
|
167
|
+
The metrics collector is also an **injectable collaborator** (`createRuntime(config, { metrics })`); see [Operability endpoints](#operability-endpoints) for the contract and the no-PII guarantee.
|
|
168
|
+
|
|
169
|
+
## Operability endpoints
|
|
170
|
+
|
|
171
|
+
The proxy serves four unauthenticated endpoints under the reserved `/__haechi/*` prefix, checked **before** auth and body-read. They never proxy upstream.
|
|
172
|
+
|
|
173
|
+
| Endpoint | Status | Body | Purpose |
|
|
174
|
+
|---|---|---|---|
|
|
175
|
+
| `GET /__haechi/live` | `200` | `{ ok: true, version }` | Cheap process liveness. |
|
|
176
|
+
| `GET /__haechi/ready` | `200` / `503` | `{ ready, version, checks }` | Readiness. **Fail-closed**: a gateway that cannot append to its audit log is **not** ready (`503`). The default JSONL sink's `checks.auditWritable` confirms its audit directory/file is writable without writing an event; a sink lacking a `ready()`/`healthCheck()` method is treated as ready. |
|
|
177
|
+
| `GET /__haechi/health` | `200` | `{ ok: true, mode, version }` | Back-compat (the original health endpoint, now with `version`). |
|
|
178
|
+
| `GET /__haechi/metrics` | `200` / `404` | Prometheus text | Telemetry (see below). `404` when `metrics.enabled: false`. |
|
|
179
|
+
|
|
180
|
+
`version` is the running package version (`package.json`).
|
|
181
|
+
|
|
182
|
+
### Telemetry (`/__haechi/metrics`)
|
|
183
|
+
|
|
184
|
+
The endpoint renders the **Prometheus text exposition format** (`# HELP` / `# TYPE` + `name{label="..."} value`), `Content-Type: text/plain`. Counters: `haechi_requests_total{route,mode,decision}` plus `haechi_blocks_total`, `haechi_auth_denied_total`, `haechi_rate_limited_total`, `haechi_upstream_timeout_total`, `haechi_upstream_error_total`, `haechi_response_unprotected_total`, `haechi_internal_error_total`; one histogram `haechi_request_duration_seconds{route}`.
|
|
185
|
+
|
|
186
|
+
**No-PII-in-telemetry invariant.** Every metric name and **every label value** is a bounded enum — a route id, a policy mode, or a fixed decision class (`forwarded` / `blocked` / `auth_denied` / `rate_limited` / `model_not_allowed` / …). A metric label is **never** an identity id/subject, a token, or a detected value: there is no per-identity or per-value label cardinality. This is the no-plaintext-in-audit invariant extended to telemetry; the metrics module additionally length-caps and charset-sanitizes label values as defence in depth.
|
|
187
|
+
|
|
188
|
+
### `providers.metrics` injection seam
|
|
189
|
+
|
|
190
|
+
The metrics collector is supplied programmatically through `createRuntime(config, providers)` — the same seam as `cryptoProvider`/`authProvider`/`rateLimiter`. It is **not** a JSON config key.
|
|
191
|
+
|
|
192
|
+
```js
|
|
193
|
+
const runtime = createRuntime(config, { metrics });
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
An injected `metrics` must implement `increment(name, labels?, amount?)`, `observe(name, value, labels?)`, and `render() -> string`; `createRuntime` fails closed at construction if it does not. The **default** is a zero-dependency in-memory collector that renders the Prometheus text above. A multi-replica operator injects a shared/remote collector satisfying the same contract.
|
|
197
|
+
|
|
198
|
+
### `correlationId` (audit + logs)
|
|
199
|
+
|
|
200
|
+
The proxy generates a per-**request** `correlationId` (a UUID). It is threaded into the protect context, so each request's request- and response-direction audit events carry the same additive top-level `correlationId` field, and into the proxy's internal-error log line — letting an operator join a logged error to its audit trail. It is `null` for non-proxy `protectJson()` calls (preserving prior behavior). The id is a UUID and is **never** a payload/identity/PII value.
|
|
201
|
+
|
|
202
|
+
## Env-var configuration overlay (deploy)
|
|
203
|
+
|
|
204
|
+
For container / 12-factor deploys, a **fixed allowlist of NON-SECRET operational keys** can be overridden from the environment. The env value **wins over the config file** and is validated **fail-closed** — an invalid value makes the process fail to start. Applied in `loadConfig()` after reading the file and before validation.
|
|
205
|
+
|
|
206
|
+
| Env var | Config key | Type / values |
|
|
207
|
+
|---|---|---|
|
|
208
|
+
| `HAECHI_PROXY_PORT` | `proxy.port` | integer 0–65535 |
|
|
209
|
+
| `HAECHI_PROXY_HOST` | `proxy.host` | non-empty string |
|
|
210
|
+
| `HAECHI_UPSTREAM` | `target.upstream` | URL string |
|
|
211
|
+
| `HAECHI_MODE` | `mode` | `dry-run` \| `report-only` \| `enforce` |
|
|
212
|
+
| `HAECHI_LOG_FORMAT` | `logging.format` | `text` \| `json` |
|
|
213
|
+
|
|
214
|
+
**Secrets are NOT overlayable — by design.** There is **no** `HAECHI_*` variable for `keys.*`, the auth token store, or any token/secret. Secrets stay in the config file or are supplied via injected providers (`createRuntime(config, { cryptoProvider, authProvider, … })`). Putting a secret in a process environment risks leaking it through `/proc`, crash dumps, and orchestrator inspect output, so the overlay allowlist excludes them. See the [operations runbook](./operations-runbook.md#2-configuration-via-the-env-var-overlay).
|
|
128
215
|
|
|
129
216
|
## `mcp`
|
|
130
217
|
|
|
@@ -173,15 +260,69 @@ Per-client controls layered on top of the base `policy`. See [Named profiles](#n
|
|
|
173
260
|
| `policy.profiles` | `{ <name>: { presets?, actions?, modelAllowlist?, rate? } }` | `{}` | Named profiles; each overrides the base policy. |
|
|
174
261
|
| `policy.profileBinding` | `{ byScope?, byLabel?, default }` | unset | Maps identity scopes/labels (`"k=v"` for labels) to profile names. `default` is **required** when `profiles` is set and should be the strictest profile (fail-closed). |
|
|
175
262
|
| `policy.modelAllowlist` | string array | unset | Allowed `model` values (base level; also settable per profile). A disallowed model → `403`. Empty/absent = allow all. |
|
|
176
|
-
| `policy.rate` | `{ requestsPerMinute }` | unset | Per-identity request rate limit (base level or per profile). Over the limit → `429`. In-memory, per-process. |
|
|
263
|
+
| `policy.rate` | `{ requestsPerMinute }` | unset | Per-identity request rate limit (base level or per profile). Over the limit → `429`. In-memory, per-process; see [Rate limiter injection](#rate-limiter-injection) for the multi-replica seam. |
|
|
177
264
|
|
|
178
265
|
### Named profiles
|
|
179
266
|
|
|
180
267
|
When an identity authenticates, its profile resolves in order **scope → label → `default`**; scope precedes label and the first match wins. Without `profiles`, or under `auth.provider: none`, the base policy applies. The resolved profile's policy engine, `modelAllowlist`, and `rate` govern that request.
|
|
181
268
|
|
|
269
|
+
### Rate limiter injection
|
|
270
|
+
|
|
271
|
+
The rate limiter is an **injectable collaborator**, supplied programmatically through the `providers` argument of `createRuntime(config, providers)` — the same seam as the external `cryptoProvider`/`authProvider`. It is **not** a JSON config key.
|
|
272
|
+
|
|
273
|
+
```js
|
|
274
|
+
const runtime = createRuntime(config, { rateLimiter });
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
An injected `rateLimiter` must implement `allow(key, limit)` returning either a `boolean` **or** a `Promise<boolean>` (where `key` is the per-identity bucket and `limit` is the resolved `requestsPerMinute`); `createRuntime` fails closed at construction if it does not. The proxy `await`s the result, so a synchronous boolean and an async shared-store limiter behave identically — the built-in default stays synchronous, while a Redis-backed limiter that resolves asynchronously gates correctly. The proxy consults `runtime.rateLimiter` for every rate-governed request.
|
|
278
|
+
|
|
279
|
+
The **default** is a per-process, in-memory fixed-window counter: it resets on restart and is **not shared across replicas**, so total throughput multiplies by the replica count behind a load balancer. Its window map is self-bounding (a lazy, amortized sweep evicts aged-out one-shot identities — no background timer). For a multi-replica deployment, enforce a per-identity limit at a shared front door **or** inject a shared-store implementation (e.g. Redis-backed) that satisfies the same `allow(key, limit)` contract — the [`haechi-ratelimit-redis`](./shared-responsibility.md#4-horizontal-scale--multiple-replicas) satellite is the reference implementation. See [Shared responsibility §4](./shared-responsibility.md#4-horizontal-scale--multiple-replicas).
|
|
280
|
+
|
|
182
281
|
## Detection types & actions
|
|
183
282
|
|
|
184
|
-
Built-in detection `type` values: `email`, `phone`, `kr_rrn`, `card`, `api_key`, `secret`, and `injection` (response-direction heuristic, report-only by default). Custom rules may introduce new types.
|
|
283
|
+
Built-in detection `type` values: `email`, `phone`, `kr_rrn`, `card`, `api_key`, `secret`, `us_ssn`, `iban`, `jp_mynumber`, `fr_nir`, `es_dni`, `uk_nino`, `it_codice_fiscale`, `sg_nric`, `in_aadhaar`, `de_steuer_id`, `nl_bsn`, and `injection` (response-direction heuristic, report-only by default). Custom rules may introduce new types.
|
|
284
|
+
|
|
285
|
+
### Supported credential & PII matrix
|
|
286
|
+
|
|
287
|
+
Detection is regex + optional validator (no ML). Every rule is **anchored tightly** to keep precision high; precision is prioritized over recall, and the corpus (`tests/fixtures/detection-corpus.json`) carries a hard-negative for each rule. The KR phone rule and the US SSN/IBAN validators reject look-alike ids/timestamps.
|
|
288
|
+
|
|
289
|
+
| Type | Detects | Anchor / validator | Notes |
|
|
290
|
+
|---|---|---|---|
|
|
291
|
+
| `email` | RFC-style addresses | local + domain + TLD | — |
|
|
292
|
+
| `phone` | KR mobile (`01[016789]`, `+82`) | bare separator-less runs must be `0`-led | KR landlines out of scope. |
|
|
293
|
+
| `phone` | E.164 international | **leading `+` required** (`+[1-9]` + 6–14 digits) | A bare digit run is never matched (collides with ids/timestamps). |
|
|
294
|
+
| `phone` | US/NANP national | **separators required** (`(NXX) NXX-XXXX` or `NXX-NXX-XXXX`) | A separator-less 10-digit run is not matched. |
|
|
295
|
+
| `kr_rrn` | KR resident registration number | check-digit validator | Shape-valid but checksum-invalid → rejected. |
|
|
296
|
+
| `card` | Payment card (PAN) | Luhn validator, 13–19 digits | — |
|
|
297
|
+
| `us_ssn` | US Social Security Number | `AAA-GG-SSSS` + SSA-range validator (rejects area `000`/`666`/`900-999`, group `00`, serial `0000`) | Separators required; a bare 9-digit id is not an SSN. |
|
|
298
|
+
| `iban` | International Bank Account Number | **mod-97 checksum** validator | The checksum is the precision guard — IBAN-shaped non-97-valid strings are rejected. |
|
|
299
|
+
| `jp_mynumber` | Japan My Number (個人番号) | 12 digits + **mod-11 weighted check digit** | The check digit is the precision guard; a wrong-check 12-digit run is rejected. **Hard-block.** |
|
|
300
|
+
| `fr_nir` | France NIR / INSEE social-security | 15 chars + **`97 - (first13 mod 97)` control key** (Corsica `2A`→19, `2B`→18) | A wrong control key is rejected. **Hard-block.** |
|
|
301
|
+
| `es_dni` | Spain DNI / NIE | 8 digits (DNI) or `X/Y/Z`+7 digits (NIE) + **mod-23 check letter** (NIE `X/Y/Z`→`0/1/2`) | A wrong check letter is rejected. **Hard-block.** |
|
|
302
|
+
| `uk_nino` | UK National Insurance Number | `[A-CEGHJ-PR-TW-Z][A-CEGHJ-NPR-TW-Z]\d{6}[A-D]` + documented invalid-prefix exclusions (`BG`/`GB`/`NK`/`KN`/`TN`/`NT`/`ZZ`, `O`-as-2nd-letter) | **Format-only — no checksum exists**, so it is NOT a hard-block type (dial-eligible: an operator can allowlist a benign FP). |
|
|
303
|
+
| `it_codice_fiscale` | Italy codice fiscale | `[A-Z]{6}\d{2}[A-Z]\d{2}[A-Z]\d{3}[A-Z]` + **mod-26 check character** (odd/even position tables over the first 15 chars) | A wrong check character is rejected. **Hard-block** — a rare 16-char mixed alpha+digit shape with a non-numeric structural anchor (measured ~3.8% collision over the shape). |
|
|
304
|
+
| `sg_nric` | Singapore NRIC / FIN | `[STFGM]\d{7}[A-Z]` + **weighted-sum check letter** (weights 2,7,6,5,4,3,2; per-prefix offset; per-series letter table) | A wrong check letter is rejected. **Hard-block** — two non-numeric anchors (prefix letter + check letter) over a rare shape (measured ~3.9% collision). |
|
|
305
|
+
| `in_aadhaar` | India Aadhaar | 12 digits (never starting `0`/`1`) + **Verhoeff checksum** | A wrong Verhoeff check digit is rejected. **NOT a hard-block type (dial-eligible)** — Verhoeff over the common 12-digit shape passes ~1/10 of random runs (measured ~9.9%, the `jp_mynumber` footgun), so an operator can allowlist a benign 12-digit-id FP. |
|
|
306
|
+
| `de_steuer_id` | Germany tax ID (Steuer-ID) | 11 digits + **ISO 7064 MOD 11,10** check digit + an "exactly one repeated digit in the first 10" structural test | A wrong check digit or wrong repeat structure is rejected. **NOT a hard-block type (dial-eligible)** — a bare 11-digit run with no non-numeric anchor over a common length (measured ~0.37% collision, but the bare-digit shape keeps it allowlist-clearable per the `jp_mynumber` discipline). |
|
|
307
|
+
| `nl_bsn` | Netherlands BSN | 9 digits + the **"11-proef"** weighted mod-11 | A run that fails the 11-proef is rejected. **NOT a hard-block type (dial-eligible)** — 9 bare digits is very common and the 11-proef passes ~1/11 of random runs (measured ~9.1%), the clearest dial-eligible case. |
|
|
308
|
+
| `api_key` | OpenAI-style / Stripe (`sk_`/`rk_`/`pk_`) | prefix + ≥24 chars | Underscore form — covers Stripe `sk_live_`/`rk_live_`/`sk_test_`/`rk_test_`. |
|
|
309
|
+
| `api_key` | AWS access key id | `AKIA`/`ASIA` + exactly 16 uppercase-alnum | — |
|
|
310
|
+
| `api_key` | Google API key | `AIza` + 35 URL-safe chars | — |
|
|
311
|
+
| `api_key` | SendGrid API key | `SG.` + 22 URL-safe + `.` + 43 URL-safe | The two fixed-length dotted segments are the anchor. |
|
|
312
|
+
| `api_key` | Twilio Account/API SID | `AC`/`SK` + exactly 32 **hex** | Hex-only body rejects random base62; the bare 32-hex AUTH TOKEN is caught via the assignment form (`auth_token`). |
|
|
313
|
+
| `secret` | OpenAI API key | `sk-` (and `sk-proj-`) + ≥20 base62-ish chars | **Hyphen** form, distinct from the underscore Stripe `sk_`; the two prefixes never overlap. |
|
|
314
|
+
| `secret` | Anthropic API key | `sk-ant-` + ≥16 chars | Stricter sibling of the OpenAI `sk-` rule (runs first for attribution). |
|
|
315
|
+
| `secret` | Google OAuth client secret | `GOCSPX-` + exactly 28 URL-safe chars | Distinct from the `AIza` API key. |
|
|
316
|
+
| `secret` | npm token | `npm_` + exactly 36 base62 chars | — |
|
|
317
|
+
| `secret` | `Bearer <token>` | `Bearer` + ≥16 chars | — |
|
|
318
|
+
| `secret` | Assignment `<key> = <value>` | key vocabulary: `api_key`, `api_secret`, `secret`, `secret_key`, `aws_secret_access_key`, `client_secret`, `private_key`, `access_token`, `refresh_token`, `auth_token`, `accountkey`, `token`, `password` | Catches bare-base64 secrets (AWS secret access key, **Azure Storage `AccountKey=`**, **Twilio auth token**) via the assignment form — an un-anchored 88-char-base64 Azure rule would false-fire on any blob, so `AccountKey=` context is the anchor. |
|
|
319
|
+
| `secret` | GitHub token | `gh[pousr]_` + ≥36 base64-ish chars | pat/oauth/user/server/refresh variants. |
|
|
320
|
+
| `secret` | Slack token | `xox[baprs]-` + ≥10-char body | bot/user/refresh/legacy variants. |
|
|
321
|
+
| `secret` | JWT | three base64url segments, first starts `eyJ` (the base64 of `{"`) | The `eyJ` anchor rejects arbitrary dotted tokens. |
|
|
322
|
+
| `secret` | PEM private key | `-----BEGIN … PRIVATE KEY-----` armor header | The header presence is the signal; prose mentioning "private key" is not matched. |
|
|
323
|
+
| `injection` | prompt-injection heuristics | response-direction only, `allow` by default | See [Action strength](#action-strength); report-only. |
|
|
324
|
+
|
|
325
|
+
Detection covers string values, JSON number leaves (request direction), and object keys. Each **string leaf is NFKC-normalized before matching**, so Unicode-evasion forms (full-width digits `4242…`, full-width `@`, mathematical/enclosed alphanumerics) are folded to their ASCII compatibility form and still detected. When the fold preserves UTF-16 length the exact evaded span is redacted/blocked; when it changes length (e.g. mathematical digits, ligatures) detection fails closed and the whole leaf is redacted/blocked. Base64/percent-encoded values (after decoding) and URL query strings remain documented exclusions (see `docs/current/threat-model.md`). On the response direction, Haechi's own transform markers and bare JSON number leaves are skipped (request direction is always full-scan).
|
|
185
326
|
|
|
186
327
|
Actions (weakest → strongest):
|
|
187
328
|
|
|
@@ -240,17 +381,32 @@ When a preset and an override (or a privacy profile) disagree, the **stronger**
|
|
|
240
381
|
|
|
241
382
|
## Binding beyond loopback
|
|
242
383
|
|
|
243
|
-
The proxy refuses non-loopback hosts unless the CLI flag is passed explicitly — `proxy.host: "0.0.0.0"` in config alone will not start, by design
|
|
384
|
+
The proxy refuses non-loopback hosts unless the CLI flag is passed explicitly — `proxy.host: "0.0.0.0"` in config alone will not start, by design. A remote bind **additionally requires TLS**: either Haechi terminates TLS itself (`proxy.tls`), or you explicitly acknowledge a fronting TLS terminator (`proxy.trustForwardedProto`). A remote bind with neither **throws at startup** — Haechi will not serve bearer tokens and payloads in plaintext on a non-loopback listener.
|
|
385
|
+
|
|
386
|
+
**Option A — Haechi terminates TLS** (serves `https`):
|
|
244
387
|
|
|
388
|
+
```jsonc
|
|
389
|
+
// haechi.config.json
|
|
390
|
+
"proxy": { "host": "0.0.0.0", "tls": { "keyFile": "/etc/haechi/tls/key.pem", "certFile": "/etc/haechi/tls/cert.pem" } }
|
|
391
|
+
// or PKCS#12: "tls": { "pfxFile": "/etc/haechi/tls/server.pfx", "passphrase": "…" }
|
|
392
|
+
```
|
|
245
393
|
```bash
|
|
246
394
|
haechi proxy --config haechi.config.json --host 0.0.0.0 --allow-remote-bind
|
|
395
|
+
# → Haechi proxy listening on https://0.0.0.0:11016
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
**Option B — a trusted reverse proxy terminates TLS** in front of Haechi (Haechi stays plain `http` on a private network behind the hop):
|
|
399
|
+
|
|
400
|
+
```jsonc
|
|
401
|
+
"proxy": { "host": "0.0.0.0", "trustForwardedProto": true }
|
|
247
402
|
```
|
|
403
|
+
With `trustForwardedProto: true`, Haechi **refuses any request whose `X-Forwarded-Proto` is not `https`** (a plaintext request that bypassed the hop) with a fail-closed `403`, checked before auth and body-read. The `/__haechi/*` liveness/metrics routes are exempt so a loopback sidecar can still scrape them. Only the trusted terminator may set `X-Forwarded-Proto` — do not enable this if untrusted clients can reach the Haechi port directly.
|
|
248
404
|
|
|
249
405
|
**The proxy ships bearer client authentication** (`auth.provider: bearer`, shipped in 0.6): a hashed token store, per-identity policy profiles, a model allowlist, and a per-identity rate limit (see [`auth`](#auth) and [Named profiles](#named-profiles)). The default `auth.provider: none` leaves the proxy unauthenticated — with `none`, anyone who can reach the port can use your upstream and the token round-trip path. The built-in rate limit is single-process (in-memory, per-process); front multiple replicas with a shared limiter. Use `--allow-remote-bind` only behind explicit network controls regardless — bind `0.0.0.0` inside a container and restrict the host port mapping (`-p 127.0.0.1:11016:11016`), or front it with a firewall/VPN/authenticating reverse proxy.
|
|
250
406
|
|
|
251
407
|
## Validation cheatsheet
|
|
252
408
|
|
|
253
|
-
These throw at load (fail-closed): unknown `keys.provider`; empty `proxy.host`; out-of-range `proxy.port`; non-`jsonl` `audit.sink`; non-`local` `tokenVault.provider`; bad `revealPolicy`; non-positive `retentionDays`; non-boolean `deterministic`/`detokenizeResponses`; empty/non-string `deterministicTypes`; empty/non-string `mcp.allowedMethods`; non-boolean `mcp.*` flags; unknown `privacy.profile`; bad `responseProtection.failureMode`; non-positive `responseProtection.maxBytes`; non-boolean `responseProtection.scanNumbers`; bad `streaming.requestMode`/`streaming.responseMode`; non-positive `streaming.maxMatchBytes`; bad `auth.provider`; empty `auth.store`; non-string `auth.allowedLabelKeys`; non-object `policy.profiles`; `policy.profileBinding` without a valid `default`; non-string `policy.modelAllowlist`; non-positive `policy.rate.requestsPerMinute`; non-positive `limits
|
|
409
|
+
These throw at load (fail-closed): unknown `keys.provider`; empty `proxy.host`; out-of-range `proxy.port`; non-boolean `proxy.trustForwardedProto`; a `proxy.tls` that is non-`null` but not an object, sets `keyFile` without `certFile` (or vice-versa), mixes `pfxFile` with `keyFile`/`certFile`, names an unreadable file, or does not resolve to usable material `((key && cert) or pfx)`; non-`jsonl` `audit.sink`; non-`local` `tokenVault.provider`; bad `revealPolicy`; non-positive `retentionDays`; non-boolean `deterministic`/`detokenizeResponses`; empty/non-string `deterministicTypes`; empty/non-string `mcp.allowedMethods`; non-boolean `mcp.*` flags; unknown `privacy.profile`; bad `responseProtection.failureMode`; non-positive `responseProtection.maxBytes`; non-boolean `responseProtection.scanNumbers`; bad `streaming.requestMode`/`streaming.responseMode`; non-positive `streaming.maxMatchBytes`; bad `auth.provider`; empty `auth.store`; non-string `auth.allowedLabelKeys`; non-object `policy.profiles`; `policy.profileBinding` without a valid `default`; non-string `policy.modelAllowlist`; non-positive `policy.rate.requestsPerMinute`; non-positive `limits.maxRequestBytes`/`limits.upstreamTimeoutMs`/`limits.maxNestingDepth`; negative or non-integer `limits.maxInFlight`/`limits.shutdownGraceMs`; non-`null`/negative/non-integer `limits.requestTimeoutMs`/`limits.headersTimeoutMs`; non-positive-integer or **newer-than-supported** `configVersion`; unknown `target.type`/`adapter`; unsafe custom regex; weakening action without `allowUnsafeOverrides`; non-`text`/`json` `logging.format`; non-boolean `metrics.enabled`; an invalid `HAECHI_*` env overlay value (bad `HAECHI_PROXY_PORT`, unknown `HAECHI_MODE`, malformed `HAECHI_UPSTREAM`, …).
|
|
254
410
|
|
|
255
411
|
# Satellite operator configuration (0.9)
|
|
256
412
|
|