@unrdf/hooks 26.4.3 → 26.4.4
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 +24 -0
- package/README.md +562 -53
- package/examples/atomvm-fibo-hooks-demo.mjs +323 -0
- package/examples/delta-monitoring-example.mjs +213 -0
- package/examples/fibo-jtbd-governance.mjs +388 -0
- package/examples/hook-chains/node_modules/.bin/jiti +0 -0
- package/examples/hook-chains/node_modules/.bin/msw +0 -0
- package/examples/hook-chains/node_modules/.bin/terser +0 -0
- package/examples/hook-chains/node_modules/.bin/tsc +0 -0
- package/examples/hook-chains/node_modules/.bin/tsserver +0 -0
- package/examples/hook-chains/node_modules/.bin/tsx +0 -0
- package/examples/hook-chains/node_modules/.bin/validate-hooks +0 -0
- package/examples/hook-chains/node_modules/.bin/vite +0 -0
- package/examples/hook-chains/node_modules/.bin/vitest +0 -0
- package/examples/hook-chains/node_modules/.bin/yaml +0 -0
- package/examples/hooks-marketplace.mjs +261 -0
- package/examples/n3-reasoning-example.mjs +279 -0
- package/examples/policy-hooks/node_modules/.bin/jiti +0 -0
- package/examples/policy-hooks/node_modules/.bin/msw +0 -0
- package/examples/policy-hooks/node_modules/.bin/terser +0 -0
- package/examples/policy-hooks/node_modules/.bin/tsc +0 -0
- package/examples/policy-hooks/node_modules/.bin/tsserver +0 -0
- package/examples/policy-hooks/node_modules/.bin/tsx +0 -0
- package/examples/policy-hooks/node_modules/.bin/validate-hooks +0 -0
- package/examples/policy-hooks/node_modules/.bin/vite +0 -0
- package/examples/policy-hooks/node_modules/.bin/vitest +0 -0
- package/examples/policy-hooks/node_modules/.bin/yaml +0 -0
- package/examples/shacl-repair-example.mjs +191 -0
- package/examples/window-condition-example.mjs +285 -0
- package/package.json +26 -23
- package/src/atomvm.mjs +9 -0
- package/src/define.mjs +114 -0
- package/src/executor.mjs +23 -0
- package/src/hooks/atomvm-bridge.mjs +332 -0
- package/src/hooks/builtin-hooks.mjs +13 -7
- package/src/hooks/condition-evaluator.mjs +684 -77
- package/src/hooks/define-hook.mjs +23 -23
- package/src/hooks/effect-executor.mjs +630 -0
- package/src/hooks/effect-sandbox.mjs +19 -9
- package/src/hooks/file-resolver.mjs +155 -1
- package/src/hooks/hook-chain-compiler.mjs +11 -1
- package/src/hooks/hook-executor.mjs +98 -73
- package/src/hooks/knowledge-hook-engine.mjs +133 -7
- package/src/hooks/ontology-learner.mjs +190 -0
- package/src/hooks/query.mjs +3 -3
- package/src/hooks/schemas.mjs +47 -3
- package/src/hooks/security/error-sanitizer.mjs +46 -24
- package/src/hooks/self-play-autonomics.mjs +423 -0
- package/src/hooks/telemetry.mjs +32 -9
- package/src/hooks/validate.mjs +100 -33
- package/src/index.mjs +2 -0
- package/src/lib/admit-hook.mjs +615 -0
- package/src/policy-compiler.mjs +12 -2
- package/dist/index.d.mts +0 -1738
- package/dist/index.d.ts +0 -1738
- package/dist/index.mjs +0 -1738
|
@@ -94,23 +94,27 @@ export const HookTriggerSchema = z.enum([
|
|
|
94
94
|
'audit-trail',
|
|
95
95
|
]);
|
|
96
96
|
|
|
97
|
-
export const HookConfigSchema = z
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
97
|
+
export const HookConfigSchema = z
|
|
98
|
+
.object({
|
|
99
|
+
name: z.string().min(1, 'Hook name is required'),
|
|
100
|
+
trigger: HookTriggerSchema,
|
|
101
|
+
// Note: No return type enforcement - runtime POKA-YOKE guard handles non-boolean returns
|
|
102
|
+
validate: z.function().optional(),
|
|
103
|
+
transform: z.function().optional(),
|
|
104
|
+
metadata: z.record(z.string(), z.any()).optional(),
|
|
105
|
+
// Old format compatibility
|
|
106
|
+
meta: z
|
|
107
|
+
.object({
|
|
108
|
+
name: z.string(),
|
|
109
|
+
description: z.string().optional(),
|
|
110
|
+
})
|
|
111
|
+
.optional(),
|
|
112
|
+
pattern: z.string().optional(),
|
|
113
|
+
run: z.function().optional(),
|
|
114
|
+
})
|
|
115
|
+
.refine(obj => obj.validate || obj.transform || obj.run, {
|
|
116
|
+
message: 'Hook must define either validate, transform, or run function',
|
|
117
|
+
});
|
|
114
118
|
|
|
115
119
|
export const HookSchema = z.object({
|
|
116
120
|
name: z.string(),
|
|
@@ -143,8 +147,8 @@ export const HookSchema = z.object({
|
|
|
143
147
|
export function defineHook(config) {
|
|
144
148
|
const validated = HookConfigSchema.parse(config);
|
|
145
149
|
|
|
146
|
-
//
|
|
147
|
-
const name = validated.name
|
|
150
|
+
// name is now required by schema, validate and transform checked via refine
|
|
151
|
+
const name = validated.name;
|
|
148
152
|
const validate = validated.validate;
|
|
149
153
|
const transform = validated.transform || validated.run;
|
|
150
154
|
const metadata = validated.metadata || {
|
|
@@ -152,10 +156,6 @@ export function defineHook(config) {
|
|
|
152
156
|
pattern: validated.pattern,
|
|
153
157
|
};
|
|
154
158
|
|
|
155
|
-
if (!validate && !transform) {
|
|
156
|
-
throw new Error('Hook must define either validate, transform, or run function');
|
|
157
|
-
}
|
|
158
|
-
|
|
159
159
|
return {
|
|
160
160
|
name,
|
|
161
161
|
trigger: validated.trigger,
|
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Effect Executor - Sandboxed file operations with path traversal defense
|
|
3
|
+
* @module effect-executor
|
|
4
|
+
*
|
|
5
|
+
* @description
|
|
6
|
+
* Provides safe file read/write/mkdir operations that defend against:
|
|
7
|
+
* - Symlink traversal (resolves real paths before access)
|
|
8
|
+
* - Path traversal via ../ sequences
|
|
9
|
+
* - Absolute path escapes outside sandbox
|
|
10
|
+
* - Encoded traversal patterns (%2e%2e, %252f, etc.)
|
|
11
|
+
* - Null byte injection
|
|
12
|
+
* - Oversized file reads (memory DoS)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { promises as fs } from 'fs';
|
|
16
|
+
import { resolve, normalize, isAbsolute } from 'path';
|
|
17
|
+
|
|
18
|
+
/** @type {string} Root directory for all sandboxed file operations */
|
|
19
|
+
export const SANDBOX_ROOT = '/tmp/hooks-sandbox';
|
|
20
|
+
|
|
21
|
+
/** @type {number} Maximum file size in bytes (10 MB) */
|
|
22
|
+
export const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
|
23
|
+
|
|
24
|
+
/** @type {number} Maximum path length */
|
|
25
|
+
const MAX_PATH_LENGTH = 4096;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Cache for the real (symlink-resolved) sandbox root path.
|
|
29
|
+
* On macOS, /tmp is a symlink to /private/tmp, so we must compare
|
|
30
|
+
* against the resolved path when checking fs.realpath() results.
|
|
31
|
+
* @type {string|null}
|
|
32
|
+
*/
|
|
33
|
+
let _resolvedSandboxRoot = null;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get the real sandbox root path, resolving symlinks (e.g., /tmp → /private/tmp on macOS).
|
|
37
|
+
* @returns {Promise<string>}
|
|
38
|
+
*/
|
|
39
|
+
async function getResolvedSandboxRoot() {
|
|
40
|
+
if (_resolvedSandboxRoot === null) {
|
|
41
|
+
await fs.mkdir(SANDBOX_ROOT, { recursive: true });
|
|
42
|
+
_resolvedSandboxRoot = await fs.realpath(SANDBOX_ROOT);
|
|
43
|
+
}
|
|
44
|
+
return _resolvedSandboxRoot;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Reset the resolved sandbox root cache (for testing).
|
|
49
|
+
*/
|
|
50
|
+
export function _resetSandboxRootCache() {
|
|
51
|
+
_resolvedSandboxRoot = null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Dangerous patterns that indicate path traversal attempts.
|
|
56
|
+
* Checked against the raw input before any normalization.
|
|
57
|
+
*/
|
|
58
|
+
const TRAVERSAL_PATTERNS = [
|
|
59
|
+
/\.\.\//, // ../
|
|
60
|
+
/\.\.\\/, // ..\
|
|
61
|
+
/\.\.%2f/i, // ..%2f (URL encoded /)
|
|
62
|
+
/\.\.%5c/i, // ..%5c (URL encoded \)
|
|
63
|
+
/\.\.%252f/i, // ..%252f (double URL encoded)
|
|
64
|
+
/\.\.%255c/i, // ..%255c (double URL encoded)
|
|
65
|
+
/\.\.%c0%af/i, // overlong UTF-8 /
|
|
66
|
+
/\.\.%c1%9c/i, // overlong UTF-8 \
|
|
67
|
+
/%2e%2e/i, // encoded ..
|
|
68
|
+
/%252e%252e/i, // double-encoded ..
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Validate and resolve a path to ensure it stays within SANDBOX_ROOT.
|
|
73
|
+
*
|
|
74
|
+
* Steps:
|
|
75
|
+
* 1. Reject null bytes, encoded traversal, and overly long paths
|
|
76
|
+
* 2. Reject absolute paths (must be relative to sandbox)
|
|
77
|
+
* 3. Normalize and resolve against SANDBOX_ROOT
|
|
78
|
+
* 4. Resolve symlinks via fs.realpath (if target exists)
|
|
79
|
+
* 5. Confirm the final real path is still under SANDBOX_ROOT
|
|
80
|
+
*
|
|
81
|
+
* @param {string} untrustedPath - The user-supplied path to validate
|
|
82
|
+
* @returns {Promise<string>} The resolved, safe absolute path
|
|
83
|
+
* @throws {Error} On any policy violation
|
|
84
|
+
*/
|
|
85
|
+
export async function validatePath(untrustedPath) {
|
|
86
|
+
// --- input type check ---
|
|
87
|
+
if (typeof untrustedPath !== 'string') {
|
|
88
|
+
throw new Error('Path must be a string');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// --- length check ---
|
|
92
|
+
if (untrustedPath.length === 0) {
|
|
93
|
+
throw new Error('Path must not be empty');
|
|
94
|
+
}
|
|
95
|
+
if (untrustedPath.length > MAX_PATH_LENGTH) {
|
|
96
|
+
throw new Error('Path exceeds maximum length');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// --- null byte injection ---
|
|
100
|
+
if (untrustedPath.includes('\x00')) {
|
|
101
|
+
throw new Error('Path traversal detected: null byte');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- encoded traversal patterns (check raw input) ---
|
|
105
|
+
for (const pattern of TRAVERSAL_PATTERNS) {
|
|
106
|
+
if (pattern.test(untrustedPath)) {
|
|
107
|
+
throw new Error('Path traversal detected');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// --- reject absolute paths (must be relative to sandbox) ---
|
|
112
|
+
if (isAbsolute(untrustedPath)) {
|
|
113
|
+
throw new Error('Absolute paths are not allowed');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --- normalize and resolve ---
|
|
117
|
+
const normalized = normalize(untrustedPath);
|
|
118
|
+
|
|
119
|
+
// After normalization, check again for traversal (handles edge cases)
|
|
120
|
+
if (normalized.startsWith('..') || normalized.includes('/..') || normalized.includes('\\..')) {
|
|
121
|
+
throw new Error('Path traversal detected');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const resolved = resolve(SANDBOX_ROOT, normalized);
|
|
125
|
+
|
|
126
|
+
// --- boundary check (pre-symlink) ---
|
|
127
|
+
if (!resolved.startsWith(SANDBOX_ROOT + '/') && resolved !== SANDBOX_ROOT) {
|
|
128
|
+
throw new Error('Path traversal detected: escapes sandbox');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --- symlink resolution ---
|
|
132
|
+
// Resolve the real sandbox root (handles /tmp → /private/tmp on macOS)
|
|
133
|
+
const realRoot = await getResolvedSandboxRoot();
|
|
134
|
+
|
|
135
|
+
// If the file/directory exists, resolve symlinks and re-check
|
|
136
|
+
try {
|
|
137
|
+
const realPath = await fs.realpath(resolved);
|
|
138
|
+
if (!realPath.startsWith(realRoot + '/') && realPath !== realRoot) {
|
|
139
|
+
throw new Error('Path traversal detected: symlink escapes sandbox');
|
|
140
|
+
}
|
|
141
|
+
return realPath;
|
|
142
|
+
} catch (err) {
|
|
143
|
+
if (err.code === 'ENOENT') {
|
|
144
|
+
// File doesn't exist yet (e.g., for writes) — check parent exists and is safe
|
|
145
|
+
const parentResolved = resolve(resolved, '..');
|
|
146
|
+
try {
|
|
147
|
+
const parentReal = await fs.realpath(parentResolved);
|
|
148
|
+
if (!parentReal.startsWith(realRoot + '/') && parentReal !== realRoot) {
|
|
149
|
+
throw new Error('Path traversal detected: parent symlink escapes sandbox');
|
|
150
|
+
}
|
|
151
|
+
} catch (parentErr) {
|
|
152
|
+
if (parentErr.code === 'ENOENT') {
|
|
153
|
+
// Parent doesn't exist either — that's OK for mkdir -p style usage
|
|
154
|
+
// The resolved path already passed the prefix check above
|
|
155
|
+
return resolved;
|
|
156
|
+
}
|
|
157
|
+
throw parentErr;
|
|
158
|
+
}
|
|
159
|
+
return resolved;
|
|
160
|
+
}
|
|
161
|
+
// Re-throw symlink escape errors
|
|
162
|
+
if (err.message.includes('Path traversal detected')) {
|
|
163
|
+
throw err;
|
|
164
|
+
}
|
|
165
|
+
throw err;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Safely read a file from within the sandbox.
|
|
171
|
+
*
|
|
172
|
+
* @param {string} filePath - Relative path within SANDBOX_ROOT
|
|
173
|
+
* @param {Object} [options] - Read options
|
|
174
|
+
* @param {string} [options.encoding='utf-8'] - File encoding
|
|
175
|
+
* @param {number} [options.maxSize] - Maximum file size in bytes (default: MAX_FILE_SIZE)
|
|
176
|
+
* @returns {Promise<string|Buffer>} File contents
|
|
177
|
+
* @throws {Error} On path violation or oversized file
|
|
178
|
+
*/
|
|
179
|
+
export async function safeFileRead(filePath, options = {}) {
|
|
180
|
+
const safePath = await validatePath(filePath);
|
|
181
|
+
const maxSize = options.maxSize ?? MAX_FILE_SIZE;
|
|
182
|
+
const encoding = options.encoding ?? 'utf-8';
|
|
183
|
+
|
|
184
|
+
// Check file size before reading to prevent memory DoS
|
|
185
|
+
const stat = await fs.stat(safePath);
|
|
186
|
+
if (stat.size > maxSize) {
|
|
187
|
+
throw new Error(`File size ${stat.size} exceeds limit of ${maxSize} bytes`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return fs.readFile(safePath, { encoding });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Safely write a file within the sandbox.
|
|
195
|
+
*
|
|
196
|
+
* @param {string} filePath - Relative path within SANDBOX_ROOT
|
|
197
|
+
* @param {string|Buffer} data - Data to write
|
|
198
|
+
* @param {Object} [options] - Write options
|
|
199
|
+
* @param {string} [options.encoding='utf-8'] - File encoding
|
|
200
|
+
* @param {number} [options.maxSize] - Maximum data size in bytes (default: MAX_FILE_SIZE)
|
|
201
|
+
* @returns {Promise<void>}
|
|
202
|
+
* @throws {Error} On path violation or oversized data
|
|
203
|
+
*/
|
|
204
|
+
export async function safeFileWrite(filePath, data, options = {}) {
|
|
205
|
+
const maxSize = options.maxSize ?? MAX_FILE_SIZE;
|
|
206
|
+
const encoding = options.encoding ?? 'utf-8';
|
|
207
|
+
|
|
208
|
+
// Check data size before writing
|
|
209
|
+
const dataSize = Buffer.byteLength(data);
|
|
210
|
+
if (dataSize > maxSize) {
|
|
211
|
+
throw new Error(`Data size ${dataSize} exceeds limit of ${maxSize} bytes`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const safePath = await validatePath(filePath);
|
|
215
|
+
await fs.writeFile(safePath, data, { encoding });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Safely create a directory within the sandbox.
|
|
220
|
+
*
|
|
221
|
+
* @param {string} dirPath - Relative path within SANDBOX_ROOT
|
|
222
|
+
* @param {Object} [options] - mkdir options
|
|
223
|
+
* @param {boolean} [options.recursive=true] - Create parent directories
|
|
224
|
+
* @returns {Promise<string|undefined>} The first directory path created, or undefined
|
|
225
|
+
* @throws {Error} On path violation
|
|
226
|
+
*/
|
|
227
|
+
export async function safeMkdir(dirPath, options = {}) {
|
|
228
|
+
const safePath = await validatePath(dirPath);
|
|
229
|
+
return fs.mkdir(safePath, { recursive: options.recursive ?? true });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Safely stat a file within the sandbox.
|
|
234
|
+
*
|
|
235
|
+
* @param {string} filePath - Relative path within SANDBOX_ROOT
|
|
236
|
+
* @returns {Promise<import('fs').Stats>} File stats
|
|
237
|
+
* @throws {Error} On path violation
|
|
238
|
+
*/
|
|
239
|
+
export async function safeStat(filePath) {
|
|
240
|
+
const safePath = await validatePath(filePath);
|
|
241
|
+
return fs.stat(safePath);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Safely list directory contents within the sandbox.
|
|
246
|
+
*
|
|
247
|
+
* @param {string} dirPath - Relative path within SANDBOX_ROOT
|
|
248
|
+
* @returns {Promise<string[]>} Directory entries
|
|
249
|
+
* @throws {Error} On path violation
|
|
250
|
+
*/
|
|
251
|
+
export async function safeReaddir(dirPath) {
|
|
252
|
+
const safePath = await validatePath(dirPath);
|
|
253
|
+
return fs.readdir(safePath);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Safely delete a file within the sandbox.
|
|
258
|
+
*
|
|
259
|
+
* @param {string} filePath - Relative path within SANDBOX_ROOT
|
|
260
|
+
* @returns {Promise<void>}
|
|
261
|
+
* @throws {Error} On path violation
|
|
262
|
+
*/
|
|
263
|
+
export async function safeUnlink(filePath) {
|
|
264
|
+
const safePath = await validatePath(filePath);
|
|
265
|
+
return fs.unlink(safePath);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Ensure the sandbox root directory exists.
|
|
270
|
+
*
|
|
271
|
+
* @returns {Promise<void>}
|
|
272
|
+
*/
|
|
273
|
+
export async function ensureSandboxRoot() {
|
|
274
|
+
await fs.mkdir(SANDBOX_ROOT, { recursive: true });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ============================================================================
|
|
278
|
+
// Code Injection Defense Layer
|
|
279
|
+
// ============================================================================
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Dangerous code patterns that indicate injection attempts.
|
|
283
|
+
* Each entry has a regex pattern and a human-readable description.
|
|
284
|
+
*/
|
|
285
|
+
const DANGEROUS_CODE_PATTERNS = [
|
|
286
|
+
// Function constructor variants
|
|
287
|
+
{ pattern: /\bFunction\s*\(/, desc: 'Function constructor call' },
|
|
288
|
+
{ pattern: /\bnew\s+Function\b/, desc: 'new Function constructor' },
|
|
289
|
+
{ pattern: /\bFunction\s*\[/, desc: 'Function bracket access' },
|
|
290
|
+
{ pattern: /\bFunction\.prototype\b/, desc: 'Function.prototype access' },
|
|
291
|
+
{ pattern: /\bFunction\.constructor\b/, desc: 'Function.constructor access' },
|
|
292
|
+
{ pattern: /=\s*Function\b/, desc: 'Function constructor assignment' },
|
|
293
|
+
|
|
294
|
+
// eval variants
|
|
295
|
+
{ pattern: /\beval\s*\(/, desc: 'eval() call' },
|
|
296
|
+
{ pattern: /\beval\s*\[/, desc: 'eval bracket access' },
|
|
297
|
+
{ pattern: /\(['"]eval['"]\)/, desc: 'indirect eval reference' },
|
|
298
|
+
|
|
299
|
+
// Constructor escape
|
|
300
|
+
{ pattern: /\.constructor\s*\(/, desc: 'constructor invocation' },
|
|
301
|
+
{ pattern: /\.constructor\s*\[/, desc: 'constructor bracket access' },
|
|
302
|
+
{ pattern: /\['constructor'\]/, desc: 'constructor string access' },
|
|
303
|
+
{ pattern: /\["constructor"\]/, desc: 'constructor string access (double quotes)' },
|
|
304
|
+
{ pattern: /\[`constructor`\]/, desc: 'constructor template literal access' },
|
|
305
|
+
|
|
306
|
+
// Prototype pollution
|
|
307
|
+
{ pattern: /__proto__/, desc: '__proto__ access' },
|
|
308
|
+
{ pattern: /\.prototype\s*\[/, desc: 'prototype bracket access' },
|
|
309
|
+
{ pattern: /\.prototype\.constructor/, desc: 'prototype.constructor chain' },
|
|
310
|
+
{ pattern: /Object\s*\.\s*setPrototypeOf/, desc: 'Object.setPrototypeOf' },
|
|
311
|
+
{ pattern: /Object\s*\.\s*getPrototypeOf/, desc: 'Object.getPrototypeOf' },
|
|
312
|
+
{ pattern: /Object\s*\.\s*defineProperty/, desc: 'Object.defineProperty' },
|
|
313
|
+
{ pattern: /Object\s*\.\s*defineProperties/, desc: 'Object.defineProperties' },
|
|
314
|
+
{ pattern: /Reflect\s*\.\s*setPrototypeOf/, desc: 'Reflect.setPrototypeOf' },
|
|
315
|
+
{ pattern: /Reflect\s*\.\s*defineProperty/, desc: 'Reflect.defineProperty' },
|
|
316
|
+
{ pattern: /Reflect\s*\.\s*construct/, desc: 'Reflect.construct' },
|
|
317
|
+
|
|
318
|
+
// require/import injection
|
|
319
|
+
{ pattern: /\brequire\s*\(/, desc: 'require() call' },
|
|
320
|
+
{ pattern: /\bimport\s*\(/, desc: 'dynamic import()' },
|
|
321
|
+
{ pattern: /\bimport\.meta\b/, desc: 'import.meta access' },
|
|
322
|
+
|
|
323
|
+
// Process/global access
|
|
324
|
+
{ pattern: /\bprocess\s*\./, desc: 'process object access' },
|
|
325
|
+
{ pattern: /\bprocess\s*\[/, desc: 'process bracket access' },
|
|
326
|
+
{ pattern: /\bglobalThis\b/, desc: 'globalThis access' },
|
|
327
|
+
|
|
328
|
+
// Dangerous object access
|
|
329
|
+
{ pattern: /\bnew\s+Proxy\b/, desc: 'new Proxy constructor' },
|
|
330
|
+
{ pattern: /\bWeakRef\s*\(/, desc: 'WeakRef constructor' },
|
|
331
|
+
{ pattern: /\bFinalizationRegistry\b/, desc: 'FinalizationRegistry access' },
|
|
332
|
+
|
|
333
|
+
// WebAssembly
|
|
334
|
+
{ pattern: /\bWebAssembly\b/, desc: 'WebAssembly access' },
|
|
335
|
+
|
|
336
|
+
// SharedArrayBuffer (Spectre-class attacks)
|
|
337
|
+
{ pattern: /\bSharedArrayBuffer\b/, desc: 'SharedArrayBuffer access' },
|
|
338
|
+
{ pattern: /\bAtomics\b/, desc: 'Atomics access' },
|
|
339
|
+
|
|
340
|
+
// Indirect eval via various global accessors
|
|
341
|
+
{ pattern: /\(0,\s*eval\)/, desc: 'indirect eval via comma operator' },
|
|
342
|
+
{ pattern: /\bthis\s*\.\s*constructor/, desc: 'this.constructor access' },
|
|
343
|
+
{ pattern: /\bthis\s*\[\s*['"`]constructor/, desc: 'this["constructor"] access' },
|
|
344
|
+
|
|
345
|
+
// String-based code execution
|
|
346
|
+
{ pattern: /\bsetTimeout\s*\(\s*['"`]/, desc: 'setTimeout with string code' },
|
|
347
|
+
{ pattern: /\bsetInterval\s*\(\s*['"`]/, desc: 'setInterval with string code' },
|
|
348
|
+
{ pattern: /\bsetTimeout\b/, desc: 'setTimeout access' },
|
|
349
|
+
{ pattern: /\bsetInterval\b/, desc: 'setInterval access' },
|
|
350
|
+
{ pattern: /\bsetImmediate\b/, desc: 'setImmediate access' },
|
|
351
|
+
|
|
352
|
+
// Symbol manipulation for sandbox escape
|
|
353
|
+
{ pattern: /Symbol\s*\.\s*unscopables/, desc: 'Symbol.unscopables manipulation' },
|
|
354
|
+
{ pattern: /Symbol\s*\.\s*hasInstance/, desc: 'Symbol.hasInstance manipulation' },
|
|
355
|
+
{ pattern: /Symbol\s*\.\s*toPrimitive/, desc: 'Symbol.toPrimitive manipulation' },
|
|
356
|
+
];
|
|
357
|
+
|
|
358
|
+
/** @type {number} Maximum allowed code length */
|
|
359
|
+
const MAX_CODE_LENGTH = 100000;
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Validate code string against dangerous injection patterns
|
|
363
|
+
* @param {string} codeString - Code to validate
|
|
364
|
+
* @returns {{ valid: boolean, violations: string[] }} Validation result
|
|
365
|
+
*/
|
|
366
|
+
export function validateCodeInjection(codeString) {
|
|
367
|
+
if (typeof codeString !== 'string') {
|
|
368
|
+
return { valid: false, violations: ['Code must be a string'] };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (codeString.length > MAX_CODE_LENGTH) {
|
|
372
|
+
return { valid: false, violations: [`Code exceeds maximum length of ${MAX_CODE_LENGTH}`] };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const violations = [];
|
|
376
|
+
|
|
377
|
+
for (const { pattern, desc } of DANGEROUS_CODE_PATTERNS) {
|
|
378
|
+
if (pattern.test(codeString)) {
|
|
379
|
+
violations.push(`Blocked: ${desc}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
valid: violations.length === 0,
|
|
385
|
+
violations,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Validate a function by converting to string and checking patterns
|
|
391
|
+
* @param {Function} fn - Function to validate
|
|
392
|
+
* @returns {{ valid: boolean, violations: string[] }} Validation result
|
|
393
|
+
*/
|
|
394
|
+
export function validateFunction(fn) {
|
|
395
|
+
if (typeof fn !== 'function') {
|
|
396
|
+
return { valid: false, violations: ['Input must be a function'] };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return validateCodeInjection(fn.toString());
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Create a hardened execution context with all dangerous
|
|
404
|
+
* constructors and functions neutralized
|
|
405
|
+
* @returns {Object} Hardened context (frozen)
|
|
406
|
+
*/
|
|
407
|
+
export function createHardenedContext() {
|
|
408
|
+
const context = Object.create(null);
|
|
409
|
+
|
|
410
|
+
// Safe globals
|
|
411
|
+
context.Math = Math;
|
|
412
|
+
context.JSON = JSON;
|
|
413
|
+
context.Array = Array;
|
|
414
|
+
context.String = String;
|
|
415
|
+
context.Object = Object;
|
|
416
|
+
context.Number = Number;
|
|
417
|
+
context.Boolean = Boolean;
|
|
418
|
+
context.Date = Date;
|
|
419
|
+
context.RegExp = RegExp;
|
|
420
|
+
context.Map = Map;
|
|
421
|
+
context.Set = Set;
|
|
422
|
+
context.parseInt = parseInt;
|
|
423
|
+
context.parseFloat = parseFloat;
|
|
424
|
+
context.isNaN = isNaN;
|
|
425
|
+
context.isFinite = isFinite;
|
|
426
|
+
context.encodeURI = encodeURI;
|
|
427
|
+
context.decodeURI = decodeURI;
|
|
428
|
+
context.encodeURIComponent = encodeURIComponent;
|
|
429
|
+
context.decodeURIComponent = decodeURIComponent;
|
|
430
|
+
|
|
431
|
+
// Disable dangerous constructors
|
|
432
|
+
context.Function = undefined;
|
|
433
|
+
context.eval = undefined;
|
|
434
|
+
context.constructor = undefined;
|
|
435
|
+
|
|
436
|
+
// Disable process/global/require
|
|
437
|
+
context.process = undefined;
|
|
438
|
+
context.global = undefined;
|
|
439
|
+
context.globalThis = undefined;
|
|
440
|
+
context.require = undefined;
|
|
441
|
+
|
|
442
|
+
// Disable timers (DoS prevention)
|
|
443
|
+
context.setTimeout = undefined;
|
|
444
|
+
context.setInterval = undefined;
|
|
445
|
+
context.setImmediate = undefined;
|
|
446
|
+
context.clearTimeout = undefined;
|
|
447
|
+
context.clearInterval = undefined;
|
|
448
|
+
context.clearImmediate = undefined;
|
|
449
|
+
|
|
450
|
+
// Disable dangerous APIs
|
|
451
|
+
context.Proxy = undefined;
|
|
452
|
+
context.Reflect = undefined;
|
|
453
|
+
context.WebAssembly = undefined;
|
|
454
|
+
context.SharedArrayBuffer = undefined;
|
|
455
|
+
context.Atomics = undefined;
|
|
456
|
+
context.WeakRef = undefined;
|
|
457
|
+
context.FinalizationRegistry = undefined;
|
|
458
|
+
context.Buffer = undefined;
|
|
459
|
+
|
|
460
|
+
// Safe console (write-only logging)
|
|
461
|
+
context.console = Object.freeze({
|
|
462
|
+
log: (...args) => console.log('[Sandbox]', ...args),
|
|
463
|
+
warn: (...args) => console.warn('[Sandbox]', ...args),
|
|
464
|
+
error: (...args) => console.error('[Sandbox]', ...args),
|
|
465
|
+
info: (...args) => console.info('[Sandbox]', ...args),
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
return Object.freeze(context);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Create a SafeFunction proxy that rejects all construction
|
|
473
|
+
* and execution attempts. Used as a drop-in replacement for
|
|
474
|
+
* the Function constructor in sandboxed contexts.
|
|
475
|
+
* @returns {Proxy} SafeFunction proxy
|
|
476
|
+
*/
|
|
477
|
+
export function createSafeFunctionProxy() {
|
|
478
|
+
const handler = {
|
|
479
|
+
construct() {
|
|
480
|
+
throw new Error(
|
|
481
|
+
'SecurityError: Function constructor is disabled in sandbox'
|
|
482
|
+
);
|
|
483
|
+
},
|
|
484
|
+
apply() {
|
|
485
|
+
throw new Error(
|
|
486
|
+
'SecurityError: Function execution is disabled in sandbox'
|
|
487
|
+
);
|
|
488
|
+
},
|
|
489
|
+
get(target, prop) {
|
|
490
|
+
if (prop === 'constructor' || prop === 'prototype' || prop === '__proto__') {
|
|
491
|
+
return undefined;
|
|
492
|
+
}
|
|
493
|
+
if (prop === Symbol.hasInstance) {
|
|
494
|
+
return () => false;
|
|
495
|
+
}
|
|
496
|
+
return undefined;
|
|
497
|
+
},
|
|
498
|
+
set() {
|
|
499
|
+
throw new Error(
|
|
500
|
+
'SecurityError: Cannot modify SafeFunction proxy'
|
|
501
|
+
);
|
|
502
|
+
},
|
|
503
|
+
defineProperty() {
|
|
504
|
+
throw new Error(
|
|
505
|
+
'SecurityError: Cannot define properties on SafeFunction proxy'
|
|
506
|
+
);
|
|
507
|
+
},
|
|
508
|
+
deleteProperty() {
|
|
509
|
+
throw new Error(
|
|
510
|
+
'SecurityError: Cannot delete properties on SafeFunction proxy'
|
|
511
|
+
);
|
|
512
|
+
},
|
|
513
|
+
getPrototypeOf() {
|
|
514
|
+
return null;
|
|
515
|
+
},
|
|
516
|
+
setPrototypeOf() {
|
|
517
|
+
throw new Error(
|
|
518
|
+
'SecurityError: Cannot set prototype on SafeFunction proxy'
|
|
519
|
+
);
|
|
520
|
+
},
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
// Use arrow function (no .prototype property) with null prototype
|
|
524
|
+
// to satisfy Proxy invariants for non-configurable properties
|
|
525
|
+
const target = () => {
|
|
526
|
+
throw new Error('SecurityError: SafeFunction cannot be called');
|
|
527
|
+
};
|
|
528
|
+
Object.setPrototypeOf(target, null);
|
|
529
|
+
Object.freeze(target);
|
|
530
|
+
|
|
531
|
+
return new Proxy(target, handler);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Log an injection attempt with full context
|
|
536
|
+
* @param {string} code - The code that was blocked (truncated)
|
|
537
|
+
* @param {string[]} violations - List of violations detected
|
|
538
|
+
* @param {object[]} log - Array to append the attempt to
|
|
539
|
+
*/
|
|
540
|
+
export function logInjectionAttempt(code, violations, log = []) {
|
|
541
|
+
const truncated = typeof code === 'string' ? code.substring(0, 500) : '';
|
|
542
|
+
let hash = 0;
|
|
543
|
+
for (let i = 0; i < truncated.length; i++) {
|
|
544
|
+
hash = ((hash << 5) - hash + truncated.charCodeAt(i)) | 0;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const attempt = {
|
|
548
|
+
code: truncated,
|
|
549
|
+
violations,
|
|
550
|
+
timestamp: new Date(),
|
|
551
|
+
codeHash: hash.toString(16),
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
log.push(attempt);
|
|
555
|
+
|
|
556
|
+
console.warn(
|
|
557
|
+
`[EffectExecutor] INJECTION ATTEMPT BLOCKED:\n` +
|
|
558
|
+
` Violations: ${violations.join(', ')}\n` +
|
|
559
|
+
` Code hash: ${attempt.codeHash}\n` +
|
|
560
|
+
` Timestamp: ${attempt.timestamp.toISOString()}`
|
|
561
|
+
);
|
|
562
|
+
|
|
563
|
+
return attempt;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Execute a function with full code injection validation and
|
|
568
|
+
* hardened context. Combines validation + context creation + execution.
|
|
569
|
+
* @param {Function} effect - Effect function to execute
|
|
570
|
+
* @param {Object} [context={}] - Execution context (event, store, delta)
|
|
571
|
+
* @param {Object} [options={}] - Options
|
|
572
|
+
* @param {number} [options.timeout=30000] - Execution timeout in ms
|
|
573
|
+
* @param {boolean} [options.logAttempts=true] - Whether to log blocked attempts
|
|
574
|
+
* @returns {Promise<Object>} Execution result
|
|
575
|
+
*/
|
|
576
|
+
export async function executeHardened(effect, context = {}, options = {}) {
|
|
577
|
+
const timeout = options.timeout ?? 30000;
|
|
578
|
+
const startTime = Date.now();
|
|
579
|
+
const injectionLog = options._injectionLog ?? [];
|
|
580
|
+
|
|
581
|
+
// Step 1: Validate the function code
|
|
582
|
+
const validation = validateFunction(effect);
|
|
583
|
+
|
|
584
|
+
if (!validation.valid) {
|
|
585
|
+
if (options.logAttempts !== false) {
|
|
586
|
+
logInjectionAttempt(effect.toString(), validation.violations, injectionLog);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return {
|
|
590
|
+
success: false,
|
|
591
|
+
error: `SecurityError: Code injection detected - ${validation.violations.join('; ')}`,
|
|
592
|
+
violations: validation.violations,
|
|
593
|
+
duration: Date.now() - startTime,
|
|
594
|
+
blocked: true,
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Step 2: Create hardened context
|
|
599
|
+
const hardenedContext = createHardenedContext();
|
|
600
|
+
|
|
601
|
+
// Step 3: Execute with timeout
|
|
602
|
+
try {
|
|
603
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
604
|
+
const timer = setTimeout(() => {
|
|
605
|
+
reject(new Error(`Execution timeout after ${timeout}ms`));
|
|
606
|
+
}, timeout);
|
|
607
|
+
if (timer.unref) timer.unref();
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
const executionPromise = Promise.resolve().then(() => {
|
|
611
|
+
return effect.call(hardenedContext, context);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
const result = await Promise.race([executionPromise, timeoutPromise]);
|
|
615
|
+
|
|
616
|
+
return {
|
|
617
|
+
success: true,
|
|
618
|
+
result,
|
|
619
|
+
duration: Date.now() - startTime,
|
|
620
|
+
blocked: false,
|
|
621
|
+
};
|
|
622
|
+
} catch (error) {
|
|
623
|
+
return {
|
|
624
|
+
success: false,
|
|
625
|
+
error: error.message,
|
|
626
|
+
duration: Date.now() - startTime,
|
|
627
|
+
blocked: false,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
}
|