From c32f03aee6387dad33bbd07366cffa59ab8194ff Mon Sep 17 00:00:00 2001 From: Alexander Do Date: Wed, 28 Oct 2026 15:20:39 +0100 Subject: [PATCH 1/3] disable os acces --- .ci/compose/entrypoint.sh | 17 +++ .ci/deb/Dockerfile | 103 ++++++++++++++ .ci/deb/test.sh | 18 +++ .dockerignore | 6 + README.rst | 43 ++++++ docker-compose.yml | 50 +++++++ examples/scale35.py | 26 ++-- include/python35.h | 4 + include/script_sandbox.h | 211 +++++++++++++++++++++++++++ python35_filter.cpp | 180 +++++++++++++++++------ readings35.py | 1 - tests/test_python35.cpp | 292 ++++++++++++++++++++++++++++++++++++++ 12 files changed, 889 insertions(+), 62 deletions(-) create mode 100644 .ci/compose/entrypoint.sh create mode 100644 .ci/deb/Dockerfile create mode 100644 .ci/deb/test.sh create mode 100644 .dockerignore create mode 100644 docker-compose.yml create mode 100644 include/script_sandbox.h diff --git a/.ci/compose/entrypoint.sh b/.ci/compose/entrypoint.sh new file mode 100644 index 0000000..751e862 --- /dev/null +++ b/.ci/compose/entrypoint.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +export FLEDGE_ROOT=/usr/local/fledge +export FLEDGE_DATA="${FLEDGE_DATA:-/usr/local/fledge/data}" + +# Fledge logs to syslog +rm -f /var/run/rsyslogd.pid +touch /var/log/syslog +# Ubuntu's rsyslog drops privileges to the syslog user, Debian's stays root +chown syslog:adm /var/log/syslog 2>/dev/null || chmod 666 /var/log/syslog +rsyslogd + +"${FLEDGE_ROOT}/bin/fledge" start + +# Keep the container in the foreground and show the Fledge logs +exec tail -F /var/log/syslog diff --git a/.ci/deb/Dockerfile b/.ci/deb/Dockerfile new file mode 100644 index 0000000..772b274 --- /dev/null +++ b/.ci/deb/Dockerfile @@ -0,0 +1,103 @@ +ARG BASE_IMAGE=ubuntu:22.04 + +FROM ${BASE_IMAGE} AS deps +ENV DEBIAN_FRONTEND=noninteractive \ + TZ=UTC \ + LANG=C.UTF-8 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + sudo ca-certificates curl wget git unzip dos2unix \ + build-essential cmake g++ make autoconf automake libtool pkg-config \ + uuid-dev libssl-dev libz-dev libbz2-dev libreadline-dev liblzma-dev libffi-dev \ + libboost-dev libboost-system-dev libboost-thread-dev \ + libpq-dev libcurl4-openssl-dev \ + python3 python3-dev python3-pip python3-setuptools python3-numpy python3-distutils-extra \ + sqlite3 \ + krb5-user \ + avahi-daemon \ + dpkg-dev + +FROM deps AS builder + +WORKDIR /build +COPY . /build/plugin + +RUN find /build/plugin -type f -print0 \ + | xargs -0 -r dos2unix -q 2>/dev/null || true \ + && chmod +x /build/plugin/fledge/requirements.sh \ + /build/plugin/.ci/deb/generate_control.sh \ + /build/plugin/.ci/deb/package_deb.sh + +WORKDIR /build/plugin/fledge +RUN bash ./requirements.sh +RUN make -j"$(nproc)" + +WORKDIR /build/plugin +RUN if [ -f requirements.sh ]; then bash ./requirements.sh; fi + +ENV FLEDGE_INSTALL=/usr/local/fledge +ENV LD_LIBRARY_PATH=/usr/local/fledge/lib + +WORKDIR /build/plugin +RUN mkdir -p build && cd build \ + && cmake -DFLEDGE_SRC=/build/plugin/fledge -DFLEDGE_INSTALL="${FLEDGE_INSTALL}" .. \ + && make -j"$(nproc)" \ + && make install + +RUN .ci/deb/package_deb.sh "${FLEDGE_INSTALL}" /artifacts + +# Run the plugin unit tests (gtest) against the Fledge build above: +# docker build -f .ci/deb/Dockerfile --target test . +FROM builder AS test + +RUN apt-get update && apt-get install -y --no-install-recommends libgtest-dev googletest \ + && rm -rf /var/lib/apt/lists/* +# Some distros ship the gtest sources only +RUN if ! ls /usr/lib/*/libgtest.* /usr/lib/libgtest.* >/dev/null 2>&1; then \ + cmake -S /usr/src/googletest -B /tmp/gtest-build \ + && cmake --build /tmp/gtest-build -j"$(nproc)" \ + && cp /tmp/gtest-build/lib/*.a /usr/lib/; \ + fi + +ENV FLEDGE_DATA=/tmp +ENV LD_LIBRARY_PATH=/build/plugin/fledge/cmake_build/C/lib:/usr/local/fledge/lib + +WORKDIR /build/plugin +# Current gtest releases need C++14, the test CMakeLists pins C++11 +RUN sed -i 's/-std=c++11/-std=c++14/' tests/CMakeLists.txt \ + && mkdir -p tests-build && cd tests-build \ + && cmake -DFLEDGE_SRC=/build/plugin/fledge ../tests \ + && make -j"$(nproc)" +RUN cd tests-build && ./RunTests + +# Runnable Fledge (from the submodule) with this plugin installed, see +# docker-compose.yml in the repository root. +FROM builder AS runtime + +RUN apt-get update && apt-get install -y --no-install-recommends rsyslog curl \ + && rm -rf /var/lib/apt/lists/* \ + && sed -i '/imklog/d' /etc/rsyslog.conf + +WORKDIR /build/plugin/fledge +RUN make install + +# South sinusoid plugin, pinned to a tag and verified by commit +ARG SINUSOID_TAG=v3.1.0 +ARG SINUSOID_COMMIT=4ff6eab5f21671fbcfd244e716572e699d0974da +RUN git clone --quiet --depth 1 --branch "${SINUSOID_TAG}" \ + https://github.com/fledge-iot/fledge-south-sinusoid.git /tmp/sinusoid \ + && test "$(git -C /tmp/sinusoid rev-parse HEAD)" = "${SINUSOID_COMMIT}" \ + && cp -R /tmp/sinusoid/python/fledge/plugins/south/sinusoid \ + /usr/local/fledge/python/fledge/plugins/south/ \ + && rm -rf /tmp/sinusoid + +# Taken from the builder stage, where line endings have been normalised +RUN cp /build/plugin/.ci/compose/entrypoint.sh /usr/local/bin/entrypoint.sh \ + && chmod +x /usr/local/bin/entrypoint.sh + +ENV FLEDGE_ROOT=/usr/local/fledge +EXPOSE 8081 1995 +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] + +FROM scratch AS artifact +COPY --from=builder /artifacts/ / diff --git a/.ci/deb/test.sh b/.ci/deb/test.sh new file mode 100644 index 0000000..fd9a7d3 --- /dev/null +++ b/.ci/deb/test.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Build Fledge and the plugin in Docker and run the unit tests. +# Usage: .ci/deb/test.sh [base image] [platform] +set -euo pipefail + +BASE_IMAGE="${1:-ubuntu:24.04}" +PLATFORM="${2:-linux/amd64}" + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$REPO_ROOT" + +docker build \ + --file .ci/deb/Dockerfile \ + --target test \ + --platform "$PLATFORM" \ + --build-arg BASE_IMAGE="$BASE_IMAGE" \ + --progress=plain \ + . diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f9aa168 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +# Keep host build output out of the image +fledge/cmake_build +build +build-out +tests-build +__pycache__ diff --git a/README.rst b/README.rst index 91bd6fb..4216873 100644 --- a/README.rst +++ b/README.rst @@ -67,3 +67,46 @@ Examples: $ cmake -DFLEDGE_INSTALL=/home/source/develop/Fledge $ cmake -DFLEDGE_INSTALL=/usr/local/fledge + +Script restrictions +------------------- + +Filter scripts (south and north, including those authored through the +Configurator) run in a restricted environment (PT-HLN-HDP-26A-002, CWE-78). +The script is checked when the filter starts and on every reconfigure, before +any of its code runs: + +- an AST check rejects imports of anything outside the allow-list, relative + and star imports, dunder attribute access (``__class__``, ``__subclasses__`` + ...), references to ``__import__``/``__builtins__``, and to ``eval``, + ``exec``, ``compile``, ``open``, ``getattr`` and similar builtins; +- the script is then executed with restricted builtins and an ``__import__`` + that enforces the same allow-list at run time. + +A rejected script is logged with the reason and line, the filter stays failed +and drops readings (fail closed). + +Default allow-list: ``json math cmath time datetime re copy collections +itertools functools string decimal fractions statistics numbers random bisect +heapq struct base64 binascii hashlib hmac enum typing textwrap unicodedata``. +Never importable: ``os subprocess socket sys ctypes importlib shutil io +threading multiprocessing pickle urllib http logging.config`` and others, see +``include/script_sandbox.h``. Scripts that used ``logging``, ``os`` or ``sys`` +(for example the old ``examples/scale35.py``) must be changed. + +**Decision: the allow-list is configurable per deployment, by the operator +only.** The environment variable ``FLEDGE_PYTHON35_EXTRA_IMPORTS`` +(comma separated exact module names) adds modules to the default list. It is +read from the Fledge service environment, not from the filter configuration +category, because that category is editable by the same users who write the +script. The deny-list can not be overridden. Adding modules widens the attack +surface, review each one for ways to reach the OS (``logging`` can write +files, for instance). + +This is defence in depth, not a complete sandbox: in-process Python +restrictions are known to be bypassable by a determined author. Keep running +Fledge with least privilege and restrict who can edit filter scripts. Checks +at save time in the Portal/Configurator are a courtesy only; the enforcement +is here. + +Tests: the ``PYTHON35.Sandbox*`` cases in ``tests/test_python35.cpp``. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3ea9e77 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,50 @@ +# Fledge built from the ./fledge submodule with this plugin installed. +# +# docker compose up --build start Fledge (REST API on :8081) +# docker compose down -v stop and remove the data +# docker compose --profile test run --build --rm test +# build and run the unit tests (see the test service) +# +# The first build compiles all of Fledge and takes a long time. +# The south sinusoid plugin is installed, add it with: +# curl -X POST localhost:8081/fledge/service -d '{"name":"sin","type":"south","plugin":"sinusoid","enabled":true}' +# The example scripts are mounted read-only at /examples. +services: + fledge: + image: fledge-python35:dev + build: + context: . + dockerfile: .ci/deb/Dockerfile + target: runtime + args: + BASE_IMAGE: ${BASE_IMAGE:-ubuntu:24.04} + ports: + - "8081:8081" + - "1995:1995" + volumes: + - fledge-data:/usr/local/fledge/data + - ./examples:/examples:ro + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:8081/fledge/ping"] + interval: 5s + timeout: 3s + retries: 30 + start_period: 20s + + # Unit tests. Building the image already runs them (the Dockerfile test stage + # fails the build if a test fails); running the service runs them again, e.g. + # docker compose --profile test run --rm test --gtest_filter='*Sandbox*' + test: + image: fledge-python35:test + profiles: ["test"] + build: + context: . + dockerfile: .ci/deb/Dockerfile + target: test + args: + BASE_IMAGE: ${BASE_IMAGE:-ubuntu:24.04} + working_dir: /build/plugin/tests-build + entrypoint: ["./RunTests"] + +volumes: + fledge-data: diff --git a/examples/scale35.py b/examples/scale35.py index f263267..0da4e42 100644 --- a/examples/scale35.py +++ b/examples/scale35.py @@ -10,22 +10,18 @@ __license__ = "Apache 2.0" __version__ = "${VERSION}" -import sys import json -import logging -from logging.handlers import SysLogHandler -import os - -# Log to syslog -handler = SysLogHandler(address='/dev/log') -formatter = logging.Formatter(fmt='Fledge[%(process)d] %(levelname)s: %(name)s: %(message)s') -handler.setFormatter(formatter) -# When embedded Python is in use sys.argv is not defined, -logger = logging.getLogger(os.path.basename(__file__)) - -# Set mininum log severity -logger.setLevel(logging.DEBUG) -logger.addHandler(handler) + +# Filter scripts run in a restricted environment: os, sys, subprocess, socket, +# logging and similar modules can not be imported, so diagnostics go to stdout. + + +class _Logger(object): + def debug(self, msg): + print("DEBUG: " + msg) + + +logger = _Logger() """ Filter configuration set by set_filter_config(config) diff --git a/include/python35.h b/include/python35.h index a4a5312..cb95012 100644 --- a/include/python35.h +++ b/include/python35.h @@ -40,6 +40,7 @@ class Python35Filter : public FledgeFilter { m_pModule = NULL; m_pFunc = NULL; + m_sandbox = NULL; m_init = false; m_encode_names = true; m_logger = Logger::getLogger(); @@ -64,6 +65,7 @@ class Python35Filter : public FledgeFilter void lock() { m_configMutex.lock(); }; void unlock() { m_configMutex.unlock(); }; void logErrorMessage(); + PyObject* loadScript(const std::string& scriptName); // Filtering methods for Reading objects PyObject* createReadingsList(const std::vector& readings); @@ -75,6 +77,8 @@ class Python35Filter : public FledgeFilter PyObject* m_pModule; // Python 3.5 callable method handle PyObject* m_pFunc; + // Restricted script loader function + PyObject* m_sandbox; // Python 3.5 script name std::string m_pythonScript; // Python interpreter has been started by this plugin diff --git a/include/script_sandbox.h b/include/script_sandbox.h new file mode 100644 index 0000000..15aeb5e --- /dev/null +++ b/include/script_sandbox.h @@ -0,0 +1,211 @@ +#ifndef _SCRIPT_SANDBOX_H +#define _SCRIPT_SANDBOX_H +/* + * Fledge "Python 3.5" filter: restricted loading of user supplied scripts. + * + * Released under the Apache 2.0 Licence + * + * User supplied filter scripts must not be able to import modules such as + * os, subprocess, socket, sys or ctypes (CWE-78, PT-HLN-HDP-26A-002). + * + * Scripts are NOT imported with the normal import machinery. Instead the + * source is read, checked with an AST walk, compiled and executed in a fresh + * module whose __builtins__ are restricted and whose __import__ only admits + * modules on an allow-list. + * + * The Python source below is run once by the plugin; it defines + * load_script(path, name) which returns the module or raises + * ScriptSecurityError (an ImportError) with a message that says what was + * rejected and where. + * + * Operators can add modules to the default allow-list with the environment + * variable FLEDGE_PYTHON35_EXTRA_IMPORTS (comma separated, exact module + * names). The deny-list can not be overridden. The allow-list is deliberately + * not part of the filter configuration category, as that is editable by the + * same users who author the script. + */ + +#define SCRIPT_SANDBOX_LOADER_FUNCTION "load_script" +#define SCRIPT_SANDBOX_EXTRA_IMPORTS_ENV "FLEDGE_PYTHON35_EXTRA_IMPORTS" + +static const char *SCRIPT_SANDBOX_SOURCE = R"PYSANDBOX( +import ast +import builtins as _builtins +import types as _types + +# Modules that can never be imported, nor referenced as an attribute +DENIED_MODULES = frozenset([ + 'os', 'posix', 'nt', 'subprocess', 'socket', 'socketserver', 'ssl', + 'sys', 'ctypes', '_ctypes', 'cffi', 'importlib', 'imp', 'runpy', 'pkgutil', + 'zipimport', 'builtins', '__builtin__', 'shutil', 'pty', 'pexpect', + 'multiprocessing', 'threading', '_thread', 'signal', 'resource', 'fcntl', + 'mmap', 'pickle', 'cPickle', 'marshal', 'shelve', 'code', 'codeop', + 'gc', 'inspect', 'platform', 'pathlib', 'tempfile', 'glob', 'fnmatch', + 'io', 'urllib', 'http', 'ftplib', 'telnetlib', 'smtplib', 'xmlrpc', + 'asyncio', 'select', 'selectors', 'webbrowser', 'pdb', 'trace', + 'sysconfig', 'site', 'distutils', 'setuptools', 'pip', 'posixpath', + 'ntpath', 'logging.config', +]) + +# Modules a filter script may import by default (exact names) +DEFAULT_ALLOWED_MODULES = frozenset([ + 'json', 'math', 'cmath', 'time', 'datetime', 're', 'copy', 'collections', + 'itertools', 'functools', 'string', 'decimal', 'fractions', 'statistics', + 'numbers', 'random', 'bisect', 'heapq', 'struct', 'base64', 'binascii', + 'hashlib', 'hmac', 'enum', 'typing', 'textwrap', 'unicodedata', +]) + +# Builtins that are made available to scripts +SAFE_BUILTIN_NAMES = ( + 'abs', 'all', 'any', 'ascii', 'bin', 'bool', 'bytearray', 'bytes', + 'callable', 'chr', 'classmethod', 'complex', 'dict', 'divmod', + 'enumerate', 'filter', 'float', 'format', 'frozenset', 'hasattr', 'hash', + 'hex', 'id', 'int', 'isinstance', 'issubclass', 'iter', 'len', 'list', + 'map', 'max', 'min', 'next', 'object', 'oct', 'ord', 'pow', 'print', + 'property', 'range', 'repr', 'reversed', 'round', 'set', 'slice', + 'sorted', 'staticmethod', 'str', 'sum', 'super', 'tuple', 'type', 'zip', + 'NotImplemented', 'Ellipsis', 'True', 'False', 'None', + '__build_class__', + # exceptions + 'BaseException', 'Exception', 'ArithmeticError', 'AssertionError', + 'AttributeError', 'IndexError', 'KeyError', 'LookupError', 'NameError', + 'NotImplementedError', 'OverflowError', 'RuntimeError', 'StopIteration', + 'TypeError', 'UnicodeError', 'ValueError', 'ZeroDivisionError', + 'ImportError', 'ModuleNotFoundError', +) + +# Builtins that are rejected with a clear message when a script refers to them +BLOCKED_BUILTIN_NAMES = frozenset([ + 'eval', 'exec', 'compile', 'open', 'input', 'breakpoint', 'getattr', + 'setattr', 'delattr', 'globals', 'locals', 'vars', 'dir', 'memoryview', + 'exit', 'quit', 'help', 'license', 'copyright', 'credits', +]) + +# Dunder names a script may read +ALLOWED_DUNDER_NAMES = frozenset(['__name__', '__file__', '__doc__']) + + +class ScriptSecurityError(ImportError): + """A script uses something that is not permitted in a filter script""" + + +def _extra_allowed(extra): + names = set(n.strip() for n in (extra or '').split(',') if n.strip()) + return frozenset(n for n in names if n.split('.')[0] not in DENIED_MODULES + and n not in DENIED_MODULES) + + +def allowed_modules(extra=None): + return DEFAULT_ALLOWED_MODULES | _extra_allowed(extra) + + +def _is_denied(name): + return name in DENIED_MODULES or name.split('.')[0] in DENIED_MODULES + + +def _import_error(name): + return ScriptSecurityError( + "import of module '%s' is not permitted in a filter script" % name) + + +class _Checker(ast.NodeVisitor): + def __init__(self, allowed, filename): + self.allowed = allowed + self.filename = filename + + def fail(self, node, msg): + err = ScriptSecurityError(msg) + err.lineno = getattr(node, 'lineno', None) + raise err + + def visit_Import(self, node): + for alias in node.names: + if _is_denied(alias.name) or alias.name not in self.allowed: + self.fail(node, "import of module '%s' is not permitted in a " + "filter script (line %s)" % (alias.name, node.lineno)) + + def visit_ImportFrom(self, node): + if node.level or not node.module: + self.fail(node, "relative imports are not permitted in a filter " + "script (line %s)" % node.lineno) + if _is_denied(node.module) or node.module not in self.allowed: + self.fail(node, "import of module '%s' is not permitted in a " + "filter script (line %s)" % (node.module, node.lineno)) + for alias in node.names: + if alias.name == '*' or _is_denied(alias.name): + self.fail(node, "import of '%s' from '%s' is not permitted in a " + "filter script (line %s)" % + (alias.name, node.module, node.lineno)) + + def visit_Attribute(self, node): + attr = node.attr + if attr.startswith('__') and attr.endswith('__'): + self.fail(node, "access to attribute '%s' is not permitted in a " + "filter script (line %s)" % (attr, node.lineno)) + if attr in DENIED_MODULES: + self.fail(node, "access to '%s' is not permitted in a filter " + "script (line %s)" % (attr, node.lineno)) + self.generic_visit(node) + + def visit_Name(self, node): + name = node.id + if isinstance(node.ctx, ast.Load) and name in BLOCKED_BUILTIN_NAMES: + self.fail(node, "use of '%s' is not permitted in a filter " + "script (line %s)" % (name, node.lineno)) + if isinstance(node.ctx, ast.Load) and name.startswith('__') \ + and name.endswith('__') and name not in ALLOWED_DUNDER_NAMES: + self.fail(node, "use of name '%s' is not permitted in a filter " + "script (line %s)" % (name, node.lineno)) + self.generic_visit(node) + + +def check_source(source, filename='