nodes.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. """Fail-closed Runner node registry and governed executor implementations."""
  2. from __future__ import annotations
  3. import ipaddress
  4. import json
  5. import math
  6. import multiprocessing
  7. import re
  8. from typing import Any, Mapping
  9. from urllib.parse import urlsplit
  10. import requests
  11. from sqlalchemy import text
  12. class NodeExecutionError(ValueError):
  13. def __init__(self, message, *, commit_outcome="not_applicable"):
  14. super().__init__(message)
  15. self.commit_outcome = commit_outcome
  16. def _statement(config):
  17. statement = config.get("statement")
  18. if not isinstance(statement, str) or not statement.strip():
  19. raise NodeExecutionError("SQL statement is required")
  20. if "\x00" in statement:
  21. raise NodeExecutionError("SQL statement contains invalid characters")
  22. return statement.strip()
  23. def _parameters(config, runtime_parameters=None):
  24. parameters = config.get("parameters", {})
  25. if not isinstance(parameters, dict):
  26. raise NodeExecutionError("SQL parameters must be an object")
  27. runtime_parameters = runtime_parameters or {}
  28. resolved = {}
  29. pattern = re.compile(r"^\$\{parameters\.([A-Za-z][A-Za-z0-9_]*)\}$")
  30. for key, value in parameters.items():
  31. if isinstance(value, str):
  32. match = pattern.fullmatch(value)
  33. if match:
  34. parameter_name = match.group(1)
  35. if parameter_name not in runtime_parameters:
  36. raise NodeExecutionError(
  37. f"runtime parameter {parameter_name} is required"
  38. )
  39. value = runtime_parameters[parameter_name]
  40. elif "${parameters." in value:
  41. raise NodeExecutionError(
  42. "runtime parameter placeholders must occupy the whole value"
  43. )
  44. resolved[key] = value
  45. return resolved
  46. def _sql_tokens(statement):
  47. without_strings = re.sub(r"'(?:''|[^'])*'", "''", statement)
  48. without_comments = re.sub(
  49. r"/\*.*?\*/|--[^\r\n]*",
  50. " ",
  51. without_strings,
  52. flags=re.DOTALL,
  53. )
  54. return re.findall(r"[A-Za-z_]+|;", without_comments.upper())
  55. def _assert_read_only(statement):
  56. tokens = _sql_tokens(statement)
  57. if not tokens or tokens[0] not in {"SELECT", "WITH"}:
  58. raise NodeExecutionError("sql.query accepts read-only statements")
  59. forbidden = {
  60. "ALTER",
  61. "CALL",
  62. "COPY",
  63. "CREATE",
  64. "DELETE",
  65. "DROP",
  66. "EXEC",
  67. "EXECUTE",
  68. "GRANT",
  69. "INSERT",
  70. "INTO",
  71. "LOCK",
  72. "MERGE",
  73. "PROGRAM",
  74. "REVOKE",
  75. "TRUNCATE",
  76. "UPDATE",
  77. }
  78. if ";" in tokens or forbidden.intersection(tokens):
  79. raise NodeExecutionError("sql.query accepts read-only statements")
  80. if "FOR" in tokens and "UPDATE" in tokens:
  81. raise NodeExecutionError("sql.query accepts read-only statements")
  82. def _assert_dml(statement):
  83. tokens = _sql_tokens(statement)
  84. if (
  85. not tokens
  86. or tokens[0] not in {"INSERT", "UPDATE", "DELETE", "MERGE"}
  87. or ";" in tokens
  88. ):
  89. raise NodeExecutionError(
  90. "sql.execute accepts one parameterized DML statement"
  91. )
  92. class SqlQueryExecutor:
  93. def __init__(self, manager, *, max_rows=1000):
  94. self.manager = manager
  95. self.max_rows = int(max_rows)
  96. def execute(self, node, runtime_parameters, **_kwargs):
  97. config = node.get("config") or {}
  98. statement = _statement(config)
  99. _assert_read_only(statement)
  100. parameters = _parameters(config, runtime_parameters)
  101. with self.manager.connect(
  102. node.get("data_source_uid"),
  103. purpose="dataflow_read",
  104. ) as connection:
  105. rows = connection.execute(
  106. text(statement),
  107. parameters,
  108. ).mappings().all()
  109. bounded = [dict(row) for row in rows[: self.max_rows]]
  110. return {
  111. "rows": bounded,
  112. "row_count": len(bounded),
  113. "truncated": len(rows) > self.max_rows,
  114. }
  115. class SqlExecuteExecutor:
  116. def __init__(self, manager):
  117. self.manager = manager
  118. def execute(
  119. self,
  120. node,
  121. runtime_parameters,
  122. *,
  123. write_authorized=False,
  124. **_kwargs,
  125. ):
  126. if node.get("purpose") != "write" or not write_authorized:
  127. raise NodeExecutionError("governed write authorization is required")
  128. idempotency = node.get("idempotency")
  129. if (
  130. not isinstance(idempotency, dict)
  131. or idempotency.get("strategy")
  132. not in {"partition_replace", "upsert", "deduplication_key"}
  133. or not str(idempotency.get("key") or "").strip()
  134. ):
  135. raise NodeExecutionError("write idempotency is required")
  136. config = node.get("config") or {}
  137. statement = _statement(config)
  138. _assert_dml(statement)
  139. try:
  140. with self.manager.connect(
  141. node.get("data_source_uid"),
  142. purpose="dataflow_write",
  143. ) as connection:
  144. result = connection.execute(
  145. text(statement),
  146. _parameters(config, runtime_parameters),
  147. )
  148. except Exception as exc:
  149. commit_outcome = getattr(exc, "commit_outcome", "not_committed")
  150. raise NodeExecutionError(
  151. "governed SQL write failed",
  152. commit_outcome=commit_outcome,
  153. ) from exc
  154. return {
  155. "affected_rows": max(0, int(result.rowcount or 0)),
  156. "commit_outcome": "committed",
  157. }
  158. def _restricted_python_child(
  159. handler,
  160. arguments,
  161. result_queue,
  162. cpu_seconds,
  163. memory_bytes,
  164. ):
  165. import resource
  166. import socket
  167. def set_soft_limit(kind, requested):
  168. _soft, hard = resource.getrlimit(kind)
  169. limit = (
  170. requested
  171. if hard == resource.RLIM_INFINITY
  172. else min(requested, hard)
  173. )
  174. try:
  175. resource.setrlimit(kind, (limit, hard))
  176. except (OSError, ValueError):
  177. # macOS does not implement RLIMIT_AS; the Linux Runner image does.
  178. if kind != resource.RLIMIT_AS:
  179. raise
  180. set_soft_limit(resource.RLIMIT_CPU, cpu_seconds)
  181. set_soft_limit(resource.RLIMIT_AS, memory_bytes)
  182. set_soft_limit(resource.RLIMIT_NOFILE, 32)
  183. def deny_network(*_args, **_kwargs):
  184. raise PermissionError("network access is disabled")
  185. socket.socket = deny_network
  186. socket.create_connection = deny_network
  187. try:
  188. result = handler(arguments)
  189. json.dumps(result)
  190. result_queue.put(("success", result))
  191. except PermissionError:
  192. result_queue.put(("network_denied", None))
  193. except (TypeError, ValueError):
  194. result_queue.put(("invalid_result", None))
  195. except BaseException:
  196. result_queue.put(("failed", None))
  197. class RestrictedPythonExecutor:
  198. """Run only pre-installed, reviewed handlers; arbitrary source is forbidden."""
  199. def __init__(
  200. self,
  201. handlers=None,
  202. *,
  203. timeout_seconds=10,
  204. memory_bytes=512 * 1024 * 1024,
  205. ):
  206. self.handlers = dict(handlers or {})
  207. self.timeout_seconds = float(timeout_seconds)
  208. self.memory_bytes = int(memory_bytes)
  209. if self.timeout_seconds <= 0 or self.memory_bytes < 64 * 1024 * 1024:
  210. raise ValueError("restricted python resource limit is invalid")
  211. def execute(self, node, _runtime_parameters, **_kwargs):
  212. config = node.get("config") or {}
  213. if set(config) - {"handler", "arguments"}:
  214. raise NodeExecutionError(
  215. "restricted python accepts only handler and arguments"
  216. )
  217. handler_name = config.get("handler")
  218. handler = self.handlers.get(handler_name)
  219. if handler is None:
  220. raise NodeExecutionError("python handler is not allowlisted")
  221. arguments = config.get("arguments", {})
  222. if not isinstance(arguments, dict):
  223. raise NodeExecutionError("python arguments must be an object")
  224. context = multiprocessing.get_context("fork")
  225. result_queue = context.Queue(maxsize=1)
  226. process = context.Process(
  227. target=_restricted_python_child,
  228. args=(
  229. handler,
  230. arguments,
  231. result_queue,
  232. max(1, int(math.ceil(self.timeout_seconds))),
  233. self.memory_bytes,
  234. ),
  235. daemon=True,
  236. )
  237. process.start()
  238. process.join(self.timeout_seconds)
  239. if process.is_alive():
  240. process.terminate()
  241. process.join(1)
  242. raise NodeExecutionError("python handler exceeded its time limit")
  243. if result_queue.empty():
  244. raise NodeExecutionError("python handler failed in the sandbox")
  245. state, result = result_queue.get_nowait()
  246. if state == "network_denied":
  247. raise NodeExecutionError("python handler network access is disabled")
  248. if state == "invalid_result":
  249. raise NodeExecutionError(
  250. "python handler result must be JSON serializable"
  251. )
  252. if state != "success":
  253. raise NodeExecutionError("python handler failed in the sandbox")
  254. return result
  255. class GovernedHttpExecutor:
  256. def __init__(
  257. self,
  258. *,
  259. allowed_hosts,
  260. session=None,
  261. timeout_seconds=15,
  262. max_response_bytes=1_000_000,
  263. ):
  264. self.allowed_hosts = {
  265. str(host).strip().lower()
  266. for host in allowed_hosts
  267. if str(host).strip()
  268. }
  269. self.session = session or requests.Session()
  270. self.timeout_seconds = int(timeout_seconds)
  271. self.max_response_bytes = int(max_response_bytes)
  272. def _validate_url(self, raw_url):
  273. parts = urlsplit(str(raw_url or ""))
  274. host = (parts.hostname or "").lower()
  275. if (
  276. parts.scheme != "https"
  277. or not host
  278. or parts.username is not None
  279. or parts.password is not None
  280. or host not in self.allowed_hosts
  281. ):
  282. raise NodeExecutionError("HTTP target is not allowlisted")
  283. try:
  284. address = ipaddress.ip_address(host)
  285. except ValueError:
  286. address = None
  287. if address and (
  288. address.is_private
  289. or address.is_loopback
  290. or address.is_link_local
  291. or address.is_reserved
  292. ):
  293. raise NodeExecutionError("HTTP target is not allowlisted")
  294. return parts.geturl()
  295. def execute(self, node, _runtime_parameters, **_kwargs):
  296. config = node.get("config") or {}
  297. unknown = set(config) - {"method", "url", "headers", "json"}
  298. if unknown:
  299. raise NodeExecutionError("HTTP node contains unsupported fields")
  300. method = str(config.get("method", "GET")).upper()
  301. if method not in {"GET", "POST", "PUT", "PATCH"}:
  302. raise NodeExecutionError("HTTP method is not allowed")
  303. headers = config.get("headers", {})
  304. if not isinstance(headers, dict):
  305. raise NodeExecutionError("HTTP headers must be an object")
  306. sensitive = {
  307. "authorization",
  308. "cookie",
  309. "proxy-authorization",
  310. "x-api-key",
  311. }
  312. if sensitive.intersection(str(key).lower() for key in headers):
  313. raise NodeExecutionError("inline HTTP credentials are forbidden")
  314. response = self.session.request(
  315. method,
  316. self._validate_url(config.get("url")),
  317. headers=headers,
  318. json=config.get("json"),
  319. timeout=self.timeout_seconds,
  320. allow_redirects=False,
  321. )
  322. content = bytes(response.content or "")
  323. if len(content) > self.max_response_bytes:
  324. raise NodeExecutionError("HTTP response exceeds the configured limit")
  325. content_type = str(response.headers.get("content-type", "")).lower()
  326. body = response.json() if "application/json" in content_type else content.decode(
  327. "utf-8", errors="replace"
  328. )
  329. return {"status_code": int(response.status_code), "body": body}
  330. class NodeRegistry:
  331. def __init__(self, executors):
  332. self.executors = dict(executors)
  333. def execute(
  334. self,
  335. node: Mapping[str, Any],
  336. parameters,
  337. *,
  338. write_authorized=False,
  339. ):
  340. executor = self.executors.get(node.get("type"))
  341. if executor is None or not callable(getattr(executor, "execute", None)):
  342. raise NodeExecutionError("node type is not registered")
  343. return executor.execute(
  344. node,
  345. parameters,
  346. write_authorized=write_authorized,
  347. )