haechi 0.5.0 → 0.7.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 +42 -0
- package/README.md +42 -0
- package/docs/README.md +2 -0
- package/docs/current/api-stability.ko.md +6 -1
- package/docs/current/api-stability.md +6 -1
- package/docs/current/configuration.ko.md +25 -2
- package/docs/current/configuration.md +25 -2
- package/docs/current/release-0.6-implementation-scope.ko.md +151 -0
- package/docs/current/release-0.6-implementation-scope.md +151 -0
- package/docs/current/release-0.7-implementation-scope.ko.md +91 -0
- package/docs/current/release-0.7-implementation-scope.md +92 -0
- package/docs/current/release-process.ko.md +26 -3
- package/docs/current/release-process.md +26 -3
- package/docs/current/risk-register-release-gate.ko.md +3 -3
- package/docs/current/risk-register-release-gate.md +4 -3
- package/docs/current/threat-model.ko.md +7 -2
- package/docs/current/threat-model.md +7 -2
- package/examples/crypto-kms-reference/README.md +47 -0
- package/examples/crypto-kms-reference/index.mjs +133 -0
- package/examples/crypto-kms-reference/package.json +19 -0
- package/haechi.config.example.json +16 -1
- package/package.json +5 -2
- package/packages/audit/index.mjs +99 -6
- package/packages/auth/index.mjs +170 -0
- package/packages/cli/bin/haechi.mjs +131 -14
- package/packages/cli/runtime.mjs +136 -8
- package/packages/core/index.mjs +18 -7
- package/packages/crypto/index.mjs +107 -0
- package/packages/policy/index.mjs +82 -0
- package/packages/proxy/index.mjs +134 -8
- package/scripts/release-checksums.mjs +100 -0
package/packages/proxy/index.mjs
CHANGED
|
@@ -7,24 +7,56 @@ export const DEFAULT_PROXY_PORT = 1016;
|
|
|
7
7
|
export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "127.0.0.1", allowRemoteBind = false }) {
|
|
8
8
|
assertSafeProxyBind({ host, allowRemoteBind });
|
|
9
9
|
const { haechi, config, protocolAdapter } = runtime;
|
|
10
|
+
const rateLimiter = createRateLimiter();
|
|
10
11
|
|
|
11
12
|
const server = createServer(async (request, response) => {
|
|
12
13
|
try {
|
|
13
14
|
if (request.method === "GET" && request.url === "/__haechi/health") {
|
|
15
|
+
// Intentionally unauthenticated; exposes only the mode.
|
|
14
16
|
writeJson(response, 200, { ok: true, mode: config.mode });
|
|
15
17
|
return;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
assertRelativeProxyTarget(request.url);
|
|
19
21
|
const routeContext = protocolAdapter.classifyRequest(request);
|
|
22
|
+
|
|
23
|
+
// Authenticate, resolve the policy profile, and rate-limit BEFORE reading
|
|
24
|
+
// the body, so a denied/throttled request cannot stream a large body.
|
|
25
|
+
const gate = await authorizeRequest({ runtime, request, routeContext, rateLimiter });
|
|
26
|
+
if (gate.denied) {
|
|
27
|
+
writeJson(response, gate.denied.status, {
|
|
28
|
+
error: gate.denied.error,
|
|
29
|
+
message: gate.denied.message
|
|
30
|
+
});
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const { identity, profile, policyEngine, modelAllowlist } = gate;
|
|
34
|
+
const authContext = { identity, profile, policyEngine };
|
|
35
|
+
|
|
20
36
|
const body = await readBody(request, {
|
|
21
37
|
maxBytes: config.limits.maxRequestBytes
|
|
22
38
|
});
|
|
23
39
|
const json = parseJsonBody(body);
|
|
24
40
|
|
|
41
|
+
// Model allowlist runs after body read (the model field is in the body).
|
|
42
|
+
if (modelAllowlist && typeof json?.model === "string" && !modelAllowlist.includes(json.model)) {
|
|
43
|
+
await recordProxyDecision({
|
|
44
|
+
runtime, routeContext, identity, profile,
|
|
45
|
+
decision: "model_not_allowed",
|
|
46
|
+
reason: `model:${json.model}`,
|
|
47
|
+
enforced: true,
|
|
48
|
+
blocked: true
|
|
49
|
+
});
|
|
50
|
+
writeJson(response, 403, {
|
|
51
|
+
error: "haechi_model_not_allowed",
|
|
52
|
+
message: `Model not allowed: ${json.model}`
|
|
53
|
+
});
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
25
57
|
if (isStreamingRequest(json, routeContext)) {
|
|
26
58
|
if (config.streaming.requestMode === "inspect") {
|
|
27
|
-
await handleInspectedStream({ runtime, request, response, routeContext, json });
|
|
59
|
+
await handleInspectedStream({ runtime, request, response, routeContext, json, authContext });
|
|
28
60
|
return;
|
|
29
61
|
}
|
|
30
62
|
|
|
@@ -32,6 +64,8 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
|
|
|
32
64
|
await recordProxyDecision({
|
|
33
65
|
runtime,
|
|
34
66
|
routeContext,
|
|
67
|
+
identity,
|
|
68
|
+
profile,
|
|
35
69
|
decision: "streaming_request_pass_through",
|
|
36
70
|
reason: "streaming_request_pass_through",
|
|
37
71
|
enforced: false,
|
|
@@ -59,6 +93,7 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
|
|
|
59
93
|
const result = routeContext.protectRequest
|
|
60
94
|
? await haechi.protectJson(json, {
|
|
61
95
|
...routeContext,
|
|
96
|
+
...authContext,
|
|
62
97
|
operation: `request:${routeContext.operation}`,
|
|
63
98
|
direction: "request",
|
|
64
99
|
mode: config.policy.mode ?? config.mode
|
|
@@ -85,6 +120,7 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
|
|
|
85
120
|
upstreamResponse,
|
|
86
121
|
routeContext,
|
|
87
122
|
runtime,
|
|
123
|
+
authContext,
|
|
88
124
|
issuedTokens: result.issuedTokens ?? []
|
|
89
125
|
});
|
|
90
126
|
|
|
@@ -120,7 +156,89 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
|
|
|
120
156
|
};
|
|
121
157
|
}
|
|
122
158
|
|
|
123
|
-
|
|
159
|
+
// Authenticate → resolve policy profile → rate-limit. Returns the request's
|
|
160
|
+
// identity/profile/policyEngine/modelAllowlist, or a denial. Auth is required
|
|
161
|
+
// exactly when an authProvider is configured (auth.provider !== "none").
|
|
162
|
+
async function authorizeRequest({ runtime, request, routeContext, rateLimiter }) {
|
|
163
|
+
const { authProvider, policyProfiles } = runtime;
|
|
164
|
+
let identity = null;
|
|
165
|
+
|
|
166
|
+
if (authProvider) {
|
|
167
|
+
try {
|
|
168
|
+
identity = await authProvider.authenticate(request);
|
|
169
|
+
} catch {
|
|
170
|
+
await recordAuthDenied({ runtime, routeContext, reason: "provider_error" });
|
|
171
|
+
return { denied: { status: 401, error: "haechi_auth_denied", message: "Authentication failed" } };
|
|
172
|
+
}
|
|
173
|
+
if (!identity) {
|
|
174
|
+
const reason = hasBearerHeader(request) ? "invalid_token" : "no_token";
|
|
175
|
+
await recordAuthDenied({ runtime, routeContext, reason });
|
|
176
|
+
return { denied: { status: 401, error: "haechi_auth_denied", message: "Authentication required" } };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const resolved = policyProfiles.resolve(identity);
|
|
181
|
+
|
|
182
|
+
if (resolved.rate && resolved.rate.requestsPerMinute) {
|
|
183
|
+
const key = identity?.id ?? "anonymous";
|
|
184
|
+
if (!rateLimiter.allow(key, resolved.rate.requestsPerMinute)) {
|
|
185
|
+
await recordProxyDecision({
|
|
186
|
+
runtime, routeContext, identity, profile: resolved.profile,
|
|
187
|
+
decision: "rate_limited",
|
|
188
|
+
reason: `rate:${resolved.rate.requestsPerMinute}`,
|
|
189
|
+
enforced: true,
|
|
190
|
+
blocked: true
|
|
191
|
+
});
|
|
192
|
+
return { denied: { status: 429, error: "haechi_rate_limited", message: "Rate limit exceeded" } };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
identity,
|
|
198
|
+
profile: resolved.profile,
|
|
199
|
+
policyEngine: resolved.policyEngine,
|
|
200
|
+
modelAllowlist: resolved.modelAllowlist
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function hasBearerHeader(request) {
|
|
205
|
+
const header = request?.headers?.authorization ?? request?.headers?.Authorization;
|
|
206
|
+
return typeof header === "string" && /^Bearer\s+/i.test(header.trim());
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function createRateLimiter() {
|
|
210
|
+
// In-memory fixed-window counter. Per-process: resets on restart, not shared
|
|
211
|
+
// across replicas — acceptable for a single-process self-hosted preview.
|
|
212
|
+
const windows = new Map();
|
|
213
|
+
const windowMs = 60000;
|
|
214
|
+
return {
|
|
215
|
+
allow(key, limit) {
|
|
216
|
+
const now = Date.now();
|
|
217
|
+
const slot = windows.get(key);
|
|
218
|
+
if (!slot || now - slot.windowStart >= windowMs) {
|
|
219
|
+
windows.set(key, { windowStart: now, count: 1 });
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
if (slot.count >= limit) {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
slot.count += 1;
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function recordAuthDenied({ runtime, routeContext, reason }) {
|
|
232
|
+
await recordProxyDecision({
|
|
233
|
+
runtime, routeContext, identity: null, profile: null,
|
|
234
|
+
decision: "auth_denied",
|
|
235
|
+
reason,
|
|
236
|
+
enforced: true,
|
|
237
|
+
blocked: true
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function handleInspectedStream({ runtime, request, response, routeContext, json, authContext = {} }) {
|
|
124
242
|
const { haechi, config } = runtime;
|
|
125
243
|
|
|
126
244
|
// Inspection needs to know the wire format and delta channel for this route.
|
|
@@ -137,6 +255,7 @@ async function handleInspectedStream({ runtime, request, response, routeContext,
|
|
|
137
255
|
const requestResult = routeContext.protectRequest
|
|
138
256
|
? await haechi.protectJson(json, {
|
|
139
257
|
...routeContext,
|
|
258
|
+
...authContext,
|
|
140
259
|
operation: `request:${routeContext.operation}`,
|
|
141
260
|
direction: "request",
|
|
142
261
|
mode: config.policy.mode ?? config.mode
|
|
@@ -162,6 +281,7 @@ async function handleInspectedStream({ runtime, request, response, routeContext,
|
|
|
162
281
|
const streamMode = config.streaming.responseMode ?? config.responseProtection.mode ?? config.policy.mode ?? config.mode;
|
|
163
282
|
const protector = haechi.createStreamProtector({
|
|
164
283
|
...routeContext,
|
|
284
|
+
...authContext,
|
|
165
285
|
operation: `response-stream:${routeContext.operation}`,
|
|
166
286
|
direction: "response",
|
|
167
287
|
mode: streamMode,
|
|
@@ -177,7 +297,10 @@ async function handleInspectedStream({ runtime, request, response, routeContext,
|
|
|
177
297
|
protector
|
|
178
298
|
});
|
|
179
299
|
|
|
180
|
-
await recordStreamDecision({
|
|
300
|
+
await recordStreamDecision({
|
|
301
|
+
runtime, routeContext, blocked, summary, mode: streamMode,
|
|
302
|
+
identity: authContext.identity ?? null, profile: authContext.profile ?? null
|
|
303
|
+
});
|
|
181
304
|
response.end();
|
|
182
305
|
}
|
|
183
306
|
|
|
@@ -200,7 +323,7 @@ async function* emptyAsyncIterable() {
|
|
|
200
323
|
// No upstream body to inspect.
|
|
201
324
|
}
|
|
202
325
|
|
|
203
|
-
async function recordStreamDecision({ runtime, routeContext, blocked, summary, mode }) {
|
|
326
|
+
async function recordStreamDecision({ runtime, routeContext, blocked, summary, mode, identity = null, profile = null }) {
|
|
204
327
|
if (typeof runtime.auditSink?.record !== "function") {
|
|
205
328
|
return;
|
|
206
329
|
}
|
|
@@ -210,7 +333,8 @@ async function recordStreamDecision({ runtime, routeContext, blocked, summary, m
|
|
|
210
333
|
protocol: routeContext?.protocol ?? "proxy",
|
|
211
334
|
operation: `response-stream:${routeContext?.operation ?? "unknown"}`,
|
|
212
335
|
mode,
|
|
213
|
-
identity
|
|
336
|
+
identity,
|
|
337
|
+
profile,
|
|
214
338
|
enforced: !["dry-run", "report-only"].includes(mode),
|
|
215
339
|
blocked,
|
|
216
340
|
decision: blocked ? "stream_blocked" : "stream_inspected",
|
|
@@ -221,7 +345,7 @@ async function recordStreamDecision({ runtime, routeContext, blocked, summary, m
|
|
|
221
345
|
});
|
|
222
346
|
}
|
|
223
347
|
|
|
224
|
-
async function maybeProtectResponse({ upstreamResponse, routeContext, runtime, issuedTokens = [] }) {
|
|
348
|
+
async function maybeProtectResponse({ upstreamResponse, routeContext, runtime, authContext = {}, issuedTokens = [] }) {
|
|
225
349
|
const headers = Object.fromEntries(upstreamResponse.headers.entries());
|
|
226
350
|
|
|
227
351
|
if (!runtime.config.responseProtection.enabled || !routeContext.protectResponse) {
|
|
@@ -311,6 +435,7 @@ async function maybeProtectResponse({ upstreamResponse, routeContext, runtime, i
|
|
|
311
435
|
|
|
312
436
|
const result = await runtime.haechi.protectJson(json, {
|
|
313
437
|
...routeContext,
|
|
438
|
+
...authContext,
|
|
314
439
|
operation: `response:${routeContext.operation}`,
|
|
315
440
|
direction: "response",
|
|
316
441
|
mode: runtime.config.responseProtection.mode ?? runtime.config.policy.mode ?? runtime.config.mode
|
|
@@ -595,7 +720,7 @@ async function cancelReader(reader) {
|
|
|
595
720
|
}
|
|
596
721
|
}
|
|
597
722
|
|
|
598
|
-
async function recordProxyDecision({ runtime, routeContext, decision, reason, enforced, blocked }) {
|
|
723
|
+
async function recordProxyDecision({ runtime, routeContext, decision, reason, enforced, blocked, identity = null, profile = null }) {
|
|
599
724
|
if (typeof runtime.auditSink?.record !== "function") {
|
|
600
725
|
return;
|
|
601
726
|
}
|
|
@@ -606,7 +731,8 @@ async function recordProxyDecision({ runtime, routeContext, decision, reason, en
|
|
|
606
731
|
protocol: routeContext?.protocol ?? "proxy",
|
|
607
732
|
operation: routeContext ? `proxy:${routeContext.protocol}:${routeContext.routeId ?? "unknown"}` : "proxy",
|
|
608
733
|
mode: runtime.config.policy.mode ?? runtime.config.mode,
|
|
609
|
-
identity
|
|
734
|
+
identity,
|
|
735
|
+
profile,
|
|
610
736
|
enforced,
|
|
611
737
|
blocked,
|
|
612
738
|
decision,
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Generate or verify a SHA256SUMS manifest for release artifacts.
|
|
3
|
+
//
|
|
4
|
+
// node scripts/release-checksums.mjs <file...> # print "<hash> <name>" lines
|
|
5
|
+
// node scripts/release-checksums.mjs --check SHA256SUMS # verify files against a manifest
|
|
6
|
+
//
|
|
7
|
+
// Standard `<sha256-hex> <basename>` format (two spaces), so `sha256sum -c`
|
|
8
|
+
// and `shasum -a 256 -c` interoperate with what this prints.
|
|
9
|
+
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { createReadStream } from "node:fs";
|
|
12
|
+
import { readFile } from "node:fs/promises";
|
|
13
|
+
import { basename, dirname, isAbsolute, join, relative } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
|
|
16
|
+
export async function sha256File(path) {
|
|
17
|
+
const hash = createHash("sha256");
|
|
18
|
+
for await (const chunk of createReadStream(path)) {
|
|
19
|
+
hash.update(chunk);
|
|
20
|
+
}
|
|
21
|
+
return hash.digest("hex");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function formatManifestLine(hashHex, name) {
|
|
25
|
+
return `${hashHex} ${name}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function parseManifest(text) {
|
|
29
|
+
return text
|
|
30
|
+
.split(/\r?\n/)
|
|
31
|
+
.map((line) => line.trim())
|
|
32
|
+
.filter(Boolean)
|
|
33
|
+
.map((line) => {
|
|
34
|
+
const match = /^([a-f0-9]{64})\s+(.+)$/.exec(line);
|
|
35
|
+
if (!match) {
|
|
36
|
+
throw new Error(`Malformed SHA256SUMS line: ${line}`);
|
|
37
|
+
}
|
|
38
|
+
return { hash: match[1], name: match[2] };
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function generateManifest(files) {
|
|
43
|
+
const lines = [];
|
|
44
|
+
for (const file of files) {
|
|
45
|
+
lines.push(formatManifestLine(await sha256File(file), basename(file)));
|
|
46
|
+
}
|
|
47
|
+
return `${lines.join("\n")}\n`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function verifyManifest(manifestPath) {
|
|
51
|
+
const baseDir = dirname(manifestPath);
|
|
52
|
+
const entries = parseManifest(await readFile(manifestPath, "utf8"));
|
|
53
|
+
const results = [];
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
// A manifest is untrusted input: never hash a path that escapes the
|
|
56
|
+
// manifest's own directory (no absolute paths, no `../` traversal).
|
|
57
|
+
const rel = relative(baseDir, join(baseDir, entry.name));
|
|
58
|
+
if (isAbsolute(entry.name) || rel.startsWith("..")) {
|
|
59
|
+
results.push({ name: entry.name, ok: false, reason: "unsafe path" });
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
let actual = null;
|
|
63
|
+
try {
|
|
64
|
+
actual = await sha256File(join(baseDir, entry.name));
|
|
65
|
+
} catch (error) {
|
|
66
|
+
results.push({ name: entry.name, ok: false, reason: error.code === "ENOENT" ? "missing" : error.message });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
results.push({ name: entry.name, ok: actual === entry.hash, reason: actual === entry.hash ? null : "hash mismatch" });
|
|
70
|
+
}
|
|
71
|
+
return { ok: results.every((r) => r.ok), results };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function main(argv) {
|
|
75
|
+
if (argv[0] === "--check") {
|
|
76
|
+
const manifestPath = argv[1];
|
|
77
|
+
if (!manifestPath) {
|
|
78
|
+
throw new Error("--check requires a SHA256SUMS path");
|
|
79
|
+
}
|
|
80
|
+
const { ok, results } = await verifyManifest(manifestPath);
|
|
81
|
+
for (const r of results) {
|
|
82
|
+
process.stderr.write(`${r.ok ? "OK " : "FAIL"} ${r.name}${r.reason ? ` (${r.reason})` : ""}\n`);
|
|
83
|
+
}
|
|
84
|
+
process.exitCode = ok ? 0 : 1;
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (argv.length === 0) {
|
|
88
|
+
throw new Error("usage: release-checksums.mjs <file...> | --check SHA256SUMS");
|
|
89
|
+
}
|
|
90
|
+
process.stdout.write(await generateManifest(argv));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Run only as a CLI (not when imported by tests). fileURLToPath handles
|
|
94
|
+
// Windows paths and URL encoding that a raw `file://` compare would miss.
|
|
95
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
96
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
97
|
+
process.stderr.write(`release-checksums: ${error.message}\n`);
|
|
98
|
+
process.exitCode = 1;
|
|
99
|
+
});
|
|
100
|
+
}
|