isolated-function 0.1.36 → 0.1.38
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.md +56 -0
- package/package.json +8 -3
- package/src/compile/build.js +1 -1
- package/src/compile/index.js +13 -11
- package/src/compile/install-dependencies.js +0 -3
- package/src/compile/transform-dependencies.js +18 -0
- package/src/index.d.ts +113 -0
- package/src/index.js +2 -2
- package/src/template/index.js +15 -18
package/README.md
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
- [Install](#install)
|
|
20
20
|
- [Quickstart](#quickstart)
|
|
21
21
|
- [Minimal privilege execution](#minimal-privilege-execution)
|
|
22
|
+
- [Granting specific permissions](#granting-specific-permissions)
|
|
22
23
|
- [Auto install dependencies](#auto-install-dependencies)
|
|
23
24
|
- [Execution profiling](#execution-profiling)
|
|
24
25
|
- [Resource limits](#resource-limits)
|
|
@@ -32,6 +33,7 @@
|
|
|
32
33
|
- [throwError](#throwerror)
|
|
33
34
|
- [timeout](#timeout)
|
|
34
35
|
- [tmpdir](#tmpdir)
|
|
36
|
+
- [allow](#allow)
|
|
35
37
|
- [=\> (fn(\[...args\]), teardown())](#-fnargs-teardown)
|
|
36
38
|
- [fn](#fn)
|
|
37
39
|
- [teardown](#teardown)
|
|
@@ -89,6 +91,28 @@ If you exceed your limit, an error will occur. Any of the following interaction
|
|
|
89
91
|
- File system access
|
|
90
92
|
- WASI
|
|
91
93
|
|
|
94
|
+
### Granting specific permissions
|
|
95
|
+
|
|
96
|
+
You can grant specific permissions to the isolated function using the `allow` option:
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
const [fn, teardown] = isolatedFunction(
|
|
100
|
+
() => {
|
|
101
|
+
const { execSync } = require('child_process')
|
|
102
|
+
return execSync('echo hello').toString().trim()
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
allow: ['child-process']
|
|
106
|
+
}
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
const { value } = await fn()
|
|
110
|
+
console.log(value) // 'hello'
|
|
111
|
+
await teardown()
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
See [#allow](#allow) to know more.
|
|
115
|
+
|
|
92
116
|
### Auto install dependencies
|
|
93
117
|
|
|
94
118
|
The hosted code is parsed for detecting `require`/`import` calls and install these dependencies:
|
|
@@ -279,6 +303,38 @@ const tmpdir = async () => {
|
|
|
279
303
|
}
|
|
280
304
|
```
|
|
281
305
|
|
|
306
|
+
##### allow
|
|
307
|
+
|
|
308
|
+
Type: `string[]`<br>
|
|
309
|
+
Default: `[]`
|
|
310
|
+
|
|
311
|
+
An array of permissions to grant to the isolated function based on [Node.js Options](https://nodejs.org/api/cli.html#options)
|
|
312
|
+
|
|
313
|
+
When empty, the function runs with minimal privileges and will throw an error if it attempts to access restricted resources. Available permissions are:
|
|
314
|
+
|
|
315
|
+
- `addons`
|
|
316
|
+
- `child-process`
|
|
317
|
+
- `fs-read`
|
|
318
|
+
- `fs-write`
|
|
319
|
+
- `inspector`
|
|
320
|
+
- `net`
|
|
321
|
+
- `wasi`
|
|
322
|
+
- `worker`
|
|
323
|
+
|
|
324
|
+
Example:
|
|
325
|
+
|
|
326
|
+
```js
|
|
327
|
+
const [fn, cleanup] = isolatedFunction(
|
|
328
|
+
async () => {
|
|
329
|
+
const http = require('node:http')
|
|
330
|
+
// Network request code here
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
allow: ['net']
|
|
334
|
+
}
|
|
335
|
+
)
|
|
336
|
+
```
|
|
337
|
+
|
|
282
338
|
### => (fn([...args]), teardown())
|
|
283
339
|
|
|
284
340
|
#### fn
|
package/package.json
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
"name": "isolated-function",
|
|
3
3
|
"description": "Runs untrusted code in a Node.js v8 sandbox.",
|
|
4
4
|
"homepage": "https://github.com/Kikobeats/isolated-function",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.38",
|
|
6
|
+
"types": "src/index.d.ts",
|
|
6
7
|
"main": "src/index.js",
|
|
7
8
|
"exports": {
|
|
8
9
|
".": "./src/index.js"
|
|
@@ -55,7 +56,8 @@
|
|
|
55
56
|
"nano-staged": "latest",
|
|
56
57
|
"simple-git-hooks": "latest",
|
|
57
58
|
"standard": "latest",
|
|
58
|
-
"standard-version": "latest"
|
|
59
|
+
"standard-version": "latest",
|
|
60
|
+
"tsd": "latest"
|
|
59
61
|
},
|
|
60
62
|
"engines": {
|
|
61
63
|
"node": ">= 20"
|
|
@@ -67,7 +69,7 @@
|
|
|
67
69
|
"clean": "rm -rf node_modules",
|
|
68
70
|
"contributors": "(npx git-authors-cli && npx finepack && git add package.json && git commit -m 'build: contributors' --no-verify) || true",
|
|
69
71
|
"coverage": "c8 report --reporter=text-lcov > coverage/lcov.info",
|
|
70
|
-
"lint": "standard",
|
|
72
|
+
"lint": "standard && tsd",
|
|
71
73
|
"postrelease": "npm run release:tags && npm run release:github && (ci-publish || npm publish --access=public)",
|
|
72
74
|
"pretest": "npm run lint",
|
|
73
75
|
"release": "standard-version -a",
|
|
@@ -101,5 +103,8 @@
|
|
|
101
103
|
"simple-git-hooks": {
|
|
102
104
|
"commit-msg": "npx commitlint --edit",
|
|
103
105
|
"pre-commit": "npx nano-staged"
|
|
106
|
+
},
|
|
107
|
+
"tsd": {
|
|
108
|
+
"directory": "test"
|
|
104
109
|
}
|
|
105
110
|
}
|
package/src/compile/build.js
CHANGED
package/src/compile/index.js
CHANGED
|
@@ -4,35 +4,37 @@ const fs = require('fs/promises')
|
|
|
4
4
|
const path = require('path')
|
|
5
5
|
|
|
6
6
|
const transformDependencies = require('./transform-dependencies')
|
|
7
|
-
const detectDependencies = require('./detect-dependencies')
|
|
8
7
|
const installDependencies = require('./install-dependencies')
|
|
9
|
-
const
|
|
10
|
-
const { duration } = require('../debug')
|
|
8
|
+
const detectDependencies = require('./detect-dependencies')
|
|
9
|
+
const { debug, duration } = require('../debug')
|
|
10
|
+
const template = require('../template')
|
|
11
11
|
const build = require('./build')
|
|
12
12
|
|
|
13
13
|
const tmpdirDefault = async () => {
|
|
14
|
+
const duration = debug.duration()
|
|
14
15
|
const cwd = await fs.mkdtemp(path.join(require('os').tmpdir(), 'compile-'))
|
|
15
16
|
await fs.mkdir(cwd, { recursive: true })
|
|
16
17
|
const cleanup = () => fs.rm(cwd, { recursive: true, force: true })
|
|
18
|
+
duration('tmpdir', { cwd })
|
|
17
19
|
return { cwd, cleanup }
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
module.exports = async (snippet, tmpdir = tmpdirDefault) => {
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
let content = transformDependencies(compiledTemplate)
|
|
24
|
-
let cleanupPromise
|
|
23
|
+
let content = template(snippet)
|
|
24
|
+
const { cwd, cleanup } = await tmpdir()
|
|
25
25
|
|
|
26
|
+
const dependencies = detectDependencies(content)
|
|
26
27
|
if (dependencies.length) {
|
|
27
|
-
|
|
28
|
+
content = transformDependencies(content)
|
|
28
29
|
await duration('npm:install', () => installDependencies({ dependencies, cwd }), {
|
|
29
30
|
dependencies
|
|
30
31
|
})
|
|
31
|
-
const result = await duration('esbuild', () => build({ content, cwd }))
|
|
32
|
-
content = result.outputFiles[0].text
|
|
33
|
-
cleanupPromise = duration('tmpDir:cleanup', cleanup)
|
|
34
32
|
}
|
|
35
33
|
|
|
34
|
+
const result = await duration('esbuild', () => build({ content, cwd }))
|
|
35
|
+
content = result.outputFiles[0].text
|
|
36
|
+
const cleanupPromise = duration('tmpDir:cleanup', cleanup)
|
|
37
|
+
|
|
36
38
|
return { content, cleanupPromise }
|
|
37
39
|
}
|
|
38
40
|
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const { execSync } = require('child_process')
|
|
4
|
-
const { writeFile } = require('fs/promises')
|
|
5
4
|
const $ = require('tinyspawn')
|
|
6
|
-
const path = require('path')
|
|
7
5
|
|
|
8
6
|
const install = (() => {
|
|
9
7
|
try {
|
|
@@ -17,6 +15,5 @@ const install = (() => {
|
|
|
17
15
|
})()
|
|
18
16
|
|
|
19
17
|
module.exports = async ({ dependencies, cwd }) => {
|
|
20
|
-
await writeFile(path.join(cwd, 'package.json'), '{}')
|
|
21
18
|
return $(`${install} ${dependencies.join(' ')}`, { cwd })
|
|
22
19
|
}
|
|
@@ -3,6 +3,24 @@
|
|
|
3
3
|
const walk = require('acorn-walk')
|
|
4
4
|
const acorn = require('acorn')
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Transforms dependency module names by removing version specifiers.
|
|
8
|
+
*
|
|
9
|
+
* Parses JavaScript code and walks through the AST to find all require() calls
|
|
10
|
+
* and import declarations. Extracts module names from strings that include
|
|
11
|
+
* version information (e.g., 'is-emoji@1.0.0' becomes 'is-emoji').
|
|
12
|
+
*
|
|
13
|
+
* Handles both:
|
|
14
|
+
* - Scoped packages: '@scope/package@1.0.0' → '@scope/package'
|
|
15
|
+
* - Regular packages: 'package@1.0.0' → 'package'
|
|
16
|
+
*
|
|
17
|
+
* This transformation is necessary because the dependency strings include
|
|
18
|
+
* version specifiers for installation tracking, but require/import statements
|
|
19
|
+
* should only reference the base module name.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} code - JavaScript code containing require() or import statements
|
|
22
|
+
* @returns {string} Transformed code with version specifiers removed from dependencies
|
|
23
|
+
*/
|
|
6
24
|
module.exports = code => {
|
|
7
25
|
const ast = acorn.parse(code, { ecmaVersion: 2023, sourceType: 'module' })
|
|
8
26
|
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profiling information about the isolated function execution
|
|
3
|
+
*/
|
|
4
|
+
export interface Profiling {
|
|
5
|
+
/** Memory usage in bytes */
|
|
6
|
+
memory: number
|
|
7
|
+
/** Execution duration in milliseconds */
|
|
8
|
+
duration: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Logging information captured from the isolated function
|
|
13
|
+
*/
|
|
14
|
+
export interface Logging {
|
|
15
|
+
log?: unknown[][]
|
|
16
|
+
info?: unknown[][]
|
|
17
|
+
debug?: unknown[][]
|
|
18
|
+
warn?: unknown[][]
|
|
19
|
+
error?: unknown[][]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Successful execution result
|
|
24
|
+
*/
|
|
25
|
+
export interface SuccessResult<T = unknown> {
|
|
26
|
+
isFulfilled: true
|
|
27
|
+
value: T
|
|
28
|
+
profiling: Profiling
|
|
29
|
+
logging: Logging
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Failed execution result
|
|
34
|
+
*/
|
|
35
|
+
export interface FailureResult {
|
|
36
|
+
isFulfilled: false
|
|
37
|
+
value: Error
|
|
38
|
+
profiling: Profiling
|
|
39
|
+
logging: Logging
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Execution result (success or failure)
|
|
44
|
+
*/
|
|
45
|
+
export type ExecutionResult<T = unknown> = SuccessResult<T> | FailureResult
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Options for creating an isolated function
|
|
49
|
+
*/
|
|
50
|
+
export interface IsolatedFunctionOptions {
|
|
51
|
+
/** Temporary directory configuration */
|
|
52
|
+
tmpdir?: () => Promise<{ cwd: string; cleanup: () => Promise<void> }>
|
|
53
|
+
/** Execution timeout in milliseconds */
|
|
54
|
+
timeout?: number
|
|
55
|
+
/** Memory limit in megabytes */
|
|
56
|
+
memory?: number
|
|
57
|
+
/** When false, errors are returned instead of thrown */
|
|
58
|
+
throwError?: boolean
|
|
59
|
+
/** Array of resources to allow access to */
|
|
60
|
+
allow?: string[]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Cleanup function to release resources
|
|
65
|
+
*/
|
|
66
|
+
export type Cleanup = () => Promise<void>
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Isolated function that can be executed with arguments
|
|
70
|
+
*/
|
|
71
|
+
export type IsolatedFn<T = unknown> = (
|
|
72
|
+
...args: unknown[]
|
|
73
|
+
) => Promise<SuccessResult<T> | FailureResult>
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Creates an isolated function that runs untrusted code in a separate Node.js process.
|
|
77
|
+
*
|
|
78
|
+
* @param snippet - The function or code string to run in isolation
|
|
79
|
+
* @param options - Configuration options for the isolated function
|
|
80
|
+
* @returns A tuple containing the isolated function and a cleanup function
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* ```js
|
|
84
|
+
* const [sum, cleanup] = isolatedFunction((a, b) => a + b, {
|
|
85
|
+
* memory: 128,
|
|
86
|
+
* timeout: 10000
|
|
87
|
+
* })
|
|
88
|
+
*
|
|
89
|
+
* const { value } = await sum(3, 2)
|
|
90
|
+
* console.log(value) // 5
|
|
91
|
+
* await cleanup()
|
|
92
|
+
* ```
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* ```js
|
|
96
|
+
* // With error handling
|
|
97
|
+
* const [fn, cleanup] = isolatedFunction(() => {
|
|
98
|
+
* throw new Error('oh no!')
|
|
99
|
+
* }, { throwError: false })
|
|
100
|
+
*
|
|
101
|
+
* const result = await fn()
|
|
102
|
+
* if (!result.isFulfilled) {
|
|
103
|
+
* console.error(result.value)
|
|
104
|
+
* }
|
|
105
|
+
* await cleanup()
|
|
106
|
+
* ```
|
|
107
|
+
*/
|
|
108
|
+
declare function isolatedFunction<T = unknown>(
|
|
109
|
+
snippet: Function | string,
|
|
110
|
+
options?: IsolatedFunctionOptions
|
|
111
|
+
): [IsolatedFn<T>, Cleanup]
|
|
112
|
+
|
|
113
|
+
export default isolatedFunction
|
package/src/index.js
CHANGED
|
@@ -49,8 +49,8 @@ module.exports = (snippet, { tmpdir, timeout, memory, throwError = true, allow =
|
|
|
49
49
|
const { isFulfilled, value, profiling, logging } = JSON.parse(stdout)
|
|
50
50
|
profiling.duration = duration()
|
|
51
51
|
debug('node', {
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
memory: `${Math.round(profiling.memory / (1024 * 1024))}MiB`,
|
|
53
|
+
duration: `${Math.round(profiling.duration / 100)}s`
|
|
54
54
|
})
|
|
55
55
|
|
|
56
56
|
return isFulfilled
|
package/src/template/index.js
CHANGED
|
@@ -2,23 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
const SERIALIZE_ERROR = require('./serialize-error')
|
|
4
4
|
|
|
5
|
-
module.exports = snippet =>
|
|
6
|
-
|
|
5
|
+
module.exports = snippet => `;(send => {
|
|
6
|
+
process.stdout.write = function () {}
|
|
7
|
+
const respond = (isFulfilled, value, logs = {}) => send(JSON.stringify({isFulfilled, logging: logs, value, profiling: {memory: process.memoryUsage().rss}}))
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
return Promise.resolve().then(async () => {
|
|
10
|
+
const args = JSON.parse(process.argv[2])
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
/* https://github.com/Kikobeats/null-prototype-object */
|
|
13
|
+
const logging = new (/* @__PURE__ */ (() => { let e = function(){}; return e.prototype = Object.create(null), Object.freeze(e.prototype), e })());
|
|
14
|
+
for (const method of ['log', 'info', 'debug', 'warn', 'error']) {
|
|
15
|
+
console[method] = function (...args) {
|
|
16
|
+
logging[method] === undefined ? logging[method] = [args] : logging[method].push(args)
|
|
17
|
+
}
|
|
14
18
|
}
|
|
15
|
-
}
|
|
16
19
|
|
|
17
|
-
;(async (send) => {
|
|
18
|
-
process.stdout.write = function () {}
|
|
19
20
|
let value
|
|
20
21
|
let isFulfilled
|
|
21
|
-
|
|
22
22
|
try {
|
|
23
23
|
value = await (${snippet.toString()})(...args)
|
|
24
24
|
isFulfilled = true
|
|
@@ -26,11 +26,8 @@ module.exports = snippet => `
|
|
|
26
26
|
value = ${SERIALIZE_ERROR}(error)
|
|
27
27
|
isFulfilled = false
|
|
28
28
|
} finally {
|
|
29
|
-
|
|
30
|
-
isFulfilled,
|
|
31
|
-
logging,
|
|
32
|
-
value,
|
|
33
|
-
profiling: { memory: process.memoryUsage().rss }
|
|
34
|
-
}))
|
|
29
|
+
respond(isFulfilled, value, logging)
|
|
35
30
|
}
|
|
36
|
-
})
|
|
31
|
+
})
|
|
32
|
+
.catch(e => respond(false, ${SERIALIZE_ERROR}(e)))
|
|
33
|
+
})(process.stdout.write.bind(process.stdout))`
|