haechi 0.3.2
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/LICENSE +154 -0
- package/README.md +102 -0
- package/SECURITY.md +31 -0
- package/docs/README.md +35 -0
- package/docs/current/api-stability.ko.md +48 -0
- package/docs/current/api-stability.md +48 -0
- package/docs/current/expert-gap-review-ai-llm-mcp-encryption.ko.md +107 -0
- package/docs/current/expert-gap-review-ai-llm-mcp-encryption.md +107 -0
- package/docs/current/global-privacy-compliance-review.ko.md +110 -0
- package/docs/current/global-privacy-compliance-review.md +110 -0
- package/docs/current/initial-plan-ai-llm-mcp-encryption.ko.md +214 -0
- package/docs/current/initial-plan-ai-llm-mcp-encryption.md +214 -0
- package/docs/current/mvp-0.1-implementation-scope.ko.md +79 -0
- package/docs/current/mvp-0.1-implementation-scope.md +79 -0
- package/docs/current/open-source-modular-architecture.ko.md +387 -0
- package/docs/current/open-source-modular-architecture.md +387 -0
- package/docs/current/prd-ai-llm-mcp-encryption.ko.md +260 -0
- package/docs/current/prd-ai-llm-mcp-encryption.md +262 -0
- package/docs/current/privacy-filtering-policy-draft.ko.md +307 -0
- package/docs/current/privacy-filtering-policy-draft.md +307 -0
- package/docs/current/release-0.2-implementation-scope.ko.md +46 -0
- package/docs/current/release-0.2-implementation-scope.md +46 -0
- package/docs/current/release-0.3-implementation-scope.ko.md +86 -0
- package/docs/current/release-0.3-implementation-scope.md +86 -0
- package/docs/current/release-0.3.2-hardening-scope.ko.md +64 -0
- package/docs/current/release-0.3.2-hardening-scope.md +64 -0
- package/docs/current/release-0.4-implementation-scope.ko.md +121 -0
- package/docs/current/release-0.4-implementation-scope.md +121 -0
- package/docs/current/release-process.ko.md +48 -0
- package/docs/current/release-process.md +48 -0
- package/docs/current/risk-register-release-gate.ko.md +154 -0
- package/docs/current/risk-register-release-gate.md +154 -0
- package/docs/current/shared-responsibility.ko.md +38 -0
- package/docs/current/shared-responsibility.md +38 -0
- package/docs/current/threat-model.ko.md +68 -0
- package/docs/current/threat-model.md +68 -0
- package/examples/llm-prompt-filtering/input.json +13 -0
- package/examples/plugins/custom-filter.plugin.json +29 -0
- package/haechi.config.example.json +70 -0
- package/package.json +74 -0
- package/packages/audit/index.mjs +262 -0
- package/packages/cli/bin/haechi.mjs +341 -0
- package/packages/cli/runtime.mjs +287 -0
- package/packages/core/index.mjs +309 -0
- package/packages/crypto/index.mjs +142 -0
- package/packages/filter/index.mjs +189 -0
- package/packages/mcp-stdio/index.mjs +105 -0
- package/packages/plugin/index.mjs +83 -0
- package/packages/policy/index.mjs +165 -0
- package/packages/policy-bundle/index.mjs +91 -0
- package/packages/privacy-profiles/index.mjs +92 -0
- package/packages/protocol-adapters/index.mjs +111 -0
- package/packages/proxy/index.mjs +534 -0
- package/packages/token-vault/index.mjs +262 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { createHaechi } from "../core/index.mjs";
|
|
4
|
+
import { createDefaultFilterEngine } from "../filter/index.mjs";
|
|
5
|
+
import { buildPolicy, createPolicyEngine } from "../policy/index.mjs";
|
|
6
|
+
import { createLocalCryptoProvider, initLocalKeyFile } from "../crypto/index.mjs";
|
|
7
|
+
import { createJsonlAuditSink } from "../audit/index.mjs";
|
|
8
|
+
import { createLocalTokenVault } from "../token-vault/index.mjs";
|
|
9
|
+
import { loadVerifiedPolicyBundleFileSync } from "../policy-bundle/index.mjs";
|
|
10
|
+
import { createProtocolAdapter } from "../protocol-adapters/index.mjs";
|
|
11
|
+
import { applyPrivacyProfile, getPrivacyProfile } from "../privacy-profiles/index.mjs";
|
|
12
|
+
import { DEFAULT_PROXY_PORT } from "../proxy/index.mjs";
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_CONFIG_PATH = "haechi.config.json";
|
|
15
|
+
|
|
16
|
+
export function defaultConfig() {
|
|
17
|
+
return {
|
|
18
|
+
mode: "dry-run",
|
|
19
|
+
target: {
|
|
20
|
+
type: "llm-http",
|
|
21
|
+
adapter: "openai-compatible",
|
|
22
|
+
upstream: "http://127.0.0.1:9999"
|
|
23
|
+
},
|
|
24
|
+
proxy: {
|
|
25
|
+
host: "127.0.0.1",
|
|
26
|
+
port: DEFAULT_PROXY_PORT
|
|
27
|
+
},
|
|
28
|
+
responseProtection: {
|
|
29
|
+
enabled: false,
|
|
30
|
+
mode: "enforce",
|
|
31
|
+
failureMode: "fail-closed",
|
|
32
|
+
allowNonJson: false,
|
|
33
|
+
allowCompressed: false,
|
|
34
|
+
maxBytes: 1048576
|
|
35
|
+
},
|
|
36
|
+
streaming: {
|
|
37
|
+
requestMode: "block"
|
|
38
|
+
},
|
|
39
|
+
limits: {
|
|
40
|
+
maxRequestBytes: 1048576,
|
|
41
|
+
upstreamTimeoutMs: 120000
|
|
42
|
+
},
|
|
43
|
+
policy: {
|
|
44
|
+
mode: "dry-run",
|
|
45
|
+
presets: ["korean-pii", "secrets-only", "llm-redact"],
|
|
46
|
+
defaultAction: "redact",
|
|
47
|
+
actions: {
|
|
48
|
+
card: "block"
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
filters: {
|
|
52
|
+
customRules: []
|
|
53
|
+
},
|
|
54
|
+
keys: {
|
|
55
|
+
provider: "local",
|
|
56
|
+
keyFile: ".haechi/dev.keys.json"
|
|
57
|
+
},
|
|
58
|
+
audit: {
|
|
59
|
+
sink: "jsonl",
|
|
60
|
+
path: ".haechi/audit.jsonl"
|
|
61
|
+
},
|
|
62
|
+
tokenVault: {
|
|
63
|
+
provider: "local",
|
|
64
|
+
path: ".haechi/token-vault.json",
|
|
65
|
+
revealPolicy: "disabled",
|
|
66
|
+
retentionDays: 30
|
|
67
|
+
},
|
|
68
|
+
privacy: {
|
|
69
|
+
profile: null
|
|
70
|
+
},
|
|
71
|
+
mcp: {
|
|
72
|
+
allowedMethods: ["initialize", "tools/call", "resources/read", "prompts/get"],
|
|
73
|
+
protectParams: true,
|
|
74
|
+
protectResults: true,
|
|
75
|
+
requireJsonRpc: true
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function loadConfig(configPath = DEFAULT_CONFIG_PATH) {
|
|
81
|
+
const raw = JSON.parse(await readFile(configPath, "utf8"));
|
|
82
|
+
return normalizeConfig(raw);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function writeDefaultConfig(configPath = DEFAULT_CONFIG_PATH, { force = false } = {}) {
|
|
86
|
+
const config = defaultConfig();
|
|
87
|
+
await mkdir(dirname(config.keys.keyFile), { recursive: true });
|
|
88
|
+
await initLocalKeyFile(config.keys.keyFile, { force });
|
|
89
|
+
|
|
90
|
+
if (!force) {
|
|
91
|
+
try {
|
|
92
|
+
await readFile(configPath, "utf8");
|
|
93
|
+
return { created: false, configPath, config };
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error.code !== "ENOENT") {
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
102
|
+
return { created: true, configPath, config };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function createRuntime(config, providers = {}) {
|
|
106
|
+
const normalized = normalizeConfig(config);
|
|
107
|
+
const cryptoProvider = providers.cryptoProvider ?? createConfiguredCryptoProvider(normalized);
|
|
108
|
+
assertProvider("cryptoProvider", cryptoProvider, ["encrypt", "decrypt"]);
|
|
109
|
+
const auditSink = providers.auditSink ?? createJsonlAuditSink({ path: normalized.audit.path });
|
|
110
|
+
assertProvider("auditSink", auditSink, ["record"]);
|
|
111
|
+
const tokenVault = providers.tokenVault ?? createLocalTokenVault({
|
|
112
|
+
path: normalized.tokenVault.path,
|
|
113
|
+
cryptoProvider,
|
|
114
|
+
revealPolicy: normalized.tokenVault.revealPolicy,
|
|
115
|
+
retentionDays: normalized.tokenVault.retentionDays,
|
|
116
|
+
auditSink
|
|
117
|
+
});
|
|
118
|
+
assertProvider("tokenVault", tokenVault, ["tokenize", "reveal", "purge"]);
|
|
119
|
+
const policySource = normalized.policy.bundlePath
|
|
120
|
+
? {
|
|
121
|
+
...loadVerifiedPolicyBundleFileSync({
|
|
122
|
+
bundlePath: normalized.policy.bundlePath,
|
|
123
|
+
keyFile: normalized.keys.keyFile
|
|
124
|
+
}).policy,
|
|
125
|
+
mode: normalized.policy.mode ?? normalized.mode
|
|
126
|
+
}
|
|
127
|
+
: {
|
|
128
|
+
...normalized.policy,
|
|
129
|
+
mode: normalized.policy.mode ?? normalized.mode
|
|
130
|
+
};
|
|
131
|
+
const policy = buildPolicy(normalized.privacy.profile
|
|
132
|
+
? applyPrivacyProfile(policySource, normalized.privacy.profile)
|
|
133
|
+
: policySource);
|
|
134
|
+
|
|
135
|
+
const filterEngine = providers.filterEngine ?? createDefaultFilterEngine(normalized.filters);
|
|
136
|
+
assertProvider("filterEngine", filterEngine, ["detect"]);
|
|
137
|
+
const policyEngine = providers.policyEngine ?? createPolicyEngine(policy);
|
|
138
|
+
assertProvider("policyEngine", policyEngine, ["decide"]);
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
config: normalized,
|
|
142
|
+
tokenVault,
|
|
143
|
+
auditSink,
|
|
144
|
+
protocolAdapter: createProtocolAdapter(normalized.target),
|
|
145
|
+
haechi: createHaechi({
|
|
146
|
+
mode: normalized.mode,
|
|
147
|
+
filterEngine,
|
|
148
|
+
policyEngine,
|
|
149
|
+
cryptoProvider,
|
|
150
|
+
tokenVault,
|
|
151
|
+
auditSink
|
|
152
|
+
})
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function normalizeConfig(config) {
|
|
157
|
+
const merged = {
|
|
158
|
+
...defaultConfig(),
|
|
159
|
+
...config,
|
|
160
|
+
target: {
|
|
161
|
+
...defaultConfig().target,
|
|
162
|
+
...(config.target ?? {})
|
|
163
|
+
},
|
|
164
|
+
proxy: {
|
|
165
|
+
...defaultConfig().proxy,
|
|
166
|
+
...(config.proxy ?? {})
|
|
167
|
+
},
|
|
168
|
+
responseProtection: {
|
|
169
|
+
...defaultConfig().responseProtection,
|
|
170
|
+
...(config.responseProtection ?? {})
|
|
171
|
+
},
|
|
172
|
+
streaming: {
|
|
173
|
+
...defaultConfig().streaming,
|
|
174
|
+
...(config.streaming ?? {})
|
|
175
|
+
},
|
|
176
|
+
limits: {
|
|
177
|
+
...defaultConfig().limits,
|
|
178
|
+
...(config.limits ?? {})
|
|
179
|
+
},
|
|
180
|
+
policy: {
|
|
181
|
+
...defaultConfig().policy,
|
|
182
|
+
...(config.policy ?? {}),
|
|
183
|
+
actions: {
|
|
184
|
+
...defaultConfig().policy.actions,
|
|
185
|
+
...(config.policy?.actions ?? {})
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
filters: {
|
|
189
|
+
...defaultConfig().filters,
|
|
190
|
+
...(config.filters ?? {})
|
|
191
|
+
},
|
|
192
|
+
keys: {
|
|
193
|
+
...defaultConfig().keys,
|
|
194
|
+
...(config.keys ?? {})
|
|
195
|
+
},
|
|
196
|
+
audit: {
|
|
197
|
+
...defaultConfig().audit,
|
|
198
|
+
...(config.audit ?? {})
|
|
199
|
+
},
|
|
200
|
+
tokenVault: {
|
|
201
|
+
...defaultConfig().tokenVault,
|
|
202
|
+
...(config.tokenVault ?? {})
|
|
203
|
+
},
|
|
204
|
+
privacy: {
|
|
205
|
+
...defaultConfig().privacy,
|
|
206
|
+
...(config.privacy ?? {})
|
|
207
|
+
},
|
|
208
|
+
mcp: {
|
|
209
|
+
...defaultConfig().mcp,
|
|
210
|
+
...(config.mcp ?? {}),
|
|
211
|
+
allowedMethods: config.mcp?.allowedMethods ?? defaultConfig().mcp.allowedMethods
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
if (!["local", "external"].includes(merged.keys.provider)) {
|
|
216
|
+
throw new Error(`Unsupported key provider: ${merged.keys.provider}`);
|
|
217
|
+
}
|
|
218
|
+
if (typeof merged.proxy.host !== "string" || !merged.proxy.host.trim()) {
|
|
219
|
+
throw new Error("proxy.host must be a non-empty string");
|
|
220
|
+
}
|
|
221
|
+
if (!isValidPort(merged.proxy.port)) {
|
|
222
|
+
throw new Error("proxy.port must be an integer from 0 to 65535");
|
|
223
|
+
}
|
|
224
|
+
if (merged.audit.sink !== "jsonl") {
|
|
225
|
+
throw new Error("Current implementation only supports jsonl audit sink");
|
|
226
|
+
}
|
|
227
|
+
if (merged.tokenVault.provider !== "local") {
|
|
228
|
+
throw new Error("0.2 only supports local token vault provider");
|
|
229
|
+
}
|
|
230
|
+
if (!["disabled", "local-dev"].includes(merged.tokenVault.revealPolicy)) {
|
|
231
|
+
throw new Error(`Invalid tokenVault.revealPolicy: ${merged.tokenVault.revealPolicy}`);
|
|
232
|
+
}
|
|
233
|
+
if (typeof merged.tokenVault.retentionDays !== "number" || merged.tokenVault.retentionDays < 1) {
|
|
234
|
+
throw new Error("tokenVault.retentionDays must be a positive number");
|
|
235
|
+
}
|
|
236
|
+
if (!Array.isArray(merged.mcp.allowedMethods) || merged.mcp.allowedMethods.length === 0) {
|
|
237
|
+
throw new Error("mcp.allowedMethods must be a non-empty array");
|
|
238
|
+
}
|
|
239
|
+
if (!merged.mcp.allowedMethods.every((method) => typeof method === "string" && method.trim())) {
|
|
240
|
+
throw new Error("mcp.allowedMethods must contain only non-empty strings");
|
|
241
|
+
}
|
|
242
|
+
if (typeof merged.mcp.protectParams !== "boolean" || typeof merged.mcp.protectResults !== "boolean") {
|
|
243
|
+
throw new Error("mcp.protectParams and mcp.protectResults must be boolean");
|
|
244
|
+
}
|
|
245
|
+
if (typeof merged.mcp.requireJsonRpc !== "boolean") {
|
|
246
|
+
throw new Error("mcp.requireJsonRpc must be boolean");
|
|
247
|
+
}
|
|
248
|
+
if (merged.privacy.profile) {
|
|
249
|
+
getPrivacyProfile(merged.privacy.profile);
|
|
250
|
+
}
|
|
251
|
+
if (!["fail-closed", "allow"].includes(merged.responseProtection.failureMode)) {
|
|
252
|
+
throw new Error(`Invalid responseProtection.failureMode: ${merged.responseProtection.failureMode}`);
|
|
253
|
+
}
|
|
254
|
+
if (typeof merged.responseProtection.maxBytes !== "number" || merged.responseProtection.maxBytes < 1) {
|
|
255
|
+
throw new Error("responseProtection.maxBytes must be a positive number");
|
|
256
|
+
}
|
|
257
|
+
if (!["block", "pass-through"].includes(merged.streaming.requestMode)) {
|
|
258
|
+
throw new Error(`Invalid streaming.requestMode: ${merged.streaming.requestMode}`);
|
|
259
|
+
}
|
|
260
|
+
if (typeof merged.limits.maxRequestBytes !== "number" || merged.limits.maxRequestBytes < 1) {
|
|
261
|
+
throw new Error("limits.maxRequestBytes must be a positive number");
|
|
262
|
+
}
|
|
263
|
+
if (typeof merged.limits.upstreamTimeoutMs !== "number" || merged.limits.upstreamTimeoutMs < 1) {
|
|
264
|
+
throw new Error("limits.upstreamTimeoutMs must be a positive number");
|
|
265
|
+
}
|
|
266
|
+
createProtocolAdapter(merged.target);
|
|
267
|
+
return merged;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function isValidPort(port) {
|
|
271
|
+
return Number.isInteger(port) && port >= 0 && port <= 65535;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function createConfiguredCryptoProvider(config) {
|
|
275
|
+
if (config.keys.provider === "external") {
|
|
276
|
+
throw new Error("keys.provider external requires createRuntime(config, { cryptoProvider })");
|
|
277
|
+
}
|
|
278
|
+
return createLocalCryptoProvider({ keyFile: config.keys.keyFile });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function assertProvider(name, provider, methods) {
|
|
282
|
+
for (const method of methods) {
|
|
283
|
+
if (typeof provider?.[method] !== "function") {
|
|
284
|
+
throw new Error(`${name} provider must implement ${method}()`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const NO_ENFORCE_MODES = new Set(["dry-run", "report-only"]);
|
|
4
|
+
|
|
5
|
+
export function createHaechi({ filterEngine, policyEngine, cryptoProvider, auditSink, tokenVault = null, mode = "dry-run" }) {
|
|
6
|
+
if (!filterEngine || !policyEngine || !cryptoProvider || !auditSink) {
|
|
7
|
+
throw new Error("Haechi requires filterEngine, policyEngine, cryptoProvider, and auditSink");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async function protectJson(payload, context = {}) {
|
|
11
|
+
const effectiveMode = context.mode ?? mode;
|
|
12
|
+
const entries = collectStringEntries(payload);
|
|
13
|
+
const detections = await filterEngine.detect({ entries, context });
|
|
14
|
+
const decisions = [];
|
|
15
|
+
|
|
16
|
+
for (const detection of detections) {
|
|
17
|
+
decisions.push(await policyEngine.decide({ detection, context, mode: effectiveMode }));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const enforced = !NO_ENFORCE_MODES.has(effectiveMode);
|
|
21
|
+
const blocked = enforced && decisions.some((decision) => decision.action === "block");
|
|
22
|
+
const protectedPayload = blocked ? null : await transformPayload(payload, detections, decisions, {
|
|
23
|
+
context,
|
|
24
|
+
cryptoProvider,
|
|
25
|
+
tokenVault,
|
|
26
|
+
enforced
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const auditEvent = buildAuditEvent({
|
|
30
|
+
context,
|
|
31
|
+
mode: effectiveMode,
|
|
32
|
+
enforced,
|
|
33
|
+
blocked,
|
|
34
|
+
payload,
|
|
35
|
+
detections,
|
|
36
|
+
decisions
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
await auditSink.record(auditEvent);
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
payload: protectedPayload,
|
|
43
|
+
blocked,
|
|
44
|
+
summary: summarize(detections, decisions),
|
|
45
|
+
auditEvent
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { protectJson };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function collectStringEntries(value, path = []) {
|
|
53
|
+
if (typeof value === "string") {
|
|
54
|
+
return [{ path, pathText: safePathToString(path), value, kind: "value" }];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Long digit runs (e.g. card numbers) can arrive as JSON numbers; scan their
|
|
58
|
+
// string form so numeric leaves are not a detection blind spot.
|
|
59
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
60
|
+
return [{ path, pathText: safePathToString(path), value: String(value), kind: "number" }];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (Array.isArray(value)) {
|
|
64
|
+
return value.flatMap((item, index) => collectStringEntries(item, path.concat(index)));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (value && typeof value === "object") {
|
|
68
|
+
// Object keys are scanned too: a PII/secret used as a map key would
|
|
69
|
+
// otherwise be forwarded upstream in plaintext.
|
|
70
|
+
return Object.entries(value).flatMap(([key, item]) => [
|
|
71
|
+
{ path: path.concat(key), pathText: safePathToString(path.concat(key)), value: key, kind: "key" },
|
|
72
|
+
...collectStringEntries(item, path.concat(key))
|
|
73
|
+
]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function pathToString(path) {
|
|
80
|
+
return path.reduce((text, part, index) => {
|
|
81
|
+
if (typeof part === "number") {
|
|
82
|
+
return `${text}[${part}]`;
|
|
83
|
+
}
|
|
84
|
+
return index === 0 ? String(part) : `${text}.${part}`;
|
|
85
|
+
}, "");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function safePathToString(path) {
|
|
89
|
+
return path.reduce((text, part, index) => {
|
|
90
|
+
if (typeof part === "number") {
|
|
91
|
+
return `${text}[${part}]`;
|
|
92
|
+
}
|
|
93
|
+
const safePart = `key_${shortHash(String(part))}`;
|
|
94
|
+
return index === 0 ? safePart : `${text}.${safePart}`;
|
|
95
|
+
}, "");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function shapeOnly(value) {
|
|
99
|
+
if (typeof value === "string") {
|
|
100
|
+
return { type: "string", length: value.length };
|
|
101
|
+
}
|
|
102
|
+
if (Array.isArray(value)) {
|
|
103
|
+
return value.map((item) => shapeOnly(item));
|
|
104
|
+
}
|
|
105
|
+
if (value && typeof value === "object") {
|
|
106
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, shapeOnly(item)]));
|
|
107
|
+
}
|
|
108
|
+
return { type: value === null ? "null" : typeof value };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function summarize(detections, decisions) {
|
|
112
|
+
const byType = {};
|
|
113
|
+
const byAction = {};
|
|
114
|
+
|
|
115
|
+
for (const detection of detections) {
|
|
116
|
+
byType[detection.type] = (byType[detection.type] ?? 0) + 1;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const decision of decisions) {
|
|
120
|
+
byAction[decision.action] = (byAction[decision.action] ?? 0) + 1;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
detectionCount: detections.length,
|
|
125
|
+
byType,
|
|
126
|
+
byAction
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function transformPayload(payload, detections, decisions, { context, cryptoProvider, tokenVault, enforced }) {
|
|
131
|
+
if (!enforced || detections.length === 0) {
|
|
132
|
+
return structuredClone(payload);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const output = structuredClone(payload);
|
|
136
|
+
const byPath = new Map();
|
|
137
|
+
|
|
138
|
+
detections.forEach((detection, index) => {
|
|
139
|
+
const key = JSON.stringify([detection.kind ?? "value", detection.path]);
|
|
140
|
+
const field = byPath.get(key) ?? [];
|
|
141
|
+
field.push({ detection, decision: decisions[index] });
|
|
142
|
+
byPath.set(key, field);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const valueGroups = [];
|
|
146
|
+
const keyGroups = [];
|
|
147
|
+
for (const [groupKey, items] of byPath.entries()) {
|
|
148
|
+
const [kind, path] = JSON.parse(groupKey);
|
|
149
|
+
(kind === "key" ? keyGroups : valueGroups).push({ kind, path, items });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
for (const { kind, path, items } of valueGroups) {
|
|
153
|
+
const original = getByPath(output, path);
|
|
154
|
+
if (kind === "number") {
|
|
155
|
+
if (typeof original !== "number") {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const transformed = await transformString(String(original), items, { context, cryptoProvider, tokenVault });
|
|
159
|
+
if (transformed !== String(original)) {
|
|
160
|
+
setByPath(output, path, transformed);
|
|
161
|
+
}
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (typeof original !== "string") {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const transformed = await transformString(original, items, { context, cryptoProvider, tokenVault });
|
|
168
|
+
setByPath(output, path, transformed);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Key renames run after value transforms (value paths reference original
|
|
172
|
+
// keys), deepest first so ancestor paths stay valid while renaming.
|
|
173
|
+
keyGroups.sort((left, right) => right.path.length - left.path.length);
|
|
174
|
+
for (const { path, items } of keyGroups) {
|
|
175
|
+
const parentPath = path.slice(0, -1);
|
|
176
|
+
const parent = parentPath.length > 0 ? getByPath(output, parentPath) : output;
|
|
177
|
+
const originalKey = path.at(-1);
|
|
178
|
+
if (!parent || typeof parent !== "object" || Array.isArray(parent)
|
|
179
|
+
|| !Object.prototype.hasOwnProperty.call(parent, originalKey)) {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const transformedKey = await transformString(String(originalKey), items, { context, cryptoProvider, tokenVault });
|
|
183
|
+
if (transformedKey === originalKey) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const childValue = parent[originalKey];
|
|
187
|
+
delete parent[originalKey];
|
|
188
|
+
let nextKey = transformedKey;
|
|
189
|
+
let suffix = 2;
|
|
190
|
+
while (Object.prototype.hasOwnProperty.call(parent, nextKey)) {
|
|
191
|
+
nextKey = `${transformedKey}#${suffix}`;
|
|
192
|
+
suffix += 1;
|
|
193
|
+
}
|
|
194
|
+
parent[nextKey] = childValue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return output;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function transformString(value, items, { context, cryptoProvider, tokenVault }) {
|
|
201
|
+
const sorted = items
|
|
202
|
+
.filter(({ decision }) => decision.action !== "allow" && decision.action !== "block")
|
|
203
|
+
.sort((left, right) => left.detection.start - right.detection.start);
|
|
204
|
+
|
|
205
|
+
let cursor = 0;
|
|
206
|
+
let output = "";
|
|
207
|
+
|
|
208
|
+
for (const { detection, decision } of sorted) {
|
|
209
|
+
if (detection.start < cursor) {
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
output += value.slice(cursor, detection.start);
|
|
214
|
+
const segment = value.slice(detection.start, detection.end);
|
|
215
|
+
output += await replacementFor(segment, detection, decision, { context, cryptoProvider, tokenVault });
|
|
216
|
+
cursor = detection.end;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
output += value.slice(cursor);
|
|
220
|
+
return output;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function replacementFor(segment, detection, decision, { context, cryptoProvider, tokenVault }) {
|
|
224
|
+
switch (decision.action) {
|
|
225
|
+
case "redact":
|
|
226
|
+
return `[REDACTED:${detection.type}]`;
|
|
227
|
+
case "mask":
|
|
228
|
+
return maskSensitive(segment);
|
|
229
|
+
case "tokenize":
|
|
230
|
+
if (tokenVault) {
|
|
231
|
+
const result = await tokenVault.tokenize({
|
|
232
|
+
plaintext: segment,
|
|
233
|
+
type: detection.type,
|
|
234
|
+
context,
|
|
235
|
+
metadata: {
|
|
236
|
+
path: detection.pathText,
|
|
237
|
+
ruleId: detection.ruleId
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
return `[TOKEN:${result.token}]`;
|
|
241
|
+
}
|
|
242
|
+
return `[TOKEN:${detection.type}:${shortHash(segment)}]`;
|
|
243
|
+
case "encrypt": {
|
|
244
|
+
const envelope = await cryptoProvider.encrypt({
|
|
245
|
+
plaintext: segment,
|
|
246
|
+
aad: {
|
|
247
|
+
context,
|
|
248
|
+
path: detection.pathText,
|
|
249
|
+
type: detection.type,
|
|
250
|
+
ruleId: detection.ruleId
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
return `[HAECHI_ENC:${base64UrlEncode(JSON.stringify(envelope))}]`;
|
|
254
|
+
}
|
|
255
|
+
default:
|
|
256
|
+
return segment;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function buildAuditEvent({ context, mode, enforced, blocked, payload, detections, decisions }) {
|
|
261
|
+
return {
|
|
262
|
+
id: randomUUID(),
|
|
263
|
+
timestamp: new Date().toISOString(),
|
|
264
|
+
protocol: context.protocol ?? "custom",
|
|
265
|
+
operation: context.operation ?? "protect",
|
|
266
|
+
mode,
|
|
267
|
+
enforced,
|
|
268
|
+
blocked,
|
|
269
|
+
payloadShapeHash: shortHash(JSON.stringify(shapeOnly(payload))),
|
|
270
|
+
detections: detections.map((detection, index) => ({
|
|
271
|
+
type: detection.type,
|
|
272
|
+
ruleId: detection.ruleId,
|
|
273
|
+
path: detection.pathText,
|
|
274
|
+
kind: detection.kind ?? "value",
|
|
275
|
+
confidence: detection.confidence,
|
|
276
|
+
action: decisions[index]?.action ?? "unknown",
|
|
277
|
+
enforced
|
|
278
|
+
})),
|
|
279
|
+
summary: summarize(detections, decisions)
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function getByPath(value, path) {
|
|
284
|
+
return path.reduce((current, part) => current?.[part], value);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function setByPath(value, path, nextValue) {
|
|
288
|
+
let current = value;
|
|
289
|
+
for (let index = 0; index < path.length - 1; index += 1) {
|
|
290
|
+
current = current[path[index]];
|
|
291
|
+
}
|
|
292
|
+
current[path[path.length - 1]] = nextValue;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function maskSensitive(value) {
|
|
296
|
+
// Short values would leak most of their content through partial masking.
|
|
297
|
+
if (value.length <= 8) {
|
|
298
|
+
return "*".repeat(value.length);
|
|
299
|
+
}
|
|
300
|
+
return `${value.slice(0, 2)}${"*".repeat(Math.max(4, value.length - 4))}${value.slice(-2)}`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function shortHash(value) {
|
|
304
|
+
return createHash("sha256").update(value).digest("hex").slice(0, 12);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function base64UrlEncode(value) {
|
|
308
|
+
return Buffer.from(value, "utf8").toString("base64url");
|
|
309
|
+
}
|