diff --git a/library/agent/protect.ts b/library/agent/protect.ts index f80541e00..209650b5c 100644 --- a/library/agent/protect.ts +++ b/library/agent/protect.ts @@ -62,6 +62,7 @@ import { FunctionSink } from "../sinks/FunctionSink"; import type { FetchListsAPI } from "./api/FetchListsAPI"; import { FetchListsAPINodeHTTP } from "./api/FetchListsAPINodeHTTP"; import shouldEnableFirewall from "../helpers/shouldEnableFirewall"; +import { NodeVm } from "../sinks/NodeVm"; function getLogger(): Logger { if (isDebugging()) { @@ -178,6 +179,7 @@ export function getWrappers() { new AwsSDKVersion2(), new AiSDK(), new GoogleGenAi(), + new NodeVm(), ]; } diff --git a/library/sinks/NodeVm.test.ts b/library/sinks/NodeVm.test.ts new file mode 100644 index 000000000..e55541c96 --- /dev/null +++ b/library/sinks/NodeVm.test.ts @@ -0,0 +1,92 @@ +import * as t from "tap"; +import { Context, runWithContext } from "../agent/Context"; +import { createTestAgent } from "../helpers/createTestAgent"; +import { NodeVm } from "./NodeVm"; + +const unsafeContext = { + remoteAddress: "::1", + method: "POST", + url: "http://localhost:4000", + query: {}, + headers: {}, + body: { + code: "'); require('child_process').execSync('id'); //", + }, + cookies: {}, + routeParams: {}, + source: "express", + route: "/posts/:id", +} satisfies Context; + +const safeContext = { + ...unsafeContext, + body: { code: "Hello, world!" }, +} satisfies Context; + +t.test("it works", async (t) => { + const agent = createTestAgent(); + + agent.start([new NodeVm()]); + + const vm = require("vm"); + + { + // @ts-expect-error Not typed + globalThis.__test = "test"; + const script = new vm.Script("globalThis.__test = 'modified';"); + script.runInThisContext(); + // @ts-expect-error Not typed + t.equal(globalThis.__test, "modified"); + // @ts-expect-error Not typed + delete globalThis.__test; + } + + { + const contextObject = { globalVar: 1 }; + vm.createContext(contextObject); + vm.runInContext("globalVar *= 2;", contextObject); + t.same(contextObject.globalVar, 2); + } + + runWithContext(safeContext, () => { + t.doesNotThrow(() => { + new vm.Script(`console.log('${safeContext.body.code}');`); + }); + t.doesNotThrow(() => { + vm.runInThisContext(`console.log('${safeContext.body.code}');`); + }); + + const contextObject = { globalVar: 1 }; + vm.createContext(contextObject); + vm.runInContext("globalVar *= 2;", contextObject); + t.same(contextObject.globalVar, 2); + + // Call with wrong arguments + t.throws(() => { + vm.createScript({}); + }, /Unexpected identifier/); + }); + + runWithContext(unsafeContext, () => { + t.throws(() => { + new vm.Script(`console.log('${unsafeContext.body.code}');`); + }, /Zen has blocked a JavaScript injection: new Script/); + + const functionsToTest = [ + "createScript", + "runInThisContext", + "runInNewContext", + "runInContext", + "compileFunction", + ]; + + for (const funcName of functionsToTest) { + t.throws( + () => { + vm[funcName](`console.log('${unsafeContext.body.code}');`); + }, + new RegExp(`Zen has blocked a JavaScript injection: ${funcName}`) + ); + } + }); +}); diff --git a/library/sinks/NodeVm.ts b/library/sinks/NodeVm.ts new file mode 100644 index 000000000..0090ff3d6 --- /dev/null +++ b/library/sinks/NodeVm.ts @@ -0,0 +1,78 @@ +import { getContext } from "../agent/Context"; +import { Hooks } from "../agent/hooks/Hooks"; +import { inspectArgs, wrapExport } from "../agent/hooks/wrapExport"; +import { Wrapper } from "../agent/Wrapper"; +import { checkContextForJsInjection } from "../vulnerabilities/js-injection/checkContextForJsInjection"; +import { getInstance } from "../agent/AgentSingleton"; + +export class NodeVm implements Wrapper { + private inspectCode(args: unknown[], operation: string) { + const context = getContext(); + if (!context) { + return undefined; + } + + if (args.length === 0 || typeof args[0] !== "string") { + return undefined; + } + + const code = args[0]; + + return checkContextForJsInjection({ + js: code, + operation, + context, + }); + } + + private onConstruct(target: any, args: unknown[]) { + const agent = getInstance(); + const context = getContext(); + + if (!agent || !context) { + return new target(...args); + } + + inspectArgs( + args, + () => this.inspectCode(args, "new Script(...)"), + context, + agent, + { + name: "vm", + type: "builtin", + }, + "new Script(...)", + "eval_op" + ); + + return new target(...args); + } + + wrap(hooks: Hooks): void { + hooks.addBuiltinModule("vm").onRequire((exports, pkgInfo) => { + // We can't use our helper wrapNewInstance because it can not inspect constructor args + exports.Script = new Proxy(exports.Script, { + construct: (target, args) => this.onConstruct(target, args), + }); + + const functionsToWrap = [ + "createScript", + "runInThisContext", + "runInNewContext", + "runInContext", + "compileFunction", + ]; + + for (const functionName of functionsToWrap) { + if (typeof exports[functionName] === "function") { + wrapExport(exports, functionName, pkgInfo, { + kind: "eval_op", + inspectArgs: (args) => + this.inspectCode(args, `${functionName}(...)`), + }); + } + } + }); + } +} diff --git a/scripts/helpers/test-helpers.mjs b/scripts/helpers/test-helpers.mjs index e74df83ef..7d5e0a4c3 100644 --- a/scripts/helpers/test-helpers.mjs +++ b/scripts/helpers/test-helpers.mjs @@ -20,6 +20,12 @@ export function throws(...args) { ); return err; } + + if (args[1] instanceof RegExp) { + assert.match(err.message ?? err.toString(), args[1]); + return err; + } + throw new TypeError( `Second argument of throws must be a string, got ${typeof args[1]}` );