haechi 0.9.0 → 1.1.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 +19 -12
- package/README.md +19 -12
- package/SECURITY.md +1 -1
- package/docs/README.md +1 -1
- package/docs/current/api-stability.ko.md +87 -41
- package/docs/current/api-stability.md +87 -41
- package/docs/current/configuration.ko.md +20 -1
- package/docs/current/configuration.md +20 -1
- package/docs/current/release-1.0-implementation-scope.ko.md +170 -0
- package/docs/current/release-1.0-implementation-scope.md +164 -0
- package/docs/current/release-1.1-implementation-scope.ko.md +128 -0
- package/docs/current/release-1.1-implementation-scope.md +128 -0
- package/docs/current/risk-register-release-gate.ko.md +26 -6
- package/docs/current/risk-register-release-gate.md +26 -6
- package/docs/current/threat-model.ko.md +22 -3
- package/docs/current/threat-model.md +22 -3
- package/package.json +7 -5
- package/packages/audit/index.mjs +13 -1
- package/packages/auth/index.mjs +173 -0
- package/packages/cli/bin/haechi.mjs +1 -1
- package/packages/cli/runtime.mjs +230 -5
- package/packages/core/index.mjs +19 -4
- package/packages/plugin/index.mjs +93 -17
- package/packages/plugin/process-sandbox.mjs +629 -0
- package/packages/plugin/sandbox-common.mjs +243 -0
- package/packages/plugin/sandbox.mjs +415 -0
- package/packages/plugin/signing.mjs +393 -0
- package/packages/ssrf/index.mjs +189 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Haechi 1.1 Implementation Scope
|
|
2
|
+
|
|
3
|
+
- Status: **Implemented + shipped** (2026-06-12; PRs #54/#55/#56 + this release cut, core 1.0.0 → 1.1.0). Design hardened after a 3-lens adversarial review with empirical Node-26 testing.
|
|
4
|
+
- Implementation notes (deltas from this design as written):
|
|
5
|
+
- The **fail-closed `--allow-net` feature detection** (`netEnforcementSupported` + `netEnforcement: "require-permission"` default) shipped in **PR1**, not PR3 — it is intrinsic to the runtime's safety: CI on Node 22 (no `--allow-net`) proved that without it the runtime would run net-uncontained, the exact "pretend to contain" failure this design rejects. Detection probes BEHAVIOR (a `--permission` child must see `net.connect` denied), immune to a flag-listed-but-unenforced Node.
|
|
6
|
+
- The **satellite re-import** of the promoted SSRF guard (§2.3) was **deferred**: forcing `haechi-auth-jwt`/`haechi-auth-oidc`/`haechi-crypto-kms` to import `haechi/ssrf` would raise their `haechi` peer floor to 1.1 and reverse their deliberate "no cross-package SSRF coupling" decision (`crypto-kms/ssrf-parity.test.mjs`). The core copy is kept honest by a core-vs-`auth-jwt` parity test instead; the drift is guarded, not yet eliminated.
|
|
7
|
+
- Risk IDs renumbered **P1-SEC-026/027 → P1-SEC-027/028** (the proposed P1-SEC-026 collided with the existing 0.9 OIDC-broker risk).
|
|
8
|
+
- Date: 2026-06-11
|
|
9
|
+
- Target version: 1.1.0 (after 1.0.0)
|
|
10
|
+
- Type: capability **enforcement** for the plugin sandbox (closes the 1.0 honest residual)
|
|
11
|
+
|
|
12
|
+
## 1. Release Goal
|
|
13
|
+
|
|
14
|
+
1.1 closes the headline **honest residual** of the 1.0 plugin sandbox: 1.0 was explicit that `node:worker_threads` is **memory/crash isolation only, not a capability sandbox** — a malicious *signed* plugin could still use `fs`/`net` and exfiltrate the credential it receives. 1.1 adds a **stronger, opt-in `process-isolated` runtime** that runs a signed `authProvider` plugin in a **child process under the Node permission model (`--permission`)**, with **network containment that fail-closed requires `--allow-net`**, **all stdio ignored** (no stdout/stderr/fd leak channel), and the plugin loaded **from a `data:` URL with no filesystem grant at all** — so a malicious signed plugin **cannot read the host filesystem, spawn, reach the network, or write to any host-visible sink**, and therefore **cannot exfiltrate the credential**.
|
|
15
|
+
|
|
16
|
+
The adversarial review of Draft 0.1 (empirically, on Node 26) reshaped this design — those corrections are baked in below:
|
|
17
|
+
|
|
18
|
+
- **A "delete `node:net`/fetch" harness is NOT containment.** `process.binding('tcp_wrap')` opens a live socket and `import('node:net')` re-resolves a fresh builtin regardless of cache deletion. So network containment **must** be the kernel-enforced `--allow-net` denial, not a JS harness. On a Node without `--allow-net` (Node 22 LTS has none), `process-isolated` **fails closed** rather than pretending to contain.
|
|
19
|
+
- **A child process adds stdout/stderr/inherited-fd write channels** that `--allow-net` does not gate. These are closed explicitly (stdio ignored + a dedicated IPC channel), or the credential leaks via a log.
|
|
20
|
+
- **`--allow-fs-read` on a temp dir invites TOCTOU + a macOS realpath/symlink failure + a hidden bundling requirement.** Loading the verified bytes from a **`data:` URL** (as the 1.0 worker already does) needs **zero fs grant**, removes the whole TOCTOU/symlink surface, and structurally enforces a self-contained single-file plugin.
|
|
21
|
+
|
|
22
|
+
**Scope decisions (2026-06-11, maintainer-confirmed; the network/mode/credential/scope choices below are the four recommended answers, refined by the review):**
|
|
23
|
+
|
|
24
|
+
1. **Isolation:** `process-isolated` = a child `node` process under `--permission`, granting **nothing by default** (no fs, no child-process, no worker, no addons, no wasi), loading the plugin from a `data:` URL, with `stdio: ['ignore','ignore','ignore','ipc']` and a scrubbed `env`.
|
|
25
|
+
2. **Network = fail-closed `--allow-net`.** Network containment is the permission model's `--allow-net` denial, **feature-detected and fail-closed**: if the running Node cannot prove `--allow-net` enforcement, `process-isolated` **refuses to construct** (the default `netEnforcement: "require-permission"`). A best-effort non-containing fallback exists only behind an explicit `allow-harness` opt-in with a loud warning that **it does not contain a malicious plugin**.
|
|
26
|
+
3. **Credential handling:** for a **standard JWT/JWKS** credential the **host** runs the audited `createJwtVerifier` (reusing the satellite path) and the plugin is **not needed**; the `process-isolated` plugin is for **custom/opaque credentials** a plugin must parse — there the plugin sees the raw credential but is contained by **net + stdio + fs denial** (it cannot exfiltrate). Any key material a custom plugin needs is **host-fetched and injected** (no plugin-chosen URLs → no plugin-driven SSRF).
|
|
27
|
+
4. **Mode + scope:** `process-isolated` is a **new, stronger, opt-in** runtime *alongside* the unchanged 1.0 `worker-isolated`; 1.1 is **focused** on this capability-enforcement runtime. Classifier/crypto plugins, a live CRL, and a registry stay in later minors.
|
|
28
|
+
|
|
29
|
+
Core stays **zero runtime dependency** (`node:child_process` + `--permission` + `node:crypto`/`node:dns`). 1.1 is additive and opt-in; the only core change beyond the new module is **promoting the SSRF `isBlockedAddress` guard into a core node:-only helper** (§2.3) so the host-mediated fetch can use it (core cannot import from a satellite).
|
|
30
|
+
|
|
31
|
+
## 2. Scope
|
|
32
|
+
|
|
33
|
+
### 2.1 The `process-isolated` authProvider runtime (kernel-enforced capabilities, no fs, no stdio)
|
|
34
|
+
|
|
35
|
+
A new manifest `runtime: "process-isolated"` (for `kind: "authProvider"`, alongside `worker-isolated`). `createProcessIsolatedAuthProvider(options)` returns a host-side `authProvider` (frozen contract) that proxies `authenticate()` into a child `node` process.
|
|
36
|
+
|
|
37
|
+
- **Load gate first (the PR2 gate, fail-closed, audited):** `verifySignedPlugin` (Ed25519 over `entrySha256` + kind/capabilities/window, trust-anchor-only resolution, pin/version-floor/revocation) over the entry bytes held **in memory**.
|
|
38
|
+
- **Load via `data:` URL — no fs grant, no TOCTOU.** The child imports the verified bytes as a `data:text/javascript;base64,…` URL (the mechanism the 1.0 worker already uses). The child is spawned with **no `--allow-fs-read`** at all → it cannot read the host filesystem. This removes the temp-dir / realpath / symlink / TOCTOU surface entirely, and structurally requires a **self-contained single-file plugin** (no runtime `import`/`require` of host files); the load gate additionally rejects an entry whose source statically references a non-`data:` specifier.
|
|
39
|
+
- **Spawn under `--permission` granting only the allowlisted capabilities:** `process.execPath` + `--permission` with **no** `--allow-fs-read`/`--allow-fs-write`/`--allow-child-process`/`--allow-worker`/`--allow-addons`/`--allow-wasi`. `env` is **scrubbed** to a minimal fixed set (no inherited host secrets — `--permission` does not protect inherited env; env-scrubbing does). `--disable-proto=delete`.
|
|
40
|
+
- **stdio fully closed (a new, load-bearing control the review surfaced):** `stdio: ['ignore','ignore','ignore','ipc']` — **no stdout, no stderr, no extra inheritable fd**; the only channel is the dedicated IPC. The host **never** forwards, logs, or audits child stdout/stderr (a plugin writing the credential to stderr would otherwise leak it into operator logs). No `sendHandle`/fd passing.
|
|
41
|
+
- **JSON-string-only IPC (no structured clone, no fd passing).** `child_process` IPC supports advanced (structured-clone) serialization + handle passing, which would reopen the object/proto/transferable smuggling the 1.0 sanitizer was built to stop. The runtime sends/receives **only JSON strings** over the IPC (`serialization: "json"`), with the correlation-id + null-proto allowlist sanitizer + host-side `buildExternalIdentity` exactly as the 1.0 worker path.
|
|
42
|
+
- **Single-occupancy + the fail-closed matrix** (timeout → kill, `maxPendingCalls`, `maxMessageBytes`, kill-switch) carry over, with the process-lifecycle additions in §2.4.
|
|
43
|
+
- **Conformance at load** runs `assertAuthProviderConformance` against the sandboxed child (randomized vectors).
|
|
44
|
+
|
|
45
|
+
### 2.2 Network containment = fail-closed `--allow-net` (the harness is not containment)
|
|
46
|
+
|
|
47
|
+
- **`--allow-net` is the only real network control.** For a `process-isolated` plugin that does not need network, the child is spawned **without** `--allow-net`; on a Node that enforces it, `net.connect`/`fetch`/`dns` → `ERR_ACCESS_DENIED` (kernel-enforced). This is what actually prevents credential exfiltration.
|
|
48
|
+
- **Feature-detected, fail-closed, no version parsing.** At construction the runtime detects `--allow-net` support via `process.allowedNodeEnvironmentFlags.has('--allow-net')`, **confirmed once by a spawn-probe** (`node --permission --allow-net -e 0` → exit 0 = supported, exit 9 = not), cached for the runtime lifetime. The default **`netEnforcement: "require-permission"`**: if support is not proven, `createRuntime`/`normalizeConfig` **throws** (refuses to start) rather than silently degrading. So the credential-containment guarantee requires a `--allow-net` Node (Node ≥ the version that ships it); Node 22 LTS without it → fail closed.
|
|
49
|
+
- **The harness is best-effort-only and labeled as such.** A portable `allow-harness` opt-in may exist for *naive/accidental* egress, but the design states plainly — in the doc, the audit (`netEnforcement: "harness"` + a startup **warning**), and the threat model — that **it does NOT contain a malicious signed plugin** (`process.binding('tcp_wrap')` and a fresh `import('node:net')` both reach the network). It must additionally stub `process.binding`/`internalBinding`, but even then is not robust. High-assurance operators use `require-permission` (the default).
|
|
50
|
+
|
|
51
|
+
### 2.3 Credential handling — host-side JWT, host-mediated key material, the SSRF guard in core
|
|
52
|
+
|
|
53
|
+
- **Standard JWT/JWKS: the host verifies; no plugin sees the raw credential.** For the common JWT case, the **host** runs the audited `createJwtVerifier` (the satellite path) and a `process-isolated` plugin is **redundant** — use the host verifier directly (`auth.provider: "external"`/the satellite). 1.1 does not route a raw JWT through a child.
|
|
54
|
+
- **Custom/opaque credentials: the plugin sees the raw credential, contained by egress denial.** The `process-isolated` plugin exists for non-standard credentials a plugin must parse. It receives the raw credential over the IPC (it must, to validate it), but with **net + stdio + fs all denied** it **cannot exfiltrate** it. It returns raw claims; the host sanitizes + builds the keyed-HMAC identity (the crypto key never crosses).
|
|
55
|
+
- **Host-mediated key material (no plugin-driven SSRF).** Any key material a custom plugin needs (e.g. a JWKS-like document) is fetched by the **host** from an **operator-declared** URL — never a plugin-chosen one — through an **SSRF-hardened guarded fetch**, and injected over the IPC. The kid-driven refetch is **rate-limited/cooldown-bounded** (as the bearer satellite already does) so an attacker's credential cannot pump the host's outbound requests.
|
|
56
|
+
- **The SSRF guard moves into core.** `isBlockedAddress` + the guarded-fetch pattern (post-DNS re-check, HTTPS-only, bounded body, fetch timeout, `redirect:"error"`) live today only in the `haechi-auth-jwt` satellite, which core cannot import. 1.1 **promotes a node:-only `isBlockedAddress`/`guardedFetch` into a core module** (core stays zero-dependency); the satellites (`auth-jwt`, `auth-oidc`, and the `crypto-kms` Vault copy) and the host-fetch all import the one core helper, ending the drift. The known DNS-rebinding window (resolve-then-connect) is documented as a residual; the single-origin/issuer coupling is relaxed for the operator-declared host-JWKS case.
|
|
57
|
+
|
|
58
|
+
### 2.4 Process lifecycle (anti-DoS) — circuit breaker + warm child
|
|
59
|
+
|
|
60
|
+
A fresh `node --permission` spawn per call is ~tens of ms; a timing-out plugin could turn every auth attempt into a cold spawn (amplification DoS). So:
|
|
61
|
+
|
|
62
|
+
- A **warmed, long-lived child** reused across calls (single-occupancy serialization preserved), spawned once and kept ready.
|
|
63
|
+
- On a timeout/crash, respawn is governed by a **circuit breaker**: N kills within T seconds **trips to permanent fail-closed deny** (`plugin.worker.terminated{cause:"respawn-storm"}`, operator reset required) with **exponential backoff** between respawns — so a flapping plugin cannot become a spawn storm.
|
|
64
|
+
- `maxPendingCalls`/`maxMessageBytes` and the kill-switch (`plugins.enabled:false`) apply.
|
|
65
|
+
|
|
66
|
+
### 2.5 Config + audit (host-computed fields only)
|
|
67
|
+
|
|
68
|
+
- `auth.provider:"plugin"` gains `plugin.isolation: "worker" | "process"` and `plugin.netEnforcement: "require-permission" | "allow-harness"` (default `"require-permission"`). `normalizeConfig` validates fail-closed: `process` requires the `process-isolated` manifest + the capability allowlist; `require-permission` on a Node without `--allow-net` **throws**; the host-fetch URL (when a custom plugin needs key material) must be operator-declared. The `worker`-vs-`process` default stays `worker` for 1.0 back-compat **but the docs steer new high-assurance operators to `process` + `require-permission`**, and the chosen mode is recorded in the audit.
|
|
69
|
+
- **Audit fields are host-computed/enum-only (never child-supplied).** The lifecycle events gain additive `isolation`, `grants` (the **host-computed** granted permission set, not echoed plugin input), and `netEnforcement` — all fixed-enum/host values. Child crash/permission-denial diagnostics map to a **fixed reason enum** (extending `PLUGIN_LOAD_REASONS`), never `error.message`/child output (the core audit sanitizer filters by key *name*, not value, so a free-text field could write a credential into the hash chain — every new field is allowlist/enum). These are on `plugin.*` lifecycle events, **outside** the frozen core protect-event schema, so the 1.0 `api-contract.test.mjs` freeze guard is unaffected (the doc states *why*, so a future maintainer doesn't mistakenly freeze lifecycle events).
|
|
70
|
+
|
|
71
|
+
### 2.6 The honest model — what 1.1 closes and what it does not
|
|
72
|
+
|
|
73
|
+
For **`process-isolated` + `require-permission` on a `--allow-net` Node**, a malicious signed plugin is contained:
|
|
74
|
+
|
|
75
|
+
- **fs / exec / worker / addons:** kernel-**enforced** denied (`--permission`, no grants); the plugin loads from a `data:` URL with no fs at all.
|
|
76
|
+
- **network:** kernel-**enforced** denied (`--allow-net` absent) → **no credential exfiltration over the network**.
|
|
77
|
+
- **stdio / fd:** **closed** (`ignore` + dedicated IPC, no inheritable fd) → no log/stderr exfil.
|
|
78
|
+
- **env secrets:** scrubbed.
|
|
79
|
+
|
|
80
|
+
**Residual surface (do NOT over-trust beyond this):** (a) a Node **without `--allow-net`** gets **no network containment** — `process-isolated` fails closed there unless the operator explicitly accepts the non-containing `allow-harness`; (b) a plugin that legitimately needs **`networkEgress:true`** is not contained; (c) the host-fetch SSRF guard has a **DNS-rebinding** window; (d) the **credential + injected key material live in child memory** — core-dump/swap exposure is out of scope; (e) `--permission` is a Node runtime control, not an OS sandbox — a Node/V8 escape would defeat it. The `worker-isolated` (1.0) mode is **unchanged** — its trust-only residual stands.
|
|
81
|
+
|
|
82
|
+
## 3. Explicit non-scope (later minors)
|
|
83
|
+
- Classifier/filter and crypto plugin loading (authProvider only).
|
|
84
|
+
- A live revocation feed / CRL; a plugin registry.
|
|
85
|
+
- Hardening the `allow-harness` fallback to real containment (it can't be, on Node without `--allow-net` — the answer is `require-permission`).
|
|
86
|
+
- OS-level sandboxing (seccomp/namespaces/sandbox-exec) beyond the Node permission model.
|
|
87
|
+
- Replacing `worker-isolated`.
|
|
88
|
+
|
|
89
|
+
## 4. Backward compatibility
|
|
90
|
+
Additive and opt-in. `worker-isolated`, injection, every provider contract, and the frozen 1.0 API/audit/config schemas are unchanged. `process-isolated` is a new manifest runtime + new `plugin.isolation`/`plugin.netEnforcement` config (defaults preserve 1.0 behavior). The `plugin.*` lifecycle audit events gain additive host-computed fields (outside the frozen protect-event schema — the contract test is unaffected). Promoting `isBlockedAddress` into a core node:-only module is additive (the satellites re-import it; core stays zero runtime dependency). Per strict 1.0 semver, 1.1 is a **minor**.
|
|
91
|
+
|
|
92
|
+
## 5. 1.1 relationship
|
|
93
|
+
1.1 strengthens the plugin sandbox from **trust-based** (1.0 worker: trust the signer) to **capability-enforced** (1.1 process: the OS/runtime bounds the signed code) for the new opt-in mode, closing the most-cited 1.0 residual *honestly* — including the parts the first draft got wrong (the harness is not containment; stdio is a leak channel; fail-closed feature detection). It keeps the zero-dependency, fail-closed core promise.
|
|
94
|
+
|
|
95
|
+
## 6. Threat-model & risk-register deltas
|
|
96
|
+
|
|
97
|
+
| Surface (1.1) | Control | Residual |
|
|
98
|
+
|---|---|---|
|
|
99
|
+
| Malicious signed plugin abusing host fs/exec/worker/addons | `--permission` child, **zero grants**, `data:`-URL load (no fs) | none on a `--permission` Node; a V8/Node escape defeats any runtime control |
|
|
100
|
+
| Credential exfil over the network | `--allow-net` **denied**, **fail-closed feature detection** (`require-permission` → throw if unsupported) | a Node without `--allow-net` → fail closed (or the explicit non-containing `allow-harness`); a `networkEgress:true` plugin |
|
|
101
|
+
| Credential exfil via **stdout/stderr/fd** | `stdio:['ignore','ignore','ignore','ipc']`, no inheritable fd, host never logs child output | none material |
|
|
102
|
+
| Object/proto/fd smuggling over `child_process` IPC | JSON-string-only IPC (`serialization:"json"`), null-proto allowlist sanitizer | none material |
|
|
103
|
+
| Plugin-driven SSRF / outbound pump | host-fetched **operator-declared** URLs only (core SSRF guard), kid-refetch cooldown | DNS-rebinding window on the guard |
|
|
104
|
+
| Audit plaintext leak via new fields | host-computed/enum-only fields, fixed reason enum, no child free-text | none material |
|
|
105
|
+
| Spawn-storm DoS | warm child + circuit breaker + backoff | a tripped breaker denies (fail-closed) until operator reset |
|
|
106
|
+
|
|
107
|
+
Risk IDs (final): **P1-SEC-027** (process-isolated capability **enforcement** — strengthens P1-SEC-024's worker residual: fs/exec/net/stdio now enforced), **P1-SEC-028** (host-mediated key material + the core SSRF guard). *(Renumbered from the proposed 026/027 — P1-SEC-026 is the existing 0.9 OIDC-broker risk.)* The 1.0 P1-SEC-024 row is annotated "enforced in 1.1 for `process-isolated` on a `--allow-net` Node." New §4 exclusions: network containment on `--allow-net`-less Node (fail-closed), `networkEgress:true` plugins, core-dump/swap, OS-level escape.
|
|
108
|
+
|
|
109
|
+
## 7. Test criteria (mapped to the PR breakdown)
|
|
110
|
+
|
|
111
|
+
### 7.1 PR1 — the `process-isolated` runtime (capability + stdio + data-URL + fail-closed net)
|
|
112
|
+
- An instrumented signed plugin in `process-isolated` mode is **denied** `fs.readFileSync('/etc/hosts')` (`ERR_ACCESS_DENIED`), cannot spawn a child/worker, and has **no fs grant** (loads from a `data:` URL).
|
|
113
|
+
- **Net red-team:** on a `--allow-net` Node, the plugin's `net.connect` / `fetch` / `dns` and a `process.binding('tcp_wrap')` socket all **fail** (kernel-denied); `createRuntime` with `require-permission` on a Node **without** `--allow-net` **throws at construction** (fail-closed) — not a silent harness downgrade.
|
|
114
|
+
- **stdio/fd red-team:** a plugin writing the credential to `stdout`/`stderr`/`console.error`/fd3 reaches **no host-visible sink** (stdio ignored; the host captures nothing).
|
|
115
|
+
- IPC is JSON-string-only (an attempt to pass a handle/structured-clone object is refused); the load gate + conformance + the fail-closed matrix (timeout→kill, sanitizer, single-occupancy, kill-switch) hold for the process mode; macOS-included cross-platform run.
|
|
116
|
+
|
|
117
|
+
### 7.2 PR2 — credential containment + host-mediated key material + the core SSRF guard
|
|
118
|
+
- A custom-credential plugin authenticates with the raw credential but, with net+stdio+fs denied, an instrumented exfil attempt (network AND stderr AND fd) reaches **no sink** (assert the credential never leaves).
|
|
119
|
+
- The host-mediated fetch uses the **promoted core** `isBlockedAddress` (a `jwksUri` resolving to a private/metadata range is refused; the plugin never names a URL); the kid-refetch cooldown bounds the outbound rate; the satellites still pass their suites importing the core guard.
|
|
120
|
+
|
|
121
|
+
### 7.3 PR3 — feature detection + lifecycle + audit + the 1.1.0 release cut
|
|
122
|
+
- `--allow-net` detection via `process.allowedNodeEnvironmentFlags` + the spawn-probe is correct on the dev Node and fail-closed when unsupported; `netEnforcement` audited; the spawn circuit-breaker trips on a respawn storm (and audits it); `normalizeConfig` `plugin.isolation`/`netEnforcement` fail-closed tests.
|
|
123
|
+
- Lifecycle audit additive fields are host-computed/enum-only (a plugin cannot smuggle a value into them); the 1.0 `api-contract.test.mjs` still passes (additive, outside the frozen protect-event schema). Threat-model/risk-register deltas (P1-SEC-026/027), wiki, README; bump core to **1.1.0**; attested publish.
|
|
124
|
+
|
|
125
|
+
## 8. Suggested PR breakdown (stacked)
|
|
126
|
+
1. **`process-isolated` runtime** — `createProcessIsolatedAuthProvider`: `data:`-URL load (no fs), `--permission` zero-grant spawn, `stdio:['ignore','ignore','ignore','ipc']` + scrubbed env, JSON-string IPC, the data-minimized wire + host identity, the fail-closed + stdio/net red-team tests. → §7.1
|
|
127
|
+
2. **Credential containment + core SSRF guard** — promote `isBlockedAddress`/`guardedFetch` into a core node:-only module (satellites re-import it); host-mediated operator-declared key fetch + IPC injection + kid cooldown; the exfil-blocked + no-SSRF tests. → §7.2
|
|
128
|
+
3. **Feature detection + lifecycle + audit + 1.1.0 cut** — `--allow-net` detect + `netEnforcement` (fail-closed `require-permission` default), warm child + circuit breaker, host-computed audit fields; `plugin.isolation`/`netEnforcement` config; docs EN/KO (this doc, threat-model + risk-register P1-SEC-026/027, the honest-model update), wiki, README; core → 1.1.0, attested publish. → §7.3
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
- 문서 상태: Draft 0.4
|
|
4
4
|
- 작성일: 2026-06-11
|
|
5
|
-
- 기준 버전: 0.
|
|
5
|
+
- 기준 버전: 1.0.0
|
|
6
6
|
- 기준 브랜치: `main`
|
|
7
7
|
|
|
8
8
|
## 1. 현재 판단
|
|
@@ -25,7 +25,9 @@
|
|
|
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-
|
|
28
|
+
| G4 | 0.9.0 observability + interactive-auth 위성 컷 | P1-SEC-026 / P1-OPS-009 mitigated 및 P2-CRYPTO-001 accepted; `haechi-dashboard` + `haechi-auth-oidc` + `haechi-crypto-kms@0.2.0` 테스트 통과; 위성 tarball zero-dep; core 0.9.0 bump(추가적 FORBIDDEN_KEYS audit 강화만) | Pass |
|
|
29
|
+
| G5 | 1.0.0 stable API contract + signed-plugin sandbox | P1-SEC-024 / P1-SEC-025 mitigated, P2-API-001 / P2-OPS-006 resolved; API freeze + deprecation policy + `tests/api-contract.test.mjs` 통과; Ed25519 signed-plugin contract + `assertAuthProviderConformance` + worker-isolated `authProvider` sandbox 테스트 통과; PR0 위성 peer-range를 `>=0.8.0 <2.0.0`로 확대 및 `check-satellite-peer-ranges.mjs` preflight 게이트 통과; core는 zero runtime dependency 유지; core 1.0.0 bump | Pass |
|
|
30
|
+
| G6 | 1.1.0 plugin capability 강제 (`process-isolated`) | P1-SEC-027 / P1-SEC-028 mitigated; `process-isolated` 런타임(`--permission` 하 자식, 부여 0, `data:` URL 로드, stdio 무시, JSON-string IPC) + fail-closed `--allow-net` 기능 탐지(`netEnforcement:"require-permission"`) + 코어 `haechi/ssrf` 가드 + 호스트 중개 키 자료 + spawn-storm 서킷 브레이커; fs/net/stdio 레드팀 + SSRF + config 테스트 통과(행동 스위트는 `--allow-net` Node에서 실행, 아니면 fail-closed로 skip); API freeze 통과 유지(additive `./ssrf` export + additive config 키); core는 zero runtime dependency 유지; core 1.1.0 bump(additive + opt-in 마이너) | Pass |
|
|
29
31
|
|
|
30
32
|
## 3. P0 배포 차단 리스크 상태
|
|
31
33
|
|
|
@@ -45,7 +47,7 @@
|
|
|
45
47
|
| P1-SEC-001 | KMS/HSM/Vault 미지원 | Resolved for OSS core | `createRuntime(config, { cryptoProvider })` 외부 crypto provider injection, external provider 없으면 fail-closed |
|
|
46
48
|
| P1-SEC-002 | TokenVault 권한 모델 부족 | Resolved | `revealPolicy: "disabled"` 기본값, `--allow-dev-reveal`, metadata export, retention/purge timestamp |
|
|
47
49
|
| P1-SEC-003 | audit 무결성 부족 | Resolved | JSONL audit SHA-256 hash chain 및 `verifyAuditChain` |
|
|
48
|
-
| P1-SEC-004 | plugin runtime 없음 | Resolved by gating | dynamic runtime 거부, `manifest-only` plugin만
|
|
50
|
+
| P1-SEC-004 | plugin runtime 없음 | Resolved by gating (P1-SEC-024이 대체) | dynamic runtime 거부, `manifest-only` plugin만 통과. **1.0에서 P1-SEC-024(§5.4)이 대체:** 1.0은 manifest-only-only 입장을 의도적으로 해제하고, 새 신뢰 통제 하에 signed·capability-gated·worker-isolated·audited `authProvider` plugin에 한해 **좁게** 동적 로딩을 허용 |
|
|
49
51
|
| P1-SEC-005 | policy conflict 처리 부족 | Resolved | preset block 등 강한 action을 약한 action으로 낮추면 conflict fail-closed |
|
|
50
52
|
| P1-SEC-006 | regex 중심 필터 정확도 한계 | Resolved for preview | KR RRN checksum, Luhn, unsafe custom regex 제한. ML/classifier plugin은 stable backlog |
|
|
51
53
|
| P1-SEC-007 | AAD/replay/stream 확장 부족 | Resolved for preview | AAD hash mismatch 명시, streaming 기본 차단. stream sequence/replay cache는 stream support 도입 시 필요 |
|
|
@@ -103,9 +105,27 @@ base64/인코딩 값 디코딩 검사, query string 검사, audit tail truncatio
|
|
|
103
105
|
|
|
104
106
|
| ID | 리스크 | 상태 | 해소 증거 |
|
|
105
107
|
|---|---|---|---|
|
|
106
|
-
| P1-SEC-
|
|
107
|
-
| P1-OPS-
|
|
108
|
-
| P2-CRYPTO-001
|
|
108
|
+
| P1-SEC-026 | 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는 범위 외 |
|
|
109
|
+
| P1-OPS-009 | 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 종단을 책임 |
|
|
110
|
+
| P2-CRYPTO-001 | 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 유지 |
|
|
111
|
+
|
|
112
|
+
## 5.4 1.0.0 Stable API Contract + Signed-Plugin Sandbox 리스크 상태
|
|
113
|
+
|
|
114
|
+
이 ID들은 1.0.0 stable 컷(API freeze + Ed25519 signed, worker-isolated `authProvider` plugin sandbox)에 한정된다. 권위 있는 threat 행과 범위는 `docs/current/release-1.0-implementation-scope.md` §6이며, 증거는 PR(#46–#49), core 소스, 그리고 테스트 스위트다.
|
|
115
|
+
|
|
116
|
+
| ID | 리스크 | 상태 | 해소 증거 |
|
|
117
|
+
|---|---|---|---|
|
|
118
|
+
| P1-SEC-024 | 동적 plugin 실행 / sandbox 신뢰 모델: worker sandbox에 로딩된 signed `authProvider` plugin이 host(`fs`/`net`/`process.env`)를 악용하거나 받은 credential을 exfiltrate할 수 있음. **P1-SEC-004의 manifest-only 입장을 대체** — 1.0이 의도적으로 해제하고 새 통제 하에 좁게 동적 로딩 허용 | Mitigated | `packages/plugin/sandbox.mjs` `createSandboxedAuthProvider`(PR #49): `node:worker_threads` memory/crash 격리, 메모리 내 검증된 spawn(경로 재해석/TOCTOU 없음), data-minimized JSON-string wire(credential slice만 전달; host가 keyed-HMAC identity 구성), null-proto claims sanitizer, single-occupancy + correlation-id 동시성, 필수 `timeoutMs` terminate + `resourceLimits`/`maxPendingCalls`/`maxMessageBytes`, kill-switch(`plugins.enabled:false`), 매 respawn마다 전체 게이트 재실행. lifecycle audit(`plugin.load.*`/`authenticate.deny`/`worker.terminated`) + 확장된 `FORBIDDEN_KEYS`; audit identity는 frozen 5 키 `{id,type,subjectHash,issuerHash,provider}`로 projection. 테스트: §7.4 fail-closed + 격리 매트릭스, `auth.provider:"plugin"` `normalizeConfig` fail-closed 테스트, `createRuntime` + proxy auth end-to-end. **잔여:** `node:worker_threads`는 memory/crash 격리 + data-minimization이지 capability sandbox가 아님 — 악의적 signed plugin의 `fs`/`net`/`process.env`는 차단되지 않고 받은 credential을 exfiltrate할 수 있음; 오직 signing/vetting 신뢰 모델로만 통제. 진짜 집행(child-process + Node permission model)은 **1.1의 opt-in `process-isolated` 런타임에서 제공됨**(P1-SEC-027, §5.5) — `--allow-net` Node에서; `worker_threads`(1.0) 모드는 불변이며 이 잔여를 유지 |
|
|
119
|
+
| P1-SEC-025 | plugin signing / trust-anchor / revocation lifecycle: signer-key confusion/downgrade/rollback, swap(TOCTOU)된 entry, 또는 revoked/expired signer의 코드 로딩 | Mitigated | `packages/plugin/signing.mjs` `verifySignedPlugin`(PR #48): `canonicalize({pluginId, kind, version, capabilities, coreVersionRange, entrySha256, notBefore, notAfter})`에 대한 Ed25519(asymmetric, `node:crypto`) 서명 — `entrySha256` 바인딩(anti-swap), **trust-anchor-only** 키 해석(`signerKeyId` ∉ allowlist면 verify 이전 거부; 알고리즘 Ed25519 고정; signer 집합은 AES rotation 키 파일과 분리), pin + `pluginId`별 version-floor(anti-rollback/malicious-update) + `revokedSignerKeyIds`/`revokedEntrySha256` denylist + `notBefore`/`notAfter` window, 모두 load 시 fail-closed이며 매 respawn마다 재검증. `assertAuthProviderConformance`(`haechi/auth`, `assertCryptoProviderConformance`의 auth 대응)는 load별 randomized vectors를 쓰는 정합성 게이트; host가 call별 PII-safety 재검증. 테스트: §7.3 reason별 거부 매트릭스(각각 `plugin.load.refused{reason}` 방출), conformance negative 테스트, `FORBIDDEN_KEYS` 확장 `sanitizeAudit` 테스트. **잔여:** 운영자가 trust anchor/pin을 curate해야 함; live revocation feed / CRL은 1.x(revocation은 다음 load에 적용; kill-switch가 live plugin을 force-drop) |
|
|
120
|
+
| P2-API-001 (1.0) | stable-contract freeze + deprecation policy: 불안정 public API / audit-schema drift가 major bump이나 마이그레이션 경로 없이 consumer를 깨뜨림 | Resolved | `docs/current/api-stability.md`(+ko)(PR #47): IN/OUT surface 표, 1.0부터 strict semver, deprecation policy(≥1-minor 유지 + `HAECHI_DEPRECATION_*` runtime-warning 계약 + disclosed-vulnerability in-minor security exception), nested sub-schema 포함 frozen audit event schema + additive `schemaVersion`, config-schema freeze unit(key presence/shape 동결; 더 안전한 default는 허용). `tests/api-contract.test.mjs`가 freeze 가드: subpath별 exports + 전체 audit event(non-null `identity` + `detections[]` 1건) + config key set + `schemaVersion`을 pin; additive 필드는 통과, 제거/개명(top-level OR nested)은 실패, `verifyAuditChain`은 synthetic additive 필드가 있어도 frozen-schema fixture를 검증. **잔여:** major bump은 설계상 깨질 수 있음(문서화된 마이그레이션); disclosed-vulnerability security exception은 advisory + 마이그레이션 경로와 함께 sanctioned in-minor break 허용 |
|
|
121
|
+
| P2-OPS-006 (1.0) | satellite peer-range / major-tracking 게이트: core를 1.0.0으로 bump하면 모든 위성의 `>=0.8.0 <1.0.0` peer가 unsatisfiable(ERESOLVE)되어 위성 설치가 깨짐 | Resolved | PR0(#46)이 네 위성의 `haechi` peer range를 `>=0.8.0 <2.0.0`로 확대(버전 auth-jwt 0.2.1, crypto-kms 0.2.1, dashboard 0.1.2, auth-oidc 0.1.2; auth-oidc의 `haechi-auth-jwt`도 `<2.0.0`)하고 lockfile 재생성(workspace-lockfile 갭). `scripts/check-satellite-peer-ranges.mjs`는 모든 위성에 대해 `semver.satisfies(coreVersionToPublish, range)`를 단언하는 `release:preflight` 게이트로 core `1.0.0`을 시뮬레이션. `api-stability.md §5`에 위성 peer 상한이 core MAJOR를 추종함을 문서화. **잔여:** core 1.0.0 출시 전에 위성을 재발행해야 1.0.0에 대해 설치됨 |
|
|
122
|
+
|
|
123
|
+
## 5.5 1.1.0 Plugin Capability 강제 리스크 상태
|
|
124
|
+
|
|
125
|
+
| ID | Risk | Status | Resolution evidence |
|
|
126
|
+
|---|---|---|---|
|
|
127
|
+
| P1-SEC-027 | Plugin capability *강제*: 1.0 `worker_threads` sandbox는 memory/crash 격리뿐이라 악의적 signed plugin이 `fs`/`net`을 써서 credential을 exfiltrate할 수 있음. **P1-SEC-024의 수용된 worker 잔여를 강화** — 1.1이 새 opt-in 런타임에 실제 강제 추가 | Mitigated | `packages/plugin/process-sandbox.mjs` `createProcessIsolatedAuthProvider`/`…Sync`(PR #54): signed `authProvider`가 `--permission` 하 자식 `node`에서 **부여 0**(fs/child-process/worker/addons/wasi 없음, `--allow-net` 없음)으로, `data:` URL 로드(fs 권한 없음 → TOCTOU/symlink 표면 없음), `stdio:['ignore','ignore','ignore','ipc']`(stdout/stderr/fd 유출 채널 없음), 정화 env, JSON-string 전용 IPC + 공유 null-proto sanitizer + 호스트측 keyed-HMAC identity로 실행. **Node 26 실측 검증**: plugin의 `fs`/`net`/`fetch`/`dns`/`child_process`/`worker`와 `process.binding('tcp_wrap')` 우회가 모두 `ERR_ACCESS_DENIED`. 네트워크 봉쇄는 **커널 `--allow-net` 거부**(삭제 가능한 JS 하니스가 아님); 기본값 `netEnforcement:"require-permission"`은 강제 못 하는 Node에서 **fail closed**(동작 probe 기능 탐지; PR #54). spawn-storm 서킷 브레이커(PR #56)가 재spawn 제한. lifecycle audit에 호스트 계산/enum 전용 `isolation`/`grants`/`netEnforcement` 추가(PR #56). config: `auth.plugin.isolation:"process"` fail-closed 배선(PR #56). 테스트: fs/net/stdio 레드팀(`--allow-net` 없는 Node에선 fail-closed라 skip) + 상시 실행 fail-closed 계약 + config 매트릭스. **잔여:** `--allow-net` 없는 Node(fail-closed, 미봉쇄); `networkEgress` 부여 plugin; 자식 메모리의 credential/키 자료(core-dump/swap); V8/Node 탈출(런타임 통제일 뿐 OS 샌드박스 아님) |
|
|
128
|
+
| P1-SEC-028 | 호스트 중개 키 자료 + SSRF: 키 자료가 필요한 커스텀 자격증명 plugin이 plugin 주도 SSRF 벡터가 될 수 있고, 코어엔 SSRF 가드가 없었음(위성 복사본은 코어에서 도달 불가) | Mitigated | 새 node:-only, 의존성 0 **`haechi/ssrf`** 코어 모듈(PR #55): `isBlockedAddress`(private/loopback/link-local/metadata), `guardedFetch`(https 전용, DNS 후 재확인, `redirect:"error"`, 본문 제한 + timeout), `createGuardedKeyFetcher`(TTL 캐시 + cooldown). `process-isolated` 런타임의 선택적 `keyMaterial:{url}`은 **호스트**가 **운영자 선언** URL에서 이 가드로 가져와 IPC로 주입 — plugin은 URL 명명 안 함(plugin 주도 SSRF 없음), kid-refetch cooldown이 아웃바운드 비율 제한; blocked-address URL은 fail closed. 테스트: 표준 `isBlockedAddress` 벡터 테이블 + 코어-대-`auth-jwt` parity 가드, `guardedFetch` SSRF 거부/제한, cooldown fail-closed, 런타임 키 주입 + no-SSRF. **잔여:** 위성은 의도적 로컬 복사본 유지(crypto/auth 패키지는 core-ssrf에 런타임 의존 금지; `crypto-kms/ssrf-parity.test.mjs`) — 코어 재import는 연기, drift는 제거가 아니라 parity로 가드; 가드의 DNS-rebinding 창(resolve-then-connect)은 운영자 선언 URL에 대해 수용 |
|
|
109
129
|
|
|
110
130
|
## 6. P2 제품/문서 리스크 상태
|
|
111
131
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
- Status: Draft 0.4
|
|
4
4
|
- Date: 2026-06-11
|
|
5
|
-
- Target version: 0.
|
|
5
|
+
- Target version: 1.0.0
|
|
6
6
|
- Branch: `main`
|
|
7
7
|
|
|
8
8
|
## 1. Current Assessment
|
|
@@ -25,7 +25,9 @@
|
|
|
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-
|
|
28
|
+
| G4 | 0.9.0 observability + interactive-auth satellite cut | P1-SEC-026 / P1-OPS-009 mitigated and P2-CRYPTO-001 accepted; `haechi-dashboard` + `haechi-auth-oidc` + `haechi-crypto-kms@0.2.0` tests green; satellite tarballs zero-dep; core bumped to 0.9.0 (only an additive FORBIDDEN_KEYS audit hardening) | Pass |
|
|
29
|
+
| G5 | 1.0.0 stable API contract + signed-plugin sandbox | P1-SEC-024 / P1-SEC-025 mitigated, P2-API-001 / P2-OPS-006 resolved; the API freeze + deprecation policy + `tests/api-contract.test.mjs` green; the Ed25519 signed-plugin contract + `assertAuthProviderConformance` + the worker-isolated `authProvider` sandbox tests green; PR0 satellite peer-ranges widened to `>=0.8.0 <2.0.0` and the `check-satellite-peer-ranges.mjs` preflight gate green; core stays zero runtime dependency; core bumped to 1.0.0 | Pass |
|
|
30
|
+
| G6 | 1.1.0 plugin capability enforcement (`process-isolated`) | P1-SEC-027 / P1-SEC-028 mitigated; the `process-isolated` runtime (child under `--permission`, zero grants, `data:`-URL load, stdio-ignored, JSON-string IPC) + the fail-closed `--allow-net` feature detection (`netEnforcement:"require-permission"`) + the core `haechi/ssrf` guard + host-mediated key material + the spawn-storm circuit breaker; the fs/net/stdio red-team + SSRF + config tests green (the behavioral suite runs on a `--allow-net` Node and skips fail-closed otherwise); the API freeze stays green (additive `./ssrf` export + additive config keys); core stays zero runtime dependency; core bumped to 1.1.0 (additive + opt-in minor) | Pass |
|
|
29
31
|
|
|
30
32
|
## 3. P0 Distribution-Blocking Risk Status
|
|
31
33
|
|
|
@@ -45,7 +47,7 @@
|
|
|
45
47
|
| P1-SEC-001 | KMS/HSM/Vault not supported | Resolved for OSS core | `createRuntime(config, { cryptoProvider })` external crypto provider injection; fails closed if no external provider is supplied |
|
|
46
48
|
| P1-SEC-002 | TokenVault permission model insufficient | Resolved | `revealPolicy: "disabled"` is the default; `--allow-dev-reveal`, metadata export, retention/purge timestamps added |
|
|
47
49
|
| P1-SEC-003 | Audit integrity insufficient | Resolved | JSONL audit SHA-256 hash chain and `verifyAuditChain` |
|
|
48
|
-
| P1-SEC-004 | No plugin runtime | Resolved by gating | Dynamic runtime is rejected; only `manifest-only` plugins pass |
|
|
50
|
+
| P1-SEC-004 | No plugin runtime | Resolved by gating (superseded by P1-SEC-024) | Dynamic runtime is rejected; only `manifest-only` plugins pass. **Superseded in 1.0 by P1-SEC-024 (§5.4):** 1.0 deliberately lifts the manifest-only-only stance, enabling dynamic loading **narrowly** for a signed, capability-gated, worker-isolated, audited `authProvider` plugin under the new trust controls |
|
|
49
51
|
| P1-SEC-005 | Policy conflict handling insufficient | Resolved | Downgrading a stronger action (e.g., preset block) to a weaker one fails closed on conflict |
|
|
50
52
|
| P1-SEC-006 | Regex-based filter accuracy limited | Resolved for preview | KR RRN checksum, Luhn, and unsafe custom regex restrictions added. ML/classifier plugin is in the stable backlog |
|
|
51
53
|
| P1-SEC-007 | AAD/replay/stream extension insufficient | Resolved for preview | AAD hash mismatch is explicit; streaming is blocked by default. Stream sequence/replay cache required when stream support is introduced |
|
|
@@ -103,9 +105,27 @@ These IDs are scoped to the 0.9.0 satellite cut (`haechi-dashboard`, `haechi-aut
|
|
|
103
105
|
|
|
104
106
|
| ID | Risk | Status | Resolution evidence |
|
|
105
107
|
|---|---|---|---|
|
|
106
|
-
| P1-SEC-
|
|
107
|
-
| P1-OPS-
|
|
108
|
-
| P2-CRYPTO-001
|
|
108
|
+
| P1-SEC-026 | 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 |
|
|
109
|
+
| P1-OPS-009 | 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 |
|
|
110
|
+
| P2-CRYPTO-001 | 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 |
|
|
111
|
+
|
|
112
|
+
## 5.4 1.0.0 Stable API Contract + Signed-Plugin Sandbox Risk Status
|
|
113
|
+
|
|
114
|
+
These IDs are scoped to the 1.0.0 stable cut (the API freeze + the Ed25519 signed, worker-isolated `authProvider` plugin sandbox). The authoritative threat rows and scope are `docs/current/release-1.0-implementation-scope.md` §6; evidence is the PRs (#46–#49), the core source, and the test suites.
|
|
115
|
+
|
|
116
|
+
| ID | Risk | Status | Resolution evidence |
|
|
117
|
+
|---|---|---|---|
|
|
118
|
+
| P1-SEC-024 | Dynamic plugin execution / sandbox trust model: a signed `authProvider` plugin loaded into the worker sandbox could abuse the host (`fs`/`net`/`process.env`) or exfiltrate the credential it receives. **Supersedes P1-SEC-004's manifest-only stance** — 1.0 deliberately lifts it, enabling dynamic loading narrowly under new controls | Mitigated | `packages/plugin/sandbox.mjs` `createSandboxedAuthProvider` (PR #49): `node:worker_threads` memory/crash isolation, in-memory verified spawn (no path re-resolution / TOCTOU), data-minimized JSON-string wire (only the credential slice crosses; the host builds the keyed-HMAC identity), null-proto claims sanitizer, single-occupancy + correlation-id concurrency, required `timeoutMs` terminate + `resourceLimits`/`maxPendingCalls`/`maxMessageBytes`, kill-switch (`plugins.enabled:false`), and the full gate re-run on every respawn. Lifecycle audit (`plugin.load.*`/`authenticate.deny`/`worker.terminated`) + extended `FORBIDDEN_KEYS`; the audit identity is projected to the frozen 5 keys `{id,type,subjectHash,issuerHash,provider}`. Tests: the §7.4 fail-closed + isolation matrix, the `auth.provider:"plugin"` `normalizeConfig` fail-closed tests, and the `createRuntime` + proxy auth end-to-end. **Residual:** `node:worker_threads` is memory/crash isolation + data-minimization, NOT a capability sandbox — a malicious signed plugin's `fs`/`net`/`process.env` is not blocked and it CAN exfiltrate the credential it receives; gated only by the signing/vetting trust model. True enforcement (child-process + Node permission model) is **delivered in 1.1 for the opt-in `process-isolated` runtime** (P1-SEC-027, §5.5) on a `--allow-net` Node; the `worker_threads` (1.0) mode is unchanged and keeps this residual |
|
|
119
|
+
| P1-SEC-025 | Plugin signing / trust-anchor / revocation lifecycle: signer-key confusion/downgrade/rollback, a swapped (TOCTOU) entry, or a revoked/expired signer loading code | Mitigated | `packages/plugin/signing.mjs` `verifySignedPlugin` (PR #48): Ed25519 (asymmetric, `node:crypto`) signature over `canonicalize({pluginId, kind, version, capabilities, coreVersionRange, entrySha256, notBefore, notAfter})` — binding `entrySha256` (anti-swap), **trust-anchor-only** key resolution (refuse before verify if `signerKeyId` ∉ allowlist; algorithm pinned to Ed25519; signer set is separate from the AES rotation key file), pin + per-`pluginId` version-floor (anti-rollback/malicious-update) + `revokedSignerKeyIds`/`revokedEntrySha256` denylists + `notBefore`/`notAfter` window, all fail-closed at load and re-verified on every respawn. `assertAuthProviderConformance` (`haechi/auth`, the auth analog of `assertCryptoProviderConformance`) is a correctness gate with per-load randomized vectors; the host re-validates PII-safety per call. Tests: the §7.3 per-reason refusal matrix (each emits `plugin.load.refused{reason}`), the conformance negative tests, and the `FORBIDDEN_KEYS`-extension `sanitizeAudit` test. **Residual:** the operator must curate trust anchors/pins; a live revocation feed / CRL is 1.x (revocation takes effect at next load; the kill-switch force-drops a live plugin) |
|
|
120
|
+
| P2-API-001 (1.0) | Stable-contract freeze + deprecation policy: an unstable public API / audit-schema drift that breaks consumers without a major bump or a migration path | Resolved | `docs/current/api-stability.md`(+ko) (PR #47): the IN/OUT surface table, strict semver from 1.0, the deprecation policy (≥1-minor retention + `HAECHI_DEPRECATION_*` runtime-warning contract + the disclosed-vulnerability in-minor security exception), the frozen audit event schema including nested sub-schemas + an additive `schemaVersion`, and the config-schema freeze unit (key presence/shape frozen; safer defaults still allowed). `tests/api-contract.test.mjs` is the freeze guard: it pins the per-subpath exports + a full audit event (non-null `identity` + a `detections[]` entry) + the config key set + `schemaVersion`; an additive field passes, a removed/renamed field (top-level OR nested) fails, and `verifyAuditChain` still verifies a frozen-schema fixture with a synthetic additive field. **Residual:** a major bump can break by design (documented migration); the disclosed-vulnerability security exception permits a sanctioned in-minor break with an advisory + migration path |
|
|
121
|
+
| P2-OPS-006 (1.0) | Satellite peer-range / major-tracking gate: bumping core to 1.0.0 makes every satellite's `>=0.8.0 <1.0.0` peer unsatisfiable (ERESOLVE), breaking satellite installs | Resolved | PR0 (#46) widened all four satellites' `haechi` peer range to `>=0.8.0 <2.0.0` (versions auth-jwt 0.2.1, crypto-kms 0.2.1, dashboard 0.1.2, auth-oidc 0.1.2; auth-oidc's `haechi-auth-jwt` likewise to `<2.0.0`) and regenerated the lockfile (the workspace-lockfile gotcha). `scripts/check-satellite-peer-ranges.mjs` is a `release:preflight` gate that asserts `semver.satisfies(coreVersionToPublish, range)` for every satellite, simulating core `1.0.0`. `api-stability.md §5` documents that the satellite peer upper bound tracks the core MAJOR. **Residual:** the satellites must be republished before core 1.0.0 ships so they install against it |
|
|
122
|
+
|
|
123
|
+
## 5.5 1.1.0 Plugin Capability Enforcement Risk Status
|
|
124
|
+
|
|
125
|
+
| ID | Risk | Status | Resolution evidence |
|
|
126
|
+
|---|---|---|---|
|
|
127
|
+
| P1-SEC-027 | Plugin capability *enforcement*: the 1.0 `worker_threads` sandbox is memory/crash isolation only, so a malicious signed plugin can use `fs`/`net` and exfiltrate the credential. **Strengthens P1-SEC-024's accepted worker residual** — 1.1 adds real enforcement for a new opt-in runtime | Mitigated | `packages/plugin/process-sandbox.mjs` `createProcessIsolatedAuthProvider`/`…Sync` (PR #54): a signed `authProvider` runs in a child `node` under `--permission` with **zero grants** (no fs/child-process/worker/addons/wasi, no `--allow-net`), loaded from a `data:` URL (no fs grant → no TOCTOU/symlink surface), `stdio:['ignore','ignore','ignore','ipc']` (no stdout/stderr/fd leak channel), scrubbed env, JSON-string-only IPC + the shared null-proto sanitizer + host-side keyed-HMAC identity. **Empirically validated on Node 26**: the plugin's `fs`/`net`/`fetch`/`dns`/`child_process`/`worker` and the `process.binding('tcp_wrap')` bypass are all `ERR_ACCESS_DENIED`. Network containment is the **kernel `--allow-net` denial**, not a deletable JS harness; the default `netEnforcement:"require-permission"` **fails closed** (behavior-probed feature detection; PR #54) on a Node that cannot enforce it. A spawn-storm circuit breaker (PR #56) bounds respawns. Lifecycle audit gains host-computed/enum-only `isolation`/`grants`/`netEnforcement` (PR #56). Config: `auth.plugin.isolation:"process"` wired fail-closed (PR #56). Tests: the fs/net/stdio red-team (skipped on a Node without `--allow-net`, where the runtime fails closed instead) + the always-run fail-closed contract + the config matrix. **Residual:** a Node without `--allow-net` (fail-closed, not contained); a `networkEgress`-granted plugin; credential/key material in child memory (core-dump/swap); a V8/Node escape (a runtime control, not an OS sandbox) |
|
|
128
|
+
| P1-SEC-028 | Host-mediated key material + SSRF: a custom-credential plugin needing key material could be a plugin-driven SSRF vector, and core had no SSRF guard (the satellites' copies are unreachable from core) | Mitigated | A new node:-only, zero-dependency **`haechi/ssrf`** core module (PR #55): `isBlockedAddress` (private/loopback/link-local/metadata), `guardedFetch` (https-only, post-DNS re-check, `redirect:"error"`, bounded body + timeout), `createGuardedKeyFetcher` (TTL cache + cooldown). The `process-isolated` runtime's optional `keyMaterial:{url}` is fetched by the **host** from the **operator-declared** URL through this guard and injected over the IPC — the plugin never names a URL (no plugin-driven SSRF), and the kid-refetch cooldown bounds the outbound rate; a blocked-address URL fails closed. Tests: the canonical `isBlockedAddress` vector table + a core-vs-`auth-jwt` parity guard, `guardedFetch` SSRF refusal/bounding, the cooldown fail-closed, and the runtime key-injection + no-SSRF tests. **Residual:** the satellites keep their DELIBERATE local copies (a crypto/auth package must not runtime-depend on core-ssrf; `crypto-kms/ssrf-parity.test.mjs`) — the core re-import is deferred and the drift is guarded by parity, not eliminated; the guard's DNS-rebinding window (resolve-then-connect) is accepted for an operator-declared URL |
|
|
109
129
|
|
|
110
130
|
## 6. P2 Product/Documentation Risk Status
|
|
111
131
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
- 문서 상태: Draft 0.1
|
|
4
4
|
- 작성일: 2026-06-10
|
|
5
|
-
- 기준 버전: 0.
|
|
5
|
+
- 기준 버전: 1.0.0
|
|
6
6
|
|
|
7
7
|
## 1. 보호 대상
|
|
8
8
|
|
|
@@ -65,12 +65,21 @@ Haechi가 보호하려는 주요 자산은 다음이다.
|
|
|
65
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
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
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 외부 |
|
|
68
|
+
| 동적 로딩된 악의적/침해된 signed plugin (1.0) | signed `authProvider` plugin이 worker sandbox에 로딩된 뒤 실행 중 host를 악용 | `canonicalize({pluginId, kind, version, capabilities, coreVersionRange, entrySha256, notBefore, notAfter})`에 대한 Ed25519 서명, **trust-anchor-only** 키 해석(`signerKeyId`가 allowlist된 anchor가 아니면 verify 이전 거부; 알고리즘은 Ed25519로 고정), pin + `pluginId`별 version-floor + revocation denylist(`revokedSignerKeyIds`/`revokedEntrySha256`) + validity-window 집행, `assertAuthProviderConformance` 정합성 게이트, `node:worker_threads` memory/crash 격리 + per-call timeout-terminate, 전체 lifecycle audit(`plugin.load.*`/`authenticate.deny`/`worker.terminated`). 전체 게이트는 매 respawn마다 재실행. **수용된 잔여:** signed plugin 자신의 `fs`/`fetch`/`process.env`는 차단되지 않으며(`networkEgress: false`는 선언일 뿐 1.0에서 집행 통제 아님) 정당하게 받은 credential을 exfiltrate할 수 있음 — 오직 signing/vetting 신뢰 모델로만 통제됨. **1.1이 새 opt-in `process-isolated` 런타임에 대해 이 잔여를 닫는다**(다음 행, P1-SEC-027); `worker_threads`(1.0) 모드는 불변이며 이 수용된 잔여를 유지 |
|
|
69
|
+
| plugin으로의 PII/secret leak (1.0) | request body·crypto 키·token vault·raw claim이 worker 경계를 넘어 유출 | host는 worker에 **credential slice만** 전달(`Authorization` 헤더 / bearer token — request body 절대 안 보냄, crypto 키 절대 안 보냄); wire는 MessagePort 위 평문 JSON 문자열; **null-prototype, own-key-allowlist claims sanitizer**가 `__proto__`/`constructor`/`prototype`을 제거하고 크기를 bound한 뒤 **host**가 `buildExternalIdentity`로 keyed-HMAC identity를 구성(HMAC 키는 worker에 들어가지 않음). **수용된 잔여:** auth plugin이 정당하게 검증하는 credential은 그 plugin에 보임(위 행 참조) |
|
|
70
|
+
| 경계 간 object/proto smuggling (1.0) | 악의적 claims object가 host prototype을 오염시키거나 raw 값을 경계 너머로 밀반입 | JSON-string wire만 사용(structured-clone 없음, `SharedArrayBuffer`/transferables 없음 → shared-memory·object-graph 채널 없음) + `buildExternalIdentity` 이전 null-proto own-key-allowlist sanitizer. 실질적 잔여 없음 |
|
|
71
|
+
| plugin entry의 swap / TOCTOU (1.0) | 서명 검사 후 실행 전에 검증된 entry 바이트가 swap됨(예: symlink 경로 재해석) | 서명이 `entrySha256`을 바인딩; loader는 entry를 **메모리로** 읽어 hash·verify하고 **메모리 내 검증된 소스에서** Worker를 spawn(`eval: true`)하며 검증 후 경로를 재해석하지 않고 symlink entry를 거부. 실질적 잔여 없음 |
|
|
72
|
+
| signer-key confusion / downgrade / rollback / malicious update (1.0) | confused-deputy가 검증 키/알고리즘을 바꾸거나, 신뢰 signer가 같은 anchor로 새/old-vulnerable entry를 조용히 배포 | trust-anchor-only 해석 + Ed25519 알고리즘 고정(alg agility 없음, HS/RS confusion 없음; signer 집합은 별도 curated 목록이며 AES rotation 키 파일이 아님) + pin(`version`/`entrySha256`/`manifestSha256`) + `pluginId`별 version-floor + revocation denylist. **잔여:** 운영자가 anchor/pin을 curate해야 함 |
|
|
73
|
+
| Plugin DoS (1.0) | 버그 있거나 악의적인 signed plugin이 hang/runaway하거나 host를 flood | call별 필수 양의 `timeoutMs`(timeout 시 host가 **worker를 terminate**하고 `null` 반환, lazily respawn), heap `resourceLimits`, `maxPendingCalls`(초과 → deny), `maxMessageBytes`(초과 → deny), single-occupancy worker(per-call terminate가 sibling을 죽일 수 없음). **잔여:** signed plugin이 timeout 내에서 할당된 CPU를 소진할 수 있음(CPU/fd/socket은 1.0에서 bound 안 됨) |
|
|
74
|
+
| 감사되지 않는 code-load (1.0) | tamper-evident 기록 없이 third-party 코드를 로딩/실행 | 모든 load/deny/terminate 결정이 chained audit 이벤트 — `plugin.load.accepted`/`plugin.load.refused{reason}`/`plugin.authenticate.deny{reason}`/`plugin.worker.terminated{cause}`(ids/hashes/counts만); `FORBIDDEN_KEYS`를 확장(`claims`, `subject`, `issuer`, `credential`, `authorization`, `signature`, `entry`, 추가로 `scopes`/`labels`)해 defense-in-depth 적용, audit identity는 frozen 5 키 `{id, type, subjectHash, issuerHash, provider}`로 projection. — |
|
|
75
|
+
| conformance test/prod 괴리 (1.0) | signed plugin이 고정된 conformance 테스트를 감지해 정상 행동한 뒤 운영에서 오작동 | `assertAuthProviderConformance`는 **load별 예측 불가 randomized vectors**를 사용하고, load-bearing하게 **host가 매 call마다 PII-safety를 재검증**(`buildExternalIdentity` + sanitizer가 요청별 실행)하며 load 시점에만 검증하지 않음. **잔여:** conformance-pass가 신뢰성을 함의하지 않음 — 악의적 plugin이 통과 후 오작동 가능(conformance가 아니라 signing+vetting 게이트로 통제) |
|
|
76
|
+
| **`process-isolated`(1.1)** 하에서 host capability를 악용하는 악의적 signed plugin | signed `authProvider` plugin이 host 파일시스템 읽기, spawn, 네트워크 도달, 로그/fd 쓰기로 받은 credential을 exfiltrate 시도 | **커널 강제** capability 거부: plugin이 `--permission` 하의 자식 `node`에서 **부여 0**(fs/child-process/worker/addons/wasi 없음)으로, **`--allow-net` 없이** 실행되며 `data:` URL로 로드(fs 권한 없음 → TOCTOU/symlink 표면 없음). `--allow-net` Node에서 커널이 `net`/`fetch`/`dns`와 `process.binding('tcp_wrap')` 우회까지 거부; `stdio:['ignore','ignore','ignore','ipc']`가 stdout/stderr/fd 유출 채널을 차단; env 정화; IPC는 JSON-문자열 전용. 네트워크 봉쇄는 **fail-closed 기능 탐지** — 기본값 `netEnforcement:"require-permission"`은 `--allow-net`을 강제 못 하는 Node에서 생성 거부. 호스트 중개 키 자료는 호스트가 코어 SSRF 가드로 가져옴(plugin은 URL 명명 안 함). spawn-storm 서킷 브레이커가 재spawn 제한(P1-SEC-027 / P1-SEC-028). **잔여:** `--allow-net` 없는 Node(fail-closed, 미봉쇄); `networkEgress`를 정당하게 부여받은 plugin; 호스트 fetch DNS-rebinding 창; 자식 메모리의 credential + 주입된 키 자료(core-dump/swap 범위 밖); V8/Node 탈출은 모든 런타임 통제를 무력화 |
|
|
68
77
|
|
|
69
78
|
## 4. 명시적 제외
|
|
70
79
|
|
|
71
|
-
|
|
80
|
+
Haechi는 다음을 보장하지 않는다.
|
|
72
81
|
|
|
73
|
-
- 운영 KMS/HSM/Vault adapter
|
|
82
|
+
- 코어 자체의 운영 KMS/HSM/Vault adapter 제공(`haechi-crypto-kms` satellite가 외부 `cryptoProvider` 계약을 통해 AWS/GCP/Azure/Vault adapter를 제공한다)
|
|
74
83
|
- internet-facing gateway 인증/인가
|
|
75
84
|
- `streaming.maxMatchBytes`보다 긴 cross-frame 매칭(스트림 프레임에 걸쳐 분할될 수 있음)
|
|
76
85
|
- `block`이 발동되기 전에 이미 방출된 스트림 바이트의 회수
|
|
@@ -86,6 +95,16 @@ Haechi가 보호하려는 주요 자산은 다음이다.
|
|
|
86
95
|
- refresh-token rotation / silent renewal / 장수명 broker 세션 — 0.9 세션은 absolute-TTL + idle-timeout만; `offline_access`는 제거되고 access token은 폐기 (0.9)
|
|
87
96
|
- Dashboard write action(reveal, purge, policy edit) — `haechi-dashboard`는 읽기 전용으로 `POST`/`DELETE` surface 없음; mutation은 reveal governance 하의 CLI에 유지 (0.9)
|
|
88
97
|
- OIDC broker의 `at_hash`/`c_hash` 검증 — broker가 access token을 사용하지 않으므로 정확히 범위 외 (0.9)
|
|
98
|
+
- **`worker_threads`(1.0)** 모드에서 악의적 signed plugin에 대한 capability *집행*(`fs`/`net`/`process.env` 차단) — worker 격리는 memory/crash 격리 + data-minimization만 제공. **1.1의 opt-in `process-isolated` 런타임에서 집행됨**(`--permission` 하 자식 프로세스, 부여 0, `--allow-net` Node에서 — P1-SEC-027)
|
|
99
|
+
- `worker_threads`(1.0) signed plugin이 정당하게 받는 credential의 봉쇄 — de-facto network egress가 있어 exfiltrate 가능; 오직 signing/vetting 신뢰 모델로만 통제. **1.1 `process-isolated`에서 봉쇄됨**(net+stdio+fs 거부, `--allow-net` Node)
|
|
100
|
+
- `--allow-net` **없는** Node에서 `process-isolated` plugin의 네트워크 봉쇄 — 런타임이 거기서 **fail closed**(생성 거부)하며 미봉쇄로 실행하지 않음; `worker_threads` 또는 `--allow-net` Node 사용 (1.1)
|
|
101
|
+
- `networkEgress`를 명시적으로 부여받은 `process-isolated` plugin의 봉쇄 — net egress를 허용한 운영자는 그 채널의 exfiltration에 대해 봉쇄되지 않음 (1.1)
|
|
102
|
+
- 운영자 선언 키 URL에 대한 호스트 중개 키 fetch의 DNS-rebinding 창(resolve-then-connect) — bearer satellite와 동일 입장으로 수용 (1.1)
|
|
103
|
+
- 자식 프로세스 메모리에 상주하는 credential + 호스트 주입 키 자료 — core-dump/swap 노출은 범위 밖 (1.1)
|
|
104
|
+
- V8/Node 샌드박스 탈출 — `--permission`은 OS 수준 샌드박스(seccomp/namespaces)가 아니라 Node 런타임 통제; 런타임 탈출은 이를 무력화 (1.1)
|
|
105
|
+
- classifier/filter 및 crypto plugin 로딩 — 1.0에서 동적 로딩 가능한 plugin kind는 `authProvider`뿐; 다른 kind는 injection-only 유지 (1.0)
|
|
106
|
+
- unsigned dev/loader 경로 — unsigned plugin loader는 없음; 개발은 `createRuntime(config, providers)` 주입 사용 (1.0)
|
|
107
|
+
- live revocation feed / CRL — revocation은 다음 load/restart에 적용(global/per-plugin kill-switch가 live plugin을 즉시 force-drop); live CRL은 1.x (1.0)
|
|
89
108
|
|
|
90
109
|
## 5. 남은 운영 전제
|
|
91
110
|
|
|
@@ -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: 1.0.0
|
|
6
6
|
|
|
7
7
|
## 1. Assets Under Protection
|
|
8
8
|
|
|
@@ -65,12 +65,21 @@ The primary assets Haechi protects are:
|
|
|
65
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
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
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 |
|
|
68
|
+
| Malicious/compromised signed plugin loaded dynamically (1.0) | A signed `authProvider` plugin is loaded into the worker sandbox and abuses the host once running | Ed25519 signature over `canonicalize({pluginId, kind, version, capabilities, coreVersionRange, entrySha256, notBefore, notAfter})`, **trust-anchor-only** key resolution (refuse before verify if `signerKeyId` is not an allowlisted anchor; algorithm pinned to Ed25519), pin + per-`pluginId` version-floor + revocation denylist (`revokedSignerKeyIds`/`revokedEntrySha256`) + validity-window enforcement, `assertAuthProviderConformance` correctness gate, `node:worker_threads` memory/crash isolation + per-call timeout-terminate, and full lifecycle audit (`plugin.load.*`/`authenticate.deny`/`worker.terminated`). The full gate re-runs on every respawn. **Accepted residual:** a signed plugin's own `fs`/`fetch`/`process.env` is NOT blocked — `networkEgress: false` is a declaration, not an enforced control in 1.0 — and the plugin CAN exfiltrate the credential it legitimately receives; this is gated only by the signing/vetting trust model. **1.1 closes this residual for the new opt-in `process-isolated` runtime** (next row, P1-SEC-027); the `worker_threads` (1.0) mode is unchanged and keeps this accepted residual |
|
|
69
|
+
| PII/secret leak to a plugin (1.0) | The request body, crypto key, token vault, or a raw claim leaks across the worker boundary | The host sends the worker **only the credential slice** (the `Authorization` header / bearer token — never the request body, never the crypto key); the wire is a plain JSON string over the MessagePort; a **null-prototype, own-key-allowlist claims sanitizer** strips `__proto__`/`constructor`/`prototype` and bounds size before the **host** builds the keyed-HMAC identity via `buildExternalIdentity` (the HMAC key never enters the worker). **Accepted residual:** the credential the auth plugin legitimately validates is visible to it (see the row above) |
|
|
70
|
+
| Cross-boundary object/proto smuggling (1.0) | A hostile claims object pollutes the host prototype or smuggles a raw value back across the boundary | JSON-string wire only (no structured-clone, no `SharedArrayBuffer`/transferables → no shared-memory or object-graph channel) + the null-proto own-key-allowlist sanitizer before `buildExternalIdentity`. None material |
|
|
71
|
+
| Swap / TOCTOU on the plugin entry (1.0) | The verified entry bytes are swapped (e.g. a symlinked path re-resolved) after signature check but before execution | The signature binds `entrySha256`; the loader reads the entry **into memory**, hashes, verifies, and spawns the Worker **from the in-memory verified source** (`eval: true`), never re-resolving the path after verification, and refuses a symlinked entry. None material |
|
|
72
|
+
| Signer-key confusion / downgrade / rollback / malicious update (1.0) | A confused-deputy swaps the verification key/algorithm, or a trusted signer silently ships a new or old-vulnerable entry under the same anchor | Trust-anchor-only resolution + pinned Ed25519 algorithm (no alg agility, no HS/RS confusion; the signer set is a separate curated list, never the AES rotation key file) + pin (`version`/`entrySha256`/`manifestSha256`) + per-`pluginId` version-floor + revocation denylist. **Residual:** the operator must curate anchors/pins |
|
|
73
|
+
| Plugin DoS (1.0) | A buggy or hostile signed plugin hangs, runs away, or floods the host | Required positive `timeoutMs` per call (on timeout the host **terminates the worker** and returns `null`, respawning lazily), heap `resourceLimits`, `maxPendingCalls` (excess → deny), `maxMessageBytes` (oversized → deny), single-occupancy worker (a per-call terminate can never kill a sibling). **Residual:** a signed plugin can burn its allotted CPU within the timeout (CPU/fd/socket are not bounded in 1.0) |
|
|
74
|
+
| Unaudited code-load (1.0) | Loading or executing third-party code without a tamper-evident record | Every load/deny/terminate decision is a chained audit event — `plugin.load.accepted`/`plugin.load.refused{reason}`/`plugin.authenticate.deny{reason}`/`plugin.worker.terminated{cause}` (ids/hashes/counts only); `FORBIDDEN_KEYS` is extended (`claims`, `subject`, `issuer`, `credential`, `authorization`, `signature`, `entry`, plus `scopes`/`labels`) as defense-in-depth, and the audit identity is projected to the frozen 5 keys `{id, type, subjectHash, issuerHash, provider}`. — |
|
|
75
|
+
| Conformance test/prod divergence (1.0) | A signed plugin detects a fixed conformance test and behaves, then misbehaves in production | `assertAuthProviderConformance` uses **unpredictable per-load randomized vectors**, and — load-bearing — the **host re-validates PII-safety on every call** (`buildExternalIdentity` + the sanitizer run per request), not just at load. **Residual:** conformance-pass does not imply trustworthiness — a malicious plugin can pass then misbehave (covered by the signing+vetting gate, not by conformance) |
|
|
76
|
+
| Malicious signed plugin abusing host capabilities under **`process-isolated` (1.1)** | A signed `authProvider` plugin tries to read the host filesystem, spawn, reach the network, or write to a log/fd to exfiltrate the credential it receives | **Kernel-enforced** capability denial: the plugin runs in a child `node` under `--permission` with **zero grants** (no fs/child-process/worker/addons/wasi) and **no `--allow-net`**, loaded from a `data:` URL (no fs grant → no TOCTOU/symlink surface). On a `--allow-net` Node the kernel denies `net`/`fetch`/`dns` AND the `process.binding('tcp_wrap')` bypass; `stdio:['ignore','ignore','ignore','ipc']` closes the stdout/stderr/fd leak channel; env is scrubbed; the IPC is JSON-string-only. Network containment is **fail-closed feature-detected** — the default `netEnforcement:"require-permission"` refuses to construct on a Node that cannot enforce `--allow-net`. Host-mediated key material is fetched by the host through the core SSRF guard (the plugin never names a URL). A spawn-storm circuit breaker bounds respawns (P1-SEC-027 / P1-SEC-028). **Residual:** a Node without `--allow-net` (fail-closed, not contained); a plugin legitimately granted `networkEgress`; the host-fetch DNS-rebinding window; credential + injected key material in child memory (core-dump/swap out of scope); a V8/Node escape defeats any runtime control |
|
|
68
77
|
|
|
69
78
|
## 4. Explicit Exclusions
|
|
70
79
|
|
|
71
|
-
|
|
80
|
+
Haechi does not guarantee:
|
|
72
81
|
|
|
73
|
-
- A production KMS/HSM/Vault adapter
|
|
82
|
+
- A production KMS/HSM/Vault adapter in core (the `haechi-crypto-kms` satellite provides AWS/GCP/Azure/Vault adapters via the external `cryptoProvider` contract)
|
|
74
83
|
- Authentication/authorization for internet-facing gateways
|
|
75
84
|
- Cross-frame matches longer than `streaming.maxMatchBytes` (may still split across stream frames)
|
|
76
85
|
- Retraction of stream bytes already emitted before a `block` fires
|
|
@@ -86,6 +95,16 @@ The primary assets Haechi protects are:
|
|
|
86
95
|
- 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
96
|
- 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
97
|
- `at_hash`/`c_hash` validation in the OIDC broker — out of scope precisely because the broker never uses the access token (0.9)
|
|
98
|
+
- Capability *enforcement* against a malicious signed plugin (blocking `fs`/`net`/`process.env`) in the **`worker_threads` (1.0)** mode — worker isolation is memory/crash isolation + data-minimization only. **Enforced in 1.1 for the opt-in `process-isolated` runtime** (child process under `--permission`, zero grants, on a `--allow-net` Node — P1-SEC-027)
|
|
99
|
+
- Containment of the credential a `worker_threads` (1.0) signed plugin legitimately receives — it has de-facto network egress and can exfiltrate it; gated only by the signing/vetting trust model. **Contained in 1.1 under `process-isolated`** (net+stdio+fs denial) on a `--allow-net` Node
|
|
100
|
+
- Network containment of a `process-isolated` plugin on a Node **without `--allow-net`** — the runtime **fails closed** there (refuses to construct) rather than run uncontained; use `worker_threads` or a `--allow-net` Node (1.1)
|
|
101
|
+
- Containment of a `process-isolated` plugin explicitly granted `networkEgress` — an operator who allows net egress is not contained against exfiltration over that channel (1.1)
|
|
102
|
+
- The host-mediated key fetch's DNS-rebinding window (resolve-then-connect) on an operator-declared key URL — accepted, same stance as the bearer satellite (1.1)
|
|
103
|
+
- Credential + host-injected key material resident in the child process's memory — core-dump / swap exposure is out of scope (1.1)
|
|
104
|
+
- A V8 / Node sandbox escape — `--permission` is a Node runtime control, not an OS-level sandbox (seccomp/namespaces); a runtime escape defeats it (1.1)
|
|
105
|
+
- Classifier/filter and crypto plugin loading — `authProvider` is the only dynamically loadable plugin kind in 1.0; other kinds stay injection-only (1.0)
|
|
106
|
+
- An unsigned dev/loader path — there is no unsigned plugin loader; development uses `createRuntime(config, providers)` injection (1.0)
|
|
107
|
+
- A live revocation feed / CRL — revocation takes effect at the next load/restart (a global/per-plugin kill-switch force-drops a live plugin immediately); a live CRL is 1.x (1.0)
|
|
89
108
|
|
|
90
109
|
## 5. Remaining Operational Assumptions
|
|
91
110
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "haechi",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.1.0",
|
|
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",
|
|
7
7
|
"workspaces": [
|
|
@@ -50,7 +50,8 @@
|
|
|
50
50
|
"./runtime": "./packages/cli/runtime.mjs",
|
|
51
51
|
"./token-vault": "./packages/token-vault/index.mjs",
|
|
52
52
|
"./stream-filter": "./packages/stream-filter/index.mjs",
|
|
53
|
-
"./auth": "./packages/auth/index.mjs"
|
|
53
|
+
"./auth": "./packages/auth/index.mjs",
|
|
54
|
+
"./ssrf": "./packages/ssrf/index.mjs"
|
|
54
55
|
},
|
|
55
56
|
"files": [
|
|
56
57
|
"README.md",
|
|
@@ -72,8 +73,9 @@
|
|
|
72
73
|
"sbom": "node scripts/generate-sbom.mjs",
|
|
73
74
|
"checksums": "node scripts/release-checksums.mjs",
|
|
74
75
|
"bench:payload": "node scripts/bench-payload.mjs",
|
|
75
|
-
"
|
|
76
|
-
"release:preflight
|
|
76
|
+
"check:peer-ranges": "node scripts/check-satellite-peer-ranges.mjs",
|
|
77
|
+
"release:preflight": "node scripts/release-preflight.mjs && node scripts/check-satellite-peer-ranges.mjs",
|
|
78
|
+
"release:preflight:npm": "node scripts/release-preflight.mjs --require-npm-auth && node scripts/check-satellite-peer-ranges.mjs",
|
|
77
79
|
"haechi": "node packages/cli/bin/haechi.mjs",
|
|
78
80
|
"demo:init": "node packages/cli/bin/haechi.mjs init --force",
|
|
79
81
|
"demo:protect": "node packages/cli/bin/haechi.mjs protect examples/llm-prompt-filtering/input.json --config haechi.config.json",
|
package/packages/audit/index.mjs
CHANGED
|
@@ -15,7 +15,19 @@ const FORBIDDEN_KEYS = new Set([
|
|
|
15
15
|
// elsewhere, and the broker already self-guards them via its own allowlist
|
|
16
16
|
// projection.
|
|
17
17
|
"access_token", "id_token", "refresh_token", "code", "code_verifier",
|
|
18
|
-
"client_secret", "state", "nonce"
|
|
18
|
+
"client_secret", "state", "nonce",
|
|
19
|
+
// Plugin/claims surface (1.0): a dynamically-loaded auth plugin's lifecycle
|
|
20
|
+
// events carry only ids/hashes/counts, but this additive membership is
|
|
21
|
+
// defense-in-depth so a future plugin event can never leak a raw claim, the
|
|
22
|
+
// received credential/authorization, the signer's signature, or the entry
|
|
23
|
+
// source into the chained log.
|
|
24
|
+
"claims", "subject", "issuer", "credential", "authorization", "signature", "entry",
|
|
25
|
+
// The frozen 1.0 audit-identity contract is exactly {id,type,subjectHash,
|
|
26
|
+
// issuerHash,provider} — scopes/labels are NOT part of it. This additive
|
|
27
|
+
// guard ensures that even if a future code path passes an un-projected
|
|
28
|
+
// identity object, scopes/labels (which can carry attacker-controlled plugin
|
|
29
|
+
// claim values) can never enter the hash-chained audit record.
|
|
30
|
+
"scopes", "labels"
|
|
19
31
|
]);
|
|
20
32
|
|
|
21
33
|
export function createJsonlAuditSink({ path, anchor = null }) {
|