import fs from "fs"; import path from "path"; import vm from "node:vm"; import { createRequire } from "node:module"; import axios from "axios"; import pino from "pino"; import { createKvApi } from "./kv-store.js"; import { createFingerprintApi } from "./script-fingerprint.js"; import { SCRIPTS_DIR } from "./paths.js"; import { isSecret, Secret, unwrapSecretsDeep } from "./secret-value.js"; import { getSecretPlaintext } from "./secrets-store.js"; const hostRequire = createRequire(import.meta.url); const ALLOWED_MODULES = new Set(["axios", "jsonata", "node-html-parser", "rss-parser"]); const BUILTIN_NAMES = [ "Infinity", "NaN", "undefined", "Object", "Array", "String", "Boolean", "Number", "Symbol", "BigInt", "Error", "AggregateError", "EvalError", "RangeError", "ReferenceError", "SyntaxError", "TypeError", "URIError", "Math", "Date", "JSON", "RegExp", "Map", "Set", "WeakMap", "WeakSet", "WeakRef", "FinalizationRegistry", "ArrayBuffer", "DataView", "Int8Array", "Uint8Array", "Uint8ClampedArray", "Int16Array", "Uint16Array", "Int32Array", "Uint32Array", "Float16Array", "Float32Array", "Float64Array", "BigInt64Array", "BigUint64Array", "Promise", "Proxy", "Reflect", "Intl", "URL", "URLSearchParams", "Headers", "Request", "Response", "FormData", "Blob", "File", "AbortController", "AbortSignal", "TextEncoder", "TextDecoder", "Atomics", "parseInt", "parseFloat", "isNaN", "isFinite", "encodeURI", "encodeURIComponent", "decodeURI", "decodeURIComponent", "setTimeout", "setInterval", "clearTimeout", "clearInterval", "setImmediate", "clearImmediate", "queueMicrotask", "structuredClone", "fetch", "atob", "btoa", "Buffer", "crypto", "performance", ]; /** * @type {Map} */ const scriptCache = new Map(); export function clearScriptCache() { scriptCache.clear(); } function pickBuiltins() { /** @type {Record} */ const builtins = {}; for (const name of BUILTIN_NAMES) { if (typeof globalThis[name] !== "undefined") { builtins[name] = globalThis[name]; } } return builtins; } function transformEsmToCjs(source) { let code = source; code = code.replace( /^\s*import\s+(\w+)\s+from\s+["']([^"']+)["'];?\s*$/gm, 'const $1 = require("$2");', ); code = code.replace( /^\s*import\s+\*\s+as\s+(\w+)\s+from\s+["']([^"']+)["'];?\s*$/gm, 'const $1 = require("$2");', ); code = code.replace( /^\s*import\s+\{([^}]+)\}\s+from\s+["']([^"']+)["'];?\s*$/gm, "const {$1} = require(\"$2\");", ); code = code.replace( /^\s*import\s+["']([^"']+)["'];?\s*$/gm, 'require("$1");', ); const replaced = code.replace( /^\s*export\s+default\s+/m, "module.exports.default = ", ); if (replaced === code) { throw new Error("script must have a default export"); } return replaced; } function wrapScriptSource(source, filename) { const body = transformEsmToCjs(source); return `"use strict"; var module = { exports: {} }; var exports = module.exports; ${body} if (typeof module.exports.default !== "function") { throw new TypeError(${JSON.stringify(filename)} + " default export must be a function"); } module.exports.default; `; } /** * @param {import("pino").Logger} logger */ function createConsole(logger) { const write = (level) => (...args) => { if (args.length === 0) { logger[level](""); return; } const [first, ...rest] = args; if (typeof first === "string") { if (rest.length === 0) { logger[level](first); } else { logger[level]({ args: rest }, first); } return; } logger[level]({ args }); }; return { log: write("info"), info: write("info"), warn: write("warn"), error: write("error"), debug: write("debug"), trace: write("trace"), }; } function isBlockedHostname(hostname) { const host = hostname.toLowerCase().replace(/\.$/, ""); if (host === "metadata.google.internal" || host === "metadata.goog") { return true; } const ipv4Match = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); if (!ipv4Match) { return false; } const octets = ipv4Match.slice(1).map(Number); if (octets.some((octet) => octet > 255)) { return true; } const [a, b] = octets; if (a === 10) return true; if (a === 0) return true; if (a === 169 && b === 254) return true; if (a === 172 && b >= 16 && b <= 31) return true; if (a === 192 && b === 168) return true; if (a === 100 && b >= 64 && b <= 127) return true; return false; } function resolveRequestUrl(config) { const target = config.url; if (typeof target !== "string" || target.length === 0) { throw new Error("axios request URL is required"); } if (/^https?:\/\//i.test(target)) { return new URL(target); } const base = config.baseURL; if (typeof base !== "string" || base.length === 0) { throw new Error(`axios request URL must be absolute: ${target}`); } return new URL(target, base); } function screenRequestUrl(url, log) { if (url.protocol !== "http:" && url.protocol !== "https:") { throw new Error(`Request blocked: unsupported protocol ${url.protocol}`); } if (isBlockedHostname(url.hostname)) { const message = `Request blocked: ${url.href}`; log.warn({ url: url.href, hostname: url.hostname }, message); throw new Error(message); } } /** * @param {import("pino").Logger} log */ function createScreenedAxios(log) { const instance = axios.create(); instance.interceptors.request.use((config) => { const url = resolveRequestUrl(config); screenRequestUrl(url, log); return config; }); for (const method of [ "request", "get", "delete", "head", "options", "post", "put", "patch", ]) { const orig = instance[method].bind(instance); instance[method] = (...args) => orig(...unwrapSecretsDeep(args)); } return new Proxy(instance, { apply(_target, _thisArg, args) { return instance.request(...args); }, }); } function createRestrictedRequire(screenedAxios) { return function restrictedRequire(id) { if (typeof id !== "string" || !ALLOWED_MODULES.has(id)) { throw new Error(`require(${JSON.stringify(id)}) is not allowed`); } if (id === "axios") { return screenedAxios; } return hostRequire(id); }; } /** * @param {string} owner */ function createSecretsApi(owner) { return { /** * @param {string} name */ async get(name) { const value = await getSecretPlaintext(owner, name); if (value == null) { throw new Error(`secret "${name}" not found`); } return new Secret(value); }, /** * @param {unknown} value */ reveal(value) { if (isSecret(value)) return value.reveal(); throw new Error("reveal() expects a Secret from $secrets.get()"); }, }; } const $workflowsStub = { async trigger() { throw new Error("workflow runner is not available"); }, }; /** * @param {{ * log: import("pino").Logger, * script: string, * workflowName: string, * owner?: string, * $workflows?: { trigger: (name: string, data?: unknown) => Promise }, * }} opts */ function createScriptSandbox({ log, script, workflowName, owner = "default", $workflows = $workflowsStub, }) { const scriptLog = log.child({ workflow: workflowName, script }); const $axios = createScreenedAxios(scriptLog); const $kv = createKvApi(workflowName); const $fingerprint = createFingerprintApi($kv); const $secrets = createSecretsApi(owner); const sandbox = { ...pickBuiltins(), log: scriptLog, console: createConsole(scriptLog), $axios, $kv, $fingerprint, $secrets, $workflows, require: createRestrictedRequire($axios), }; vm.createContext(sandbox, { name: `scrunner:${workflowName}:${script}`, codeGeneration: { strings: false, wasm: false }, }); return sandbox; } function compileScriptSource(source, filename) { return new vm.Script(wrapScriptSource(source, filename), { filename }); } const inspectLog = pino({ level: "silent" }); /** * @param {unknown} fn * @returns {{ meta: Record | null, metaError: string | null }} */ export function extractScriptMeta(fn) { if (typeof fn !== "function") { return { meta: null, metaError: "default export must be a function" }; } if (!("meta" in fn) || fn.meta == null) { return { meta: null, metaError: null }; } try { const serialized = JSON.parse(JSON.stringify(fn.meta)); if (serialized == null || typeof serialized !== "object" || Array.isArray(serialized)) { return { meta: null, metaError: "script.meta must be a plain object" }; } return { meta: serialized, metaError: null }; } catch (err) { return { meta: null, metaError: err instanceof Error ? err.message : "script.meta could not be serialized", }; } } /** * @param {import("node:vm").Script} compiled * @param {{ * log: import("pino").Logger, * script: string, * workflowName: string, * owner?: string, * $workflows?: { trigger: (name: string, data?: unknown) => Promise }, * }} opts */ function instantiateCompiled(compiled, { log, script, workflowName, owner, $workflows }) { const sandbox = createScriptSandbox({ log, script, workflowName, owner, $workflows }); return compiled.runInContext(sandbox); } /** * Compile source and return the default-export function plus extracted meta. * Does not call the script. * * @param {string} script * @param {string} source * @param {{ * log?: import("pino").Logger, * workflowName?: string, * owner?: string, * $workflows?: { trigger: (name: string, data?: unknown) => Promise }, * }} [opts] */ export function instantiateScriptSource(script, source, opts = {}) { const compiled = compileScriptSource(source, script); const fn = instantiateCompiled(compiled, { log: opts.log ?? inspectLog, script, workflowName: opts.workflowName ?? "inspect", owner: opts.owner ?? "default", $workflows: opts.$workflows, }); return { fn, ...extractScriptMeta(fn) }; } /** * Evaluate module-level code and read `defaultExport.meta` without calling the script. * * @param {string} script * @param {string} source */ export function inspectScriptSource(script, source) { try { const { meta, metaError } = instantiateScriptSource(script, source); return { meta, metaError }; } catch (err) { return { meta: null, metaError: err instanceof Error ? err.message : String(err), }; } } function loadCompiledScript(script) { const filePath = path.join(SCRIPTS_DIR, script); const { mtimeMs } = fs.statSync(filePath); const cached = scriptCache.get(script); if (cached && cached.mtimeMs === mtimeMs) { return cached.compiled; } const source = fs.readFileSync(filePath, "utf8"); const compiled = compileScriptSource(source, filePath); scriptCache.set(script, { compiled, mtimeMs }); return compiled; } /** * Evaluate a workflow script's default export inside a restricted vm context. * * @param {string} script * @param {unknown} ctx * @param {{ * log: import("pino").Logger, * workflowName: string, * owner?: string, * $workflows?: { trigger: (name: string, data?: unknown) => Promise }, * }} opts */ export async function runScript(script, ctx, { log, workflowName, owner, $workflows }) { const compiled = loadCompiledScript(script); const fn = instantiateCompiled(compiled, { log, script, workflowName, owner, $workflows, }); return await fn(ctx); } /** * Evaluate script source in memory (dry-run). Does not read from disk or use the disk cache. * * @param {string} script * @param {string} source * @param {unknown} ctx * @param {{ * log: import("pino").Logger, * workflowName: string, * owner?: string, * $workflows?: { trigger: (name: string, data?: unknown) => Promise }, * }} opts */ export async function runScriptSource(script, source, ctx, { log, workflowName, owner, $workflows }) { const { fn } = instantiateScriptSource(script, source, { log, workflowName, owner, $workflows, }); return await fn(ctx); }