haechi 1.3.2 → 1.3.3

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.
@@ -21,6 +21,17 @@ docker compose up -d # 참조 스택 빌드 + 실행
21
21
  docker compose logs -f haechi
22
22
  ```
23
23
 
24
+ **사전 빌드 이미지(GHCR).** 각 `v<semver>` 릴리스는 cosign 서명된 이미지를 `ghcr.io/<owner>/haechi`에 발행하며(태그 `<major>.<minor>.<patch>`, `<major>.<minor>`, `<major>`, `latest`), 실행 전에 검증하십시오 — 서명과 provenance가 이미지를 이 repo의 release workflow에 묶습니다:
25
+
26
+ ```bash
27
+ cosign verify ghcr.io/<owner>/haechi:1.3.3 \
28
+ --certificate-identity-regexp '^https://github.com/<owner>/haechi/' \
29
+ --certificate-oidc-issuer https://token.actions.githubusercontent.com
30
+ gh attestation verify oci://ghcr.io/<owner>/haechi:1.3.3 --repo <owner>/haechi
31
+ ```
32
+
33
+ 이미지는 `proxy.trustForwardedProto: true`를 구워 넣으므로(TLS를 종단하는 리버스 프록시 뒤에서 `0.0.0.0`에 바인딩 — 아래 참조), Haechi는 보호되는 모든 요청에 `X-Forwarded-Proto: https`를 요구합니다. Haechi가 직접 TLS를 종단하게 하려면 `proxy.tls`가 설정된 본인의 설정을 마운트하십시오.
34
+
24
35
  **TLS + 인증으로 앞단을 보호하십시오.** Haechi는 자체 TLS가 없습니다. 포트는 TLS를 종단하고 인증하는 리버스 프록시(nginx / Caddy / Traefik / API 게이트웨이)에만 공개하고, 원시 Haechi 포트를 공개 인터페이스에 절대 노출하지 마십시오. compose 예제는 바로 이 이유로 호스트 loopback(`127.0.0.1:11016`)에만 공개합니다.
25
36
 
26
37
  **Loopback 너머 바인딩.** 컨테이너 내부에서는 매핑된 포트가 도달 가능하도록 Haechi가 `0.0.0.0`에 바인딩해야 하며, 이는 `--allow-remote-bind`를 요구합니다(참조 `CMD`가 전달합니다). 호스트에서는 기본 loopback 바인딩을 선호하고 리버스 프록시를 통해 Haechi에 접근하십시오. [Loopback 너머 바인딩](./configuration.ko.md)을 참고하십시오.
@@ -30,6 +30,23 @@ docker compose up -d # build + run the reference stack
30
30
  docker compose logs -f haechi
31
31
  ```
32
32
 
33
+ **Pre-built image (GHCR).** Each `v<semver>` release publishes a cosign-signed
34
+ image to `ghcr.io/<owner>/haechi` (tags `<major>.<minor>.<patch>`, `<major>.<minor>`,
35
+ `<major>`, `latest`). Verify it before running — the signature and provenance bind
36
+ the image to this repo's release workflow:
37
+
38
+ ```bash
39
+ cosign verify ghcr.io/<owner>/haechi:1.3.3 \
40
+ --certificate-identity-regexp '^https://github.com/<owner>/haechi/' \
41
+ --certificate-oidc-issuer https://token.actions.githubusercontent.com
42
+ gh attestation verify oci://ghcr.io/<owner>/haechi:1.3.3 --repo <owner>/haechi
43
+ ```
44
+
45
+ The image bakes `proxy.trustForwardedProto: true` (it binds `0.0.0.0` behind a
46
+ TLS-terminating reverse proxy — see below), so Haechi requires `X-Forwarded-Proto:
47
+ https` on every protected request; mount your own config with `proxy.tls` set
48
+ instead if you want Haechi to terminate TLS itself.
49
+
33
50
  **Front it with TLS + auth.** Haechi has no TLS of its own. Publish its port only
34
51
  to a TLS-terminating, authenticating reverse proxy (nginx / Caddy / Traefik / an
35
52
  API gateway); never expose the raw Haechi port on a public interface. The compose
@@ -68,6 +68,7 @@ npm audit signatures
68
68
  |---|---|---|---|
69
69
  | `.github/workflows/ci.yml` | — | 모든 push/PR | test, release preflight, SBOM artifact |
70
70
  | `.github/workflows/npm-publish.yml` | `haechi` | `v<semver>` | npm provenance publish + 체크섬/증명 release 자산 |
71
+ | `.github/workflows/container-publish.yml` | `ghcr.io/<owner>/haechi` 이미지 | `v<semver>` | 루트 Dockerfile 빌드, GHCR로 push, digest 기준 keyless cosign 서명 + sigstore build-provenance 증명 |
71
72
  | `.github/workflows/crypto-kms-publish.yml` | `haechi-crypto-kms` | `crypto-kms-v<semver>` | satellite publish, 동일한 서명 아티팩트 경로 |
72
73
  | `.github/workflows/auth-jwt-publish.yml` | `haechi-auth-jwt` | `auth-jwt-v<semver>` | satellite publish, 동일한 서명 아티팩트 경로 |
73
74
  | `.github/workflows/dashboard-publish.yml` | `haechi-dashboard` | `dashboard-v<semver>` | satellite publish, 동일한 서명 아티팩트 경로 |
@@ -68,6 +68,7 @@ npm audit signatures
68
68
  |---|---|---|---|
69
69
  | `.github/workflows/ci.yml` | — | any push/PR | Tests, release preflight, SBOM artifact |
70
70
  | `.github/workflows/npm-publish.yml` | `haechi` | `v<semver>` | npm provenance publish + checksummed/attested release assets |
71
+ | `.github/workflows/container-publish.yml` | `ghcr.io/<owner>/haechi` image | `v<semver>` | Build the root Dockerfile, push to GHCR, keyless cosign sign by digest + sigstore build-provenance attestation |
71
72
  | `.github/workflows/crypto-kms-publish.yml` | `haechi-crypto-kms` | `crypto-kms-v<semver>` | satellite publish, same signed-artifacts path |
72
73
  | `.github/workflows/auth-jwt-publish.yml` | `haechi-auth-jwt` | `auth-jwt-v<semver>` | satellite publish, same signed-artifacts path |
73
74
  | `.github/workflows/dashboard-publish.yml` | `haechi-dashboard` | `dashboard-v<semver>` | satellite publish, same signed-artifacts path |
@@ -1,6 +1,6 @@
1
1
  # 신뢰성 하드닝 트랙 (Reliability Hardening Track)
2
2
 
3
- - 상태: 계획 (2026-06-12 확정; 1.1.1 코어에 대한 5-렌즈 읽기 전용 감사에 근거)
3
+ - 상태: 출시 완료 — WS1–WS6 전부 core 1.2.0으로 전달·컷됨(릴리스 게이트 G7 Pass). 이 문서는 계획/감사 기록으로 보존합니다. (2026-06-12 확정; 1.1.1 코어에 대한 5-렌즈 읽기 전용 감사에 근거.)
4
4
  - 대상 라인: 1.1.2(patch) → 1.2.0(minor); 신규 제품 표면 없음
5
5
  - 목적: Haechi를 **상용 솔루션 수준의 신뢰성**으로 끌어올립니다 — 운영 AI 보안 게이트웨이에 기대되는 신뢰·운영성·탐지 품질의 밀도입니다. 이것은 품질 목표이지 상용화 계획이 아닙니다. 모든 항목은 **이미 존재하는 것을 조이거나, 측정하거나, 문서화**하며, 신규 기능을 추가하지 않습니다.
6
6
 
@@ -1,6 +1,6 @@
1
1
  # Reliability Hardening Track
2
2
 
3
- - Status: Plan (pinned 2026-06-12; grounded in a 5-lens read-only audit of the 1.1.1 core)
3
+ - Status: Shipped — WS1–WS6 all delivered and cut in core 1.2.0 (release gate G7 Pass). This doc is retained as the planning/audit record. (Pinned 2026-06-12; grounded in a 5-lens read-only audit of the 1.1.1 core.)
4
4
  - Target line: 1.1.2 (patch) → 1.2.0 (minor); no new product surface
5
5
  - Purpose: raise Haechi to **commercial-solution-level reliability** — the trust, operability, and detection-quality density a production AI-security gateway is expected to have. This is a quality objective, not a commercialization plan. Every item **tightens, measures, or documents what already exists**; none adds a new feature.
6
6
 
@@ -14,9 +14,9 @@ Haechi는 `1.x` stable 라인을 출시했습니다. developer preview 게이트
14
14
  | 구분 | 판단 | 이유 |
15
15
  |---|---|---|
16
16
  | GitHub public | 허용 | 보안 한계, threat model, shared responsibility가 문서화됨 |
17
- | GitHub release/tag | 허용 (`v1.3.2` 릴리스됨) | `v1.3.2` CR2 보완 컷이 태깅·릴리스됨; §5.7 및 §5.8(`CR2-001..008`) 항목이 모두 Resolved이고 G9/G10은 Pass |
18
- | npm stable | `haechi@1.3.2` publish됨 | CR2 보완이 `haechi@1.3.2` attested OIDC publish(2026-06-16)로 발행됨; 이전 `1.3.1`은 CR2 수정 이전 동작을 담고 있음 |
19
- | production use | 운영자 게이트; `1.3.2`로 업그레이드 | 운영자 네트워크 통제, 인가/인증, key custody가 있을 때만 지원; `haechi@1.3.1` 운영자는 민감한 제3자 업스트림 트래픽을 프록시로 라우팅하기 전에 CR2 수정(특히 `CR2-001` 프록시 upstream-cancel과 `CR2-002` token-vault audit hygiene)을 반영하도록 `1.3.2`로 업그레이드해야 함 |
17
+ | GitHub release/tag | 허용 (`v1.3.3` 릴리스됨) | `v1.3.3`이 현재 릴리스(CR2 컷 `1.3.2` 위의 선제적 하드닝 패치); §5.7 및 §5.8(`CR2-001..008`) 항목은 모두 Resolved 유지, G9/G10은 Pass |
18
+ | npm stable | `haechi@1.3.3` publish됨 | `1.3.3`은 CR2-보완된 `1.3.2` 기준 위에 response-direction marker-skip 강화 + cosign 서명 GHCR 컨테이너 이미지를 더한 attested OIDC publish |
19
+ | production use | 운영자 게이트; `1.3.3`로 업그레이드 | 운영자 네트워크 통제, 인가/인증, key custody가 있을 때만 지원; 운영자는 민감한 제3자 업스트림 트래픽을 프록시로 라우팅하기 전에 최신 `haechi@1.3.3`(1.3.2의 CR2 수정 + marker-skip 하드닝 포함)을 실행해야 함 |
20
20
 
21
21
  ## 2. 릴리스 게이트
22
22
 
@@ -14,9 +14,9 @@ Haechi has shipped its `1.x` stable line. The developer-preview gate (G2, `haech
14
14
  | Category | Judgment | Rationale |
15
15
  |---|---|---|
16
16
  | GitHub public | Allowed | Security limitations, threat model, and shared responsibility are documented |
17
- | GitHub release/tag | Allowed (`v1.3.2` released) | The `v1.3.2` CR2 remediation cut is tagged and released; all §5.7 and §5.8 (`CR2-001..008`) findings are Resolved and G9/G10 are Pass |
18
- | npm stable | `haechi@1.3.2` published | The CR2 remediation shipped in the `haechi@1.3.2` attested OIDC publish (2026-06-16); the prior `1.3.1` carries the pre-CR2-fix behavior |
19
- | Production use | Operator-gated; upgrade to `1.3.2` | Supported only with operator network controls, authz/authn, and key custody; operators on `haechi@1.3.1` should upgrade to `1.3.2` to pick up the CR2 fixes (notably the `CR2-001` proxy upstream-cancel and `CR2-002` token-vault audit hygiene) before routing sensitive third-party upstream traffic through the proxy |
17
+ | GitHub release/tag | Allowed (`v1.3.3` released) | `v1.3.3` is the current release (a proactive-hardening patch over the CR2 cut `1.3.2`); all §5.7 and §5.8 (`CR2-001..008`) findings remain Resolved and G9/G10 are Pass |
18
+ | npm stable | `haechi@1.3.3` published | `1.3.3` is an attested OIDC publish adding the response-direction marker-skip tightening + a cosign-signed GHCR container image, over the CR2-remediated `1.3.2` baseline |
19
+ | Production use | Operator-gated; upgrade to `1.3.3` | Supported only with operator network controls, authz/authn, and key custody; operators should run the latest `haechi@1.3.3` (it carries the CR2 fixes from `1.3.2` plus the marker-skip hardening) before routing sensitive third-party upstream traffic through the proxy |
20
20
 
21
21
  ## 2. Release Gates
22
22
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haechi",
3
- "version": "1.3.2",
3
+ "version": "1.3.3",
4
4
  "description": "Self-hosted AI context enforcement across LLM, MCP, vLLM, Ollama, and agent traffic — a stable, zero-dependency security gateway.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -540,12 +540,15 @@ function scanEntry(entry, rules, context = {}) {
540
540
  // own token. This is response-only on purpose: a REQUEST that contains a
541
541
  // marker-shaped string is NOT Haechi output (Haechi hasn't transformed it yet),
542
542
  // so it is scanned normally — otherwise an attacker could wrap a real secret in
543
- // a fake `[TOKEN:…]` to evade request-side detection.
543
+ // a fake `[TOKEN:…]` to evade request-side detection. On the RESPONSE side the
544
+ // same wrap-a-secret risk is closed by haechiMarkerSpans recording a span only
545
+ // when the inner content matches a GENUINE emitted format — a fake marker
546
+ // wrapping a real secret stays in the scan and is detected/blocked.
544
547
  // Markers are pure ASCII and NFKC-stable, so their spans are computed on the
545
548
  // ORIGINAL value exactly as before — they line up with the same-length
546
549
  // normalized scan (Case 2 below) and are irrelevant to the whole-leaf scan
547
550
  // (Case 3).
548
- const markerSpans = context?.direction === "response" ? haechiMarkerSpans(entry.value) : [];
551
+ const markerSpans = context?.direction === "response" ? haechiMarkerSpans(entry.value, rules, context) : [];
549
552
 
550
553
  // WS2d — Unicode evasion via NFKC normalization. A client can defeat every
551
554
  // regex rule by sending PII/secrets in a Unicode form that folds to ASCII
@@ -824,12 +827,157 @@ function isPositionStableNfkc(value, normalized) {
824
827
  return rebuilt === normalized;
825
828
  }
826
829
 
827
- // Spans of Haechi's own transform markers in a string, so detection can skip
828
- // them: `[TOKEN:…]`, `[HAECHI_ENC:…]`, `[REDACTED:…]`.
829
- function haechiMarkerSpans(text) {
830
+ // Spans of Haechi's own transform markers in a string, so RESPONSE-direction
831
+ // detection can skip them: `[TOKEN:…]`, `[HAECHI_ENC:…]`, `[REDACTED:…]`. A
832
+ // tokenized round-trip echoed by the model would otherwise be re-flagged as a
833
+ // secret (Haechi blocking its own output).
834
+ //
835
+ // CR-???: a span is recorded ONLY when its inner content matches a GENUINE
836
+ // format actually emitted by core's transform (packages/core/index.mjs
837
+ // replacementFor). Without this check the marker frame `[(?:TOKEN|…):[^\]]*]`
838
+ // would skip ANY inner content, so a hostile model could exfiltrate a real
839
+ // secret by wrapping it in a FAKE marker — `[TOKEN:sk-ant-api03-<secret>]`,
840
+ // `[HAECHI_ENC:<secret>]`, `[REDACTED:<secret>]` — and that span would be
841
+ // dropped from the scan. A marker-SHAPED string whose inner content is not
842
+ // genuine is left in the scan, so the wrapped secret is detected/blocked.
843
+ // Genuine inner formats:
844
+ // [REDACTED:<type>] <type> is a detection type name (lowercase
845
+ // identifier: [a-z][a-z0-9_]*).
846
+ // [TOKEN:<vaultTokenId>] vault id shape `tok_<type>_<hexhash>`
847
+ // (matches token-vault VAULT_TOKEN_SHAPE).
848
+ // [TOKEN:<type>:<shortHash>] non-vault deterministic token: type name + hex.
849
+ // [HAECHI_ENC:<base64url>] base64url that decodes to a VALID envelope
850
+ // JSON object (cryptoProvider.encrypt envelope:
851
+ // has `kid`+`aadHash`). A real secret string will
852
+ // not base64url-decode to such an object.
853
+ // Markers are pure ASCII / NFKC-stable and spans are computed on the ORIGINAL
854
+ // entry.value, so offset integrity is unchanged.
855
+
856
+ // Detection-type name shape (the `detection.type` written by core into REDACTED
857
+ // and the type segment of a non-vault TOKEN). Built-in rule types and custom
858
+ // rule types are lowercase identifiers; a real secret (hyphens, uppercase,
859
+ // length) does not match, so a wrapped secret stays in the scan.
860
+ const MARKER_TYPE_NAME = /^[a-z][a-z0-9_]*$/;
861
+ // Vault token id shape — mirrors token-vault VAULT_TOKEN_SHAPE
862
+ // (`tok_<type>_<hexhash>`, random: 16 hex, deterministic: 32 hex). Kept in sync
863
+ // with packages/token-vault/index.mjs (not exported from there).
864
+ const MARKER_VAULT_TOKEN = /^tok_[a-z0-9_]+_[a-f0-9]{16,}$/;
865
+ // Non-vault deterministic token: `<type>:<hex>` (core shortHash → 12 hex; allow
866
+ // any reasonable hex run so the check does not over-fit a single length).
867
+ const MARKER_NONVAULT_TOKEN = /^[a-z][a-z0-9_]*:[a-f0-9]{8,}$/;
868
+ // base64url alphabet only (core emits base64url with no padding).
869
+ const MARKER_BASE64URL = /^[A-Za-z0-9_-]+$/;
870
+
871
+ function isGenuineTokenInner(inner) {
872
+ return MARKER_VAULT_TOKEN.test(inner) || MARKER_NONVAULT_TOKEN.test(inner);
873
+ }
874
+
875
+ function isGenuineRedactedInner(inner) {
876
+ return MARKER_TYPE_NAME.test(inner);
877
+ }
878
+
879
+ // True only when `inner` base64url-decodes to a valid UTF-8 JSON object that
880
+ // carries the encrypt-envelope signature (`kid` + `aadHash` — the contract keys
881
+ // asserted by assertCryptoProviderConformance, present in the local AES-GCM
882
+ // envelope and any conformant external provider). Any decode/parse failure or a
883
+ // non-envelope shape → NOT a genuine marker (so a wrapped secret is scanned).
884
+ function isGenuineEncInner(inner) {
885
+ if (!MARKER_BASE64URL.test(inner)) {
886
+ return false;
887
+ }
888
+ try {
889
+ const bytes = Buffer.from(inner, "base64url");
890
+ // Reject inputs that do not round-trip through base64url (e.g. an invalid
891
+ // tail that Buffer silently truncates): a genuine marker always round-trips.
892
+ if (bytes.toString("base64url") !== inner) {
893
+ return false;
894
+ }
895
+ if (!isUtf8(bytes)) {
896
+ return false;
897
+ }
898
+ const parsed = JSON.parse(bytes.toString("utf8"));
899
+ return (
900
+ parsed !== null &&
901
+ typeof parsed === "object" &&
902
+ !Array.isArray(parsed) &&
903
+ typeof parsed.kid === "string" &&
904
+ typeof parsed.aadHash === "string"
905
+ );
906
+ } catch {
907
+ return false;
908
+ }
909
+ }
910
+
911
+ // Belt-and-suspenders for the genuine-marker shapes: even a correctly-SHAPED
912
+ // TOKEN/REDACTED inner must not itself carry a detectable secret. The lowercase-
913
+ // identifier classes (MARKER_TYPE_NAME, the type segments of the token shapes)
914
+ // overlap the body of real lowercase-bodied secrets (notably GitHub `gh[pousr]_`
915
+ // tokens), so a hostile model could smuggle such a secret as the `<type>` segment
916
+ // of an otherwise genuine-shaped marker. Re-scan the inner with the SAME rules and
917
+ // refuse to treat it as genuine if anything detectable is inside — this un-skips a
918
+ // marker exactly when skipping it would hide a leak.
919
+ function textHasDetection(text, rules, context) {
920
+ for (const rule of rules) {
921
+ if (rule.direction && rule.direction !== context?.direction) {
922
+ continue;
923
+ }
924
+ const regex = new RegExp(rule.pattern, rule.flags.includes("g") ? rule.flags : `${rule.flags}g`);
925
+ for (const match of text.matchAll(regex)) {
926
+ if (!rule.validate || rule.validate(match[0])) {
927
+ return true;
928
+ }
929
+ }
930
+ }
931
+ return false;
932
+ }
933
+
934
+ // The attacker-controllable segment(s) of a genuine-shaped marker inner — i.e. the
935
+ // `<type>` position(s) a hostile model could smuggle a secret into. For TOKEN we
936
+ // peel off the structural framing (`tok_<type>_<hex>` → `<type>`, `<type>:<hex>` →
937
+ // `<type>`) and scan the segment IN ISOLATION as well as the whole inner: a `\b`-
938
+ // anchored rule (e.g. GitHub `\bghp_…`) misses a token glued to the `tok_` prefix
939
+ // (no word boundary after `_`), but matches the segment scanned on its own.
940
+ function markerSecretSurfaces(kind, inner) {
941
+ const surfaces = [inner];
942
+ if (kind === "TOKEN") {
943
+ const vault = /^tok_(.+)_[a-f0-9]{16,}$/.exec(inner);
944
+ if (vault) {
945
+ surfaces.push(vault[1]);
946
+ }
947
+ const nonVault = /^(.+):[a-f0-9]{8,}$/.exec(inner);
948
+ if (nonVault) {
949
+ surfaces.push(nonVault[1]);
950
+ }
951
+ }
952
+ return surfaces;
953
+ }
954
+
955
+ function innerContainsDetection(kind, inner, rules, context) {
956
+ return markerSecretSurfaces(kind, inner).some((surface) => textHasDetection(surface, rules, context));
957
+ }
958
+
959
+ function haechiMarkerSpans(text, rules = [], context = {}) {
830
960
  const spans = [];
831
- for (const m of text.matchAll(/\[(?:TOKEN|HAECHI_ENC|REDACTED):[^\]]*\]/g)) {
832
- spans.push([m.index, m.index + m[0].length]);
961
+ for (const m of text.matchAll(/\[(TOKEN|HAECHI_ENC|REDACTED):([^\]]*)\]/g)) {
962
+ const kind = m[1];
963
+ const inner = m[2];
964
+ let genuine = false;
965
+ if (kind === "TOKEN") {
966
+ genuine = isGenuineTokenInner(inner);
967
+ } else if (kind === "REDACTED") {
968
+ genuine = isGenuineRedactedInner(inner);
969
+ } else {
970
+ genuine = isGenuineEncInner(inner);
971
+ }
972
+ // HAECHI_ENC is exempt from the inner re-scan: its inner is an opaque base64url
973
+ // envelope validated by decode above (a raw secret cannot forge a valid
974
+ // envelope, and the envelope's base64url body is not a detectable leaf).
975
+ if (genuine && kind !== "HAECHI_ENC" && innerContainsDetection(kind, inner, rules, context)) {
976
+ genuine = false;
977
+ }
978
+ if (genuine) {
979
+ spans.push([m.index, m.index + m[0].length]);
980
+ }
833
981
  }
834
982
  return spans;
835
983
  }