580 lines
14 KiB
JavaScript
580 lines
14 KiB
JavaScript
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 { getHttpPageByName, getHttpTemplateByName } from "./http-pages-store.js";
|
|
import { getSecretPlaintext } from "./secrets-store.js";
|
|
import { getVariablePlain } from "./variables-store.js";
|
|
|
|
const hostRequire = createRequire(import.meta.url);
|
|
|
|
const ALLOWED_MODULES = new Set([
|
|
"@aws-sdk/client-s3",
|
|
"@aws-sdk/s3-request-presigner",
|
|
"axios",
|
|
"basic-ftp",
|
|
"jsonata",
|
|
"mustache",
|
|
"node-html-parser",
|
|
"node:stream",
|
|
"nodemailer",
|
|
"rss-parser",
|
|
"ssh2-sftp-client",
|
|
]);
|
|
|
|
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<string, { compiled: import("node:vm").Script, mtimeMs: number }>}
|
|
*/
|
|
const scriptCache = new Map();
|
|
|
|
export function clearScriptCache() {
|
|
scriptCache.clear();
|
|
}
|
|
|
|
function pickBuiltins() {
|
|
/** @type {Record<string, unknown>} */
|
|
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 createVarsApi(owner) {
|
|
return {
|
|
/**
|
|
* @param {string} name
|
|
*/
|
|
async get(name) {
|
|
const value = await getVariablePlain(owner, name);
|
|
if (value == null) {
|
|
throw new Error(`variable "${name}" not found`);
|
|
}
|
|
return value;
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @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()");
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Load HTML template pages from the Responses store.
|
|
*/
|
|
function createResponsesApi() {
|
|
return {
|
|
/**
|
|
* @param {string} name
|
|
*/
|
|
async getTemplate(name) {
|
|
if (typeof name !== "string" || name.length === 0) {
|
|
throw new Error("template name is required");
|
|
}
|
|
const page = await getHttpTemplateByName(name);
|
|
if (!page) {
|
|
const any = await getHttpPageByName(name);
|
|
if (any && any.kind !== "template") {
|
|
throw new Error(
|
|
`"${name}" is an HTTP response page, not an HTML template`,
|
|
);
|
|
}
|
|
throw new Error(`html template "${name}" not found`);
|
|
}
|
|
return page.content;
|
|
},
|
|
};
|
|
}
|
|
|
|
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<unknown> },
|
|
* }} 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 $vars = createVarsApi(owner);
|
|
const $responses = createResponsesApi();
|
|
const sandbox = {
|
|
...pickBuiltins(),
|
|
log: scriptLog,
|
|
console: createConsole(scriptLog),
|
|
$axios,
|
|
$kv,
|
|
$fingerprint,
|
|
$secrets,
|
|
$vars,
|
|
$responses,
|
|
$workflows,
|
|
require: createRestrictedRequire($axios),
|
|
};
|
|
|
|
vm.createContext(sandbox, {
|
|
name: `jerapah-flow:${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<string, unknown> | 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<unknown> },
|
|
* }} 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<unknown> },
|
|
* }} [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<unknown> },
|
|
* }} 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<unknown> },
|
|
* }} 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);
|
|
}
|