| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385 |
- """Fail-closed Runner node registry and governed executor implementations."""
- from __future__ import annotations
- import ipaddress
- import json
- import math
- import multiprocessing
- import re
- from typing import Any, Mapping
- from urllib.parse import urlsplit
- import requests
- from sqlalchemy import text
- class NodeExecutionError(ValueError):
- def __init__(self, message, *, commit_outcome="not_applicable"):
- super().__init__(message)
- self.commit_outcome = commit_outcome
- def _statement(config):
- statement = config.get("statement")
- if not isinstance(statement, str) or not statement.strip():
- raise NodeExecutionError("SQL statement is required")
- if "\x00" in statement:
- raise NodeExecutionError("SQL statement contains invalid characters")
- return statement.strip()
- def _parameters(config, runtime_parameters=None):
- parameters = config.get("parameters", {})
- if not isinstance(parameters, dict):
- raise NodeExecutionError("SQL parameters must be an object")
- runtime_parameters = runtime_parameters or {}
- resolved = {}
- pattern = re.compile(r"^\$\{parameters\.([A-Za-z][A-Za-z0-9_]*)\}$")
- for key, value in parameters.items():
- if isinstance(value, str):
- match = pattern.fullmatch(value)
- if match:
- parameter_name = match.group(1)
- if parameter_name not in runtime_parameters:
- raise NodeExecutionError(
- f"runtime parameter {parameter_name} is required"
- )
- value = runtime_parameters[parameter_name]
- elif "${parameters." in value:
- raise NodeExecutionError(
- "runtime parameter placeholders must occupy the whole value"
- )
- resolved[key] = value
- return resolved
- def _sql_tokens(statement):
- without_strings = re.sub(r"'(?:''|[^'])*'", "''", statement)
- without_comments = re.sub(
- r"/\*.*?\*/|--[^\r\n]*",
- " ",
- without_strings,
- flags=re.DOTALL,
- )
- return re.findall(r"[A-Za-z_]+|;", without_comments.upper())
- def _assert_read_only(statement):
- tokens = _sql_tokens(statement)
- if not tokens or tokens[0] not in {"SELECT", "WITH"}:
- raise NodeExecutionError("sql.query accepts read-only statements")
- forbidden = {
- "ALTER",
- "CALL",
- "COPY",
- "CREATE",
- "DELETE",
- "DROP",
- "EXEC",
- "EXECUTE",
- "GRANT",
- "INSERT",
- "INTO",
- "LOCK",
- "MERGE",
- "PROGRAM",
- "REVOKE",
- "TRUNCATE",
- "UPDATE",
- }
- if ";" in tokens or forbidden.intersection(tokens):
- raise NodeExecutionError("sql.query accepts read-only statements")
- if "FOR" in tokens and "UPDATE" in tokens:
- raise NodeExecutionError("sql.query accepts read-only statements")
- def _assert_dml(statement):
- tokens = _sql_tokens(statement)
- if (
- not tokens
- or tokens[0] not in {"INSERT", "UPDATE", "DELETE", "MERGE"}
- or ";" in tokens
- ):
- raise NodeExecutionError(
- "sql.execute accepts one parameterized DML statement"
- )
- class SqlQueryExecutor:
- def __init__(self, manager, *, max_rows=1000):
- self.manager = manager
- self.max_rows = int(max_rows)
- def execute(self, node, runtime_parameters, **_kwargs):
- config = node.get("config") or {}
- statement = _statement(config)
- _assert_read_only(statement)
- parameters = _parameters(config, runtime_parameters)
- with self.manager.connect(
- node.get("data_source_uid"),
- purpose="dataflow_read",
- ) as connection:
- rows = connection.execute(
- text(statement),
- parameters,
- ).mappings().all()
- bounded = [dict(row) for row in rows[: self.max_rows]]
- return {
- "rows": bounded,
- "row_count": len(bounded),
- "truncated": len(rows) > self.max_rows,
- }
- class SqlExecuteExecutor:
- def __init__(self, manager):
- self.manager = manager
- def execute(
- self,
- node,
- runtime_parameters,
- *,
- write_authorized=False,
- **_kwargs,
- ):
- if node.get("purpose") != "write" or not write_authorized:
- raise NodeExecutionError("governed write authorization is required")
- idempotency = node.get("idempotency")
- if (
- not isinstance(idempotency, dict)
- or idempotency.get("strategy")
- not in {"partition_replace", "upsert", "deduplication_key"}
- or not str(idempotency.get("key") or "").strip()
- ):
- raise NodeExecutionError("write idempotency is required")
- config = node.get("config") or {}
- statement = _statement(config)
- _assert_dml(statement)
- try:
- with self.manager.connect(
- node.get("data_source_uid"),
- purpose="dataflow_write",
- ) as connection:
- result = connection.execute(
- text(statement),
- _parameters(config, runtime_parameters),
- )
- except Exception as exc:
- commit_outcome = getattr(exc, "commit_outcome", "not_committed")
- raise NodeExecutionError(
- "governed SQL write failed",
- commit_outcome=commit_outcome,
- ) from exc
- return {
- "affected_rows": max(0, int(result.rowcount or 0)),
- "commit_outcome": "committed",
- }
- def _restricted_python_child(
- handler,
- arguments,
- result_queue,
- cpu_seconds,
- memory_bytes,
- ):
- import resource
- import socket
- def set_soft_limit(kind, requested):
- _soft, hard = resource.getrlimit(kind)
- limit = (
- requested
- if hard == resource.RLIM_INFINITY
- else min(requested, hard)
- )
- try:
- resource.setrlimit(kind, (limit, hard))
- except (OSError, ValueError):
- # macOS does not implement RLIMIT_AS; the Linux Runner image does.
- if kind != resource.RLIMIT_AS:
- raise
- set_soft_limit(resource.RLIMIT_CPU, cpu_seconds)
- set_soft_limit(resource.RLIMIT_AS, memory_bytes)
- set_soft_limit(resource.RLIMIT_NOFILE, 32)
- def deny_network(*_args, **_kwargs):
- raise PermissionError("network access is disabled")
- socket.socket = deny_network
- socket.create_connection = deny_network
- try:
- result = handler(arguments)
- json.dumps(result)
- result_queue.put(("success", result))
- except PermissionError:
- result_queue.put(("network_denied", None))
- except (TypeError, ValueError):
- result_queue.put(("invalid_result", None))
- except BaseException:
- result_queue.put(("failed", None))
- class RestrictedPythonExecutor:
- """Run only pre-installed, reviewed handlers; arbitrary source is forbidden."""
- def __init__(
- self,
- handlers=None,
- *,
- timeout_seconds=10,
- memory_bytes=512 * 1024 * 1024,
- ):
- self.handlers = dict(handlers or {})
- self.timeout_seconds = float(timeout_seconds)
- self.memory_bytes = int(memory_bytes)
- if self.timeout_seconds <= 0 or self.memory_bytes < 64 * 1024 * 1024:
- raise ValueError("restricted python resource limit is invalid")
- def execute(self, node, _runtime_parameters, **_kwargs):
- config = node.get("config") or {}
- if set(config) - {"handler", "arguments"}:
- raise NodeExecutionError(
- "restricted python accepts only handler and arguments"
- )
- handler_name = config.get("handler")
- handler = self.handlers.get(handler_name)
- if handler is None:
- raise NodeExecutionError("python handler is not allowlisted")
- arguments = config.get("arguments", {})
- if not isinstance(arguments, dict):
- raise NodeExecutionError("python arguments must be an object")
- context = multiprocessing.get_context("fork")
- result_queue = context.Queue(maxsize=1)
- process = context.Process(
- target=_restricted_python_child,
- args=(
- handler,
- arguments,
- result_queue,
- max(1, int(math.ceil(self.timeout_seconds))),
- self.memory_bytes,
- ),
- daemon=True,
- )
- process.start()
- process.join(self.timeout_seconds)
- if process.is_alive():
- process.terminate()
- process.join(1)
- raise NodeExecutionError("python handler exceeded its time limit")
- if result_queue.empty():
- raise NodeExecutionError("python handler failed in the sandbox")
- state, result = result_queue.get_nowait()
- if state == "network_denied":
- raise NodeExecutionError("python handler network access is disabled")
- if state == "invalid_result":
- raise NodeExecutionError(
- "python handler result must be JSON serializable"
- )
- if state != "success":
- raise NodeExecutionError("python handler failed in the sandbox")
- return result
- class GovernedHttpExecutor:
- def __init__(
- self,
- *,
- allowed_hosts,
- session=None,
- timeout_seconds=15,
- max_response_bytes=1_000_000,
- ):
- self.allowed_hosts = {
- str(host).strip().lower()
- for host in allowed_hosts
- if str(host).strip()
- }
- self.session = session or requests.Session()
- self.timeout_seconds = int(timeout_seconds)
- self.max_response_bytes = int(max_response_bytes)
- def _validate_url(self, raw_url):
- parts = urlsplit(str(raw_url or ""))
- host = (parts.hostname or "").lower()
- if (
- parts.scheme != "https"
- or not host
- or parts.username is not None
- or parts.password is not None
- or host not in self.allowed_hosts
- ):
- raise NodeExecutionError("HTTP target is not allowlisted")
- try:
- address = ipaddress.ip_address(host)
- except ValueError:
- address = None
- if address and (
- address.is_private
- or address.is_loopback
- or address.is_link_local
- or address.is_reserved
- ):
- raise NodeExecutionError("HTTP target is not allowlisted")
- return parts.geturl()
- def execute(self, node, _runtime_parameters, **_kwargs):
- config = node.get("config") or {}
- unknown = set(config) - {"method", "url", "headers", "json"}
- if unknown:
- raise NodeExecutionError("HTTP node contains unsupported fields")
- method = str(config.get("method", "GET")).upper()
- if method not in {"GET", "POST", "PUT", "PATCH"}:
- raise NodeExecutionError("HTTP method is not allowed")
- headers = config.get("headers", {})
- if not isinstance(headers, dict):
- raise NodeExecutionError("HTTP headers must be an object")
- sensitive = {
- "authorization",
- "cookie",
- "proxy-authorization",
- "x-api-key",
- }
- if sensitive.intersection(str(key).lower() for key in headers):
- raise NodeExecutionError("inline HTTP credentials are forbidden")
- response = self.session.request(
- method,
- self._validate_url(config.get("url")),
- headers=headers,
- json=config.get("json"),
- timeout=self.timeout_seconds,
- allow_redirects=False,
- )
- content = bytes(response.content or "")
- if len(content) > self.max_response_bytes:
- raise NodeExecutionError("HTTP response exceeds the configured limit")
- content_type = str(response.headers.get("content-type", "")).lower()
- body = response.json() if "application/json" in content_type else content.decode(
- "utf-8", errors="replace"
- )
- return {"status_code": int(response.status_code), "body": body}
- class NodeRegistry:
- def __init__(self, executors):
- self.executors = dict(executors)
- def execute(
- self,
- node: Mapping[str, Any],
- parameters,
- *,
- write_authorized=False,
- ):
- executor = self.executors.get(node.get("type"))
- if executor is None or not callable(getattr(executor, "execute", None)):
- raise NodeExecutionError("node type is not registered")
- return executor.execute(
- node,
- parameters,
- write_authorized=write_authorized,
- )
|