haechi 0.7.0 → 0.9.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 +13 -2
- package/README.md +13 -2
- package/docs/current/api-stability.ko.md +14 -1
- package/docs/current/api-stability.md +14 -1
- package/docs/current/configuration.ko.md +106 -2
- package/docs/current/configuration.md +106 -2
- package/docs/current/release-0.6-implementation-scope.ko.md +4 -4
- package/docs/current/release-0.6-implementation-scope.md +4 -4
- package/docs/current/release-0.7-implementation-scope.ko.md +4 -4
- package/docs/current/release-0.7-implementation-scope.md +4 -4
- package/docs/current/release-0.8-implementation-scope.ko.md +145 -0
- package/docs/current/release-0.8-implementation-scope.md +145 -0
- package/docs/current/release-0.9-implementation-scope.ko.md +231 -0
- package/docs/current/release-0.9-implementation-scope.md +231 -0
- package/docs/current/release-process.ko.md +42 -5
- package/docs/current/release-process.md +42 -5
- package/docs/current/risk-register-release-gate.ko.md +18 -5
- package/docs/current/risk-register-release-gate.md +16 -4
- package/docs/current/threat-model.ko.md +16 -1
- package/docs/current/threat-model.md +16 -1
- package/examples/crypto-kms-reference/README.md +6 -40
- package/haechi.config.example.json +2 -1
- package/package.json +7 -1
- package/packages/audit/index.mjs +12 -1
- package/packages/auth/index.mjs +45 -0
- package/packages/cli/runtime.mjs +5 -1
- package/packages/core/index.mjs +4 -0
- package/packages/filter/index.mjs +58 -3
- package/packages/proxy/index.mjs +3 -0
- package/examples/crypto-kms-reference/index.mjs +0 -133
- package/examples/crypto-kms-reference/package.json +0 -19
|
@@ -63,12 +63,49 @@ npm audit signatures
|
|
|
63
63
|
|
|
64
64
|
## 4. GitHub Actions
|
|
65
65
|
|
|
66
|
-
| Workflow | 목적 |
|
|
67
|
-
|
|
68
|
-
| `.github/workflows/ci.yml` | test, release preflight, SBOM artifact |
|
|
69
|
-
| `.github/workflows/npm-publish.yml` |
|
|
66
|
+
| Workflow | 배포 대상 | 트리거 태그 | 목적 |
|
|
67
|
+
|---|---|---|---|
|
|
68
|
+
| `.github/workflows/ci.yml` | — | 모든 push/PR | test, release preflight, SBOM artifact |
|
|
69
|
+
| `.github/workflows/npm-publish.yml` | `haechi` | `v<semver>` | npm provenance publish + 체크섬/증명 release 자산 |
|
|
70
|
+
| `.github/workflows/crypto-kms-publish.yml` | `haechi-crypto-kms` | `crypto-kms-v<semver>` | satellite publish, 동일한 서명 아티팩트 경로 |
|
|
71
|
+
| `.github/workflows/auth-jwt-publish.yml` | `haechi-auth-jwt` | `auth-jwt-v<semver>` | satellite publish, 동일한 서명 아티팩트 경로 |
|
|
72
|
+
| `.github/workflows/dashboard-publish.yml` | `haechi-dashboard` | `dashboard-v<semver>` | satellite publish, 동일한 서명 아티팩트 경로 |
|
|
73
|
+
| `.github/workflows/auth-oidc-publish.yml` | `haechi-auth-oidc` | `auth-oidc-v<semver>` | satellite publish, 동일한 서명 아티팩트 경로 |
|
|
70
74
|
|
|
71
|
-
|
|
75
|
+
각 publish 워크플로는 `release: published`에서 트리거되지만 **가드**되어 둘이 교차 발화하지 않는다: core job은 `v`로 시작하는 태그에서만 실행되고(그리고 `^v[0-9]+\.[0-9]+\.[0-9]+$` 재검증), satellite job은 `crypto-kms-v…`에서만 실행된다(그리고 `^crypto-kms-v[0-9]+\.[0-9]+\.[0-9]+$` 재검증 **및** 태그 버전이 satellite `package.json` 버전과 일치하는지 검증). npmjs.com Trusted Publisher는 각 패키지의 **특정 워크플로 파일명**에 바인딩된다 — 워크플로 파일 rename은 npm 설정을 갱신할 때까지 OIDC publish를 깨뜨린다.
|
|
76
|
+
|
|
77
|
+
## 5. Satellite 패키지 (unscoped `haechi-*`)
|
|
78
|
+
|
|
79
|
+
Satellite는 npm workspaces 모노레포의 `satellites/*`에 살며 core와 **독립적으로** 발행된다(자체 semver; satellite patch가 `haechi`를 bump하지 않음). core와 동일한 서명 아티팩트 경로를 재사용한다(pack → checksum → sigstore attest → OIDC publish → upload). **unscoped** `haechi-*` 이름으로 발행하므로(`@haechi` org/scope는 제3자가 점유) **npm org가 필요 없다**.
|
|
80
|
+
|
|
81
|
+
**satellite별 부트스트랩 순서(첫 발행, org 불필요):**
|
|
82
|
+
|
|
83
|
+
1. npmjs.com에서 (아직 미발행) unscoped 이름(예: `haechi-crypto-kms`)에 **Trusted Publisher 설정**: `raeseoklee/haechi` 저장소와 satellite의 **정확한 워크플로 파일명**(예: `crypto-kms-publish.yml`) 연결. npm은 아직 발행 전인 이름에도 Trusted Publisher 설정을 허용한다.
|
|
84
|
+
2. 접두사 태그를 push하고 GitHub Release 발행(예: `crypto-kms-v0.1.0`) → 워크플로의 OIDC publish가 provenance와 함께 `0.1.0`을 생성하고 첫 발행 시 이름을 확보.
|
|
85
|
+
|
|
86
|
+
노트북에서의 수동 `npm publish`는 필요 없다. 이름이 unscoped이고 비어있으므로 org-membership 선행 요건이 없다.
|
|
87
|
+
|
|
88
|
+
**태그 → 워크플로 → 패키지 매핑:**
|
|
89
|
+
|
|
90
|
+
| 패키지 | 태그 패턴 | 워크플로 파일 | npm 버전 소스 |
|
|
91
|
+
|---|---|---|---|
|
|
92
|
+
| `haechi-crypto-kms` | `crypto-kms-v<semver>` | `crypto-kms-publish.yml` | `satellites/crypto-kms/package.json` |
|
|
93
|
+
| `haechi-auth-jwt` | `auth-jwt-v<semver>` | `auth-jwt-publish.yml` | `satellites/auth-jwt/package.json` |
|
|
94
|
+
| `haechi-dashboard` | `dashboard-v<semver>` | `dashboard-publish.yml` | `satellites/dashboard/package.json` |
|
|
95
|
+
| `haechi-auth-oidc` | `auth-oidc-v<semver>` | `auth-oidc-publish.yml` | `satellites/auth-oidc/package.json` |
|
|
96
|
+
|
|
97
|
+
**satellite 릴리스 검증** (core와 동일한 신뢰 앵커):
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
gh attestation verify haechi-crypto-kms-<version>.tgz --repo raeseoklee/haechi
|
|
101
|
+
npm view haechi-crypto-kms --json # dist.attestations 존재 확인; access "public"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**의존성 노트:** `haechi-crypto-kms`는 core를 zero-dependency로 유지한다 — `@aws-sdk/client-kms`는 **optional peer dependency**이며, 실제 AWS 클라이언트를 쓰고 주입하지 않을 때만 lazy import된다. in-memory 또는 주입형 클라이언트를 쓰는 소비자는 SDK를 설치하지 않는다. 0.2.0의 `./gcp`(`@google-cloud/kms`)와 `./azure`(`@azure/keyvault-keys` + `@azure/identity`) 백엔드도 동일한 optional-peer/lazy-import 모델을 따르며, `./vault` 백엔드는 optional peer가 없다(`node:` `fetch` 전용).
|
|
105
|
+
|
|
106
|
+
**0.9 satellite(새 unscoped 이름 — 첫 태그 *전에* Trusted Publisher 설정):** `haechi-dashboard`와 `haechi-auth-oidc`는 0.9에서 첫 발행되며 위의 satellite별 부트스트랩 순서를 동일하게 따른다. 0.8 satellite와 마찬가지로 unscoped 이름은 첫 OIDC publish 시 확보되므로, 각각의 npmjs.com Trusted Publisher를 첫 태그 **전에** 설정해야 한다 — `raeseoklee/haechi` 저장소와 정확한 워크플로 파일명(`haechi-dashboard`는 `dashboard-publish.yml`, `haechi-auth-oidc`는 `auth-oidc-publish.yml`)을 연결한 뒤, 접두사 태그(`dashboard-v0.1.0`, `auth-oidc-v0.1.0`)를 push하고 GitHub Release를 발행한다. 기존 두 satellite는 이미 부트스트랩된 태그/워크플로를 그대로 사용한다: `haechi-auth-jwt@0.2.0`은 `auth-jwt-v<semver>`(`auth-jwt-publish.yml`), `haechi-crypto-kms@0.2.0`은 `crypto-kms-v<semver>`(`crypto-kms-publish.yml`) — 이 둘은 새 Trusted Publisher 설정이 필요 없다.
|
|
107
|
+
|
|
108
|
+
## 6. 배포 차단 조건
|
|
72
109
|
|
|
73
110
|
다음 중 하나라도 실패하면 npm publish를 하지 않는다.
|
|
74
111
|
|
|
@@ -63,12 +63,49 @@ npm audit signatures
|
|
|
63
63
|
|
|
64
64
|
## 4. GitHub Actions
|
|
65
65
|
|
|
66
|
-
| Workflow | Purpose |
|
|
67
|
-
|
|
68
|
-
| `.github/workflows/ci.yml` | Tests, release preflight, SBOM artifact |
|
|
69
|
-
| `.github/workflows/npm-publish.yml` | npm provenance publish + checksummed/attested release assets
|
|
66
|
+
| Workflow | Publishes | Fires on tag | Purpose |
|
|
67
|
+
|---|---|---|---|
|
|
68
|
+
| `.github/workflows/ci.yml` | — | any push/PR | Tests, release preflight, SBOM artifact |
|
|
69
|
+
| `.github/workflows/npm-publish.yml` | `haechi` | `v<semver>` | npm provenance publish + checksummed/attested release assets |
|
|
70
|
+
| `.github/workflows/crypto-kms-publish.yml` | `haechi-crypto-kms` | `crypto-kms-v<semver>` | satellite publish, same signed-artifacts path |
|
|
71
|
+
| `.github/workflows/auth-jwt-publish.yml` | `haechi-auth-jwt` | `auth-jwt-v<semver>` | satellite publish, same signed-artifacts path |
|
|
72
|
+
| `.github/workflows/dashboard-publish.yml` | `haechi-dashboard` | `dashboard-v<semver>` | satellite publish, same signed-artifacts path |
|
|
73
|
+
| `.github/workflows/auth-oidc-publish.yml` | `haechi-auth-oidc` | `auth-oidc-v<semver>` | satellite publish, same signed-artifacts path |
|
|
70
74
|
|
|
71
|
-
|
|
75
|
+
Each publish workflow triggers on `release: published` but is **guarded** so the two never cross-fire: the core job runs only for tags starting `v` (and re-validates `^v[0-9]+\.[0-9]+\.[0-9]+$`); the satellite job runs only for `crypto-kms-v…` (and re-validates `^crypto-kms-v[0-9]+\.[0-9]+\.[0-9]+$` **and** that the tag version equals the satellite's `package.json` version). The npmjs.com Trusted Publisher for each package is bound to its **specific workflow filename** — renaming a workflow file breaks its OIDC publish until the npm config is updated.
|
|
76
|
+
|
|
77
|
+
## 5. Satellite packages (unscoped `haechi-*`)
|
|
78
|
+
|
|
79
|
+
Satellites live under `satellites/*` in the npm workspaces monorepo and publish **independently** of core (their own semver; a satellite patch never bumps `haechi`). They reuse the exact signed-artifacts path as core (pack → checksum → sigstore attest → OIDC publish → upload). They are published as **unscoped** `haechi-*` names (the `@haechi` org/scope is taken by a third party), so **no npm org is required**.
|
|
80
|
+
|
|
81
|
+
**Per-satellite bootstrap order (first publish, no org needed):**
|
|
82
|
+
|
|
83
|
+
1. On npmjs.com, **configure a Trusted Publisher** for the (not-yet-published) unscoped name (e.g. `haechi-crypto-kms`): link the `raeseoklee/haechi` repository and the satellite's **exact workflow filename** (e.g. `crypto-kms-publish.yml`). npm allows configuring a Trusted Publisher for a name you have not published yet.
|
|
84
|
+
2. Push the prefixed tag and publish a GitHub Release (e.g. `crypto-kms-v0.1.0`) → the workflow's OIDC publish creates `0.1.0` with provenance and claims the name on first publish.
|
|
85
|
+
|
|
86
|
+
No manual `npm publish` from a laptop is needed. Because the names are unscoped and free, there is no org-membership prerequisite.
|
|
87
|
+
|
|
88
|
+
**Tag → workflow → package mapping:**
|
|
89
|
+
|
|
90
|
+
| Package | Tag pattern | Workflow file | npm version source |
|
|
91
|
+
|---|---|---|---|
|
|
92
|
+
| `haechi-crypto-kms` | `crypto-kms-v<semver>` | `crypto-kms-publish.yml` | `satellites/crypto-kms/package.json` |
|
|
93
|
+
| `haechi-auth-jwt` | `auth-jwt-v<semver>` | `auth-jwt-publish.yml` | `satellites/auth-jwt/package.json` |
|
|
94
|
+
| `haechi-dashboard` | `dashboard-v<semver>` | `dashboard-publish.yml` | `satellites/dashboard/package.json` |
|
|
95
|
+
| `haechi-auth-oidc` | `auth-oidc-v<semver>` | `auth-oidc-publish.yml` | `satellites/auth-oidc/package.json` |
|
|
96
|
+
|
|
97
|
+
**Verify a satellite release** (same anchors as core):
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
gh attestation verify haechi-crypto-kms-<version>.tgz --repo raeseoklee/haechi
|
|
101
|
+
npm view haechi-crypto-kms --json # dist.attestations present; access "public"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**Dependency note:** `haechi-crypto-kms` keeps core zero-dependency — `@aws-sdk/client-kms` is an **optional peer dependency**, imported lazily only when a real AWS client is used and not injected. Consumers who use the in-memory or an injected client never install the SDK. The 0.2.0 `./gcp` (`@google-cloud/kms`) and `./azure` (`@azure/keyvault-keys` + `@azure/identity`) backends follow the same optional-peer/lazy-import model; the `./vault` backend has zero optional peer (`node:` `fetch` only).
|
|
105
|
+
|
|
106
|
+
**0.9 satellites (new unscoped names — configure Trusted Publisher *before* the first tag):** `haechi-dashboard` and `haechi-auth-oidc` are first-published in 0.9 and follow the same per-satellite bootstrap order above. As with the 0.8 satellites, the unscoped name is claimed on first OIDC publish, so the npmjs.com Trusted Publisher for each must be configured **before** its first tag — link `raeseoklee/haechi` and the exact workflow filename (`dashboard-publish.yml` for `haechi-dashboard`, `auth-oidc-publish.yml` for `haechi-auth-oidc`), then push the prefixed tag (`dashboard-v0.1.0`, `auth-oidc-v0.1.0`) and publish the GitHub Release. The two existing satellites ride their already-bootstrapped tags/workflows: `haechi-auth-jwt@0.2.0` on `auth-jwt-v<semver>` (`auth-jwt-publish.yml`) and `haechi-crypto-kms@0.2.0` on `crypto-kms-v<semver>` (`crypto-kms-publish.yml`) — no new Trusted Publisher configuration is required for those two.
|
|
107
|
+
|
|
108
|
+
## 6. Deployment block conditions
|
|
72
109
|
|
|
73
110
|
npm publish is not performed if any of the following fail.
|
|
74
111
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Haechi 리스크 레지스터 및 릴리스 게이트
|
|
2
2
|
|
|
3
|
-
- 문서 상태: Draft 0.
|
|
4
|
-
- 작성일: 2026-06-
|
|
5
|
-
- 기준 버전: 0.
|
|
3
|
+
- 문서 상태: Draft 0.4
|
|
4
|
+
- 작성일: 2026-06-11
|
|
5
|
+
- 기준 버전: 0.9.0
|
|
6
6
|
- 기준 브랜치: `main`
|
|
7
7
|
|
|
8
8
|
## 1. 현재 판단
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
| G1 | GitHub pre-release | P0 코드 리스크 해결, production-ready 표현 없음 | Pass |
|
|
26
26
|
| G2 | npm developer preview | P0 해결, preflight/SBOM/provenance 경로 준비, npm auth 확인 | Pass (`haechi@0.3.2` 2026-06-10 배포) |
|
|
27
27
|
| G3 | npm stable | P1 운영 reference, stream-aware enforcement, API stability 강화 | Blocked |
|
|
28
|
+
| G4 | 0.9.0 observability + interactive-auth 위성 컷 | P1-SEC-009 (0.9) / P1-OPS-005 (0.9) mitigated 및 P2-CRYPTO-001 (0.9) accepted; `haechi-dashboard` + `haechi-auth-oidc` + `haechi-crypto-kms@0.2.0` 테스트 통과; 위성 tarball zero-dep; core 0.9.0 bump(추가적 FORBIDDEN_KEYS audit 강화만) | Pass |
|
|
28
29
|
|
|
29
30
|
## 3. P0 배포 차단 리스크 상태
|
|
30
31
|
|
|
@@ -96,6 +97,16 @@
|
|
|
96
97
|
|
|
97
98
|
base64/인코딩 값 디코딩 검사, query string 검사, audit tail truncation 탐지는 명시적 제외로 threat model에 문서화했다 (0.4+ backlog).
|
|
98
99
|
|
|
100
|
+
## 5.3 0.9.0 Observability + Interactive-Auth 리스크 상태
|
|
101
|
+
|
|
102
|
+
이 ID들은 0.9.0 위성 컷(`haechi-dashboard`, `haechi-auth-oidc`, `haechi-crypto-kms@0.2.0`)에 한정되며, 0.9.0 섹션으로 namespace되어 위의 동일 번호 P0/P1 행과 구분된다. 증거는 위성 소스, 그 테스트 스위트, 그리고 `docs/current/release-0.9-implementation-scope.md` §6에 정리된 adversarial security review다.
|
|
103
|
+
|
|
104
|
+
| ID | 리스크 | 상태 | 해소 증거 |
|
|
105
|
+
|---|---|---|---|
|
|
106
|
+
| P1-SEC-009 (0.9) | OIDC broker 세션/로그인 보안: `haechi-auth-oidc`의 login CSRF, authorization-code injection, open-redirect, session fixation, mix-up(잘못된 IdP/RP) | Mitigated | `satellites/auth-oidc/index.mjs`: state-first `/auth/callback`(pre-auth 쿠키 바인딩 pending record를 atomic `take()` + egress 이전 constant-time `state` 비교), PKCE S256, callback에서 새 세션 id 발급(fixation 없음), `returnToAllowlist`(open-redirect 없음), issuer/endpoint pinning + RFC 9207 `iss` 검사 + 공유 `createJwtVerifier` 경유 ID-token `aud`/`azp` 프로파일(mix-up), CSRF-gated non-GET logout. `satellites/auth-oidc/auth-oidc.test.mjs`가 각 deny 케이스 검증; scope §6 adversarial review. **잔여:** multi-origin IdP는 범위 외 |
|
|
107
|
+
| P1-OPS-005 (0.9) | Dashboard audit 노출: `haechi-dashboard`의 `detections[].path` stored XSS, 미래 필드 audit leak, localhost 뷰어 DNS-rebinding 읽기, remote bind 시 인증 없는 읽기 | Mitigated | `satellites/dashboard/index.mjs` + `assets.mjs`: 엄격 CSP(`require-trusted-types-for 'script'`) + `textContent`-only 렌더링(XSS), `FORBIDDEN_KEYS` 위 재귀적 key-by-key allowlist projection(필드 leak), 요청별 anti-rebinding `Host` allowlist + CORP/COOP same-origin(rebinding), `sessionGuard` **및** TLS 종단을 요구하는 fail-closed remote bind(인증 없는 remote 읽기). `satellites/dashboard/dashboard.test.mjs`; scope §6 adversarial review. **잔여:** remote bind 시 운영자가 TLS 종단을 책임 |
|
|
108
|
+
| P2-CRYPTO-001 (0.9) | KMS backend egress: `haechi-crypto-kms@0.2.0` Vault/GCP/Azure backend가 key material이나 provider/key-path 상세를 유출하거나 의도치 않은(metadata) 엔드포인트에 도달 가능 | Accepted | `satellites/crypto-kms/{vault,gcp,azure}.mjs`: optional-peer + injected-client 모델과 faithful-mock `assertCryptoProviderConformance`(cross-key·corrupted-blob 거부, HMAC determinism/domain-separation), Vault `fetch`의 satellite-local `isBlockedAddress` SSRF 가드(dev-only `satellites/crypto-kms/ssrf-parity.test.mjs`로 auth-jwt와 parity 유지), generic fail-closed provider-error 매핑(audit에 provider/key-ARN 없음). `{vault,gcp,azure}.test.mjs` + `crypto-kms.test.mjs`; scope §6 adversarial review. **수용된 잔여:** 실제 Vault/GCP/Azure live-backend 검증은 CI 외부; 발행 tarball은 zero runtime dependency 유지 |
|
|
109
|
+
|
|
99
110
|
## 6. P2 제품/문서 리스크 상태
|
|
100
111
|
|
|
101
112
|
| ID | 기존 리스크 | 상태 | 해소 증거 |
|
|
@@ -128,8 +139,10 @@ base64/인코딩 값 디코딩 검사, query string 검사, audit tail truncatio
|
|
|
128
139
|
|---|---|---|
|
|
129
140
|
| 0.4.0 ✅ | token round-trip and adoption | 2026-06-10 구현 완료: 요청 스코프 response detokenization, deterministic tokenization(파생 키), `haechi mcp-wrap`, `haechi audit-verify`/`haechi status`, injection detection type(기본 allow), `identity`/`authProvider` 계약 예약. `docs/current/release-0.4-implementation-scope.md` 참조 |
|
|
130
141
|
| 0.5.0 ✅ | streaming hardening | 2026-06-10 출시: bounded cross-frame 버퍼를 사용한 SSE/NDJSON 스트리밍 응답 검사(`streaming.requestMode: inspect`). stream sequence AAD, replay cache, 강화된 원격 배포 가이드는 0.6+으로 이월. `docs/current/release-0.5-implementation-scope.md` 참조 |
|
|
131
|
-
| 0.6.0 ✅ | Shipped 2026-06-10 (PRs #17–#19): built-in bearer auth, named policy profiles, model allowlist, request rate limit, PII-safe identity in audit. `docs/current/release-0.6-implementation-scope.md` 참조 |
|
|
132
|
-
| 0.7.0 ✅ | Shipped 2026-06-10 (PRs #22–#24): audit head-hash anchoring + external sink contract, cryptoProvider contract hardening + `assertCryptoProviderConformance` + reference KMS adapter, 서명/체크섬된 release artifact. `docs/current/release-0.7-implementation-scope.md` 참조 |
|
|
142
|
+
| 0.6.0 ✅ | auth and per-client controls | Shipped 2026-06-10 (PRs #17–#19): built-in bearer auth, named policy profiles, model allowlist, request rate limit, PII-safe identity in audit. `docs/current/release-0.6-implementation-scope.md` 참조 |
|
|
143
|
+
| 0.7.0 ✅ | ops hardening | Shipped 2026-06-10 (PRs #22–#24): audit head-hash anchoring + external sink contract, cryptoProvider contract hardening + `assertCryptoProviderConformance` + reference KMS adapter, 서명/체크섬된 release artifact. `docs/current/release-0.7-implementation-scope.md` 참조 |
|
|
144
|
+
| 0.8.0 ✅ | ecosystem foundation + satellites | 2026-06-10 출시(PR #27–#32): npm workspaces 모노레포(루트 자기참조 `["."]` + `satellites/*`); `haechi@0.8.0`(attested), `haechi-crypto-kms`, `haechi-auth-jwt`(unscoped — `@haechi` scope 점유됨) **발행 완료**. core는 zero runtime dependency 유지(CI no-leak + zero-dep + satellite-packaging 게이트). 위성 `0.1.0`은 이름 생성을 위한 수동 부트스트랩 발행(unattested, `--provenance=false`, `0.3.2`와 동일한 갭)으로 per-name Trusted Publisher 설정 후, `0.1.1`이 첫 attested CI 릴리스(SLSA provenance + sigstore, `gh attestation verify` 통과). `docs/current/release-0.8-implementation-scope.md` 참조 |
|
|
145
|
+
| 0.9.0 | observability + interactive auth | `haechi-auth-oidc` 전체 authorization-code flow, `haechi-dashboard` 읽기 전용 audit 뷰어(hash-chain 무결성 표시, 요약/검색/타임라인), `haechi-crypto-kms` 추가 백엔드(Vault/GCP/Azure) |
|
|
133
146
|
| 1.0.0 | stable API contract | migration policy, long-term audit schema, plugin sandbox/runtime conformance 및 allowlist/manifest 통과 외부 auth/classifier package 동적 로딩 |
|
|
134
147
|
|
|
135
148
|
동적 npm package 로딩은 1.0 plugin sandbox 이전까지 금지한다. 0.4~0.7의 외부 provider는 `createRuntime(config, providers)` 프로그래매틱 주입만 지원한다.
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Haechi Risk Register and Release Gates
|
|
2
2
|
|
|
3
|
-
- Status: Draft 0.
|
|
4
|
-
- Date: 2026-06-
|
|
5
|
-
- Target version: 0.
|
|
3
|
+
- Status: Draft 0.4
|
|
4
|
+
- Date: 2026-06-11
|
|
5
|
+
- Target version: 0.9.0
|
|
6
6
|
- Branch: `main`
|
|
7
7
|
|
|
8
8
|
## 1. Current Assessment
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
| G1 | GitHub pre-release | P0 code risks resolved, no production-ready language | Pass |
|
|
26
26
|
| G2 | npm developer preview | P0 resolved, preflight/SBOM/provenance paths ready, npm auth confirmed | Pass (`haechi@0.3.2` published 2026-06-10) |
|
|
27
27
|
| G3 | npm stable | P1 production reference, stream-aware enforcement, API stability hardened | Blocked |
|
|
28
|
+
| G4 | 0.9.0 observability + interactive-auth satellite cut | P1-SEC-009 (0.9) / P1-OPS-005 (0.9) mitigated and P2-CRYPTO-001 (0.9) 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
29
|
|
|
29
30
|
## 3. P0 Distribution-Blocking Risk Status
|
|
30
31
|
|
|
@@ -96,6 +97,16 @@
|
|
|
96
97
|
|
|
97
98
|
Base64/encoded-value decode inspection, query-string inspection, and audit tail truncation detection are explicitly excluded and documented in the threat model (0.4+ backlog).
|
|
98
99
|
|
|
100
|
+
## 5.3 0.9.0 Observability + Interactive-Auth Risk Status
|
|
101
|
+
|
|
102
|
+
These IDs are scoped to the 0.9.0 satellite cut (`haechi-dashboard`, `haechi-auth-oidc`, `haechi-crypto-kms@0.2.0`); they are namespaced by the 0.9.0 section and are distinct from the like-numbered P0/P1 rows above. Evidence is the satellite source, its test suite, and the adversarial security review captured in `docs/current/release-0.9-implementation-scope.md` §6.
|
|
103
|
+
|
|
104
|
+
| ID | Risk | Status | Resolution evidence |
|
|
105
|
+
|---|---|---|---|
|
|
106
|
+
| P1-SEC-009 (0.9) | OIDC broker session/login security: login CSRF, authorization-code injection, open-redirect, session fixation, and mix-up (wrong IdP/RP) in `haechi-auth-oidc` | Mitigated | `satellites/auth-oidc/index.mjs`: state-first `/auth/callback` (atomic `take()` of a pre-auth-cookie-bound pending record + constant-time `state` compare before any egress), PKCE S256, fresh session id minted at callback (no fixation), `returnToAllowlist` (no open-redirect), issuer/endpoint pinning + RFC 9207 `iss` check + ID-token `aud`/`azp` profile via the shared `createJwtVerifier` (mix-up), CSRF-gated non-GET logout. `satellites/auth-oidc/auth-oidc.test.mjs` exercises each deny case; adversarial review in scope §6. **Residual:** multi-origin IdP out of scope |
|
|
107
|
+
| P1-OPS-005 (0.9) | Dashboard audit exposure: stored XSS via `detections[].path`, future-field audit leak, DNS-rebinding read of a localhost viewer, and unauthenticated read on remote bind in `haechi-dashboard` | Mitigated | `satellites/dashboard/index.mjs` + `assets.mjs`: strict CSP (`require-trusted-types-for 'script'`) + `textContent`-only rendering (XSS), recursive key-by-key allowlist projection over `FORBIDDEN_KEYS` (field leak), per-request anti-rebinding `Host` allowlist + CORP/COOP same-origin (rebinding), fail-closed remote bind requiring `sessionGuard` **and** TLS termination (unauthenticated remote read). `satellites/dashboard/dashboard.test.mjs`; adversarial review in scope §6. **Residual:** operator must terminate TLS for remote bind |
|
|
108
|
+
| P2-CRYPTO-001 (0.9) | KMS backend egress: the `haechi-crypto-kms@0.2.0` Vault/GCP/Azure backends could leak key material or provider/key-path detail or reach an unintended (metadata) endpoint | Accepted | `satellites/crypto-kms/{vault,gcp,azure}.mjs`: optional-peer + injected-client model with faithful-mock `assertCryptoProviderConformance` (cross-key + corrupted-blob rejection, HMAC determinism/domain-separation), satellite-local `isBlockedAddress` SSRF guard on the Vault `fetch` (kept honest by the dev-only `satellites/crypto-kms/ssrf-parity.test.mjs` vs auth-jwt), generic fail-closed provider-error mapping (no provider/key-ARN in audit). `{vault,gcp,azure}.test.mjs` + `crypto-kms.test.mjs`; adversarial review in scope §6. **Residual accepted:** live-backend (real Vault/GCP/Azure) validation is out of CI; the published tarball stays zero runtime dependency |
|
|
109
|
+
|
|
99
110
|
## 6. P2 Product/Documentation Risk Status
|
|
100
111
|
|
|
101
112
|
| ID | Risk | Status | Resolution evidence |
|
|
@@ -130,7 +141,8 @@ All checklist items below were completed for 0.3.2 on 2026-06-10 except the prov
|
|
|
130
141
|
| 0.5.0 ✅ | Streaming hardening | Shipped 2026-06-10: SSE/NDJSON streaming response inspection with bounded cross-frame buffer (`streaming.requestMode: inspect`). Stream sequence AAD, replay cache, stronger remote deployment guide deferred to 0.6+. See `docs/current/release-0.5-implementation-scope.md` |
|
|
131
142
|
| 0.6.0 ✅ | Auth and per-client controls | Shipped 2026-06-10 (PRs #17–#19): built-in bearer auth, named policy profiles, model allowlist, request rate limit, PII-safe identity in audit. See `docs/current/release-0.6-implementation-scope.md` |
|
|
132
143
|
| 0.7.0 ✅ | Ops hardening | Shipped 2026-06-10 (PRs #22–#24): audit head-hash anchoring + external sink contract, cryptoProvider contract hardening + `assertCryptoProviderConformance` + reference KMS adapter, signed/checksummed release artifacts. See `docs/current/release-0.7-implementation-scope.md` |
|
|
133
|
-
| 0.8.0 | Ecosystem
|
|
144
|
+
| 0.8.0 ✅ | Ecosystem foundation + satellites | Shipped 2026-06-10 (PRs #27–#32): npm workspaces monorepo (root self-member `["."]` + `satellites/*`); **published** `haechi@0.8.0` (attested), `haechi-crypto-kms` and `haechi-auth-jwt` (unscoped — the `@haechi` scope was taken). Core stays zero runtime dependency (CI no-leak + zero-dep + satellite-packaging gates). Satellite `0.1.0` was a manual bootstrap publish (unattested, `--provenance=false`, mirroring the `0.3.2` gap) to create the names so per-name Trusted Publishers could be configured; `0.1.1` is the first attested CI release (SLSA provenance + sigstore, `gh attestation verify` passes). See `docs/current/release-0.8-implementation-scope.md` |
|
|
145
|
+
| 0.9.0 | Observability + interactive auth | `haechi-auth-oidc` full authorization-code flow, `haechi-dashboard` read-only audit viewer (hash-chain integrity display, summary/search/timeline), additional `haechi-crypto-kms` backends (Vault/GCP/Azure) |
|
|
134
146
|
| 1.0.0 | Stable API contract | Migration policy, long-term audit schema, plugin sandbox/runtime conformance, and dynamic loading of external auth/classifier packages that pass allowlist/manifest |
|
|
135
147
|
|
|
136
148
|
Dynamic npm package loading is prohibited until the 1.0 plugin sandbox. External providers in 0.4–0.7 are supported only via `createRuntime(config, providers)` programmatic injection.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
- 문서 상태: Draft 0.1
|
|
4
4
|
- 작성일: 2026-06-10
|
|
5
|
-
- 기준 버전: 0.
|
|
5
|
+
- 기준 버전: 0.9.0
|
|
6
6
|
|
|
7
7
|
## 1. 보호 대상
|
|
8
8
|
|
|
@@ -54,6 +54,17 @@ Haechi가 보호하려는 주요 자산은 다음이다.
|
|
|
54
54
|
| audit에 원시 credentials/identity 노출 | audit 로그를 통한 token 또는 subject 유출 | Token은 keyed-HMAC 해시로만 저장; identity subject/issuer는 keyed HMAC 처리; `auth_denied` 레코드에 token 미포함 |
|
|
55
55
|
| token round-trip의 타 토큰 복원 | 클라이언트/요청 간 평문 복구 | detokenization은 opt-in(`detokenizeResponses`)이며 요청 스코프: 같은 요청을 보호하며 발급된 토큰만 복원 |
|
|
56
56
|
| tool result/응답 내 간접 prompt injection | 심어진 지시문에 의한 agent 조작 | 응답 방향 휴리스틱, 기본 report-only(`injection` action `allow`), 격상은 명시적 정책 선택. 완전 방어 아님 |
|
|
57
|
+
| Haechi 자체 변환 마커 재탐지 | 모델이 echo한 토큰 왕복이 재탐지됨(예: `[TOKEN:…]`가 `secret`으로 차단) → response-enforce에서 `detokenizeResponses` 깨짐 | **응답 방향에서만** Haechi 마커(`[TOKEN:…]`, `[HAECHI_ENC:…]`, `[REDACTED:…]`) 탐지 제외. 요청 방향은 영향 없으므로 요청에 마커 모양 문자열로 secret을 숨겨 우회할 수 없음. **수용된 잔여:** 악의적 *upstream*이 누출 값을 가짜 응답 마커로 감싸 응답 방향 탐지를 회피할 수 있음 — 응답 검사는 2차 방어(모델은 semi-trusted)이고 제외는 positional(마커 구간만 건너뜀; 인접 값은 여전히 탐지됨) |
|
|
58
|
+
| 응답 메타데이터 오탐 | `enforce` 응답 검사가 envelope 메타데이터를 PII/secret으로 오인해 정상 응답을 차단(예: unix 타임스탬프 `created`가 phone 규칙에, 나노초 `*_duration`이 `card`에 매치) | KR phone 규칙이 구분자·`0` 없는 맨 숫자열을 무시; `chatcmpl-…` 같은 id는 secret 모양 아님; **응답 방향은 bare JSON number leaf를 검사하지 않음**(`*_duration`/count/timestamp/numeric-id — 모델 누출 card/RRN이 아님; 실제 누출은 생성 *텍스트*에 나타나 여전히 검사됨). **수용된 잔여:** 악의적 모델이 bare 응답 숫자로 인코딩해 유출 가능(응답 검사는 2차 방어) — 엄격 운영자는 `responseProtection.scanNumbers: true`로 재활성화 가능. 실제 vLLM·Ollama 응답은 이제 clean; 추가 주의 시 `responseProtection.mode: report-only`(탐지·감사만, 차단 없음) |
|
|
59
|
+
| Dashboard audit 뷰어 XSS — attacker-controlled `detections[].path` (0.9) | `haechi-dashboard`의 stored XSS: `<img onerror>` 같은 요청 JSON key가 client-key 파생 `detections[].path` 필드를 통해 audit 로그에 도달한 뒤 뷰어에서 렌더됨 | 제공 페이지는 DOM을 `createElement` + `textContent`로만 구성(`innerHTML` 보간 사용 안 함)하고, 모든 응답에 `require-trusted-types-for 'script'`를 포함한 엄격 CSP를 부여(잔여 `innerHTML` sink는 브라우저에서 throw). allowlist는 필드 *이름*을, CSP + `textContent`는 악의적 *값*을 각각 독립적으로 무력화. 실질적 잔여 없음 |
|
|
60
|
+
| 뷰어를 통한 audit 필드 leak (미래 필드) (0.9) | 이후 audit 스키마에 추가된 필드가 dashboard API에 그대로 노출되어 의도치 않은 메타데이터 유출 | `/api/events`는 실제 audit 스키마에 대해 **재귀적 key-by-key 필드 allowlist projection**을 수행(`detections`/`identity`/`summary`/`auditIntegrity` 같은 중첩 sub-object를 blind하게 spread하지 않음)하며 core의 `FORBIDDEN_KEYS` 위에 적층됨. 어떤 레벨의 새 중첩 필드든 기본적으로 drop |
|
|
61
|
+
| localhost bind 뷰어에 대한 DNS-rebinding audit JSON 읽기 (0.9) | 운영자가 방문한 사이트가 `127.0.0.1`로 재해석되는 단기 TTL DNS 이름을 게시하면 피해자 브라우저가 same-origin 요청을 보내 공격자 JS가 인증 없는 loopback dashboard에서 audit JSON을 읽음 | 요청별 **anti-rebinding `Host` 헤더 allowlist**(bind 검사와 구분되는 first gate; IPv4-mapped IPv6·trailing-dot·bracketed IPv6 정규화, malformed/중복 `Host` 거부) + `Cross-Origin-Resource-Policy`/`Cross-Origin-Opener-Policy: same-origin`; CORS 헤더는 결코 방출하지 않음. 실질적 잔여 없음 |
|
|
62
|
+
| remote bind 시 인증 없는 audit 읽기 (0.9) | non-loopback host에 bind된 dashboard가 로그인 없이 audit 스트림 노출 | fail-closed precedence: `allowRemoteBind` **및** `sessionGuard`가 있고 **확인된 HTTPS 종단**(`tlsContext`, 또는 신뢰 proxy에서만 `X-Forwarded-Proto`를 신뢰하는 `trustProxy`)이 아니면 remote bind는 throw; Secure/`__Host-` 세션 쿠키는 평문 http로 전송되지 않음. 운영자가 TLS 종단을 책임 |
|
|
63
|
+
| OIDC login CSRF / authorization-code injection / open-redirect / session fixation (0.9) | 공격자가 피해자 broker 세션을 공격자 제어 로그인에 강제하거나, 탈취한 code를 주입하거나, 로그인 후 off-origin으로 redirect하거나, 사전 인지된 세션 id를 고정 | `/auth/callback`은 **state-first**: pre-auth 쿠키 바인딩된 pending record를 atomic `take()`하고 **모든 IdP egress 이전에** constant-time `state` 비교; PKCE S256 필수; callback에서 **새 세션 id 발급**(fixation 없음, pre-auth 쿠키 폐기); 로그인 후 `return_to`는 상대 경로 `returnToAllowlist`로 검증; logout은 non-GET + CSRF-header gated. 단일 IdP 기준 실질적 잔여 없음 |
|
|
64
|
+
| OIDC mix-up (잘못된 IdP / 잘못된 RP) (0.9) | confused-deputy 공격으로 IdP를 바꾸거나 다른 client용으로 발급된 code/token을 재생 | issuer/`token_endpoint`/`jwks_uri`를 `/auth/login`에서 pending record에 pin; RFC 9207 `iss` 응답 파라미터가 pinned issuer와 일치해야 함; `metadata.issuer`가 설정 issuer와 string-equal해야 함; OIDC ID-token `aud`/`azp` 프로파일(`aud`는 `clientId` 포함; multi-valued `aud`는 `azp === clientId` 필요)로 cross-client 차단. multi-origin IdP는 범위 외 |
|
|
65
|
+
| 토큰 엔드포인트 POST(및 Vault `fetch`)를 통한 broker SSRF — cloud metadata (0.9) | discovery와 request 사이에 `169.254.169.254`로 DNS-rebind되는 `token_endpoint`(또는 운영자 제공 `VAULT_ADDR`)가 instance-metadata 자격증명을 유출 | 모든 egress(discovery GET, 공유 verifier 경유 JWKS GET, token-exchange POST, end-session redirect, `haechi-crypto-kms` Vault `fetch`)가 **request 직전**(post-DNS) `lookup` 후 `isBlockedAddress` 재검사를 `redirect: "error"`·bounded body·timeout과 함께 수행. 운영자 신뢰 엔드포인트에 한함 |
|
|
66
|
+
| audit/로그로의 token/secret leak (broker) (0.9) | ID/access/refresh token, `client_secret`, `code`, `state`, `nonce`, raw `sub`가 audit 로그나 client 응답에 기록됨 | broker는 모든 audit 이벤트를 자체 allowlist로 projection해 `subjectHash`/`issuerHash`/`sessionIdHash`(keyed-HMAC) + `provider`/`reasonCode`/timestamp만 방출; core `FORBIDDEN_KEYS`를 broker token/claim key까지 확장; access token은 **폐기**(저장·사용 안 함). 실질적 잔여 없음 |
|
|
67
|
+
| KMS backend egress (Vault HTTP, GCP/Azure SDK) (0.9) | `haechi-crypto-kms` Vault/GCP/Azure backend가 key material이나 provider/key-path 상세를 유출하거나 의도치 않은 엔드포인트에 도달 | optional-peer + injected-client 모델과 **faithful-mock conformance**(cross-key·corrupted-blob 거부, HMAC determinism/domain-separation); Vault `fetch`는 위 satellite-local SSRF 가드 수행; 모든 backend는 provider 오류를 generic fail-closed 오류로 매핑하고 provider/key-ARN 상세를 audit에 기록하지 않음. live-backend 검증은 CI 외부 |
|
|
57
68
|
|
|
58
69
|
## 4. 명시적 제외
|
|
59
70
|
|
|
@@ -71,6 +82,10 @@ Haechi가 보호하려는 주요 자산은 다음이다.
|
|
|
71
82
|
- URL query string 내 민감값 검사 (JSON body만 검사)
|
|
72
83
|
- 마지막 anchor 이후의 audit tail truncation — `audit.anchor`(0.7)는 anchor가 추가 전용/별도 미디어에 있을 때 마지막 anchor까지의 레코드 삭제를 탐지한다; 마지막 anchor 이후 기록된 레코드와 동일 파일시스템 anchor는 대상에서 제외된다
|
|
73
84
|
- JSON-RPC batch 메시지 처리 (MCP stdio filter는 batch를 fail-closed로 거부)
|
|
85
|
+
- `haechi-auth-oidc`의 multi-origin / CDN-fronted IdP(issuer host ≠ `token_endpoint`/`jwks_uri` host) — single-origin만 지원, `haechi-auth-jwt`와 동일 제약 (0.9)
|
|
86
|
+
- refresh-token rotation / silent renewal / 장수명 broker 세션 — 0.9 세션은 absolute-TTL + idle-timeout만; `offline_access`는 제거되고 access token은 폐기 (0.9)
|
|
87
|
+
- Dashboard write action(reveal, purge, policy edit) — `haechi-dashboard`는 읽기 전용으로 `POST`/`DELETE` surface 없음; mutation은 reveal governance 하의 CLI에 유지 (0.9)
|
|
88
|
+
- OIDC broker의 `at_hash`/`c_hash` 검증 — broker가 access token을 사용하지 않으므로 정확히 범위 외 (0.9)
|
|
74
89
|
|
|
75
90
|
## 5. 남은 운영 전제
|
|
76
91
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
- Status: Draft 0.1
|
|
4
4
|
- Date: 2026-06-10
|
|
5
|
-
- Target version: 0.
|
|
5
|
+
- Target version: 0.9.0
|
|
6
6
|
|
|
7
7
|
## 1. Assets Under Protection
|
|
8
8
|
|
|
@@ -54,6 +54,17 @@ The primary assets Haechi protects are:
|
|
|
54
54
|
| Raw credentials/identity in audit | Token or subject leak through the audit log | Tokens stored only as keyed-HMAC hashes; identity subject/issuer are keyed HMAC; `auth_denied` records no token |
|
|
55
55
|
| Token round-trip restoring foreign tokens | Cross-client/request plaintext recovery | Detokenization is opt-in (`detokenizeResponses`) and request-scoped: only tokens issued while protecting the same request are restored |
|
|
56
56
|
| Indirect prompt injection in tool results/responses | Agent manipulation via planted instructions | Response-direction heuristics, report-only by default (`injection` action `allow`); escalation is an explicit policy choice. Not a complete defense |
|
|
57
|
+
| Haechi re-detecting its own transform markers | A tokenized round-trip echoed by the model is re-flagged (e.g. `[TOKEN:…]` blocked as a `secret`), breaking `detokenizeResponses` under response-enforce | **Response direction only**, detection skips Haechi markers (`[TOKEN:…]`, `[HAECHI_ENC:…]`, `[REDACTED:…]`). Request-direction is unaffected, so a marker-shaped string in a request can't smuggle a secret past detection. **Accepted residual:** a hostile *upstream* could wrap a leaked value in a fake response marker to dodge response-direction detection — response inspection is a secondary defense (the model is semi-trusted) and the exclusion is positional (only the marker span is skipped; adjacent values are still detected) |
|
|
58
|
+
| Response-metadata false positives | Response inspection in `enforce` blocks legitimate completions because envelope metadata looks PII/secret-shaped (e.g. a unix-timestamp `created` matching the phone rule, a nanosecond `*_duration` matching `card`) | The KR phone rule ignores bare separator-less non-`0`-led digit runs; ids like `chatcmpl-…` are not secret-shaped; and the **response direction does not scan bare JSON number leaves** (`*_duration`/count/timestamp/numeric-id — never a model-leaked card/RRN; a real leak lands in generated *text*, still inspected). **Accepted residual:** a hostile model could exfiltrate a value encoded as a bare response number (response inspection is a secondary defense) — a strict deployment can opt back in with `responseProtection.scanNumbers: true`. Real vLLM + Ollama responses now scan clean; for extra caution use `responseProtection.mode: report-only` (detect + audit, no block) |
|
|
59
|
+
| Dashboard audit-viewer XSS via attacker-controlled `detections[].path` (0.9) | Stored XSS in `haechi-dashboard`: a request JSON key like `<img onerror>` reaches the audit log via the client-key-derived `detections[].path` field, then renders in the viewer | The served page builds DOM with `createElement` + `textContent` only (never `innerHTML` interpolation), and every response carries a strict CSP including `require-trusted-types-for 'script'` (any stray `innerHTML` sink throws in-browser). The allowlist bounds field *names* and CSP + `textContent` neutralize malicious *values* — independent layers. None material |
|
|
60
|
+
| Audit field leak via the viewer (future field) (0.9) | A field added to the audit schema later is surfaced verbatim by the dashboard API, leaking metadata never meant to be exposed | `/api/events` runs a **recursive, key-by-key field allowlist projection** against the real audit schema (no nested sub-object — `detections`/`identity`/`summary`/`auditIntegrity` — is spread through blind), layered over core's `FORBIDDEN_KEYS`. A new nested field at any level defaults to dropped |
|
|
61
|
+
| DNS-rebinding read of audit JSON from a localhost-bound viewer (0.9) | A site the operator browses publishes a short-TTL DNS name re-resolving to `127.0.0.1`, so the victim's browser makes same-origin requests and the attacker's JS reads the audit JSON from an unauthenticated loopback dashboard | Per-request **anti-rebinding `Host`-header allowlist** (first gate, distinct from the bind check; normalizes IPv4-mapped IPv6, trailing-dot, bracketed IPv6, rejects malformed/duplicate `Host`) plus `Cross-Origin-Resource-Policy`/`Cross-Origin-Opener-Policy: same-origin`; CORS headers are never emitted. None material |
|
|
62
|
+
| Unauthenticated audit read on remote bind (0.9) | The dashboard bound to a non-loopback host exposes the audit stream with no login | Fail-closed precedence: remote bind throws unless `allowRemoteBind` **and** a `sessionGuard` are present **and** confirmed HTTPS termination (a `tlsContext`, or `trustProxy` honoring `X-Forwarded-Proto` only from a trusted proxy); a Secure/`__Host-` session cookie is never sent over plaintext http. Operator must terminate TLS |
|
|
63
|
+
| OIDC login CSRF / authorization-code injection / open-redirect / session fixation (0.9) | An attacker forces a victim's broker session onto an attacker-controlled login, injects a stolen code, redirects post-login off-origin, or fixes a pre-known session id | `/auth/callback` is **state-first**: atomic `take()` of a pre-auth-cookie-bound pending record and constant-time `state` compare **before any IdP egress**; PKCE S256 mandatory; a **fresh session id minted at callback** (no fixation, pre-auth cookie discarded); post-login `return_to` validated against a relative-path `returnToAllowlist`; logout is non-GET + CSRF-header gated. None material for a single IdP |
|
|
64
|
+
| OIDC mix-up (wrong IdP / wrong RP) (0.9) | A confused-deputy attack swaps the IdP or replays a code/token minted for a different client | Issuer/`token_endpoint`/`jwks_uri` are pinned into the pending record at `/auth/login`; the RFC 9207 `iss` response param must equal the pinned issuer; `metadata.issuer` must string-equal the configured issuer; and the OIDC ID-token `aud`/`azp` profile (`aud` must contain `clientId`; multi-valued `aud` requires `azp === clientId`) closes cross-client. Multi-origin IdP out of scope |
|
|
65
|
+
| Broker SSRF to cloud metadata via the token-endpoint POST (and Vault `fetch`) (0.9) | A `token_endpoint` (or operator-supplied `VAULT_ADDR`) that DNS-rebinds to `169.254.169.254` between discovery and request exfiltrates instance-metadata credentials | Every egress (discovery GET, JWKS GET via the shared verifier, token-exchange POST, end-session redirect, and the `haechi-crypto-kms` Vault `fetch`) runs a `lookup`-then-`isBlockedAddress` re-check **immediately before the request** (post-DNS), with `redirect: "error"`, a bounded response body, and a timeout. Operator-trusted endpoints only |
|
|
66
|
+
| Token/secret leak into audit/logs (broker) (0.9) | An ID/access/refresh token, `client_secret`, `code`, `state`, `nonce`, or raw `sub` is written to the audit log or a client response | The broker projects every audit event through its own allowlist and emits only `subjectHash`/`issuerHash`/`sessionIdHash` (keyed-HMAC) + `provider`/`reasonCode`/timestamp; core's `FORBIDDEN_KEYS` is extended to cover the broker token/claim keys; the access token is **discarded** (never stored or used). None material |
|
|
67
|
+
| KMS backend egress (Vault HTTP, GCP/Azure SDK) (0.9) | A `haechi-crypto-kms` Vault/GCP/Azure backend leaks key material or provider/key-path detail, or reaches an unintended endpoint | Optional-peer + injected-client model with **faithful-mock conformance** (cross-key + corrupted-blob rejection, HMAC determinism/domain-separation); the Vault `fetch` runs the satellite-local SSRF guard above; all backends map provider errors to a generic fail-closed error and never write provider/key-ARN detail to audit. Live-backend validation is out of CI |
|
|
57
68
|
|
|
58
69
|
## 4. Explicit Exclusions
|
|
59
70
|
|
|
@@ -71,6 +82,10 @@ The primary assets Haechi protects are:
|
|
|
71
82
|
- Detection of sensitive values in URL query strings (JSON body only)
|
|
72
83
|
- 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
|
|
73
84
|
- JSON-RPC batch message processing (the MCP stdio filter rejects batches fail-closed)
|
|
85
|
+
- Multi-origin / CDN-fronted IdP for `haechi-auth-oidc` (issuer host ≠ `token_endpoint`/`jwks_uri` host) — single-origin only, same constraint as `haechi-auth-jwt` (0.9)
|
|
86
|
+
- Refresh-token rotation / silent renewal / long-lived broker sessions — 0.9 sessions are absolute-TTL + idle-timeout only; `offline_access` is stripped and the access token is discarded (0.9)
|
|
87
|
+
- Dashboard write actions (reveal, purge, policy edits) — `haechi-dashboard` is read-only with no `POST`/`DELETE` surface; mutation stays in the CLI under reveal governance (0.9)
|
|
88
|
+
- `at_hash`/`c_hash` validation in the OIDC broker — out of scope precisely because the broker never uses the access token (0.9)
|
|
74
89
|
|
|
75
90
|
## 5. Remaining Operational Assumptions
|
|
76
91
|
|
|
@@ -1,47 +1,13 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `haechi-crypto-kms` moved
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The KMS-backed `cryptoProvider` reference that used to live here has been **promoted to a published satellite**. It now lives in the monorepo at [`satellites/crypto-kms/`](../../satellites/crypto-kms/) and is published as **`haechi-crypto-kms`**.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
Envelope encryption: each `encrypt` generates a fresh AES-256-GCM **data key**, encrypts the plaintext locally, and **wraps the data key with the KMS**. The KMS master key never leaves the KMS. `decrypt` unwraps the data key via the KMS and decrypts locally. `hmac` derives a per-domain key from the KMS, preserving Haechi's domain-separation discipline (tokens, identity, policy bundles).
|
|
8
|
-
|
|
9
|
-
The envelope matches Haechi's contract (`v, alg, kid, iv, ct, tag, aadHash`) plus a `wrappedKey`. AAD is canonicalized and bound exactly as the local provider does, so it passes `assertCryptoProviderConformance`.
|
|
10
|
-
|
|
11
|
-
## The KMS client interface
|
|
12
|
-
|
|
13
|
-
Inject any client implementing:
|
|
14
|
-
|
|
15
|
-
```js
|
|
16
|
-
{
|
|
17
|
-
keyId: string,
|
|
18
|
-
async wrap(dataKey: Buffer): string, // KMS-encrypt a data key
|
|
19
|
-
async unwrap(wrapped: string): Buffer, // KMS-decrypt it
|
|
20
|
-
async deriveHmacKey(domain: string): Buffer // KMS-derived per-domain key
|
|
21
|
-
}
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
`createInMemoryKms()` is a process-local stand-in for examples/tests. A real deployment swaps in an AWS KMS / HashiCorp Vault client (e.g. `@aws-sdk/client-kms` `GenerateDataKey`/`Decrypt`, plus an HKDF for `deriveHmacKey`).
|
|
25
|
-
|
|
26
|
-
## Usage
|
|
27
|
-
|
|
28
|
-
In 0.7 this is a repo reference example — import it by relative path
|
|
29
|
-
(`./examples/crypto-kms-reference/index.mjs`). From 0.8 it is published as
|
|
30
|
-
`@haechi/crypto-kms` and imported by name, as shown below.
|
|
31
|
-
|
|
32
|
-
```js
|
|
33
|
-
import { createRuntime } from "haechi/runtime";
|
|
34
|
-
import { createKmsCryptoProvider, createInMemoryKms } from "@haechi/crypto-kms";
|
|
35
|
-
|
|
36
|
-
const cryptoProvider = createKmsCryptoProvider({ kms: createInMemoryKms() });
|
|
37
|
-
const runtime = createRuntime({ keys: { provider: "external" }, /* ... */ }, { cryptoProvider });
|
|
5
|
+
```sh
|
|
6
|
+
npm install haechi-crypto-kms
|
|
38
7
|
```
|
|
39
8
|
|
|
40
|
-
## Self-test
|
|
41
|
-
|
|
42
9
|
```js
|
|
43
|
-
import {
|
|
44
|
-
await assertCryptoProviderConformance(cryptoProvider); // throws on any contract violation
|
|
10
|
+
import { createKmsCryptoProvider, createInMemoryKms } from "haechi-crypto-kms";
|
|
45
11
|
```
|
|
46
12
|
|
|
47
|
-
|
|
13
|
+
See [`satellites/crypto-kms/README.md`](../../satellites/crypto-kms/README.md) for usage. Core (`haechi`) stays zero-runtime-dependency; the satellite carries any KMS-client dependency itself.
|
package/package.json
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "haechi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Experimental developer preview for self-hosted AI context enforcement across LLM, MCP, vLLM, Ollama, and agent traffic.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
|
+
"workspaces": [
|
|
8
|
+
".",
|
|
9
|
+
"satellites/*"
|
|
10
|
+
],
|
|
7
11
|
"repository": {
|
|
8
12
|
"type": "git",
|
|
9
13
|
"url": "git+https://github.com/raeseoklee/haechi.git"
|
|
@@ -63,6 +67,8 @@
|
|
|
63
67
|
"check:types": "tsc -p jsconfig.json --noEmit",
|
|
64
68
|
"pack:dry": "npm pack --dry-run",
|
|
65
69
|
"scan:stale-names": "node scripts/stale-name-scan.mjs",
|
|
70
|
+
"check:packaging": "node scripts/check-core-packaging.mjs",
|
|
71
|
+
"check:satellite-packaging": "node scripts/check-satellite-packaging.mjs",
|
|
66
72
|
"sbom": "node scripts/generate-sbom.mjs",
|
|
67
73
|
"checksums": "node scripts/release-checksums.mjs",
|
|
68
74
|
"bench:payload": "node scripts/bench-payload.mjs",
|
package/packages/audit/index.mjs
CHANGED
|
@@ -5,7 +5,18 @@ import { dirname } from "node:path";
|
|
|
5
5
|
import { createInterface } from "node:readline";
|
|
6
6
|
import { setTimeout as delay } from "node:timers/promises";
|
|
7
7
|
|
|
8
|
-
const FORBIDDEN_KEYS = new Set([
|
|
8
|
+
const FORBIDDEN_KEYS = new Set([
|
|
9
|
+
"value", "plaintext", "payload", "content", "message", "prompt", "secret",
|
|
10
|
+
// OIDC-broker / OAuth token, secret, and authorization-flow parameter keys.
|
|
11
|
+
// These are never part of a current audit event shape; the membership is a
|
|
12
|
+
// defense-in-depth guard so a future audit field can never leak a token,
|
|
13
|
+
// client secret, or flow parameter through the core sink. `sub`/`email` are
|
|
14
|
+
// intentionally NOT listed — they can be legitimate non-secret field names
|
|
15
|
+
// elsewhere, and the broker already self-guards them via its own allowlist
|
|
16
|
+
// projection.
|
|
17
|
+
"access_token", "id_token", "refresh_token", "code", "code_verifier",
|
|
18
|
+
"client_secret", "state", "nonce"
|
|
19
|
+
]);
|
|
9
20
|
|
|
10
21
|
export function createJsonlAuditSink({ path, anchor = null }) {
|
|
11
22
|
if (!path) {
|
package/packages/auth/index.mjs
CHANGED
|
@@ -117,6 +117,51 @@ export async function buildIdentity(record, cryptoProvider) {
|
|
|
117
117
|
};
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
// PII-safe identity builder for EXTERNAL auth providers (e.g. the haechi-auth-jwt
|
|
121
|
+
// satellite). Core owns identity construction so the keyed-HMAC domain and the
|
|
122
|
+
// identity shape stay authoritative here — a satellite supplies raw claims and
|
|
123
|
+
// never sees or stores the IDENTITY_DOMAIN. subject/issuer become keyed HMACs;
|
|
124
|
+
// the raw values are never returned. Throws (fail-closed) on a missing
|
|
125
|
+
// cryptoProvider.hmac, an empty subject/issuer, an invalid type, bad scopes, or
|
|
126
|
+
// a disallowed label.
|
|
127
|
+
export async function buildExternalIdentity(
|
|
128
|
+
{ provider, subject, issuer, type = "user", scopes = [], labels = {}, allowedLabelKeys = DEFAULT_ALLOWED_LABEL_KEYS },
|
|
129
|
+
cryptoProvider
|
|
130
|
+
) {
|
|
131
|
+
if (typeof cryptoProvider?.hmac !== "function") {
|
|
132
|
+
throw new Error("buildExternalIdentity requires a cryptoProvider with hmac()");
|
|
133
|
+
}
|
|
134
|
+
if (!provider || typeof provider !== "string") {
|
|
135
|
+
throw new Error("identity requires a non-empty provider string");
|
|
136
|
+
}
|
|
137
|
+
if (!subject || typeof subject !== "string") {
|
|
138
|
+
throw new Error("identity requires a non-empty subject");
|
|
139
|
+
}
|
|
140
|
+
if (!issuer || typeof issuer !== "string") {
|
|
141
|
+
throw new Error("identity requires a non-empty issuer");
|
|
142
|
+
}
|
|
143
|
+
if (!VALID_IDENTITY_TYPES.has(type)) {
|
|
144
|
+
throw new Error(`Invalid identity type: ${type} (expected user | service | agent)`);
|
|
145
|
+
}
|
|
146
|
+
if (!Array.isArray(scopes) || !scopes.every((scope) => typeof scope === "string" && scope.trim())) {
|
|
147
|
+
throw new Error("scopes must be an array of non-empty strings");
|
|
148
|
+
}
|
|
149
|
+
validateLabels(labels, allowedLabelKeys);
|
|
150
|
+
|
|
151
|
+
const subjectHash = await cryptoProvider.hmac({ data: subject, domain: IDENTITY_DOMAIN });
|
|
152
|
+
const issuerHash = await cryptoProvider.hmac({ data: issuer, domain: IDENTITY_DOMAIN });
|
|
153
|
+
return {
|
|
154
|
+
// Non-PII, stable per subject: derived from the keyed subject hash.
|
|
155
|
+
id: `${provider}:${subjectHash.slice(0, 16)}`,
|
|
156
|
+
type,
|
|
157
|
+
subjectHash,
|
|
158
|
+
issuerHash,
|
|
159
|
+
provider,
|
|
160
|
+
scopes,
|
|
161
|
+
labels
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
120
165
|
function bearerTokenFromRequest(request) {
|
|
121
166
|
const header = request?.headers?.authorization ?? request?.headers?.Authorization;
|
|
122
167
|
if (typeof header !== "string") {
|
package/packages/cli/runtime.mjs
CHANGED
|
@@ -32,7 +32,8 @@ export function defaultConfig() {
|
|
|
32
32
|
failureMode: "fail-closed",
|
|
33
33
|
allowNonJson: false,
|
|
34
34
|
allowCompressed: false,
|
|
35
|
-
maxBytes: 1048576
|
|
35
|
+
maxBytes: 1048576,
|
|
36
|
+
scanNumbers: false
|
|
36
37
|
},
|
|
37
38
|
streaming: {
|
|
38
39
|
requestMode: "block",
|
|
@@ -320,6 +321,9 @@ export function normalizeConfig(config) {
|
|
|
320
321
|
if (typeof merged.responseProtection.maxBytes !== "number" || merged.responseProtection.maxBytes < 1) {
|
|
321
322
|
throw new Error("responseProtection.maxBytes must be a positive number");
|
|
322
323
|
}
|
|
324
|
+
if (typeof merged.responseProtection.scanNumbers !== "boolean") {
|
|
325
|
+
throw new Error("responseProtection.scanNumbers must be boolean");
|
|
326
|
+
}
|
|
323
327
|
if (!["block", "pass-through", "inspect"].includes(merged.streaming.requestMode)) {
|
|
324
328
|
throw new Error(`Invalid streaming.requestMode: ${merged.streaming.requestMode}`);
|
|
325
329
|
}
|
package/packages/core/index.mjs
CHANGED
|
@@ -15,6 +15,10 @@ export function createHaechi({ filterEngine, policyEngine, cryptoProvider, audit
|
|
|
15
15
|
const effectiveMode = context.mode ?? mode;
|
|
16
16
|
const engine = contextEngine ?? policyEngine;
|
|
17
17
|
const entries = collectStringEntries(payload);
|
|
18
|
+
// `context` is threaded into detection as-is and is LOAD-BEARING: e.g.
|
|
19
|
+
// `context.direction` ("request" | "response") gates direction-scoped rules
|
|
20
|
+
// (injection) and the response-only marker exclusion in the filter engine.
|
|
21
|
+
// The proxy sets it per direction; do not drop it here.
|
|
18
22
|
const detections = await filterEngine.detect({ entries, context });
|
|
19
23
|
const decisions = [];
|
|
20
24
|
|
|
@@ -7,11 +7,14 @@ const DEFAULT_RULES = [
|
|
|
7
7
|
confidence: 0.95
|
|
8
8
|
},
|
|
9
9
|
{
|
|
10
|
+
// KR mobile numbers (01[016789] prefixes); landlines are out of scope.
|
|
11
|
+
// krPhoneValid keeps a bare separator-less run from matching a timestamp/id.
|
|
10
12
|
id: "kr-phone",
|
|
11
13
|
type: "phone",
|
|
12
14
|
pattern: "(?:\\+82[-\\s]?)?0?1[016789][-.\\s]?\\d{3,4}[-.\\s]?\\d{4}",
|
|
13
15
|
flags: "g",
|
|
14
|
-
confidence: 0.9
|
|
16
|
+
confidence: 0.9,
|
|
17
|
+
validate: krPhoneValid
|
|
15
18
|
},
|
|
16
19
|
{
|
|
17
20
|
id: "kr-rrn-like",
|
|
@@ -116,6 +119,27 @@ export function createDefaultFilterEngine({ customRules = [] } = {}) {
|
|
|
116
119
|
|
|
117
120
|
export function detectEntry(entry, rules, context = {}) {
|
|
118
121
|
const detections = [];
|
|
122
|
+
// On the RESPONSE direction, a bare JSON NUMBER leaf is inference-server
|
|
123
|
+
// metadata (a nanosecond `*_duration`, a token count, a numeric id/timestamp) —
|
|
124
|
+
// never a model-leaked card/phone/RRN. Scanning it only yields false positives:
|
|
125
|
+
// a long Luhn-passing duration matches `card`, a 13-digit one matches `kr_rrn`.
|
|
126
|
+
// The REQUEST direction still scans numbers (a client CAN send a card as a
|
|
127
|
+
// number); model-leaked PII lands in generated TEXT (string leaves), which are
|
|
128
|
+
// still inspected. (Accepted residual: a hostile model could exfiltrate a value
|
|
129
|
+
// as a bare response number — response inspection is a secondary defense.) A
|
|
130
|
+
// strict deployment can opt back in with `responseProtection.scanNumbers: true`
|
|
131
|
+
// (threaded as context.scanNumbers), accepting the metadata false positives.
|
|
132
|
+
if (context?.direction === "response" && entry.kind === "number" && !context?.scanNumbers) {
|
|
133
|
+
return detections;
|
|
134
|
+
}
|
|
135
|
+
// On the RESPONSE direction only, skip Haechi's own transform markers so they
|
|
136
|
+
// aren't re-detected: a tokenized round-trip echoes `[TOKEN:tok_…]` back, which
|
|
137
|
+
// reads like a `token:<secret>` assignment — without this, Haechi blocks its
|
|
138
|
+
// own token. This is response-only on purpose: a REQUEST that contains a
|
|
139
|
+
// marker-shaped string is NOT Haechi output (Haechi hasn't transformed it yet),
|
|
140
|
+
// so it is scanned normally — otherwise an attacker could wrap a real secret in
|
|
141
|
+
// a fake `[TOKEN:…]` to evade request-side detection.
|
|
142
|
+
const markerSpans = context?.direction === "response" ? haechiMarkerSpans(entry.value) : [];
|
|
119
143
|
|
|
120
144
|
for (const rule of rules) {
|
|
121
145
|
// Direction-scoped rules (e.g. injection heuristics) only run on the
|
|
@@ -129,14 +153,19 @@ export function detectEntry(entry, rules, context = {}) {
|
|
|
129
153
|
if (rule.validate && !rule.validate(value)) {
|
|
130
154
|
continue;
|
|
131
155
|
}
|
|
156
|
+
const start = match.index;
|
|
157
|
+
const end = match.index + value.length;
|
|
158
|
+
if (overlapsAny(start, end, markerSpans)) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
132
161
|
detections.push({
|
|
133
162
|
type: rule.type,
|
|
134
163
|
ruleId: rule.id,
|
|
135
164
|
path: entry.path,
|
|
136
165
|
pathText: entry.pathText,
|
|
137
166
|
kind: entry.kind ?? "value",
|
|
138
|
-
start
|
|
139
|
-
end
|
|
167
|
+
start,
|
|
168
|
+
end,
|
|
140
169
|
confidence: rule.confidence,
|
|
141
170
|
value
|
|
142
171
|
});
|
|
@@ -146,6 +175,32 @@ export function detectEntry(entry, rules, context = {}) {
|
|
|
146
175
|
return removeOverlaps(detections);
|
|
147
176
|
}
|
|
148
177
|
|
|
178
|
+
// Spans of Haechi's own transform markers in a string, so detection can skip
|
|
179
|
+
// them: `[TOKEN:…]`, `[HAECHI_ENC:…]`, `[REDACTED:…]`.
|
|
180
|
+
function haechiMarkerSpans(text) {
|
|
181
|
+
const spans = [];
|
|
182
|
+
for (const m of text.matchAll(/\[(?:TOKEN|HAECHI_ENC|REDACTED):[^\]]*\]/g)) {
|
|
183
|
+
spans.push([m.index, m.index + m[0].length]);
|
|
184
|
+
}
|
|
185
|
+
return spans;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function overlapsAny(start, end, spans) {
|
|
189
|
+
return spans.some(([s, e]) => start < e && end > s);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// A bare digit run with no separators and no +82 country code is only treated as
|
|
193
|
+
// a KR phone number when it starts with the trunk prefix 0 (e.g. 01012345678);
|
|
194
|
+
// otherwise an ambiguous 10-digit value (a unix timestamp, an id, a counter)
|
|
195
|
+
// merely looks phone-shaped. Separated/prefixed forms (010-1234-5678,
|
|
196
|
+
// +82 10 1234 5678) always pass.
|
|
197
|
+
function krPhoneValid(match) {
|
|
198
|
+
if (/[-.\s+]/.test(match)) {
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
return match.startsWith("0");
|
|
202
|
+
}
|
|
203
|
+
|
|
149
204
|
function normalizeCustomRule(rule) {
|
|
150
205
|
if (!rule.id || !rule.type || !rule.pattern) {
|
|
151
206
|
throw new Error("Custom filter rule requires id, type, and pattern");
|