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.
Files changed (39) hide show
  1. package/README.ko.md +46 -11
  2. package/README.md +46 -11
  3. package/SECURITY.md +7 -1
  4. package/docs/README.md +2 -0
  5. package/docs/current/compliance-mapping.ko.md +53 -0
  6. package/docs/current/compliance-mapping.md +53 -0
  7. package/docs/current/config-version.ko.md +30 -0
  8. package/docs/current/config-version.md +51 -0
  9. package/docs/current/configuration.ko.md +165 -9
  10. package/docs/current/configuration.md +165 -9
  11. package/docs/current/operations-runbook.ko.md +155 -0
  12. package/docs/current/operations-runbook.md +241 -0
  13. package/docs/current/release-process.ko.md +5 -1
  14. package/docs/current/release-process.md +5 -1
  15. package/docs/current/risk-register-release-gate.ko.md +5 -3
  16. package/docs/current/risk-register-release-gate.md +13 -3
  17. package/docs/current/security-whitepaper.ko.md +102 -0
  18. package/docs/current/security-whitepaper.md +102 -0
  19. package/docs/current/shared-responsibility.ko.md +2 -2
  20. package/docs/current/shared-responsibility.md +2 -2
  21. package/docs/current/threat-model.ko.md +4 -2
  22. package/docs/current/threat-model.md +4 -2
  23. package/examples/local-proxy-demo/README.md +51 -0
  24. package/examples/local-proxy-demo/demo.mjs +144 -0
  25. package/examples/local-proxy-demo/demo.tape +19 -0
  26. package/examples/local-proxy-demo/live-demo.mjs +121 -0
  27. package/examples/local-proxy-demo/live-demo.tape +25 -0
  28. package/haechi.config.example.json +20 -3
  29. package/package.json +7 -2
  30. package/packages/audit/index.mjs +26 -2
  31. package/packages/cli/bin/haechi.mjs +57 -10
  32. package/packages/cli/runtime.mjs +402 -10
  33. package/packages/core/index.mjs +143 -8
  34. package/packages/filter/index.mjs +975 -12
  35. package/packages/metrics/index.mjs +181 -0
  36. package/packages/privacy-profiles/index.mjs +72 -3
  37. package/packages/protocol-adapters/index.mjs +99 -1
  38. package/packages/proxy/index.mjs +525 -40
  39. package/packages/stream-filter/index.mjs +69 -7
@@ -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 detections = await filterEngine.detect({ entries, context });
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
- return {
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