haechi 1.1.2 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +46 -11
- package/README.md +46 -11
- package/SECURITY.md +7 -1
- package/docs/README.md +2 -0
- package/docs/current/compliance-mapping.ko.md +53 -0
- package/docs/current/compliance-mapping.md +53 -0
- package/docs/current/config-version.ko.md +30 -0
- package/docs/current/config-version.md +51 -0
- package/docs/current/configuration.ko.md +165 -9
- package/docs/current/configuration.md +165 -9
- package/docs/current/operations-runbook.ko.md +155 -0
- package/docs/current/operations-runbook.md +241 -0
- package/docs/current/release-process.ko.md +5 -1
- package/docs/current/release-process.md +5 -1
- package/docs/current/risk-register-release-gate.ko.md +5 -3
- package/docs/current/risk-register-release-gate.md +13 -3
- package/docs/current/security-whitepaper.ko.md +102 -0
- package/docs/current/security-whitepaper.md +102 -0
- package/docs/current/shared-responsibility.ko.md +2 -2
- package/docs/current/shared-responsibility.md +2 -2
- package/docs/current/threat-model.ko.md +4 -2
- package/docs/current/threat-model.md +4 -2
- package/examples/local-proxy-demo/README.md +51 -0
- package/examples/local-proxy-demo/demo.mjs +144 -0
- package/examples/local-proxy-demo/demo.tape +19 -0
- package/examples/local-proxy-demo/live-demo.mjs +121 -0
- package/examples/local-proxy-demo/live-demo.tape +25 -0
- package/haechi.config.example.json +20 -3
- package/package.json +7 -2
- package/packages/audit/index.mjs +26 -2
- package/packages/cli/bin/haechi.mjs +57 -10
- package/packages/cli/runtime.mjs +402 -10
- package/packages/core/index.mjs +143 -8
- package/packages/filter/index.mjs +975 -12
- package/packages/metrics/index.mjs +181 -0
- package/packages/privacy-profiles/index.mjs +72 -3
- package/packages/protocol-adapters/index.mjs +99 -1
- package/packages/proxy/index.mjs +525 -40
- package/packages/stream-filter/index.mjs +69 -7
package/packages/core/index.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { HARD_BLOCK_TYPES } from "../filter/index.mjs";
|
|
2
3
|
|
|
3
4
|
const NO_ENFORCE_MODES = new Set(["dry-run", "report-only"]);
|
|
4
5
|
|
|
@@ -10,7 +11,7 @@ const NO_ENFORCE_MODES = new Set(["dry-run", "report-only"]);
|
|
|
10
11
|
// limits.maxNestingDepth through createHaechi → protectJson instead.
|
|
11
12
|
export const DEFAULT_MAX_NESTING_DEPTH = 256;
|
|
12
13
|
|
|
13
|
-
export function createHaechi({ filterEngine, policyEngine, cryptoProvider, auditSink, tokenVault = null, mode = "dry-run", limits = {} }) {
|
|
14
|
+
export function createHaechi({ filterEngine, policyEngine, cryptoProvider, auditSink, tokenVault = null, mode = "dry-run", limits = {}, precision = {} }) {
|
|
14
15
|
if (!filterEngine || !policyEngine || !cryptoProvider || !auditSink) {
|
|
15
16
|
throw new Error("Haechi requires filterEngine, policyEngine, cryptoProvider, and auditSink");
|
|
16
17
|
}
|
|
@@ -20,6 +21,15 @@ export function createHaechi({ filterEngine, policyEngine, cryptoProvider, audit
|
|
|
20
21
|
? limits.maxNestingDepth
|
|
21
22
|
: DEFAULT_MAX_NESTING_DEPTH;
|
|
22
23
|
|
|
24
|
+
// WS2c precision controls, resolved once. `minConfidence` is the precision dial
|
|
25
|
+
// (drop a detection below the threshold) and `allowlist` is the operator FP
|
|
26
|
+
// exception set. Both are FAIL-OPEN-FOR-PROTECTION: they may only TRIM
|
|
27
|
+
// precision-risky soft-type detections and can NEVER suppress a hard-block type
|
|
28
|
+
// (secret/api_key/kr_rrn/card) — that load-bearing exemption is enforced in
|
|
29
|
+
// applyPrecisionControls, not trusted to config. Default {} = current behavior.
|
|
30
|
+
const minConfidence = Number.isFinite(precision.minConfidence) ? precision.minConfidence : 0;
|
|
31
|
+
const allowlist = compileAllowlist(precision.allowlist);
|
|
32
|
+
|
|
23
33
|
async function protectJson(payload, rawContext = {}) {
|
|
24
34
|
// A per-request policy engine (a named profile selected from identity)
|
|
25
35
|
// overrides the default. It is a control object, NOT data: strip it before
|
|
@@ -35,7 +45,13 @@ export function createHaechi({ filterEngine, policyEngine, cryptoProvider, audit
|
|
|
35
45
|
// `context.direction` ("request" | "response") gates direction-scoped rules
|
|
36
46
|
// (injection) and the response-only marker exclusion in the filter engine.
|
|
37
47
|
// The proxy sets it per direction; do not drop it here.
|
|
38
|
-
const
|
|
48
|
+
const rawDetections = await filterEngine.detect({ entries, context });
|
|
49
|
+
// WS2c precision controls run AFTER detect and BEFORE decide: drop a low-
|
|
50
|
+
// confidence soft-type detection (minConfidence) and suppress an allowlisted
|
|
51
|
+
// soft-type detection — never a hard-block type. `precisionAudit` carries the
|
|
52
|
+
// per-type counts of what was suppressed/dropped so the audit event records
|
|
53
|
+
// it (counts/types only, never the raw value). See applyPrecisionControls.
|
|
54
|
+
const { detections, precisionAudit } = applyPrecisionControls(rawDetections, { minConfidence, allowlist });
|
|
39
55
|
const decisions = [];
|
|
40
56
|
|
|
41
57
|
for (const detection of detections) {
|
|
@@ -62,7 +78,8 @@ export function createHaechi({ filterEngine, policyEngine, cryptoProvider, audit
|
|
|
62
78
|
blocked,
|
|
63
79
|
payload,
|
|
64
80
|
detections,
|
|
65
|
-
decisions
|
|
81
|
+
decisions,
|
|
82
|
+
precisionAudit
|
|
66
83
|
});
|
|
67
84
|
|
|
68
85
|
await auditSink.record(auditEvent);
|
|
@@ -70,7 +87,7 @@ export function createHaechi({ filterEngine, policyEngine, cryptoProvider, audit
|
|
|
70
87
|
return {
|
|
71
88
|
payload: protectedPayload,
|
|
72
89
|
blocked,
|
|
73
|
-
summary: summarize(detections, decisions),
|
|
90
|
+
summary: summarize(detections, decisions, precisionAudit),
|
|
74
91
|
auditEvent,
|
|
75
92
|
issuedTokens: [...issuedTokens]
|
|
76
93
|
};
|
|
@@ -274,7 +291,7 @@ export function shapeOnly(value) {
|
|
|
274
291
|
return { type: value === null ? "null" : typeof value };
|
|
275
292
|
}
|
|
276
293
|
|
|
277
|
-
export function summarize(detections, decisions) {
|
|
294
|
+
export function summarize(detections, decisions, precisionAudit = null) {
|
|
278
295
|
const byType = {};
|
|
279
296
|
const byAction = {};
|
|
280
297
|
|
|
@@ -286,11 +303,121 @@ export function summarize(detections, decisions) {
|
|
|
286
303
|
byAction[decision.action] = (byAction[decision.action] ?? 0) + 1;
|
|
287
304
|
}
|
|
288
305
|
|
|
289
|
-
|
|
306
|
+
const summary = {
|
|
290
307
|
detectionCount: detections.length,
|
|
291
308
|
byType,
|
|
292
309
|
byAction
|
|
293
310
|
};
|
|
311
|
+
|
|
312
|
+
// WS2c: additively record how many detections the precision controls removed
|
|
313
|
+
// before decide — `suppressedCount`/`suppressedByType` for allowlist FP
|
|
314
|
+
// exceptions and `droppedCount`/`droppedByType` for sub-minConfidence drops.
|
|
315
|
+
// Counts and types only; the matched value is NEVER recorded (no-plaintext-in-
|
|
316
|
+
// audit). Omitted entirely when nothing was removed, so 1.1 events are byte-
|
|
317
|
+
// identical and the audit hash-chain canonicalization is unaffected.
|
|
318
|
+
if (precisionAudit && precisionAudit.suppressedCount > 0) {
|
|
319
|
+
summary.suppressedCount = precisionAudit.suppressedCount;
|
|
320
|
+
summary.suppressedByType = precisionAudit.suppressedByType;
|
|
321
|
+
}
|
|
322
|
+
if (precisionAudit && precisionAudit.droppedCount > 0) {
|
|
323
|
+
summary.droppedCount = precisionAudit.droppedCount;
|
|
324
|
+
summary.droppedByType = precisionAudit.droppedByType;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return summary;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Compile the configured allowlist into fast lookup sets. An entry is either a
|
|
331
|
+
// bare string (an exact matched-VALUE exception) or an object { value?, path? }
|
|
332
|
+
// (value exception, JSON-path exception via the PII-safe pathText, or both —
|
|
333
|
+
// when both are present BOTH must match). Returns null when there is nothing to
|
|
334
|
+
// allowlist so the hot path can skip the work entirely.
|
|
335
|
+
function compileAllowlist(allowlist) {
|
|
336
|
+
if (!Array.isArray(allowlist) || allowlist.length === 0) {
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
const values = new Set();
|
|
340
|
+
const paths = new Set();
|
|
341
|
+
const pairs = [];
|
|
342
|
+
for (const entry of allowlist) {
|
|
343
|
+
if (typeof entry === "string") {
|
|
344
|
+
values.add(entry);
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
const hasValue = typeof entry.value === "string";
|
|
348
|
+
const hasPath = typeof entry.path === "string";
|
|
349
|
+
if (hasValue && hasPath) {
|
|
350
|
+
pairs.push({ value: entry.value, path: entry.path });
|
|
351
|
+
} else if (hasValue) {
|
|
352
|
+
values.add(entry.value);
|
|
353
|
+
} else if (hasPath) {
|
|
354
|
+
paths.add(entry.path);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return { values, paths, pairs };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Does this detection's matched value / JSON path match an allowlist entry? The
|
|
361
|
+
// path comparison uses the PII-safe `pathText` (the same hashed path the audit
|
|
362
|
+
// records), so an operator allowlists `key_<hash>.…` — never a raw key name.
|
|
363
|
+
function isAllowlisted(detection, allowlist) {
|
|
364
|
+
if (!allowlist) {
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
const { values, paths, pairs } = allowlist;
|
|
368
|
+
if (typeof detection.value === "string" && values.has(detection.value)) {
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
if (typeof detection.pathText === "string" && paths.has(detection.pathText)) {
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
for (const pair of pairs) {
|
|
375
|
+
if (detection.value === pair.value && detection.pathText === pair.path) {
|
|
376
|
+
return true;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// WS2c precision controls — run AFTER detect, BEFORE decide. Returns the kept
|
|
383
|
+
// detections plus a precisionAudit of what was removed (counts/types only).
|
|
384
|
+
//
|
|
385
|
+
// HARD-BLOCK INVARIANT (load-bearing, fail-closed): a detection whose type is in
|
|
386
|
+
// HARD_BLOCK_TYPES (secret/api_key/kr_rrn/card) is NEVER removed here — neither a
|
|
387
|
+
// low confidence nor an allowlist entry can suppress it. minConfidence trims only
|
|
388
|
+
// the precision-risky SOFT types; an allowlist entry that would suppress a hard-
|
|
389
|
+
// block type is ignored and the detection still fires. This guard lives in core
|
|
390
|
+
// (not trusted to config) so the invariant holds for every caller.
|
|
391
|
+
export function applyPrecisionControls(detections, { minConfidence = 0, allowlist = null } = {}) {
|
|
392
|
+
const kept = [];
|
|
393
|
+
const suppressedByType = {};
|
|
394
|
+
const droppedByType = {};
|
|
395
|
+
let suppressedCount = 0;
|
|
396
|
+
let droppedCount = 0;
|
|
397
|
+
|
|
398
|
+
for (const detection of detections) {
|
|
399
|
+
const hardBlock = HARD_BLOCK_TYPES.has(detection.type);
|
|
400
|
+
// Allowlist suppression first (an operator-declared FP exception), but never
|
|
401
|
+
// for a hard-block type.
|
|
402
|
+
if (!hardBlock && isAllowlisted(detection, allowlist)) {
|
|
403
|
+
suppressedByType[detection.type] = (suppressedByType[detection.type] ?? 0) + 1;
|
|
404
|
+
suppressedCount += 1;
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
// minConfidence drop — only for soft types. A low-confidence hard-block
|
|
408
|
+
// detection (e.g. a card at confidence 0.75) is kept and acted on.
|
|
409
|
+
if (!hardBlock && Number.isFinite(detection.confidence) && detection.confidence < minConfidence) {
|
|
410
|
+
droppedByType[detection.type] = (droppedByType[detection.type] ?? 0) + 1;
|
|
411
|
+
droppedCount += 1;
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
kept.push(detection);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
detections: kept,
|
|
419
|
+
precisionAudit: { suppressedCount, suppressedByType, droppedCount, droppedByType }
|
|
420
|
+
};
|
|
294
421
|
}
|
|
295
422
|
|
|
296
423
|
async function transformPayload(payload, detections, decisions, { context, cryptoProvider, tokenVault, enforced, issuedTokens = null }) {
|
|
@@ -424,7 +551,7 @@ async function replacementFor(segment, detection, decision, { context, cryptoPro
|
|
|
424
551
|
}
|
|
425
552
|
}
|
|
426
553
|
|
|
427
|
-
function buildAuditEvent({ context, mode, enforced, blocked, payload, detections, decisions }) {
|
|
554
|
+
function buildAuditEvent({ context, mode, enforced, blocked, payload, detections, decisions, precisionAudit = null }) {
|
|
428
555
|
return {
|
|
429
556
|
// Reader-facing audit-event schema version (frozen as part of the 1.0 API
|
|
430
557
|
// contract — see docs/current/api-stability.md). Additive-only: a new field
|
|
@@ -433,6 +560,14 @@ function buildAuditEvent({ context, mode, enforced, blocked, payload, detections
|
|
|
433
560
|
// and so is self-consistent for hash-chain verification of new events.
|
|
434
561
|
schemaVersion: "1",
|
|
435
562
|
id: randomUUID(),
|
|
563
|
+
// Per-REQUEST correlation id (WS4-A). Additive top-level field: the proxy
|
|
564
|
+
// generates one randomUUID() per request and threads it into the protect
|
|
565
|
+
// context, so the request- and response-direction events of ONE request
|
|
566
|
+
// share it (and it appears in the structured error log for the same request).
|
|
567
|
+
// It is null when no context.correlationId is set, preserving the existing
|
|
568
|
+
// non-proxy protectJson() behavior and keeping the api-contract subset green.
|
|
569
|
+
// It is a UUID — never a payload/identity/PII value.
|
|
570
|
+
correlationId: context.correlationId ?? null,
|
|
436
571
|
timestamp: new Date().toISOString(),
|
|
437
572
|
protocol: context.protocol ?? "custom",
|
|
438
573
|
operation: context.operation ?? "protect",
|
|
@@ -463,7 +598,7 @@ function buildAuditEvent({ context, mode, enforced, blocked, payload, detections
|
|
|
463
598
|
action: decisions[index]?.action ?? "unknown",
|
|
464
599
|
enforced
|
|
465
600
|
})),
|
|
466
|
-
summary: summarize(detections, decisions)
|
|
601
|
+
summary: summarize(detections, decisions, precisionAudit)
|
|
467
602
|
};
|
|
468
603
|
}
|
|
469
604
|
|