diff --git a/.devcontainer/centos_franken_php/Dockerfile b/.devcontainer/centos_franken_php/Dockerfile
new file mode 100644
index 000000000..afdc60b5a
--- /dev/null
+++ b/.devcontainer/centos_franken_php/Dockerfile
@@ -0,0 +1,99 @@
+FROM --platform=linux/amd64 centos:8
+
+ARG PHP_VERSION=8.4
+ARG PHP_FULL_VERSION=8.4.14
+ARG FRANKENPHP_VERSION=1.9.1
+
+WORKDIR /etc/yum.repos.d/
+RUN sed -i 's/mirrorlist/#mirrorlist/g' /etc/yum.repos.d/CentOS-*
+RUN sed -i 's|#baseurl=http://mirror.centos.org|baseurl=http://vault.centos.org|g' /etc/yum.repos.d/CentOS-*
+RUN yum update -y
+RUN yum install -y yum-utils
+RUN yum install -y https://rpms.remirepo.net/enterprise/remi-release-8.4.rpm
+RUN yum install -y epel-release
+RUN dnf config-manager --set-enabled powertools || dnf config-manager --set-enabled PowerTools || true
+RUN yum install -y httpd
+RUN yum install -y cpio
+RUN yum install -y unzip
+RUN yum install -y nano
+RUN yum install -y lsof
+RUN yum install -y jq
+RUN yum install -y libcurl-devel
+RUN curl -O https://dl.google.com/go/go1.23.3.linux-amd64.tar.gz
+RUN tar -C /usr/local -xzf go1.23.3.linux-amd64.tar.gz
+ENV PATH="/usr/local/go/bin:${PATH}"
+RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
+RUN go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
+ENV PROTOC_ZIP=protoc-28.3-linux-x86_64.zip
+RUN curl -OL https://github.com/protocolbuffers/protobuf/releases/download/v28.3/$PROTOC_ZIP
+RUN unzip -o $PROTOC_ZIP -d /usr/local bin/protoc
+RUN unzip -o $PROTOC_ZIP -d /usr/local include/*
+RUN rm -f $PROTOC_ZIP
+ENV PATH="$HOME/go/bin:${PATH}"
+RUN yum install -y rpmdevtools
+RUN yum install -y git
+RUN yum install -y nginx
+RUN yum install -y sudo
+RUN yum install -y gcc gcc-c++ make
+RUN dnf -y module enable python39
+RUN yum install -y python39 python39-devel
+RUN ln -sf /usr/bin/python3.9 /usr/bin/python3 && ln -sf /usr/bin/python3.9 /usr/bin/python
+RUN pip3 install psutil flask requests --quiet --no-input
+
+# Checkout PHP sources at the requested full version tag/branch (e.g., PHP-8.4.14)
+RUN git clone --depth 1 --branch PHP-${PHP_FULL_VERSION} https://github.com/php/php-src.git /usr/local/src/php-src
+
+## Build and install a recent re2c required by PHP build system
+ENV RE2C_VERSION=3.1
+RUN curl -fsSL -o /tmp/re2c.tar.xz https://github.com/skvadrik/re2c/releases/download/${RE2C_VERSION}/re2c-${RE2C_VERSION}.tar.xz \
+ && mkdir -p /tmp/re2c-src \
+ && tar -xJf /tmp/re2c.tar.xz -C /tmp/re2c-src --strip-components=1 \
+ && cd /tmp/re2c-src \
+ && ./configure \
+ && make -j"$(nproc)" \
+ && make install
+
+RUN yum install -y \
+ autoconf automake libtool bison pkgconfig \
+ libxml2-devel \
+ oniguruma-devel \
+ libicu-devel \
+ mariadb-devel \
+ openssl-devel \
+ zlib-devel \
+ libzip-devel \
+ sqlite-devel \
+ && cd /usr/local/src/php-src \
+ && sed -i 's/\[\([0-9]\+\.[0-9]\+\.[0-9]\+\)-dev\]/[\1]/' configure.ac \
+ && ./buildconf --force \
+ && CFLAGS="$CFLAGS -fPIE -fPIC" LDFLAGS="$LDFLAGS -pie" ./configure \
+ --enable-embed \
+ --enable-zts \
+ --enable-pdo \
+ --disable-zend-signals \
+ --enable-zend-max-execution-timers \
+ --with-extra-version="" \
+ --with-config-file-scan-dir=/etc/php.d \
+ --enable-mbstring \
+ --enable-pcntl \
+ --with-curl \
+ --with-mysqli \
+ --with-openssl \
+ --with-zlib \
+ --with-zip \
+ --with-sqlite3 \
+ --with-pdo-sqlite \
+ && make -j"$(nproc)" \
+ && make install
+
+RUN mkdir -p /etc/php.d
+
+RUN ln -sf /usr/local/bin/php /usr/bin/php
+
+# Install FrankenPHP from RPM
+RUN FRANKENPHP_RPM_URL="https://github.com/php/frankenphp/releases/download/v${FRANKENPHP_VERSION}/frankenphp-${FRANKENPHP_VERSION}-1.x86_64.rpm" \
+ && curl -fsSL -L -o /tmp/frankenphp.rpm "$FRANKENPHP_RPM_URL" \
+ && yum install -y /tmp/frankenphp.rpm \
+ && rm -f /tmp/frankenphp.rpm
+
+
diff --git a/.devcontainer/centos_franken_php/devcontainer.json b/.devcontainer/centos_franken_php/devcontainer.json
new file mode 100644
index 000000000..8b7233269
--- /dev/null
+++ b/.devcontainer/centos_franken_php/devcontainer.json
@@ -0,0 +1,34 @@
+{
+ "name": "Centos FrankenPHP Dev Container",
+ "runArgs": [
+ "--privileged"
+ ],
+ "mounts": [
+ "source=${localWorkspaceFolder}/.devcontainer/shared,target=/shared,type=bind"
+ ],
+ "build": {
+ "platform": "linux/amd64",
+ "dockerfile": "Dockerfile",
+ "args": {
+ "PHP_VERSION": "8.4",
+ "PHP_FULL_VERSION": "8.4.14",
+ "FRANKENPHP_VERSION": "1.9.1"
+ }
+ },
+ "remoteUser": "root",
+ "customizations": {
+ "vscode": {
+ "extensions": [
+ "golang.go",
+ "github.vscode-github-actions",
+ "ms-vscode.cpptools-extension-pack",
+ "ms-vscode.cpptools",
+ "ms-vscode.cpptools-themes",
+ "austin.code-gnu-global",
+ "ms-vscode.makefile-tools",
+ "ms-python.vscode-pylance"
+ ]
+ }
+ }
+}
+
diff --git a/.devcontainer/centos_franken_php_arm/Dockerfile b/.devcontainer/centos_franken_php_arm/Dockerfile
new file mode 100644
index 000000000..97c99501d
--- /dev/null
+++ b/.devcontainer/centos_franken_php_arm/Dockerfile
@@ -0,0 +1,114 @@
+FROM --platform=linux/arm64 centos:8
+
+ARG PHP_VERSION=8.4
+ARG PHP_FULL_VERSION=8.4.14
+ARG FRANKENPHP_VERSION=1.9.1
+
+WORKDIR /etc/yum.repos.d/
+RUN sed -i 's/mirrorlist/#mirrorlist/g' /etc/yum.repos.d/CentOS-* \
+ && sed -i 's|#baseurl=http://mirror.centos.org|baseurl=http://vault.centos.org|g' /etc/yum.repos.d/CentOS-* \
+ && yum update -y \
+ && yum install -y yum-utils \
+ && yum install -y https://rpms.remirepo.net/enterprise/remi-release-8.4.rpm \
+ && yum install -y epel-release \
+ && dnf config-manager --set-enabled powertools || dnf config-manager --set-enabled PowerTools || true \
+ && yum install -y httpd cpio unzip nano lsof jq libcurl-devel rpmdevtools git nginx sudo gcc gcc-c++ make \
+ && yum clean all && rm -rf /var/cache/yum
+
+# Go toolchain (arm64) and protoc (aarch_64) similar to centos_arm
+RUN curl -O https://dl.google.com/go/go1.23.3.linux-arm64.tar.gz \
+ && tar -C /usr/local -xzf go1.23.3.linux-arm64.tar.gz
+ENV PATH="/usr/local/go/bin:${PATH}"
+RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@latest \
+ && go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
+ENV PROTOC_ZIP=protoc-30.2-linux-aarch_64.zip
+RUN curl -OL https://github.com/protocolbuffers/protobuf/releases/download/v30.2/$PROTOC_ZIP \
+ && unzip -o $PROTOC_ZIP -d /usr/local bin/protoc \
+ && unzip -o $PROTOC_ZIP -d /usr/local include/* \
+ && rm -f $PROTOC_ZIP
+ENV PATH="$HOME/go/bin:${PATH}"
+
+# Python 3.9 for PHP build helpers
+RUN dnf -y module enable python39 \
+ && yum install -y python39 python39-devel \
+ && ln -sf /usr/bin/python3.9 /usr/bin/python3 && ln -sf /usr/bin/python3.9 /usr/bin/python
+
+RUN pip3 install psutil flask requests --quiet --no-input
+
+# Fetch PHP sources
+RUN git clone --depth 1 --branch PHP-${PHP_FULL_VERSION} https://github.com/php/php-src.git /usr/local/src/php-src
+
+# Build re2c from source (arm64)
+ENV RE2C_VERSION=3.1
+RUN curl -fsSL -o /tmp/re2c.tar.xz https://github.com/skvadrik/re2c/releases/download/${RE2C_VERSION}/re2c-${RE2C_VERSION}.tar.xz \
+ && mkdir -p /tmp/re2c-src \
+ && tar -xJf /tmp/re2c.tar.xz -C /tmp/re2c-src --strip-components=1 \
+ && cd /tmp/re2c-src \
+ && ./configure \
+ && make -j"$(nproc)" \
+ && make install \
+ && rm -rf /tmp/re2c.tar.xz /tmp/re2c-src
+
+# Build latest GDB from release tarball (more stable than git)
+ENV GDB_VERSION=14.2
+RUN yum install -y texinfo mpfr-devel gmp-devel ncurses-devel readline-devel zlib-devel expat-devel \
+ libmpc-devel gettext-devel \
+ && curl -fsSL -o /tmp/gdb-${GDB_VERSION}.tar.xz https://ftp.gnu.org/gnu/gdb/gdb-${GDB_VERSION}.tar.xz \
+ && mkdir -p /tmp/gdb-src \
+ && tar -xJf /tmp/gdb-${GDB_VERSION}.tar.xz -C /tmp/gdb-src --strip-components=1 \
+ && cd /tmp/gdb-src \
+ && ./configure --prefix=/usr/local \
+ --with-python=/usr/bin/python3.9 \
+ --enable-tui \
+ --with-readline \
+ && make -j"$(nproc)" \
+ && make install \
+ && echo 'set auto-load safe-path /' > /root/.gdbinit
+
+RUN yum install -y \
+ autoconf automake libtool bison pkgconfig \
+ libxml2-devel \
+ oniguruma-devel \
+ libicu-devel \
+ mariadb-devel \
+ openssl-devel \
+ libzip-devel \
+ sqlite-devel \
+ && cd /usr/local/src/php-src \
+ && sed -i 's/\[\([0-9]\+\.[0-9]\+\.[0-9]\+\)-dev\]/[\1]/' configure.ac \
+ && ./buildconf --force \
+ && CFLAGS="$CFLAGS -fPIE -fPIC" LDFLAGS="$LDFLAGS -pie" ./configure \
+ --enable-embed \
+ --enable-zts \
+ --enable-pdo \
+ --disable-zend-signals \
+ --enable-zend-max-execution-timers \
+ --with-extra-version="" \
+ --with-config-file-scan-dir=/etc/php.d \
+ --enable-mbstring \
+ --enable-pcntl \
+ --with-curl \
+ --with-mysqli \
+ --with-openssl \
+ --with-zlib \
+ --with-zip \
+ --with-sqlite3 \
+ --with-pdo-sqlite \
+ && make -j"$(nproc)" \
+ && make install
+
+RUN mkdir -p /etc/php.d
+
+RUN ln -sf /usr/local/bin/php /usr/bin/php
+
+# Try to install FrankenPHP for arm64 (optional)
+RUN FRANKENPHP_ARCH=aarch64 \
+ && FRANKENPHP_RPM_URL="https://github.com/php/frankenphp/releases/download/v${FRANKENPHP_VERSION}/frankenphp-${FRANKENPHP_VERSION}-1.${FRANKENPHP_ARCH}.rpm" \
+ && curl -fsSL -L -o /tmp/frankenphp.rpm "$FRANKENPHP_RPM_URL" \
+ && yum install -y /tmp/frankenphp.rpm || true \
+ && rm -f /tmp/frankenphp.rpm || true
+
+# Final PATH to include phpize/php-config if installed
+ENV PATH="/usr/local/bin:/usr/local/sbin:${PATH}"
+
+
diff --git a/.devcontainer/centos_franken_php_arm/devcontainer.json b/.devcontainer/centos_franken_php_arm/devcontainer.json
new file mode 100644
index 000000000..93d7c634c
--- /dev/null
+++ b/.devcontainer/centos_franken_php_arm/devcontainer.json
@@ -0,0 +1,32 @@
+{
+ "name": "Centos FrankenPHP (arm64)",
+ "runArgs": [],
+ "mounts": [
+ "source=${localWorkspaceFolder}/.devcontainer/shared,target=/shared,type=bind"
+ ],
+ "build": {
+ "platform": "linux/arm64",
+ "dockerfile": "Dockerfile",
+ "args": {
+ "PHP_VERSION": "8.4",
+ "PHP_FULL_VERSION": "8.4.14",
+ "FRANKENPHP_VERSION": "1.9.1"
+ }
+ },
+ "remoteUser": "root",
+ "customizations": {
+ "vscode": {
+ "extensions": [
+ "golang.go",
+ "github.vscode-github-actions",
+ "ms-vscode.cpptools-extension-pack",
+ "ms-vscode.cpptools",
+ "ms-vscode.cpptools-themes",
+ "austin.code-gnu-global",
+ "ms-vscode.makefile-tools"
+ ]
+ }
+ }
+}
+
+
diff --git a/.devcontainer/centos_php_test_nts/Dockerfile b/.devcontainer/centos_php_test_nts/Dockerfile
new file mode 100644
index 000000000..ae217015c
--- /dev/null
+++ b/.devcontainer/centos_php_test_nts/Dockerfile
@@ -0,0 +1,230 @@
+# syntax=docker/dockerfile:1.7
+# CentOS Stream 9 test image with PHP built from source in NTS mode
+# Used for testing the extension with standard PHP (non-thread-safe)
+
+ARG BASE_IMAGE=quay.io/centos/centos:stream9
+ARG PHP_VERSION=8.3
+ARG PHP_SRC_REF=PHP-${PHP_VERSION}
+
+FROM ${BASE_IMAGE} AS base
+SHELL ["/bin/bash", "-euo", "pipefail", "-c"]
+
+ENV TZ=Etc/UTC \
+ LC_ALL=C.UTF-8 \
+ LANG=C.UTF-8 \
+ PHP_VERSION=${PHP_VERSION}
+
+RUN yum install -y yum-utils && \
+ dnf config-manager --set-enabled crb || dnf config-manager --set-enabled powertools || true
+
+# Install minimal tools needed for re2c build (replace curl-minimal with full curl)
+RUN yum install -y xz tar gcc gcc-c++ make
+
+ENV RE2C_VERSION=3.1
+RUN curl -fsSL -o /tmp/re2c.tar.xz https://github.com/skvadrik/re2c/releases/download/${RE2C_VERSION}/re2c-${RE2C_VERSION}.tar.xz \
+ && mkdir -p /tmp/re2c-src \
+ && tar -xJf /tmp/re2c.tar.xz -C /tmp/re2c-src --strip-components=1 \
+ && cd /tmp/re2c-src \
+ && ./configure \
+ && make -j"$(nproc)" \
+ && make install \
+ && rm -rf /tmp/re2c-src /tmp/re2c.tar.xz
+
+# Install remaining build dependencies and tools
+RUN yum install -y autoconf bison pkgconfig \
+ libxml2-devel sqlite-devel libcurl-devel openssl-devel \
+ libzip-devel oniguruma-devel libjpeg-turbo-devel libpng-devel libwebp-devel \
+ libicu-devel readline-devel libxslt-devel \
+ git wget \
+ python3 python3-devel python3-pip \
+ nginx httpd httpd-devel procps-ng mysql-server \
+ cpio unzip nano lsof jq rpmdevtools sudo \
+ && yum clean all
+
+# Install mariadb-devel separately (may need different repo or skip if not critical)
+RUN yum install -y mariadb-devel || yum install -y mariadb-connector-c-devel || echo "Warning: mariadb-devel not available, continuing without it"
+
+# Install Go toolchain (architecture-aware)
+RUN ARCH=$(uname -m) && \
+ if [ "$ARCH" = "x86_64" ] || [ "$ARCH" = "amd64" ]; then \
+ curl -O https://dl.google.com/go/go1.23.3.linux-amd64.tar.gz && \
+ tar -C /usr/local -xzf go1.23.3.linux-amd64.tar.gz && \
+ rm -f go1.23.3.linux-amd64.tar.gz; \
+ elif [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then \
+ curl -O https://dl.google.com/go/go1.23.3.linux-arm64.tar.gz && \
+ tar -C /usr/local -xzf go1.23.3.linux-arm64.tar.gz && \
+ rm -f go1.23.3.linux-arm64.tar.gz; \
+ else \
+ echo "Unsupported architecture: $ARCH" && exit 1; \
+ fi
+ENV PATH="/usr/local/go/bin:${PATH}"
+
+# Install protoc and Go protobuf plugins
+RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@latest \
+ && go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
+RUN ARCH=$(uname -m) && \
+ if [ "$ARCH" = "x86_64" ] || [ "$ARCH" = "amd64" ]; then \
+ PROTOC_ZIP=protoc-28.3-linux-x86_64.zip && \
+ curl -OL https://github.com/protocolbuffers/protobuf/releases/download/v28.3/$PROTOC_ZIP && \
+ unzip -o $PROTOC_ZIP -d /usr/local bin/protoc && \
+ unzip -o $PROTOC_ZIP -d /usr/local include/* && \
+ rm -f $PROTOC_ZIP; \
+ elif [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then \
+ PROTOC_ZIP=protoc-28.3-linux-aarch_64.zip && \
+ curl -OL https://github.com/protocolbuffers/protobuf/releases/download/v28.3/$PROTOC_ZIP && \
+ unzip -o $PROTOC_ZIP -d /usr/local bin/protoc && \
+ unzip -o $PROTOC_ZIP -d /usr/local include/* && \
+ rm -f $PROTOC_ZIP; \
+ else \
+ echo "Unsupported architecture: $ARCH" && exit 1; \
+ fi
+ENV PATH="$HOME/go/bin:${PATH}"
+
+# Fetch and build PHP from source with NTS
+FROM base AS php-build
+ARG PHP_SRC_REF
+WORKDIR /usr/src
+RUN git clone --depth 1 --branch "${PHP_SRC_REF}" https://github.com/php/php-src.git
+WORKDIR /usr/src/php-src
+
+RUN sed -i 's/\[\([0-9]\+\.[0-9]\+\.[0-9]\+\)-dev\]/[\1]/' configure.ac
+RUN ./buildconf --force
+
+# Patch openssl.c for OpenSSL compatibility (RSA_SSLV23_PADDING may not be available in newer OpenSSL)
+RUN if [ -f ext/openssl/openssl.c ] && grep -q 'REGISTER_LONG_CONSTANT("OPENSSL_SSLV23_PADDING"' ext/openssl/openssl.c; then \
+ awk '/REGISTER_LONG_CONSTANT\("OPENSSL_SSLV23_PADDING"/ { \
+ indent = $0; \
+ gsub(/[^[:space:]].*/, "", indent); \
+ print indent "#ifdef RSA_SSLV23_PADDING"; \
+ gsub(/^[[:space:]]*/, indent " "); \
+ print; \
+ print indent "#endif"; \
+ next \
+ } \
+ { print }' ext/openssl/openssl.c > ext/openssl/openssl.c.new && \
+ mv ext/openssl/openssl.c.new ext/openssl/openssl.c; \
+ fi || true
+
+# Build PHP with NTS (no ZTS flags)
+RUN ./configure \
+ --prefix=/usr/local \
+ --with-config-file-path=/usr/local/lib \
+ --with-config-file-scan-dir=/usr/local/etc/php/conf.d \
+ --enable-fpm \
+ --enable-mbstring \
+ --enable-pcntl \
+ --with-extra-version="" \
+ --with-curl \
+ --with-mysqli \
+ --with-openssl \
+ --with-zlib \
+ --with-zip \
+ --with-apxs2=/usr/bin/apxs \
+&& make -j"$(nproc)" \
+&& make install \
+&& strip /usr/local/bin/php /usr/local/sbin/php-fpm || true
+
+# Final image with PHP and test infrastructure
+FROM base AS final
+COPY --from=php-build /usr/local /usr/local
+# Copy libphp.so module (installed by apxs to Apache modules directory, not /usr/local)
+# Note: /usr/lib64/ is the standard path for 64-bit libraries on both x86_64 and aarch64
+# The architecture is determined by the binary itself (ELF header), not the directory path
+# apxs installs to /usr/lib64/httpd/modules/ on CentOS/RHEL, or /usr/lib/httpd/modules/ on some distros
+# We need to copy it from the build stage since COPY --from=php-build /usr/local only copies /usr/local
+RUN mkdir -p /usr/lib64/httpd/modules /usr/lib/httpd/modules
+# Copy from lib64 (standard on CentOS/RHEL for both x86_64 and aarch64)
+# If libphp.so exists in the build stage, it will be copied; if not, COPY will fail gracefully
+COPY --from=php-build /usr/lib64/httpd/modules/ /usr/lib64/httpd/modules/
+
+RUN EXTENSION_DIR=$(php -i | grep "^extension_dir" | awk '{print $3}') && \
+ if [ -z "$EXTENSION_DIR" ]; then \
+ echo "Error: Could not determine extension_dir"; \
+ exit 1; \
+ fi && \
+ mkdir -p "$EXTENSION_DIR" \
+ echo "Created extension_dir: $EXTENSION_DIR"
+
+# Verify NTS (ZTS should NOT be enabled)
+RUN php -v | grep -v "ZTS" >/dev/null || (echo "ERROR: ZTS is enabled but should be NTS!" && exit 1) && \
+ php -m | grep -E 'curl|mysqli' >/dev/null
+
+ENV PATH="/usr/local/bin:${PATH}"
+
+RUN ln -sf /usr/local/bin/php /usr/bin/php && \
+ ln -sf /usr/local/sbin/php-fpm /usr/sbin/php-fpm || true
+
+RUN mkdir -p /etc/php-fpm.d && \
+ mkdir -p /run/php-fpm && \
+ mkdir -p /var/run && \
+ mkdir -p /var/log/php-fpm && \
+ mkdir -p /etc/httpd || true && \
+ mkdir -p /usr/local/etc/php-fpm.d && \
+ mkdir -p /usr/local/etc/php/conf.d && \
+
+ ln -sf /usr/local/etc/php/conf.d /etc/php.d || true && \
+
+ echo "[global]" > /usr/local/etc/php-fpm.conf && \
+ echo "pid = /run/php-fpm/php-fpm.pid" >> /usr/local/etc/php-fpm.conf && \
+ echo "error_log = /var/log/php-fpm/error.log" >> /usr/local/etc/php-fpm.conf && \
+ echo "daemonize = yes" >> /usr/local/etc/php-fpm.conf && \
+ echo "include=/usr/local/etc/php-fpm.d/*.conf" >> /usr/local/etc/php-fpm.conf && \
+ echo "include=/etc/php-fpm.d/*.conf" >> /usr/local/etc/php-fpm.conf && \
+
+ echo "[www]" > /usr/local/etc/php-fpm.d/www.conf && \
+ echo "user = root" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "group = root" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "listen = 127.0.0.1:9000" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "listen.owner = root" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "listen.group = root" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "pm = dynamic" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "pm.max_children = 5" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "pm.start_servers = 2" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "pm.min_spare_servers = 1" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "pm.max_spare_servers = 3" >> /usr/local/etc/php-fpm.d/www.conf && \
+
+ php-fpm -t -y /usr/local/etc/php-fpm.conf 2>&1 | grep -v "Nothing matches the include pattern" || true && \
+ php-fpm -t -y /usr/local/etc/php-fpm.conf >/dev/null 2>&1 || \
+ (PHP_MAJOR=$(php -r 'echo PHP_MAJOR_VERSION;') && \
+ PHP_MINOR=$(php -r 'echo PHP_MINOR_VERSION;') && \
+ echo "PHP-FPM config test failed for PHP ${PHP_MAJOR}.${PHP_MINOR}" && \
+ echo "Config file contents:" && cat /usr/local/etc/php-fpm.conf && \
+ echo "Pool config:" && cat /usr/local/etc/php-fpm.d/www.conf && \
+ exit 1) && \
+
+ ln -sf /usr/local/etc/php-fpm.conf /etc/php-fpm.conf && \
+ ln -sf /usr/local/etc/php-fpm.d/www.conf /etc/php-fpm.d/www.conf || true
+
+# Configure MySQL socket path for mysqli (so "localhost" connections work)
+RUN mkdir -p /usr/local/etc/php/conf.d && \
+ echo "mysqli.default_socket = /var/lib/mysql/mysql.sock" > /usr/local/etc/php/conf.d/mysql-socket.ini
+
+RUN mkdir -p /etc/php/${PHP_VERSION}/apache2/conf.d && \
+ echo "Created Apache mod_php directory: /etc/php/${PHP_VERSION}/apache2/conf.d"
+
+# Configure Apache to load libphp.so module (if it exists)
+RUN if [ -f /usr/lib64/httpd/modules/libphp.so ]; then \
+ echo "# Load PHP module for Apache" > /etc/httpd/conf.modules.d/10-php.conf && \
+ echo "LoadModule php_module modules/libphp.so" >> /etc/httpd/conf.modules.d/10-php.conf && \
+ echo "" >> /etc/httpd/conf.modules.d/10-php.conf && \
+ echo "# Configure PHP file handling" >> /etc/httpd/conf.modules.d/10-php.conf && \
+ echo "" >> /etc/httpd/conf.modules.d/10-php.conf && \
+ echo " SetHandler application/x-httpd-php" >> /etc/httpd/conf.modules.d/10-php.conf && \
+ echo "" >> /etc/httpd/conf.modules.d/10-php.conf && \
+ echo "" >> /etc/httpd/conf.modules.d/10-php.conf && \
+ echo "# Directory index" >> /etc/httpd/conf.modules.d/10-php.conf && \
+ echo "DirectoryIndex index.php index.html" >> /etc/httpd/conf.modules.d/10-php.conf && \
+ echo "Created Apache PHP module configuration at /etc/httpd/conf.modules.d/10-php.conf"; \
+ else \
+ echo "Warning: libphp.so not found, skipping Apache PHP module configuration"; \
+ fi
+
+# Python deps used by test harness
+RUN python3 -m pip install --no-cache-dir --upgrade pip && \
+ python3 -m pip install --no-cache-dir flask requests psutil
+
+# Quality-of-life
+WORKDIR /work
+CMD ["bash"]
+
+
diff --git a/.github/workflows/Dockerfile.build-extension b/.github/workflows/Dockerfile.build-extension-nts
similarity index 100%
rename from .github/workflows/Dockerfile.build-extension
rename to .github/workflows/Dockerfile.build-extension-nts
diff --git a/.github/workflows/Dockerfile.build-extension-zts b/.github/workflows/Dockerfile.build-extension-zts
new file mode 100644
index 000000000..2fc2b99dd
--- /dev/null
+++ b/.github/workflows/Dockerfile.build-extension-zts
@@ -0,0 +1,98 @@
+# syntax=docker/dockerfile:1.7
+
+ARG BASE_IMAGE=ubuntu:20.04
+ARG PHP_VERSION=8.3
+ARG PHP_SRC_REF=PHP-${PHP_VERSION}
+
+FROM ${BASE_IMAGE} AS base
+SHELL ["/bin/bash", "-eo", "pipefail", "-c"]
+
+ENV DEBIAN_FRONTEND=noninteractive \
+ TZ=Etc/UTC \
+ LC_ALL=C.UTF-8 \
+ LANG=C.UTF-8 \
+ LANGUAGE=C.UTF-8
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends tzdata ca-certificates git wget curl xz-utils \
+ && ln -fs /usr/share/zoneinfo/${TZ} /etc/localtime \
+ && echo "${TZ}" > /etc/timezone \
+ && dpkg-reconfigure -f noninteractive tzdata \
+ && update-ca-certificates \
+ && git config --global http.sslCAInfo /etc/ssl/certs/ca-certificates.crt \
+ && git config --global http.sslVerify true \
+ && rm -rf /var/lib/apt/lists/*
+
+# Builder: toolchain + dev libs
+FROM base AS build-deps
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ build-essential autoconf bison re2c pkg-config \
+ libxml2-dev libsqlite3-dev libcurl4-openssl-dev libssl-dev \
+ libzip-dev libonig-dev libjpeg-dev libpng-dev libwebp-dev \
+ libicu-dev libreadline-dev libxslt1-dev default-libmysqlclient-dev \
+ wget tar \
+ && rm -rf /var/lib/apt/lists/*
+
+
+# Fetch php-src
+FROM build-deps AS php-src
+ARG PHP_SRC_REF
+WORKDIR /usr/src
+RUN git clone --depth 1 --branch "${PHP_SRC_REF}" https://github.com/php/php-src.git
+WORKDIR /usr/src/php-src
+
+RUN sed -i 's/\[\([0-9]\+\.[0-9]\+\.[0-9]\+\)-dev\]/[\1]/' configure.ac
+RUN ./buildconf --force
+
+# Patch openssl.c for OpenSSL compatibility (RSA_SSLV23_PADDING may not be available in newer OpenSSL)
+RUN if [ -f ext/openssl/openssl.c ] && grep -q 'REGISTER_LONG_CONSTANT("OPENSSL_SSLV23_PADDING"' ext/openssl/openssl.c; then \
+ awk '/REGISTER_LONG_CONSTANT\("OPENSSL_SSLV23_PADDING"/ { \
+ indent = $0; \
+ gsub(/[^[:space:]].*/, "", indent); \
+ print indent "#ifdef RSA_SSLV23_PADDING"; \
+ gsub(/^[[:space:]]*/, indent " "); \
+ print; \
+ print indent "#endif"; \
+ next \
+ } \
+ { print }' ext/openssl/openssl.c > ext/openssl/openssl.c.new && \
+ mv ext/openssl/openssl.c.new ext/openssl/openssl.c; \
+ fi || true
+
+# Build PHP with ZTS enabled
+FROM php-src AS php-build
+# Configure flags with --enable-zts for thread safety
+RUN ./configure \
+ --prefix=/usr/local \
+ --with-config-file-path=/usr/local/lib \
+ --with-config-file-scan-dir=/usr/local/etc/php/conf.d \
+ --enable-zts \
+ --enable-maintainer-zts \
+ --enable-mbstring \
+ --enable-pcntl \
+ --with-extra-version="" \
+ --with-curl \
+ --with-mysqli \
+ --with-openssl \
+ --with-zlib \
+ --with-zip \
+&& make -j"$(nproc)" \
+&& make install \
+&& strip /usr/local/bin/php || true
+
+
+FROM build-deps AS dev
+COPY --from=php-build /usr/local /usr/local
+# Sanity check: verify ZTS is enabled and required extensions are available
+RUN php -v | grep -q "ZTS" || (echo "ERROR: ZTS not enabled!" && exit 1) && \
+ php -m | grep -E 'curl|mysqli' >/dev/null
+ENV PATH="/usr/local/bin:${PATH}"
+
+RUN mkdir -p /usr/local/etc/php/conf.d
+WORKDIR /work
+CMD ["php", "-v"]
+
+
+
+
+
diff --git a/.github/workflows/Dockerfile.centos-php-test b/.github/workflows/Dockerfile.centos-php-test
deleted file mode 100644
index 3e2bd8084..000000000
--- a/.github/workflows/Dockerfile.centos-php-test
+++ /dev/null
@@ -1,36 +0,0 @@
-# syntax=docker/dockerfile:1.7
-# CentOS Stream 9 test image with PHP (from Remi) preinstalled per version,
-# plus httpd (mod_php), nginx + php-fpm, MySQL server and Python deps.
-
-ARG BASE_IMAGE=quay.io/centos/centos:stream9
-ARG PHP_VERSION=8.3
-
-FROM ${BASE_IMAGE}
-SHELL ["/bin/bash", "-euo", "pipefail", "-c"]
-
-
-# Remi repo + chosen PHP stream
-ARG PHP_VERSION
-RUN yum install -y yum-utils
-RUN dnf -y install https://rpms.remirepo.net/enterprise/remi-release-9.rpm
-RUN yum install -y gcc
-RUN yum install -y python3-devel
-RUN dnf --assumeyes module reset php
-RUN dnf --assumeyes --nogpgcheck module install php:remi-${PHP_VERSION}
-RUN dnf --assumeyes install php-pdo
-RUN dnf --assumeyes install php-mysqlnd
-RUN if [ "$(printf '%s\n' "${PHP_VERSION}" "8.5" | sort -V | head -n1)" != "8.5" ]; then \
- dnf --assumeyes install php-opcache || true; \
- fi
-RUN yum install -y mod_php nginx php-fpm procps-ng mysql-server
-
-
-# Python deps used by your test harness
-RUN python3 -m pip install --no-cache-dir --upgrade pip \
- && python3 -m pip install --no-cache-dir flask requests psutil
-
- RUN yum install -y httpd
-# Quality-of-life
-ENV TZ=Etc/UTC
-WORKDIR /work
-CMD ["bash"]
diff --git a/.github/workflows/Dockerfile.centos-php-test-nts b/.github/workflows/Dockerfile.centos-php-test-nts
new file mode 100644
index 000000000..854530aa1
--- /dev/null
+++ b/.github/workflows/Dockerfile.centos-php-test-nts
@@ -0,0 +1,210 @@
+# syntax=docker/dockerfile:1.7
+# CentOS Stream 9 test image with PHP built from source in NTS mode
+# Used for testing the extension with standard PHP (non-thread-safe)
+
+ARG BASE_IMAGE=quay.io/centos/centos:stream9
+ARG PHP_VERSION=8.3
+ARG PHP_SRC_REF=PHP-${PHP_VERSION}
+
+FROM ${BASE_IMAGE} AS base
+SHELL ["/bin/bash", "-euo", "pipefail", "-c"]
+
+ENV TZ=Etc/UTC \
+ LC_ALL=C.UTF-8 \
+ LANG=C.UTF-8 \
+ PHP_VERSION=${PHP_VERSION}
+
+RUN yum install -y yum-utils && \
+ dnf config-manager --set-enabled crb || dnf config-manager --set-enabled powertools || true
+
+# Install minimal tools needed for re2c build (replace curl-minimal with full curl)
+RUN yum install -y xz tar gcc gcc-c++ make
+
+ENV RE2C_VERSION=3.1
+RUN curl -fsSL -o /tmp/re2c.tar.xz https://github.com/skvadrik/re2c/releases/download/${RE2C_VERSION}/re2c-${RE2C_VERSION}.tar.xz \
+ && mkdir -p /tmp/re2c-src \
+ && tar -xJf /tmp/re2c.tar.xz -C /tmp/re2c-src --strip-components=1 \
+ && cd /tmp/re2c-src \
+ && ./configure \
+ && make -j"$(nproc)" \
+ && make install \
+ && rm -rf /tmp/re2c-src /tmp/re2c.tar.xz
+
+# Install remaining build dependencies and tools
+RUN yum install -y autoconf bison pkgconfig \
+ libxml2-devel sqlite-devel libcurl-devel openssl-devel \
+ libzip-devel oniguruma-devel libjpeg-turbo-devel libpng-devel libwebp-devel \
+ libicu-devel readline-devel libxslt-devel \
+ git wget \
+ python3 python3-devel python3-pip \
+ nginx httpd httpd-devel procps-ng mysql-server \
+ && yum clean all
+
+# Install mariadb-devel separately (may need different repo or skip if not critical)
+RUN yum install -y mariadb-devel || yum install -y mariadb-connector-c-devel || echo "Warning: mariadb-devel not available, continuing without it"
+
+# Fetch and build PHP from source with NTS
+FROM base AS php-build
+ARG PHP_SRC_REF
+WORKDIR /usr/src
+RUN git clone --depth 1 --branch "${PHP_SRC_REF}" https://github.com/php/php-src.git
+WORKDIR /usr/src/php-src
+
+RUN sed -i 's/\[\([0-9]\+\.[0-9]\+\.[0-9]\+\)-dev\]/[\1]/' configure.ac
+RUN ./buildconf --force
+
+# Patch openssl.c for OpenSSL compatibility (RSA_SSLV23_PADDING may not be available in newer OpenSSL)
+RUN if [ -f ext/openssl/openssl.c ] && grep -q 'REGISTER_LONG_CONSTANT("OPENSSL_SSLV23_PADDING"' ext/openssl/openssl.c; then \
+ awk '/REGISTER_LONG_CONSTANT\("OPENSSL_SSLV23_PADDING"/ { \
+ indent = $0; \
+ gsub(/[^[:space:]].*/, "", indent); \
+ print indent "#ifdef RSA_SSLV23_PADDING"; \
+ gsub(/^[[:space:]]*/, indent " "); \
+ print; \
+ print indent "#endif"; \
+ next \
+ } \
+ { print }' ext/openssl/openssl.c > ext/openssl/openssl.c.new && \
+ mv ext/openssl/openssl.c.new ext/openssl/openssl.c; \
+ fi || true
+
+RUN mkdir -p /usr/local/etc/php/conf.d
+
+# Configure Apache to use prefork MPM (non-threaded) before building PHP
+# This prevents PHP's configure script from auto-enabling ZTS when --with-apxs2 is used
+RUN mkdir -p /etc/httpd/conf.modules.d && \
+ sed -i 's/^LoadModule mpm_event_module/#LoadModule mpm_event_module/g' /etc/httpd/conf.modules.d/*.conf 2>/dev/null || true && \
+ sed -i 's/^LoadModule mpm_worker_module/#LoadModule mpm_worker_module/g' /etc/httpd/conf.modules.d/*.conf 2>/dev/null || true && \
+ sed -i 's/^LoadModule mpm_prefork_module/#LoadModule mpm_prefork_module/g' /etc/httpd/conf.modules.d/*.conf 2>/dev/null || true && \
+ echo "LoadModule mpm_prefork_module modules/mod_mpm_prefork.so" > /etc/httpd/conf.modules.d/00-mpm-prefork.conf
+
+# Build PHP with NTS (no ZTS flags)
+RUN ./configure \
+ --prefix=/usr/local \
+ --with-config-file-path=/usr/local/lib \
+ --with-config-file-scan-dir=/usr/local/etc/php/conf.d \
+ --enable-fpm \
+ --enable-mbstring \
+ --enable-pcntl \
+ --enable-cgi \
+ --with-extra-version="" \
+ --with-curl \
+ --with-mysqli \
+ --with-openssl \
+ --with-zlib \
+ --with-zip \
+ --with-apxs2=/usr/bin/apxs \
+ --disable-zts \
+&& make -j"$(nproc)" \
+&& make install \
+&& strip /usr/local/bin/php /usr/local/sbin/php-fpm || true
+
+# Final image with PHP and test infrastructure
+FROM base AS final
+COPY --from=php-build /usr/local /usr/local
+
+RUN mkdir -p /usr/lib64/httpd/modules
+COPY --from=php-build /usr/lib64/httpd/modules/ /usr/lib64/httpd/modules/
+
+RUN EXTENSION_DIR=$(php -i | grep "^extension_dir" | awk '{print $3}') && \
+ if [ -z "$EXTENSION_DIR" ]; then \
+ echo "Error: Could not determine extension_dir"; \
+ exit 1; \
+ fi && \
+ mkdir -p "$EXTENSION_DIR" \
+ echo "Created extension_dir: $EXTENSION_DIR"
+
+# Verify NTS (ZTS should NOT be enabled)
+RUN php -v | grep -v "ZTS" >/dev/null || (echo "ERROR: ZTS is enabled but should be NTS!" && exit 1) && \
+ php -m | grep -E 'curl|mysqli' >/dev/null
+
+ENV PATH="/usr/local/bin:${PATH}"
+
+RUN ln -sf /usr/local/bin/php /usr/bin/php && \
+ ln -sf /usr/local/sbin/php-fpm /usr/sbin/php-fpm || true && \
+ ln -sf /usr/local/bin/php-cgi /usr/bin/php-cgi
+
+RUN mkdir -p /etc/php-fpm.d && \
+ mkdir -p /run/php-fpm && \
+ mkdir -p /var/run && \
+ mkdir -p /var/log/php-fpm && \
+ mkdir -p /etc/httpd || true && \
+ mkdir -p /usr/local/etc/php-fpm.d && \
+ mkdir -p /usr/local/etc/php/conf.d && \
+
+ ln -sf /usr/local/etc/php/conf.d /etc/php.d || true && \
+
+ echo "[global]" > /usr/local/etc/php-fpm.conf && \
+ echo "pid = /run/php-fpm/php-fpm.pid" >> /usr/local/etc/php-fpm.conf && \
+ echo "error_log = /var/log/php-fpm/error.log" >> /usr/local/etc/php-fpm.conf && \
+ echo "daemonize = yes" >> /usr/local/etc/php-fpm.conf && \
+ echo "include=/usr/local/etc/php-fpm.d/*.conf" >> /usr/local/etc/php-fpm.conf && \
+ echo "include=/etc/php-fpm.d/*.conf" >> /usr/local/etc/php-fpm.conf && \
+
+ echo "[www]" > /usr/local/etc/php-fpm.d/www.conf && \
+ echo "user = root" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "group = root" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "listen = 127.0.0.1:9000" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "listen.owner = root" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "listen.group = root" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "pm = dynamic" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "pm.max_children = 5" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "pm.start_servers = 2" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "pm.min_spare_servers = 1" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "pm.max_spare_servers = 3" >> /usr/local/etc/php-fpm.d/www.conf && \
+
+ php-fpm -t -y /usr/local/etc/php-fpm.conf 2>&1 | grep -v "Nothing matches the include pattern" || true && \
+ php-fpm -t -y /usr/local/etc/php-fpm.conf >/dev/null 2>&1 || \
+ (PHP_MAJOR=$(php -r 'echo PHP_MAJOR_VERSION;') && \
+ PHP_MINOR=$(php -r 'echo PHP_MINOR_VERSION;') && \
+ echo "PHP-FPM config test failed for PHP ${PHP_MAJOR}.${PHP_MINOR}" && \
+ echo "Config file contents:" && cat /usr/local/etc/php-fpm.conf && \
+ echo "Pool config:" && cat /usr/local/etc/php-fpm.d/www.conf && \
+ exit 1) && \
+
+ ln -sf /usr/local/etc/php-fpm.conf /etc/php-fpm.conf && \
+ ln -sf /usr/local/etc/php-fpm.d/www.conf /etc/php-fpm.d/www.conf || true
+
+# Configure MySQL socket path for mysqli (so "localhost" connections work)
+# Note: /usr/local/etc/php/conf.d was created before PHP build
+RUN echo "mysqli.default_socket = /var/lib/mysql/mysql.sock" > /usr/local/etc/php/conf.d/mysql-socket.ini
+
+# Configure Apache to load libphp.so module (if it exists)
+# PHP 7 uses libphp7.so, other versions use libphp.so
+RUN PHP_MAJOR=$(php -r 'echo PHP_MAJOR_VERSION;') && \
+ if [ "$PHP_MAJOR" = "7" ]; then \
+ LIBPHP_NAME="libphp7.so"; \
+ else \
+ LIBPHP_NAME="libphp.so"; \
+ fi && \
+ if [ -f "/usr/lib64/httpd/modules/${LIBPHP_NAME}" ]; then \
+ echo "# Load PHP module for Apache" > /etc/httpd/conf.modules.d/10-php.conf && \
+ if [ "$PHP_MAJOR" = "7" ]; then \
+ echo "LoadModule php7_module /usr/lib64/httpd/modules/${LIBPHP_NAME}" >> /etc/httpd/conf.modules.d/10-php.conf && \
+ echo "" >> /etc/httpd/conf.modules.d/10-php.conf && \
+ echo "# Configure PHP file handling" >> /etc/httpd/conf.modules.d/10-php.conf && \
+ echo "" >> /etc/httpd/conf.modules.d/10-php.conf; \
+ else \
+ echo "LoadModule php_module /usr/lib64/httpd/modules/${LIBPHP_NAME}" >> /etc/httpd/conf.modules.d/10-php.conf && \
+ echo "" >> /etc/httpd/conf.modules.d/10-php.conf && \
+ echo "# Configure PHP file handling" >> /etc/httpd/conf.modules.d/10-php.conf && \
+ echo "" >> /etc/httpd/conf.modules.d/10-php.conf; \
+ fi && \
+ echo " PHPIniDir /usr/local/etc/php" >> /etc/httpd/conf.modules.d/10-php.conf && \
+ echo " " >> /etc/httpd/conf.modules.d/10-php.conf && \
+ echo " SetHandler application/x-httpd-php" >> /etc/httpd/conf.modules.d/10-php.conf && \
+ echo " " >> /etc/httpd/conf.modules.d/10-php.conf && \
+ echo " DirectoryIndex index.php index.html" >> /etc/httpd/conf.modules.d/10-php.conf && \
+ echo "" >> /etc/httpd/conf.modules.d/10-php.conf && \
+ echo "Created Apache PHP module configuration at /etc/httpd/conf.modules.d/10-php.conf with ${LIBPHP_NAME}"; \
+ else \
+ echo "Warning: ${LIBPHP_NAME} not found, skipping Apache PHP module configuration"; \
+ fi
+
+# Python deps used by test harness
+RUN python3 -m pip install --no-cache-dir --upgrade pip && \
+ python3 -m pip install --no-cache-dir flask requests psutil
+
+# Quality-of-life
+WORKDIR /work
+CMD ["bash"]
diff --git a/.github/workflows/Dockerfile.centos-php-test-zts b/.github/workflows/Dockerfile.centos-php-test-zts
new file mode 100644
index 000000000..d65b1312f
--- /dev/null
+++ b/.github/workflows/Dockerfile.centos-php-test-zts
@@ -0,0 +1,234 @@
+# syntax=docker/dockerfile:1.7
+# CentOS Stream 9 test image with PHP built from source in ZTS mode
+# Used for testing the extension with FrankenPHP and other ZTS environments
+
+ARG BASE_IMAGE=quay.io/centos/centos:stream9
+ARG PHP_VERSION=8.3
+ARG PHP_SRC_REF=PHP-${PHP_VERSION}
+
+FROM ${BASE_IMAGE} AS base
+SHELL ["/bin/bash", "-euo", "pipefail", "-c"]
+
+ARG PHP_VERSION
+ENV TZ=Etc/UTC \
+ LC_ALL=C.UTF-8 \
+ LANG=C.UTF-8 \
+ PHP_VERSION=${PHP_VERSION}
+
+RUN yum install -y yum-utils && \
+ dnf config-manager --set-enabled crb || dnf config-manager --set-enabled powertools || true
+
+# Install minimal tools needed for re2c build (replace curl-minimal with full curl)
+RUN yum install -y xz tar gcc gcc-c++ make cmake
+
+ENV RE2C_VERSION=3.1
+RUN curl -fsSL -o /tmp/re2c.tar.xz https://github.com/skvadrik/re2c/releases/download/${RE2C_VERSION}/re2c-${RE2C_VERSION}.tar.xz \
+ && mkdir -p /tmp/re2c-src \
+ && tar -xJf /tmp/re2c.tar.xz -C /tmp/re2c-src --strip-components=1 \
+ && cd /tmp/re2c-src \
+ && ./configure \
+ && make -j"$(nproc)" \
+ && make install \
+ && rm -rf /tmp/re2c-src /tmp/re2c.tar.xz
+
+# Install remaining build dependencies and tools
+RUN yum install -y autoconf bison pkgconfig \
+ libxml2-devel sqlite-devel libcurl-devel openssl-devel \
+ libzip-devel oniguruma-devel libjpeg-turbo-devel libpng-devel libwebp-devel \
+ libicu-devel readline-devel libxslt-devel \
+ git wget \
+ python3 python3-devel python3-pip \
+ nginx httpd httpd-devel procps-ng mysql-server \
+ && yum clean all
+
+# Install mariadb-devel separately (may need different repo or skip if not critical)
+RUN yum install -y mariadb-devel || yum install -y mariadb-connector-c-devel || echo "Warning: mariadb-devel not available, continuing without it"
+
+# Fetch and build PHP from source with ZTS
+FROM base AS php-build
+ARG PHP_SRC_REF
+WORKDIR /usr/src
+RUN git clone --depth 1 --branch "${PHP_SRC_REF}" https://github.com/php/php-src.git
+WORKDIR /usr/src/php-src
+
+RUN sed -i 's/\[\([0-9]\+\.[0-9]\+\.[0-9]\+\)-dev\]/[\1]/' configure.ac
+RUN ./buildconf --force
+
+# Patch openssl.c for OpenSSL compatibility (RSA_SSLV23_PADDING may not be available in newer OpenSSL)
+RUN if [ -f ext/openssl/openssl.c ] && grep -q 'REGISTER_LONG_CONSTANT("OPENSSL_SSLV23_PADDING"' ext/openssl/openssl.c; then \
+ awk '/REGISTER_LONG_CONSTANT\("OPENSSL_SSLV23_PADDING"/ { \
+ indent = $0; \
+ gsub(/[^[:space:]].*/, "", indent); \
+ print indent "#ifdef RSA_SSLV23_PADDING"; \
+ gsub(/^[[:space:]]*/, indent " "); \
+ print; \
+ print indent "#endif"; \
+ next \
+ } \
+ { print }' ext/openssl/openssl.c > ext/openssl/openssl.c.new && \
+ mv ext/openssl/openssl.c.new ext/openssl/openssl.c; \
+ fi || true
+
+RUN mkdir -p /usr/local/etc/php/conf.d
+
+# Copy lexbor static library and headers from base stage (includes lexbor if PHP 8.4+)
+ARG PHP_VERSION
+COPY --from=base /usr/local/include /usr/local/include
+COPY --from=base /usr/local/lib /usr/local/lib
+COPY --from=base /usr/local/lib64 /usr/local/lib64
+
+# Build PHP with ZTS enabled and conditionally link lexbor for PHP 8.4+
+RUN if [ -f /usr/local/lib64/liblexbor_static.a ]; then \
+ echo "Building PHP ${PHP_VERSION} with static lexbor (8.4+)"; \
+ export CFLAGS="-I/usr/local/include"; \
+ export LDFLAGS="-L/usr/local/lib -L/usr/local/lib64"; \
+ export LIBS="/usr/local/lib64/liblexbor_static.a"; \
+ ls -lh /usr/local/lib*/liblexbor* || true; \
+ else \
+ echo "Building PHP ${PHP_VERSION} without lexbor (< 8.4)"; \
+ fi && \
+ ./configure \
+ --prefix=/usr/local \
+ --with-config-file-path=/usr/local/lib \
+ --with-config-file-scan-dir=/usr/local/etc/php/conf.d \
+ --enable-zts \
+ --enable-maintainer-zts \
+ --enable-fpm \
+ --enable-mbstring \
+ --enable-pcntl \
+ --enable-cgi \
+ --enable-embed \
+ --enable-dom \
+ --enable-xml \
+ --enable-simplexml \
+ --enable-xmlreader \
+ --enable-xmlwriter \
+ --with-xsl \
+ --with-extra-version="" \
+ --with-curl \
+ --with-mysqli \
+ --with-openssl \
+ --with-zlib \
+ --with-zip \
+ --disable-zend-signals \
+ --enable-zend-max-execution-timers \
+&& make -j"$(nproc)" \
+&& make install \
+&& strip /usr/local/bin/php /usr/local/sbin/php-fpm || true
+
+# Verify lexbor symbols are embedded in libphp.so (only for PHP 8.4+)
+ARG PHP_VERSION
+RUN if [ "$(echo "${PHP_VERSION}" | cut -d. -f1)" -ge 8 ] && [ "$(echo "${PHP_VERSION}" | cut -d. -f2)" -ge 4 ]; then \
+ echo "Checking if libphp.so has EMBEDDED lexbor symbols (PHP ${PHP_VERSION}):" && \
+ nm /usr/local/lib/libphp.so | grep lxb_ | head -n 5 && \
+ echo "✓ Lexbor symbols found - static linking successful!" || \
+ echo "✗ WARNING: No lexbor symbols found in libphp.so"; \
+ else \
+ echo "Skipping lexbor verification for PHP ${PHP_VERSION} (< 8.4)"; \
+ fi
+
+WORKDIR /usr/src
+RUN git clone https://github.com/e-dant/watcher.git
+WORKDIR /usr/src/watcher
+RUN cmake -S . -B build \
+ -DCMAKE_BUILD_TYPE=Release \
+ -DCMAKE_INSTALL_PREFIX=/usr/local \
+ -DBUILD_LIB=ON \
+ -DBUILD_BIN=ON \
+ -DBUILD_HDR=ON && \
+ cmake --build build && \
+ cmake --install build && \
+ ldconfig /usr/local/lib /usr/local/lib64
+
+FROM base AS final
+
+COPY --from=php-build /usr/local /usr/local
+
+RUN echo "/usr/local/lib" > /etc/ld.so.conf.d/usr-local-lib.conf && \
+ echo "/usr/local/lib64" >> /etc/ld.so.conf.d/usr-local-lib.conf && \
+ ldconfig
+
+COPY frankenphp-binary/ /tmp/frankenphp-binary/
+RUN if [ -f /tmp/frankenphp-binary/frankenphp ]; then \
+ cp /tmp/frankenphp-binary/frankenphp /usr/bin/frankenphp && \
+ chmod +x /usr/bin/frankenphp && \
+ mkdir -p /etc/frankenphp/caddy.d && \
+ mkdir -p /etc/frankenphp/php.d && \
+ mkdir -p /usr/lib/frankenphp/modules; \
+ fi
+
+RUN EXTENSION_DIR=$(php -i | grep "^extension_dir" | awk '{print $3}') && \
+ if [ -z "$EXTENSION_DIR" ]; then \
+ echo "Error: Could not determine extension_dir"; \
+ exit 1; \
+ fi && \
+ mkdir -p "$EXTENSION_DIR" && \
+ echo "Created extension_dir: $EXTENSION_DIR"
+
+# Verify ZTS is enabled
+RUN php -v | grep -q "ZTS" || (echo "ERROR: ZTS not enabled!" && exit 1) && \
+ php -m | grep -E 'curl|mysqli' >/dev/null
+
+ENV PATH="/usr/local/bin:${PATH}" \
+ LD_LIBRARY_PATH="/usr/local/lib:/usr/local/lib64:${LD_LIBRARY_PATH:-}"
+
+RUN ln -sf /usr/local/bin/php /usr/bin/php && \
+ ln -sf /usr/local/sbin/php-fpm /usr/sbin/php-fpm || true && \
+ ln -sf /usr/local/bin/php-cgi /usr/bin/php-cgi
+
+RUN mkdir -p /etc/php-fpm.d && \
+ mkdir -p /run/php-fpm && \
+ mkdir -p /var/run && \
+ mkdir -p /var/log/php-fpm && \
+ mkdir -p /etc/httpd || true && \
+ mkdir -p /usr/local/etc/php-fpm.d && \
+ mkdir -p /usr/local/etc/php/conf.d && \
+
+ ln -sf /usr/local/etc/php/conf.d /etc/php.d || true && \
+
+ echo "[global]" > /usr/local/etc/php-fpm.conf && \
+ echo "pid = /run/php-fpm/php-fpm.pid" >> /usr/local/etc/php-fpm.conf && \
+ echo "error_log = /var/log/php-fpm/error.log" >> /usr/local/etc/php-fpm.conf && \
+ echo "daemonize = yes" >> /usr/local/etc/php-fpm.conf && \
+ echo "include=/usr/local/etc/php-fpm.d/*.conf" >> /usr/local/etc/php-fpm.conf && \
+ echo "include=/etc/php-fpm.d/*.conf" >> /usr/local/etc/php-fpm.conf && \
+
+ echo "[www]" > /usr/local/etc/php-fpm.d/www.conf && \
+ echo "user = root" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "group = root" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "listen = 127.0.0.1:9000" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "listen.owner = root" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "listen.group = root" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "pm = dynamic" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "pm.max_children = 5" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "pm.start_servers = 2" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "pm.min_spare_servers = 1" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "pm.max_spare_servers = 3" >> /usr/local/etc/php-fpm.d/www.conf && \
+
+ php-fpm -t -y /usr/local/etc/php-fpm.conf 2>&1 | grep -v "Nothing matches the include pattern" || true && \
+ php-fpm -t -y /usr/local/etc/php-fpm.conf >/dev/null 2>&1 || \
+ (PHP_MAJOR=$(php -r 'echo PHP_MAJOR_VERSION;') && \
+ PHP_MINOR=$(php -r 'echo PHP_MINOR_VERSION;') && \
+ echo "PHP-FPM config test failed for PHP ${PHP_MAJOR}.${PHP_MINOR}" && \
+ echo "Config file contents:" && cat /usr/local/etc/php-fpm.conf && \
+ echo "Pool config:" && cat /usr/local/etc/php-fpm.d/www.conf && \
+ exit 1) && \
+
+ ln -sf /usr/local/etc/php-fpm.conf /etc/php-fpm.conf && \
+ ln -sf /usr/local/etc/php-fpm.d/www.conf /etc/php-fpm.d/www.conf || true
+
+RUN echo "mysqli.default_socket = /var/lib/mysql/mysql.sock" > /usr/local/etc/php/conf.d/mysql-socket.ini
+
+RUN if [ -f /usr/local/bin/frankenphp ]; then \
+ frankenphp -v || echo "Warning: frankenphp version check failed"; \
+ else \
+ echo "WARNING: frankenphp binary not found; FrankenPHP-specific tests will be skipped."; \
+ fi
+
+# Python deps used by test harness
+RUN python3 -m pip install --no-cache-dir --upgrade pip && \
+ python3 -m pip install --no-cache-dir flask requests psutil
+
+# Quality-of-life
+WORKDIR /work
+CMD ["bash"]
diff --git a/.github/workflows/Dockerfile.ubuntu-php-test b/.github/workflows/Dockerfile.ubuntu-php-test
deleted file mode 100644
index c17fc8b34..000000000
--- a/.github/workflows/Dockerfile.ubuntu-php-test
+++ /dev/null
@@ -1,117 +0,0 @@
-# syntax=docker/dockerfile:1.7
-FROM ubuntu:24.04
-
-ARG DEBIAN_FRONTEND=noninteractive
-ARG PHP_VERSION=7.2
-
-ENV PHP_VERSION=${PHP_VERSION}
-
-RUN apt-get update && \
- apt-get install -y --no-install-recommends \
- ca-certificates curl gnupg lsb-release tzdata locales \
- software-properties-common apt-transport-https \
- git make unzip xz-utils \
- # web servers & DB (installed later after PPA)
- && rm -rf /var/lib/apt/lists/*
-
-# Timezone to UTC
-RUN ln -fs /usr/share/zoneinfo/Etc/UTC /etc/localtime && \
- echo "Etc/UTC" > /etc/timezone && \
- dpkg-reconfigure -f noninteractive tzdata
-
-
-RUN add-apt-repository -y universe && \
- add-apt-repository -y ppa:ondrej/php
-
-RUN apt-get update
-
-RUN set -eux; \
- PHP_PKG="php${PHP_VERSION}"; \
- apt-get install -y --no-install-recommends \
- nginx \
- apache2 \
- mariadb-server \
- ${PHP_PKG} ${PHP_PKG}-cli ${PHP_PKG}-common ${PHP_PKG}-fpm \
- ${PHP_PKG}-curl ${PHP_PKG}-sqlite3 ${PHP_PKG}-mysql \
- ${PHP_PKG}-mbstring ${PHP_PKG}-xml ${PHP_PKG}-zip \
- libapache2-mod-php${PHP_VERSION} \
- ; \
- # Apache: switch to prefork for mod_php scenario and enable rewrite
- a2dismod mpm_event || true; \
- a2dismod mpm_worker || true; \
- a2enmod mpm_prefork rewrite || true
-
-RUN if [ "$(printf '%s\n' "${PHP_VERSION}" "8.5" | sort -V | head -n1)" != "8.5" ]; then \
- apt-get install -y --no-install-recommends php${PHP_VERSION}-opcache; \
- fi
-
-# ---- Python toolchain used by tests ----
-ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
- PYTHONDONTWRITEBYTECODE=1 \
- VIRTUAL_ENV=/opt/ci-venv \
- PATH="/opt/ci-venv/bin:${PATH}"
-
-RUN apt-get update && apt-get install -y --no-install-recommends \
- python3 python3-venv python3-pip python3-dev \
- && python3 -m venv "$VIRTUAL_ENV" \
- && "$VIRTUAL_ENV/bin/pip" install --no-cache-dir \
- flask pandas psutil requests \
- && apt-get clean && rm -rf /var/lib/apt/lists/*
-
-# PHP-CGI + Apache CGI modules for tests that require CGI
-RUN set -eux; \
- apt-get update; \
- apt-get install -y --no-install-recommends \
- php${PHP_VERSION}-cgi \
- apache2-bin; \
- a2enmod cgi cgid || true; \
- mkdir -p /usr/lib/cgi-bin; \
- # Provide a php-cgi wrapper in the standard location
- ln -sf /usr/bin/php-cgi /usr/lib/cgi-bin/php-cgi
-
-# Helper: start MariaDB
-RUN mkdir -p /usr/local/bin /var/lib/mysql /run/mysqld && \
- printf '%s\n' '#!/usr/bin/env bash' \
- 'set -euo pipefail' \
- 'mkdir -p /var/lib/mysql /run/mysqld' \
- 'chown -R mysql:mysql /var/lib/mysql /run/mysqld' \
- 'if [ ! -d /var/lib/mysql/mysql ]; then' \
- ' mysqld --initialize-insecure --user=mysql --datadir=/var/lib/mysql' \
- 'fi' \
- 'mysqld --user=mysql --datadir=/var/lib/mysql &' \
- 'pid=$!' \
- 'for i in {1..30}; do mysqladmin ping --silent && break; sleep 1; done' \
- 'mysql -u root -e "CREATE DATABASE IF NOT EXISTS db;" || true' \
- 'mysql -u root -e "ALTER USER '\''root'\''@'\''localhost'\'' IDENTIFIED BY '\''pwd'\''; FLUSH PRIVILEGES;" || true' \
- 'wait $pid' \
- > /usr/local/bin/start-mariadb && \
- chmod +x /usr/local/bin/start-mariadb
-
-# Robust Apache PHP switcher (handles module names, MPM, restart, verification)
-RUN printf '%s\n' '#!/usr/bin/env bash' \
- 'set -euo pipefail' \
- 'ver="${1:-${PHP_VERSION:-8.2}}"' \
- 'a2dismod mpm_event >/dev/null 2>&1 || true' \
- 'a2dismod mpm_worker >/dev/null 2>&1 || true' \
- 'a2enmod mpm_prefork >/dev/null 2>&1 || true' \
- 'if ! a2query -m "php${ver}" >/dev/null 2>&1; then' \
- ' apt-get update && apt-get install -y --no-install-recommends "libapache2-mod-php${ver}"' \
- 'fi' \
- 'for m in php5 php7 php7.0 php7.1 php7.2 php7.3 php7.4 php8 php8.0 php8.1 php8.2 php8.3 php8.4 php8.5; do' \
- ' a2query -m "$m" >/dev/null 2>&1 && a2dismod "$m" >/dev/null 2>&1 || true' \
- 'done' \
- 'a2enmod "php${ver}"' \
- 'apache2ctl -t' \
- 'apache2ctl -k graceful || apache2ctl -k restart' \
- 'if ! apache2ctl -M 2>/dev/null | grep -Eiq "php[0-9]*_module"; then' \
- ' echo "Apache does not have a PHP module loaded:"' \
- ' apache2ctl -M || true' \
- ' exit 1' \
- 'fi' \
- 'echo "Apache now using mod_php for PHP ${ver}"' \
- > /usr/local/bin/a2-switch-php && \
- chmod +x /usr/local/bin/a2-switch-php
-
-
-WORKDIR /work
-
diff --git a/.github/workflows/Dockerfile.ubuntu-php-test-nts b/.github/workflows/Dockerfile.ubuntu-php-test-nts
new file mode 100644
index 000000000..c775b4ee5
--- /dev/null
+++ b/.github/workflows/Dockerfile.ubuntu-php-test-nts
@@ -0,0 +1,246 @@
+# syntax=docker/dockerfile:1.7
+# Ubuntu test image with PHP built from source in NTS mode
+# Used for testing the extension with standard PHP (non-thread-safe)
+
+ARG DEBIAN_FRONTEND=noninteractive
+ARG PHP_VERSION=8.3
+ARG PHP_SRC_REF=PHP-${PHP_VERSION}
+
+FROM ubuntu:24.04 AS base
+SHELL ["/bin/bash", "-eo", "pipefail", "-c"]
+
+ENV DEBIAN_FRONTEND=noninteractive \
+ TZ=Etc/UTC \
+ LC_ALL=C.UTF-8 \
+ LANG=C.UTF-8 \
+ LANGUAGE=C.UTF-8 \
+ PHP_VERSION=${PHP_VERSION}
+
+# Install base dependencies and build tools
+RUN apt-get update && \
+ apt-get install -y --no-install-recommends \
+ ca-certificates curl gnupg lsb-release tzdata locales \
+ software-properties-common apt-transport-https \
+ git make unzip xz-utils \
+ build-essential autoconf bison re2c pkg-config \
+ libxml2-dev libsqlite3-dev libcurl4-openssl-dev libssl-dev \
+ libzip-dev libonig-dev libjpeg-dev libpng-dev libwebp-dev \
+ libicu-dev libreadline-dev libxslt1-dev default-libmysqlclient-dev \
+ apache2 \
+ apache2-bin \
+ apache2-dev \
+ nginx \
+ mariadb-server \
+ && rm -rf /var/lib/apt/lists/*
+
+# Timezone to UTC
+RUN ln -fs /usr/share/zoneinfo/${TZ} /etc/localtime && \
+ echo "${TZ}" > /etc/timezone && \
+ dpkg-reconfigure -f noninteractive tzdata
+
+# Fetch and build PHP from source with NTS
+FROM base AS php-build
+ARG PHP_SRC_REF
+WORKDIR /usr/src
+RUN git clone --depth 1 --branch "${PHP_SRC_REF}" https://github.com/php/php-src.git
+WORKDIR /usr/src/php-src
+
+RUN sed -i 's/\[\([0-9]\+\.[0-9]\+\.[0-9]\+\)-dev\]/[\1]/' configure.ac
+RUN ./buildconf --force
+
+# Patch openssl.c for OpenSSL compatibility
+RUN if [ -f ext/openssl/openssl.c ] && grep -q 'REGISTER_LONG_CONSTANT("OPENSSL_SSLV23_PADDING"' ext/openssl/openssl.c; then \
+ awk '/REGISTER_LONG_CONSTANT\("OPENSSL_SSLV23_PADDING"/ { \
+ indent = $0; \
+ gsub(/[^[:space:]].*/, "", indent); \
+ print indent "#ifdef RSA_SSLV23_PADDING"; \
+ gsub(/^[[:space:]]*/, indent " "); \
+ print; \
+ print indent "#endif"; \
+ next \
+ } \
+ { print }' ext/openssl/openssl.c > ext/openssl/openssl.c.new && \
+ mv ext/openssl/openssl.c.new ext/openssl/openssl.c; \
+ fi || true
+
+RUN mkdir -p /usr/local/etc/php/conf.d
+
+# Apache: switch to prefork for mod_php scenario and enable rewrite(needed by php build)
+RUN a2dismod mpm_event || true && \
+ a2dismod mpm_worker || true && \
+ a2enmod mpm_prefork rewrite cgi cgid || true
+
+# Build PHP with NTS (no ZTS flags)
+RUN ./configure \
+ --prefix=/usr/local \
+ --with-config-file-path=/usr/local/lib \
+ --with-config-file-scan-dir=/usr/local/etc/php/conf.d \
+ --enable-fpm \
+ --enable-mbstring \
+ --enable-pcntl \
+ --enable-cgi \
+ --with-extra-version="" \
+ --with-curl \
+ --with-mysqli \
+ --with-openssl \
+ --with-zlib \
+ --with-zip \
+ --with-apxs2=/usr/bin/apxs \
+ --disable-zts \
+&& mkdir -p /usr/lib/apache2/modules \
+&& make -j"$(nproc)" \
+&& make install \
+&& strip /usr/local/bin/php /usr/local/sbin/php-fpm || true
+
+# Final image with PHP and test infrastructure
+FROM base AS final
+COPY --from=php-build /usr/local /usr/local
+
+RUN mkdir -p /usr/lib/apache2/modules
+COPY --from=php-build /usr/lib/apache2/modules/ /usr/lib/apache2/modules/
+
+# Configure Apache to use prefork MPM (non-threaded) for NTS PHP
+# This must be done in the final stage since the base image has default MPM settings
+RUN service apache2 stop || true
+
+# Apache: switch to prefork for mod_php scenario and enable rewrite
+RUN a2dismod mpm_event || true && \
+ a2dismod mpm_worker || true && \
+ a2enmod mpm_prefork rewrite cgi cgid || true
+
+RUN service apache2 start || true
+
+RUN EXTENSION_DIR=$(php -i | grep "^extension_dir" | awk '{print $3}') && \
+ if [ -z "$EXTENSION_DIR" ]; then \
+ echo "Error: Could not determine extension_dir"; \
+ exit 1; \
+ fi && \
+ mkdir -p "$EXTENSION_DIR" \
+ echo "Created extension_dir: $EXTENSION_DIR"
+
+# Verify NTS (ZTS should NOT be enabled)
+RUN php -v | grep -v "ZTS" >/dev/null || (echo "ERROR: ZTS is enabled but should be NTS!" && exit 1) && \
+ php -m | grep -E 'curl|mysqli' >/dev/null
+
+ENV PATH="/usr/local/bin:${PATH}"
+
+RUN ln -sf /usr/local/bin/php /usr/bin/php && \
+ ln -sf /usr/local/sbin/php-fpm /usr/sbin/php-fpm || true && \
+ ln -sf /usr/local/bin/php-cgi /usr/bin/php-cgi
+
+RUN PHP_VER=$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;') && \
+ mkdir -p /etc/php/${PHP_VER}/fpm && \
+ mkdir -p /etc/php/${PHP_VER}/fpm/pool.d && \
+ mkdir -p /run/php && \
+ mkdir -p /run/php-fpm && \
+ mkdir -p /var/run && \
+ mkdir -p /var/log && \
+ mkdir -p /usr/local/etc/php-fpm.d && \
+ mkdir -p /usr/local/etc/php/conf.d && \
+
+ ln -sf /usr/local/etc/php/conf.d /etc/php/${PHP_VER}/fpm/conf.d || true && \
+
+ echo "[global]" > /usr/local/etc/php-fpm.conf && \
+ echo "pid = /run/php-fpm/php-fpm.pid" >> /usr/local/etc/php-fpm.conf && \
+ echo "error_log = /var/log/php${PHP_VER}-fpm.log" >> /usr/local/etc/php-fpm.conf && \
+ echo "daemonize = yes" >> /usr/local/etc/php-fpm.conf && \
+ echo "include=/usr/local/etc/php-fpm.d/*.conf" >> /usr/local/etc/php-fpm.conf && \
+ echo "include=/etc/php/${PHP_VER}/fpm/pool.d/*.conf" >> /usr/local/etc/php-fpm.conf && \
+
+ echo "[www]" > /usr/local/etc/php-fpm.d/www.conf && \
+ echo "user = root" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "group = root" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "listen = 127.0.0.1:9000" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "listen.owner = root" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "listen.group = root" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "pm = dynamic" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "pm.max_children = 5" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "pm.start_servers = 2" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "pm.min_spare_servers = 1" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "pm.max_spare_servers = 3" >> /usr/local/etc/php-fpm.d/www.conf && \
+
+ php-fpm -t -y /usr/local/etc/php-fpm.conf 2>&1 | grep -v "Nothing matches the include pattern" || true && \
+ php-fpm -t -y /usr/local/etc/php-fpm.conf >/dev/null 2>&1 || \
+ (PHP_MAJOR=$(php -r 'echo PHP_MAJOR_VERSION;') && \
+ PHP_MINOR=$(php -r 'echo PHP_MINOR_VERSION;') && \
+ echo "PHP-FPM config test failed for PHP ${PHP_MAJOR}.${PHP_MINOR}" && \
+ echo "Config file contents:" && cat /usr/local/etc/php-fpm.conf && \
+ echo "Pool config:" && cat /usr/local/etc/php-fpm.d/www.conf && \
+ exit 1) && \
+
+ ln -sf /usr/local/etc/php-fpm.conf /etc/php/${PHP_VER}/fpm/php-fpm.conf && \
+ ln -sf /usr/local/etc/php-fpm.d/www.conf /etc/php/${PHP_VER}/fpm/pool.d/www.conf || true
+
+# Configure MySQL socket path for mysqli (so "localhost" connections work)
+# Note: /usr/local/etc/php/conf.d was created before PHP build
+RUN echo "mysqli.default_socket = /var/lib/mysql/mysql.sock" > /usr/local/etc/php/conf.d/mysql-socket.ini
+
+# Configure Apache to load libphp module (if it exists)
+# PHP 7 uses libphp7.so, other versions use libphp.so
+# Check for both possible filenames to handle any case
+RUN if [ -f "/usr/lib/apache2/modules/libphp7.so" ]; then \
+ LIBPHP_NAME="libphp7.so"; \
+ MODULE_NAME="php7_module"; \
+ elif [ -f "/usr/lib/apache2/modules/libphp.so" ]; then \
+ LIBPHP_NAME="libphp.so"; \
+ MODULE_NAME="php_module"; \
+ else \
+ echo "Warning: No libphp module found in /usr/lib/apache2/modules/" && \
+ ls -la /usr/lib/apache2/modules/ || true && \
+ exit 0; \
+ fi && \
+ if [ -n "$LIBPHP_NAME" ]; then \
+ echo "# Load PHP module for Apache" > /etc/apache2/conf-available/php.conf && \
+ echo "LoadModule ${MODULE_NAME} /usr/lib/apache2/modules/${LIBPHP_NAME}" >> /etc/apache2/conf-available/php.conf && \
+ echo "" >> /etc/apache2/conf-available/php.conf && \
+ echo "# Configure PHP file handling" >> /etc/apache2/conf-available/php.conf && \
+ echo "" >> /etc/apache2/conf-available/php.conf && \
+ echo " PHPIniDir /usr/local/etc/php" >> /etc/apache2/conf-available/php.conf && \
+ echo " " >> /etc/apache2/conf-available/php.conf && \
+ echo " SetHandler application/x-httpd-php" >> /etc/apache2/conf-available/php.conf && \
+ echo " " >> /etc/apache2/conf-available/php.conf && \
+ echo " DirectoryIndex index.php index.html" >> /etc/apache2/conf-available/php.conf && \
+ echo "" >> /etc/apache2/conf-available/php.conf && \
+ a2enconf php >/dev/null 2>&1 || true && \
+ echo "Created Apache PHP module configuration with ${LIBPHP_NAME} (${MODULE_NAME})"; \
+ fi
+
+# ---- Python toolchain used by tests ----
+ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
+ PYTHONDONTWRITEBYTECODE=1 \
+ VIRTUAL_ENV=/opt/ci-venv \
+ PATH="/opt/ci-venv/bin:${PATH}"
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ python3 python3-venv python3-pip python3-dev \
+ && python3 -m venv "$VIRTUAL_ENV" \
+ && "$VIRTUAL_ENV/bin/pip" install --no-cache-dir \
+ flask pandas psutil requests \
+ && apt-get clean && rm -rf /var/lib/apt/lists/*
+
+# Helper: start MariaDB
+RUN mkdir -p /usr/local/bin /var/lib/mysql /run/mysqld && \
+ printf '%s\n' '#!/usr/bin/env bash' \
+ 'set -euo pipefail' \
+ 'mkdir -p /var/lib/mysql /run/mysqld' \
+ 'chown -R mysql:mysql /var/lib/mysql /run/mysqld' \
+ 'if [ ! -d /var/lib/mysql/mysql ]; then' \
+ ' mysqld --initialize-insecure --user=mysql --datadir=/var/lib/mysql' \
+ 'fi' \
+ 'mysqld --user=mysql --datadir=/var/lib/mysql &' \
+ 'pid=$!' \
+ 'for i in {1..30}; do mysqladmin ping --silent && break; sleep 1; done' \
+ 'mysql -u root -e "CREATE DATABASE IF NOT EXISTS db;" || true' \
+ 'mysql -u root -e "ALTER USER '\''root'\''@'\''localhost'\'' IDENTIFIED BY '\''pwd'\''; FLUSH PRIVILEGES;" || true' \
+ 'wait $pid' \
+ > /usr/local/bin/start-mariadb && \
+ chmod +x /usr/local/bin/start-mariadb
+
+# Create PHP-CGI symlink for CGI tests (using source-built PHP)
+RUN mkdir -p /usr/lib/cgi-bin && \
+ ln -sf /usr/local/bin/php-cgi /usr/lib/cgi-bin/php-cgi || \
+ (echo "Note: php-cgi may not be available in source build" && true)
+
+WORKDIR /work
+CMD ["bash"]
+
diff --git a/.github/workflows/Dockerfile.ubuntu-php-test-zts b/.github/workflows/Dockerfile.ubuntu-php-test-zts
new file mode 100644
index 000000000..1d63d6b6d
--- /dev/null
+++ b/.github/workflows/Dockerfile.ubuntu-php-test-zts
@@ -0,0 +1,285 @@
+# syntax=docker/dockerfile:1.7
+# Ubuntu test image with PHP built from source in ZTS mode
+# Used for testing the extension with FrankenPHP and other ZTS environments
+
+ARG DEBIAN_FRONTEND=noninteractive
+ARG PHP_VERSION=8.3
+ARG PHP_SRC_REF=PHP-${PHP_VERSION}
+
+FROM ubuntu:24.04 AS base
+SHELL ["/bin/bash", "-eo", "pipefail", "-c"]
+
+ENV DEBIAN_FRONTEND=noninteractive \
+ TZ=Etc/UTC \
+ LC_ALL=C.UTF-8 \
+ LANG=C.UTF-8 \
+ LANGUAGE=C.UTF-8 \
+ PHP_VERSION=${PHP_VERSION}
+
+# Install base dependencies and build tools
+RUN apt-get update && \
+ apt-get install -y --no-install-recommends \
+ ca-certificates curl gnupg lsb-release tzdata locales \
+ software-properties-common apt-transport-https \
+ git make unzip xz-utils \
+ build-essential autoconf bison re2c pkg-config \
+ libxml2-dev libsqlite3-dev libcurl4-openssl-dev libssl-dev \
+ libzip-dev libonig-dev libjpeg-dev libpng-dev libwebp-dev \
+ libicu-dev libreadline-dev libxslt1-dev default-libmysqlclient-dev cmake \
+ && rm -rf /var/lib/apt/lists/*
+
+# Timezone to UTC
+RUN ln -fs /usr/share/zoneinfo/${TZ} /etc/localtime && \
+ echo "${TZ}" > /etc/timezone && \
+ dpkg-reconfigure -f noninteractive tzdata
+
+# Fetch and build PHP from source with ZTS
+FROM base AS php-build
+ARG PHP_SRC_REF
+WORKDIR /usr/src
+RUN git clone --depth 1 --branch "${PHP_SRC_REF}" https://github.com/php/php-src.git
+WORKDIR /usr/src/php-src
+
+RUN sed -i 's/\[\([0-9]\+\.[0-9]\+\.[0-9]\+\)-dev\]/[\1]/' configure.ac
+RUN ./buildconf --force
+
+# Patch openssl.c for OpenSSL compatibility
+RUN if [ -f ext/openssl/openssl.c ] && grep -q 'REGISTER_LONG_CONSTANT("OPENSSL_SSLV23_PADDING"' ext/openssl/openssl.c; then \
+ awk '/REGISTER_LONG_CONSTANT\("OPENSSL_SSLV23_PADDING"/ { \
+ indent = $0; \
+ gsub(/[^[:space:]].*/, "", indent); \
+ print indent "#ifdef RSA_SSLV23_PADDING"; \
+ gsub(/^[[:space:]]*/, indent " "); \
+ print; \
+ print indent "#endif"; \
+ next \
+ } \
+ { print }' ext/openssl/openssl.c > ext/openssl/openssl.c.new && \
+ mv ext/openssl/openssl.c.new ext/openssl/openssl.c; \
+ fi || true
+
+RUN mkdir -p /usr/local/etc/php/conf.d
+
+# Apache: switch to prefork for mod_php scenario and enable rewrite
+RUN a2dismod mpm_event || true && \
+ a2dismod mpm_worker || true && \
+ a2enmod mpm_prefork rewrite cgi cgid || true
+
+# Copy lexbor static library and headers from base stage (includes lexbor if PHP 8.4+)
+ARG PHP_VERSION
+COPY --from=base /usr/local/include /usr/local/include
+COPY --from=base /usr/local/lib /usr/local/lib
+
+# Build PHP with ZTS enabled and conditionally link lexbor for PHP 8.4+
+RUN if [ -f /usr/local/lib/liblexbor_static.a ]; then \
+ echo "Building PHP ${PHP_VERSION} with static lexbor (8.4+)"; \
+ export CFLAGS="-I/usr/local/include"; \
+ export LDFLAGS="-L/usr/local/lib"; \
+ export LIBS="/usr/local/lib/liblexbor_static.a"; \
+ ls -lh /usr/local/lib/liblexbor* || true; \
+ else \
+ echo "Building PHP ${PHP_VERSION} without lexbor (< 8.4)"; \
+ fi && \
+ ./configure \
+ --prefix=/usr/local \
+ --with-config-file-path=/usr/local/lib \
+ --with-config-file-scan-dir=/usr/local/etc/php/conf.d \
+ --enable-zts \
+ --enable-maintainer-zts \
+ --enable-fpm \
+ --enable-mbstring \
+ --enable-pcntl \
+ --enable-cgi \
+ --enable-embed \
+ --enable-dom \
+ --enable-xml \
+ --enable-simplexml \
+ --enable-xmlreader \
+ --enable-xmlwriter \
+ --with-xsl \
+ --with-extra-version="" \
+ --with-curl \
+ --with-mysqli \
+ --with-openssl \
+ --with-zlib \
+ --with-zip \
+ --disable-zend-signals \
+ --enable-zend-max-execution-timers \
+&& make -j"$(nproc)" \
+&& make install \
+&& strip /usr/local/bin/php /usr/local/sbin/php-fpm || true
+
+# Verify lexbor symbols are embedded in libphp.so (only for PHP 8.4+)
+ARG PHP_VERSION
+RUN if [ "$(echo "${PHP_VERSION}" | cut -d. -f1)" -ge 8 ] && [ "$(echo "${PHP_VERSION}" | cut -d. -f2)" -ge 4 ]; then \
+ echo "Checking if libphp.so has EMBEDDED lexbor symbols (PHP ${PHP_VERSION}):" && \
+ nm /usr/local/lib/libphp.so | grep lxb_ | head -n 5 && \
+ echo "✓ Lexbor symbols found - static linking successful!" || \
+ echo "✗ WARNING: No lexbor symbols found in libphp.so"; \
+ else \
+ echo "Skipping lexbor verification for PHP ${PHP_VERSION} (< 8.4)"; \
+ fi
+
+WORKDIR /usr/src
+RUN git clone https://github.com/e-dant/watcher.git
+WORKDIR /usr/src/watcher
+RUN cmake -S . -B build \
+ -DCMAKE_BUILD_TYPE=Release \
+ -DCMAKE_INSTALL_PREFIX=/usr/local \
+ -DBUILD_LIB=ON \
+ -DBUILD_BIN=ON \
+ -DBUILD_HDR=ON && \
+ cmake --build build && \
+ cmake --install build && \
+ ldconfig
+
+# Final image with PHP and test infrastructure
+FROM base AS final
+
+COPY --from=php-build /usr/local /usr/local
+
+RUN echo "/usr/local/lib" > /etc/ld.so.conf.d/usr-local-lib.conf && \
+ ldconfig
+
+COPY frankenphp-binary/ /tmp/frankenphp-binary/
+RUN if [ -f /tmp/frankenphp-binary/frankenphp ]; then \
+ cp /tmp/frankenphp-binary/frankenphp /usr/bin/frankenphp && \
+ chmod +x /usr/bin/frankenphp && \
+ mkdir -p /etc/frankenphp/caddy.d && \
+ mkdir -p /etc/frankenphp/php.d && \
+ mkdir -p /usr/lib/frankenphp/modules; \
+ fi
+
+
+RUN EXTENSION_DIR=$(php -i | grep "^extension_dir" | awk '{print $3}') && \
+ if [ -z "$EXTENSION_DIR" ]; then \
+ echo "Error: Could not determine extension_dir"; \
+ exit 1; \
+ fi && \
+ mkdir -p "$EXTENSION_DIR" \
+ echo "Created extension_dir: $EXTENSION_DIR"
+
+# Verify ZTS is enabled
+RUN php -v | grep -q "ZTS" || (echo "ERROR: ZTS not enabled!" && exit 1) && \
+ php -m | grep -E 'curl|mysqli' >/dev/null
+
+ENV PATH="/usr/local/bin:${PATH}" \
+ LD_LIBRARY_PATH="/usr/local/lib:${LD_LIBRARY_PATH:-}"
+
+RUN ln -sf /usr/local/bin/php /usr/bin/php && \
+ ln -sf /usr/local/sbin/php-fpm /usr/sbin/php-fpm || true && \
+ ln -sf /usr/local/bin/php-cgi /usr/bin/php-cgi
+
+RUN PHP_VER=$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;') && \
+ mkdir -p /etc/php/${PHP_VER}/fpm && \
+ mkdir -p /etc/php/${PHP_VER}/fpm/pool.d && \
+ mkdir -p /run/php && \
+ mkdir -p /run/php-fpm && \
+ mkdir -p /var/run && \
+ mkdir -p /var/log && \
+ mkdir -p /usr/local/etc/php-fpm.d && \
+ mkdir -p /usr/local/etc/php/conf.d && \
+
+ ln -sf /usr/local/etc/php/conf.d /etc/php/${PHP_VER}/fpm/conf.d || true && \
+
+ echo "[global]" > /usr/local/etc/php-fpm.conf && \
+ echo "pid = /run/php-fpm/php-fpm.pid" >> /usr/local/etc/php-fpm.conf && \
+ echo "error_log = /var/log/php${PHP_VER}-fpm.log" >> /usr/local/etc/php-fpm.conf && \
+ echo "daemonize = yes" >> /usr/local/etc/php-fpm.conf && \
+ echo "include=/usr/local/etc/php-fpm.d/*.conf" >> /usr/local/etc/php-fpm.conf && \
+ echo "include=/etc/php/${PHP_VER}/fpm/pool.d/*.conf" >> /usr/local/etc/php-fpm.conf && \
+
+ echo "[www]" > /usr/local/etc/php-fpm.d/www.conf && \
+ echo "user = root" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "group = root" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "listen = 127.0.0.1:9000" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "listen.owner = root" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "listen.group = root" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "pm = dynamic" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "pm.max_children = 5" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "pm.start_servers = 2" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "pm.min_spare_servers = 1" >> /usr/local/etc/php-fpm.d/www.conf && \
+ echo "pm.max_spare_servers = 3" >> /usr/local/etc/php-fpm.d/www.conf && \
+
+ php-fpm -t -y /usr/local/etc/php-fpm.conf 2>&1 | grep -v "Nothing matches the include pattern" || true && \
+ php-fpm -t -y /usr/local/etc/php-fpm.conf >/dev/null 2>&1 || \
+ (PHP_MAJOR=$(php -r 'echo PHP_MAJOR_VERSION;') && \
+ PHP_MINOR=$(php -r 'echo PHP_MINOR_VERSION;') && \
+ echo "PHP-FPM config test failed for PHP ${PHP_MAJOR}.${PHP_MINOR}" && \
+ echo "Config file contents:" && cat /usr/local/etc/php-fpm.conf && \
+ echo "Pool config:" && cat /usr/local/etc/php-fpm.d/www.conf && \
+ exit 1) && \
+
+ ln -sf /usr/local/etc/php-fpm.conf /etc/php/${PHP_VER}/fpm/php-fpm.conf && \
+ ln -sf /usr/local/etc/php-fpm.d/www.conf /etc/php/${PHP_VER}/fpm/pool.d/www.conf || true
+
+RUN echo "mysqli.default_socket = /var/lib/mysql/mysql.sock" > /usr/local/etc/php/conf.d/mysql-socket.ini
+
+RUN if [ -f /usr/local/bin/frankenphp ]; then \
+ frankenphp -v || echo "Warning: frankenphp version check failed"; \
+ else \
+ echo "WARNING: frankenphp binary not found; FrankenPHP-specific tests will be skipped."; \
+ fi
+
+# Install web servers and database (without PHP packages)
+RUN apt-get update && \
+ apt-get install -y --no-install-recommends \
+ nginx \
+ apache2 \
+ mariadb-server \
+ apache2-bin \
+ && rm -rf /var/lib/apt/lists/*
+
+
+RUN service apache2 stop || true
+
+# Apache: switch to prefork for mod_php scenario and enable rewrite
+RUN a2dismod mpm_event || true && \
+ a2dismod mpm_worker || true && \
+ a2enmod mpm_prefork rewrite cgi cgid || true
+
+RUN service apache2 start || true
+
+# ---- Python toolchain used by tests ----
+ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
+ PYTHONDONTWRITEBYTECODE=1 \
+ VIRTUAL_ENV=/opt/ci-venv \
+ PATH="/opt/ci-venv/bin:${PATH}"
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ python3 python3-venv python3-pip python3-dev \
+ && python3 -m venv "$VIRTUAL_ENV" \
+ && "$VIRTUAL_ENV/bin/pip" install --no-cache-dir \
+ flask pandas psutil requests \
+ && apt-get clean && rm -rf /var/lib/apt/lists/*
+
+# Helper: start MariaDB
+RUN mkdir -p /usr/local/bin /var/lib/mysql /run/mysqld && \
+ printf '%s\n' '#!/usr/bin/env bash' \
+ 'set -euo pipefail' \
+ 'mkdir -p /var/lib/mysql /run/mysqld' \
+ 'chown -R mysql:mysql /var/lib/mysql /run/mysqld' \
+ 'if [ ! -d /var/lib/mysql/mysql ]; then' \
+ ' mysqld --initialize-insecure --user=mysql --datadir=/var/lib/mysql' \
+ 'fi' \
+ 'mysqld --user=mysql --datadir=/var/lib/mysql &' \
+ 'pid=$!' \
+ 'for i in {1..30}; do mysqladmin ping --silent && break; sleep 1; done' \
+ 'mysql -u root -e "CREATE DATABASE IF NOT EXISTS db;" || true' \
+ 'mysql -u root -e "ALTER USER '\''root'\''@'\''localhost'\'' IDENTIFIED BY '\''pwd'\''; FLUSH PRIVILEGES;" || true' \
+ 'wait $pid' \
+ > /usr/local/bin/start-mariadb && \
+ chmod +x /usr/local/bin/start-mariadb
+
+# Create PHP-CGI symlink for CGI tests (using source-built PHP)
+RUN mkdir -p /usr/lib/cgi-bin && \
+ ln -sf /usr/local/bin/php-cgi /usr/lib/cgi-bin/php-cgi || \
+ (echo "Note: php-cgi may not be available in source build" && true)
+
+WORKDIR /work
+CMD ["bash"]
+
+
+
+
+
diff --git a/.github/workflows/build-centos-php-test-images.yml b/.github/workflows/build-centos-php-test-images-nts.yml
similarity index 89%
rename from .github/workflows/build-centos-php-test-images.yml
rename to .github/workflows/build-centos-php-test-images-nts.yml
index ad9be9694..6546ac349 100644
--- a/.github/workflows/build-centos-php-test-images.yml
+++ b/.github/workflows/build-centos-php-test-images-nts.yml
@@ -1,16 +1,16 @@
-name: Build CentOS PHP test images
+name: Build CentOS PHP test images (NTS)
on:
workflow_dispatch:
push:
paths:
- - .github/workflows/Dockerfile.centos-php-test
- - .github/workflows/build-centos-php-test-images.yml
+ - .github/workflows/Dockerfile.centos-php-test-nts
+ - .github/workflows/build-centos-php-test-images-nts.yml
env:
REGISTRY: ghcr.io
- IMAGE_NAME: aikidosec/firewall-php-test-centos
- VERSION: v1
+ IMAGE_NAME: aikidosec/firewall-php-test-centos-nts
+ VERSION: v2
jobs:
build-amd64:
@@ -32,7 +32,7 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
- file: .github/workflows/Dockerfile.centos-php-test
+ file: .github/workflows/Dockerfile.centos-php-test-nts
platforms: linux/amd64
push: true
build-args: |
@@ -60,7 +60,7 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
- file: .github/workflows/Dockerfile.centos-php-test
+ file: .github/workflows/Dockerfile.centos-php-test-nts
platforms: linux/arm64
push: true
build-args: |
diff --git a/.github/workflows/build-centos-php-test-images-zts.yml b/.github/workflows/build-centos-php-test-images-zts.yml
new file mode 100644
index 000000000..0a1af9973
--- /dev/null
+++ b/.github/workflows/build-centos-php-test-images-zts.yml
@@ -0,0 +1,126 @@
+name: Build CentOS PHP test images (ZTS)
+
+on:
+ workflow_dispatch:
+ push:
+ paths:
+ - .github/workflows/Dockerfile.centos-php-test-zts
+ - .github/workflows/build-centos-php-test-images-zts.yml
+
+env:
+ REGISTRY: ghcr.io
+ IMAGE_NAME: aikidosec/firewall-php-test-centos-zts
+ VERSION: v2
+
+jobs:
+ build-amd64:
+ runs-on: ubuntu-24.04
+ strategy:
+ matrix:
+ php_version: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5']
+ fail-fast: false
+ permissions: { contents: read, packages: write }
+ steps:
+ - uses: actions/checkout@v4
+ - uses: docker/setup-buildx-action@v3
+ - uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ - name: Prepare FrankenPHP binary directory
+ run: mkdir -p frankenphp-binary
+
+ - name: Extract FrankenPHP binary from Docker image
+ if: matrix.php_version >= '8.2'
+ run: |
+ PHP_VERSION="${{ matrix.php_version }}"
+ docker pull --platform linux/amd64 dunglas/frankenphp:php${PHP_VERSION}-bookworm
+ docker create --platform linux/amd64 --name temp-frankenphp dunglas/frankenphp:php${PHP_VERSION}-bookworm
+ docker cp temp-frankenphp:/usr/local/bin/frankenphp frankenphp-binary/frankenphp
+ mkdir -p frankenphp-binary/lib
+ echo "NOTE: Not copying libphp.so for CentOS (GLIBC 2.34) - using compiled version"
+ docker cp temp-frankenphp:/usr/local/lib/libwatcher-c.so.0 frankenphp-binary/lib/libwatcher-c.so.0 || true
+ docker cp temp-frankenphp:/lib/x86_64-linux-gnu/libargon2.so.1 frankenphp-binary/lib/libargon2.so.1 || true
+ docker rm temp-frankenphp
+ chmod +x frankenphp-binary/frankenphp
+ echo "Extracted FrankenPHP files:"
+ ls -lh frankenphp-binary/
+ ls -lh frankenphp-binary/lib/ || true
+
+ - name: Build & push (amd64)
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: .github/workflows/Dockerfile.centos-php-test-zts
+ platforms: linux/amd64
+ push: true
+ build-args: |
+ PHP_VERSION=${{ matrix.php_version }}
+ tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ matrix.php_version }}-amd64-${{ env.VERSION }}
+ #cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:cache-${{ matrix.php_version }}-amd64-${{ env.VERSION }}
+ #cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:cache-${{ matrix.php_version }}-amd64-${{ env.VERSION }},mode=max
+
+ build-arm64:
+ runs-on: ubuntu-24.04-arm
+ strategy:
+ matrix:
+ php_version: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5']
+ fail-fast: false
+ permissions: { contents: read, packages: write }
+ steps:
+ - uses: actions/checkout@v4
+ - uses: docker/setup-buildx-action@v3
+ - uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ - name: Prepare FrankenPHP binary directory
+ run: mkdir -p frankenphp-binary
+
+ - name: Extract FrankenPHP binary from Docker image
+ if: matrix.php_version >= '8.2'
+ run: |
+ PHP_VERSION="${{ matrix.php_version }}"
+ docker pull --platform linux/arm64 dunglas/frankenphp:php${PHP_VERSION}-bookworm
+ docker create --platform linux/arm64 --name temp-frankenphp dunglas/frankenphp:php${PHP_VERSION}-bookworm
+ docker cp temp-frankenphp:/usr/local/bin/frankenphp frankenphp-binary/frankenphp
+
+ - name: Build & push (arm64)
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: .github/workflows/Dockerfile.centos-php-test-zts
+ platforms: linux/arm64
+ push: true
+ build-args: |
+ PHP_VERSION=${{ matrix.php_version }}
+ tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ matrix.php_version }}-arm64-${{ env.VERSION }}
+ #cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:cache-${{ matrix.php_version }}-arm64-${{ env.VERSION }}
+ #cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:cache-${{ matrix.php_version }}-arm64-${{ env.VERSION }},mode=max
+
+ publish-manifests:
+ runs-on: ubuntu-24.04
+ needs: [build-amd64, build-arm64]
+ strategy:
+ matrix:
+ php_version: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5']
+ fail-fast: false
+ permissions: { contents: read, packages: write }
+ steps:
+ - uses: docker/setup-buildx-action@v3
+ - uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ - name: Create multi-arch manifest
+ run: |
+ IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+ V=${{ matrix.php_version }}
+ docker buildx imagetools create \
+ --tag ${IMAGE}:${V}-${{ env.VERSION }} \
+ ${IMAGE}:${V}-amd64-${{ env.VERSION }} \
+ ${IMAGE}:${V}-arm64-${{ env.VERSION }}
+
diff --git a/.github/workflows/build-extension-images.yml b/.github/workflows/build-extension-images-nts.yml
similarity index 88%
rename from .github/workflows/build-extension-images.yml
rename to .github/workflows/build-extension-images-nts.yml
index a40c73b27..40d855570 100644
--- a/.github/workflows/build-extension-images.yml
+++ b/.github/workflows/build-extension-images-nts.yml
@@ -1,16 +1,16 @@
-name: Create images for building extension
+name: Create images for building extension (NTS)
on:
workflow_dispatch:
push:
paths:
- - .github/workflows/Dockerfile.build-extension
- - .github/workflows/build-extension-images.yml
+ - .github/workflows/Dockerfile.build-extension-nts
+ - .github/workflows/build-extension-images-nts.yml
env:
REGISTRY: ghcr.io
- IMAGE_NAME: aikidosec/firewall-php-build-extension
- VERSION: v1
+ IMAGE_NAME: aikidosec/firewall-php-build-extension-nts
+ VERSION: v2
jobs:
build-amd64:
@@ -18,7 +18,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- php_version: ['7.2','7.3','7.4','8.0','8.1','8.2','8.3','8.4','8.5']
+ php_version: ['7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5']
permissions:
contents: read
packages: write
@@ -36,7 +36,7 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
- file: .github/workflows/Dockerfile.build-extension
+ file: .github/workflows/Dockerfile.build-extension-nts
target: dev
platforms: linux/amd64
push: true
@@ -71,7 +71,7 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
- file: .github/workflows/Dockerfile.build-extension
+ file: .github/workflows/Dockerfile.build-extension-nts
target: dev
platforms: linux/arm64
push: true
diff --git a/.github/workflows/build-extension-images-zts.yml b/.github/workflows/build-extension-images-zts.yml
new file mode 100644
index 000000000..52786e633
--- /dev/null
+++ b/.github/workflows/build-extension-images-zts.yml
@@ -0,0 +1,114 @@
+name: Create images for building extension (ZTS)
+
+on:
+ workflow_dispatch:
+ push:
+ paths:
+ - .github/workflows/Dockerfile.build-extension-zts
+ - .github/workflows/build-extension-images-zts.yml
+
+env:
+ REGISTRY: ghcr.io
+ IMAGE_NAME: aikidosec/firewall-php-build-extension-zts
+ VERSION: v2
+
+jobs:
+ build-amd64:
+ runs-on: ubuntu-24.04
+ strategy:
+ fail-fast: false
+ matrix:
+ php_version: ['7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5']
+ permissions:
+ contents: read
+ packages: write
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: docker/setup-buildx-action@v3
+ - uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build & push (amd64)
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: .github/workflows/Dockerfile.build-extension-zts
+ target: dev
+ platforms: linux/amd64
+ push: true
+ build-args: |
+ PHP_VERSION=${{ matrix.php_version }}
+ PHP_SRC_REF=PHP-${{ matrix.php_version }}
+ tags: |
+ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ matrix.php_version }}-amd64-${{ env.VERSION }}
+ cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:cache-${{ matrix.php_version }}-amd64-${{ env.VERSION }}
+ cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:cache-${{ matrix.php_version }}-amd64-${{ env.VERSION }},mode=max
+
+ build-arm64:
+ runs-on: ubuntu-24.04-arm
+ strategy:
+ fail-fast: false
+ matrix:
+ php_version: ['7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5']
+ permissions:
+ contents: read
+ packages: write
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: docker/setup-buildx-action@v3
+ - uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build & push (arm64)
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: .github/workflows/Dockerfile.build-extension-zts
+ target: dev
+ platforms: linux/arm64
+ push: true
+ build-args: |
+ PHP_VERSION=${{ matrix.php_version }}
+ PHP_SRC_REF=PHP-${{ matrix.php_version }}
+ tags: |
+ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ matrix.php_version }}-arm64-${{ env.VERSION }}
+ cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:cache-${{ matrix.php_version }}-arm64-${{ env.VERSION }}
+ cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:cache-${{ matrix.php_version }}-arm64-${{ env.VERSION }},mode=max
+
+
+ # ---- stitch images into multi-arch manifests ----
+ publish-manifests:
+ runs-on: ubuntu-24.04
+ needs: [build-amd64, build-arm64]
+ strategy:
+ fail-fast: false
+ matrix:
+ php_version: ['7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5']
+ permissions:
+ contents: read
+ packages: write
+ steps:
+ - uses: docker/setup-buildx-action@v3
+ - uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Create multi-arch manifest
+ run: |
+ IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+ V=${{ matrix.php_version }}
+ docker buildx imagetools create \
+ --tag ${IMAGE}:${V}-${{ env.VERSION }} \
+ ${IMAGE}:${V}-amd64-${{ env.VERSION }} \
+ ${IMAGE}:${V}-arm64-${{ env.VERSION }}
+
diff --git a/.github/workflows/build-ubuntu-php-test-images.yml b/.github/workflows/build-ubuntu-php-test-images-nts.yml
similarity index 89%
rename from .github/workflows/build-ubuntu-php-test-images.yml
rename to .github/workflows/build-ubuntu-php-test-images-nts.yml
index 90a916b2d..c218942a4 100644
--- a/.github/workflows/build-ubuntu-php-test-images.yml
+++ b/.github/workflows/build-ubuntu-php-test-images-nts.yml
@@ -1,16 +1,16 @@
-name: Build Ubuntu PHP test images
+name: Build Ubuntu PHP test images (NTS)
on:
workflow_dispatch:
push:
paths:
- - .github/workflows/Dockerfile.ubuntu-php-test
- - .github/workflows/build-ubuntu-php-test-images.yml
+ - .github/workflows/Dockerfile.ubuntu-php-test-nts
+ - .github/workflows/build-ubuntu-php-test-images-nts.yml
env:
REGISTRY: ghcr.io
- IMAGE_NAME: aikidosec/firewall-php-test-ubuntu
- VERSION: v1
+ IMAGE_NAME: aikidosec/firewall-php-test-ubuntu-nts
+ VERSION: v2
jobs:
build-amd64:
@@ -30,7 +30,7 @@ jobs:
- uses: docker/build-push-action@v6
with:
context: .
- file: .github/workflows/Dockerfile.ubuntu-php-test
+ file: .github/workflows/Dockerfile.ubuntu-php-test-nts
platforms: linux/amd64
push: true
build-args: |
@@ -56,7 +56,7 @@ jobs:
- uses: docker/build-push-action@v6
with:
context: .
- file: .github/workflows/Dockerfile.ubuntu-php-test
+ file: .github/workflows/Dockerfile.ubuntu-php-test-nts
platforms: linux/arm64
push: true
build-args: |
diff --git a/.github/workflows/build-ubuntu-php-test-images-zts.yml b/.github/workflows/build-ubuntu-php-test-images-zts.yml
new file mode 100644
index 000000000..c95b5503b
--- /dev/null
+++ b/.github/workflows/build-ubuntu-php-test-images-zts.yml
@@ -0,0 +1,121 @@
+name: Build Ubuntu PHP test images (ZTS)
+
+on:
+ workflow_dispatch:
+ push:
+ paths:
+ - .github/workflows/Dockerfile.ubuntu-php-test-zts
+ - .github/workflows/build-ubuntu-php-test-images-zts.yml
+
+env:
+ REGISTRY: ghcr.io
+ IMAGE_NAME: aikidosec/firewall-php-test-ubuntu-zts
+ VERSION: v2
+
+jobs:
+ build-amd64:
+ runs-on: ubuntu-24.04
+ strategy:
+ matrix: { php_version: ['7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] }
+ fail-fast: false
+ permissions: { contents: read, packages: write }
+ steps:
+ - uses: actions/checkout@v4
+ - uses: docker/setup-buildx-action@v3
+ - uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ - name: Prepare FrankenPHP binary directory
+ run: mkdir -p frankenphp-binary
+
+ - name: Extract FrankenPHP binary from Docker image
+ if: matrix.php_version >= '8.2'
+ run: |
+ PHP_VERSION="${{ matrix.php_version }}"
+ docker pull --platform linux/amd64 dunglas/frankenphp:php${PHP_VERSION}-bookworm
+ docker create --platform linux/amd64 --name temp-frankenphp dunglas/frankenphp:php${PHP_VERSION}-bookworm
+ docker cp temp-frankenphp:/usr/local/bin/frankenphp frankenphp-binary/frankenphp
+ mkdir -p frankenphp-binary/lib
+ docker cp temp-frankenphp:/usr/local/lib/libphp.so frankenphp-binary/lib/libphp.so
+ docker cp temp-frankenphp:/usr/local/lib/libwatcher-c.so.0 frankenphp-binary/lib/libwatcher-c.so.0 || true
+ docker cp temp-frankenphp:/lib/x86_64-linux-gnu/libargon2.so.1 frankenphp-binary/lib/libargon2.so.1 || true
+ docker rm temp-frankenphp
+ chmod +x frankenphp-binary/frankenphp
+ echo "Extracted FrankenPHP files:"
+ ls -lh frankenphp-binary/
+ ls -lh frankenphp-binary/lib/ || true
+
+ - uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: .github/workflows/Dockerfile.ubuntu-php-test-zts
+ platforms: linux/amd64
+ push: true
+ build-args: |
+ PHP_VERSION=${{ matrix.php_version }}
+ tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ matrix.php_version }}-amd64-${{ env.VERSION }}
+ #cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:cache-${{ matrix.php_version }}-amd64-${{ env.VERSION }}
+ #cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:cache-${{ matrix.php_version }}-amd64-${{ env.VERSION }},mode=max
+
+ build-arm64:
+ runs-on: ubuntu-24.04-arm
+ strategy:
+ matrix: { php_version: ['7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] }
+ fail-fast: false
+ permissions: { contents: read, packages: write }
+ steps:
+ - uses: actions/checkout@v4
+ - uses: docker/setup-buildx-action@v3
+ - uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ - name: Prepare FrankenPHP binary directory
+ run: mkdir -p frankenphp-binary
+
+ - name: Extract FrankenPHP binary from Docker image
+ if: matrix.php_version >= '8.2'
+ run: |
+ PHP_VERSION="${{ matrix.php_version }}"
+ docker pull --platform linux/arm64 dunglas/frankenphp:php${PHP_VERSION}-bookworm
+ docker create --platform linux/arm64 --name temp-frankenphp dunglas/frankenphp:php${PHP_VERSION}-bookworm
+ docker cp temp-frankenphp:/usr/local/bin/frankenphp frankenphp-binary/frankenphp
+
+ - uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: .github/workflows/Dockerfile.ubuntu-php-test-zts
+ platforms: linux/arm64
+ push: true
+ build-args: |
+ PHP_VERSION=${{ matrix.php_version }}
+ tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ matrix.php_version }}-arm64-${{ env.VERSION }}
+ #cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:cache-${{ matrix.php_version }}-arm64-${{ env.VERSION }}
+ #cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:cache-${{ matrix.php_version }}-arm64-${{ env.VERSION }},mode=max
+
+ publish-manifests:
+ runs-on: ubuntu-24.04
+ needs: [build-amd64, build-arm64]
+ strategy:
+ matrix: { php_version: ['7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] }
+ fail-fast: false
+ permissions: { contents: read, packages: write }
+ steps:
+ - uses: docker/setup-buildx-action@v3
+ - uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ - name: Create multi-arch manifest
+ run: |
+ IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+ V=${{ matrix.php_version }}
+ docker buildx imagetools create \
+ --tag ${IMAGE}:${V}-${{ env.VERSION }} \
+ ${IMAGE}:${V}-amd64-${{ env.VERSION }} \
+ ${IMAGE}:${V}-arm64-${{ env.VERSION }}
+
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index dfe32fe68..35093e9a1 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -11,7 +11,7 @@ on:
jobs:
build_libs:
- name: Build Go libs${{ matrix.arch }}
+ name: Build Go libs ${{ matrix.arch == '' && 'x86_64' || 'arm' }}
runs-on: ubuntu-24.04${{ matrix.arch }}
container:
image: ghcr.io/aikidosec/firewall-php-build-libs:v1
@@ -39,6 +39,7 @@ jobs:
cd lib
protoc --go_out=agent --go-grpc_out=agent ipc.proto
cd agent
+ go clean -cache -testcache -modcache
go get main/ipc/protos
go get google.golang.org/grpc
go get github.com/stretchr/testify/assert
@@ -52,6 +53,7 @@ jobs:
cd lib
protoc --go_out=request-processor --go-grpc_out=request-processor ipc.proto
cd request-processor
+ go clean -cache -testcache -modcache
go mod tidy
go get google.golang.org/grpc
go get github.com/stretchr/testify/assert
@@ -78,10 +80,10 @@ jobs:
path: |
${{ github.workspace }}/build/aikido-request-processor.so
- build_php_extension:
- name: Build php${{ matrix.php_version }} extension${{ matrix.arch }}
+ build_php_extension_nts:
+ name: Build php ${{ matrix.php_version }} extension NTS ${{ matrix.arch == '' && 'x86_64' || 'arm' }}
runs-on: ubuntu-24.04${{ matrix.arch }}
- container: ghcr.io/aikidosec/firewall-php-build-extension:${{ matrix.php_version }}-v1
+ container: ghcr.io/aikidosec/firewall-php-build-extension-nts:${{ matrix.php_version }}-v2
strategy:
matrix:
php_version: ['7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5']
@@ -104,10 +106,61 @@ jobs:
- name: Check PHP setup
run: |
- which php
- php -v
- php -i
- php -m | grep -E 'curl|mysqli' || (echo "Required extensions missing" && php -m && exit 1)
+ php -v | grep -v "ZTS" > /dev/null || (echo "ERROR: PHP is ZTS, expected NTS!" && php -v && exit 1)
+
+
+ - name: Build extension
+ run: |
+ rm -rf build
+ mkdir build
+ cd lib/php-extension
+ phpize
+ cd ../../build
+ CXX=g++ CXXFLAGS="-fPIC -g -O2 -I../lib/php-extension/include" LDFLAGS="-lstdc++" ../lib/php-extension/configure
+ make -j"$(nproc)"
+
+ - name: Version Aikido extension
+ run: |
+ cd ./build/modules
+ mv aikido.so ${{ env.AIKIDO_ARTIFACT }}-nts.so
+
+ - name: Archive build artifacts
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: ${{ env.AIKIDO_ARTIFACT }}-nts-${{ env.ARCH }}
+ if-no-files-found: error
+ path: |
+ ${{ github.workspace }}/build/modules/${{ env.AIKIDO_ARTIFACT }}-nts.so
+ ${{ github.workspace }}/tests/*.diff
+
+ build_php_extension_zts:
+ name: Build php ${{ matrix.php_version }} extension ZTS ${{ matrix.arch == '' && 'x86_64' || 'arm' }}
+ runs-on: ubuntu-24.04${{ matrix.arch }}
+ container: ghcr.io/aikidosec/firewall-php-build-extension-zts:${{ matrix.php_version }}-v2
+ strategy:
+ matrix:
+ php_version: ['7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5']
+ arch: [ '', '-arm' ]
+ fail-fast: false
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Get Arch
+ run: echo "ARCH=$(uname -m)" >> $GITHUB_ENV
+
+ - name: Get Aikido version
+ run: |
+ AIKIDO_VERSION=$(grep '#define PHP_AIKIDO_VERSION' lib/php-extension/include/php_aikido.h | awk -F'"' '{print $2}')
+ echo $AIKIDO_VERSION
+ echo "AIKIDO_VERSION=$AIKIDO_VERSION" >> $GITHUB_ENV
+ echo "AIKIDO_ARTIFACT=aikido-extension-php-${{ matrix.php_version }}" >> $GITHUB_ENV
+
+ - name: Check PHP setup
+ run: |
+ php -v | grep -q "ZTS" || (echo "ERROR: PHP is not ZTS!" && php -v && exit 1)
- name: Build extension
run: |
@@ -122,20 +175,20 @@ jobs:
- name: Version Aikido extension
run: |
cd ./build/modules
- mv aikido.so ${{ env.AIKIDO_ARTIFACT }}.so
+ mv aikido.so ${{ env.AIKIDO_ARTIFACT }}-zts.so
- name: Archive build artifacts
uses: actions/upload-artifact@v4
if: always()
with:
- name: ${{ env.AIKIDO_ARTIFACT }}-${{ env.ARCH }}
+ name: ${{ env.AIKIDO_ARTIFACT }}-zts-${{ env.ARCH }}
if-no-files-found: error
path: |
- ${{ github.workspace }}/build/modules/${{ env.AIKIDO_ARTIFACT }}.so
+ ${{ github.workspace }}/build/modules/${{ env.AIKIDO_ARTIFACT }}-zts.so
${{ github.workspace }}/tests/*.diff
build_rpm:
- name: Build rpm${{ matrix.arch }}
+ name: Build rpm ${{ matrix.arch == '' && 'x86_64' || 'arm' }}
runs-on: ubuntu-24.04${{ matrix.arch }}
container:
image: quay.io/centos/centos:stream9
@@ -143,7 +196,7 @@ jobs:
matrix:
arch: ['', '-arm']
fail-fast: false
- needs: [ build_libs, build_php_extension ]
+ needs: [ build_libs, build_php_extension_nts, build_php_extension_zts ]
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -165,11 +218,17 @@ jobs:
echo "AIKIDO_LIBZEN=libzen_internals_${{ env.ARCH }}-unknown-linux-gnu.so" >> $GITHUB_ENV
echo "AIKIDO_LIBZEN_VERSION=0.1.48" >> $GITHUB_ENV
- - name: Download artifacts
+ - name: Download artifacts (NTS)
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4
with:
pattern: |
- aikido-extension-php-*-${{ env.ARCH }}
+ aikido-extension-php-*-nts-${{ env.ARCH }}
+
+ - name: Download artifacts (ZTS)
+ uses: actions/download-artifact@v4
+ with:
+ pattern: |
+ aikido-extension-php-*-zts-${{ env.ARCH }}
- name: Download artifacts
uses: actions/download-artifact@v4
@@ -204,8 +263,20 @@ jobs:
mv aikido-agent-${{ env.ARCH }}/aikido-agent package/rpm/opt/aikido/aikido-agent
mv aikido-request-processor-${{ env.ARCH }}/aikido-request-processor.so package/rpm/opt/aikido/aikido-request-processor.so
mv ${{ env.AIKIDO_LIBZEN }} package/rpm/opt/aikido/${{ env.AIKIDO_LIBZEN }}
- ls -lR aikido-extension-php-*
- mv aikido-extension-php-*/__w/firewall-php/firewall-php/build/modules/aikido-extension-php-* package/rpm/opt/aikido/
+ # Copy NTS extensions
+ for dir in aikido-extension-php-*-nts-*/; do
+ if [ -d "$dir" ]; then
+ find "$dir" -name "aikido-extension-php-*-nts.so" -exec mv {} package/rpm/opt/aikido/ \;
+ fi
+ done
+ # Copy ZTS extensions
+ for dir in aikido-extension-php-*-zts-*/; do
+ if [ -d "$dir" ]; then
+ find "$dir" -name "aikido-extension-php-*-zts.so" -exec mv {} package/rpm/opt/aikido/ \;
+ fi
+ done
+ echo "Extensions in package:"
+ ls -la package/rpm/opt/aikido/aikido-extension-php-*.so || true
mv package/rpm/opt/aikido package/rpm/opt/aikido-${{ env.AIKIDO_VERSION }}
chmod 777 package/rpm/opt/aikido-${{ env.AIKIDO_VERSION }}/*
rpmdev-setuptree
@@ -242,7 +313,7 @@ jobs:
~/rpmbuild/RPMS/${{ env.ARCH }}/${{ env.AIKIDO_ARTIFACT_RELEASE }}
build_deb:
- name: Build deb${{ matrix.arch }}
+ name: Build deb ${{ matrix.arch == '' && 'x86_64' || 'arm' }}
runs-on: ubuntu-24.04${{ matrix.arch }}
container:
image: ubuntu:22.04
@@ -311,10 +382,10 @@ jobs:
${{ env.AIKIDO_ARTIFACT }}
test_php_centos:
- name: CentOS php-${{ matrix.php_version }} ${{ matrix.server }}${{ matrix.arch }}
+ name: CentOS NTS php-${{ matrix.php_version }} ${{ matrix.server }} ${{ matrix.arch == '' && 'x86_64' || 'arm' }}
runs-on: ubuntu-24.04${{ matrix.arch }}
container:
- image: ghcr.io/aikidosec/firewall-php-test-centos:${{ matrix.php_version }}-v1
+ image: ghcr.io/aikidosec/firewall-php-test-centos-nts:${{ matrix.php_version }}-v2
options: --privileged
needs: [ build_rpm ]
strategy:
@@ -389,6 +460,9 @@ jobs:
- name: Run CLI tests
run: |
export TEST_PHP_EXECUTABLE=/usr/bin/php
+ cd lib/php-extension/
+ phpize
+ cd ../../
php lib/php-extension/run-tests.php ./tests/cli
- name: Run ${{ matrix.server }} server tests
@@ -397,10 +471,10 @@ jobs:
python3 run_server_tests.py ../tests/server ../tests/testlib --server=${{ matrix.server }} --max-runs=3
test_php_ubuntu:
- name: Ubuntu php-${{ matrix.php_version }} ${{ matrix.server }}${{ matrix.arch }}
+ name: Ubuntu NTS php-${{ matrix.php_version }} ${{ matrix.server }} ${{ matrix.arch == '' && 'x86_64' || 'arm' }}
runs-on: ubuntu-24.04${{ matrix.arch }}
container:
- image: ghcr.io/aikidosec/firewall-php-test-ubuntu:${{ matrix.php_version }}-v1
+ image: ghcr.io/aikidosec/firewall-php-test-ubuntu-nts:${{ matrix.php_version }}-v2
options: --privileged
needs: [ build_deb ]
strategy:
@@ -430,9 +504,15 @@ jobs:
${{ env.AIKIDO_DEB }}
- name: Prepare php-fpm
+ if: matrix.server == 'nginx-php-fpm'
run: |
- ls -l /usr/sbin | grep php
- ln -s /usr/sbin/php-fpm${{ matrix.php_version }} /usr/sbin/php-fpm
+ # Verify NTS-built PHP-FPM exists and is NTS (not ZTS-enabled)
+ /usr/local/sbin/php-fpm -v
+ /usr/local/sbin/php-fpm -i | grep -q "Thread Safety => disabled" || (echo "ERROR: PHP-FPM not built with NTS!" && exit 1)
+ # Create symlink for nginx to find php-fpm
+ ln -sf /usr/local/sbin/php-fpm /usr/sbin/php-fpm || true
+ # Verify symlink works
+ php-fpm -i | grep -q "Thread Safety => disabled" || (echo "ERROR: php-fpm symlink not working or not NTS!" && exit 1)
# MariaDB startup compatible with your current approach
- name: Start MariaDB (background)
@@ -441,11 +521,78 @@ jobs:
sleep 5
mysql -u root -ppwd -e "SELECT 1" || (echo "MySQL not up" && exit 1)
- # For Apache mod_php tests, ensure the right PHP module is active
- - name: Ensure Apache uses PHP ${{ matrix.php_version }}
- if: matrix.server == 'apache-mod-php'
+ - name: Install DEB
+ run: |
+ dpkg -i -E ${{ env.AIKIDO_DEB }}/${{ env.AIKIDO_DEB }}
+
+ - name: Run CLI tests
+ run: |
+ export TEST_PHP_EXECUTABLE=/usr/local/bin/php
+ cd lib/php-extension/
+ phpize
+ cd ../../
+ php lib/php-extension/run-tests.php ./tests/cli
+
+ - name: Run ${{ matrix.server }} server tests
run: |
- a2-switch-php ${{ matrix.php_version }}
+ . /etc/apache2/envvars
+ cd tools
+ python3 run_server_tests.py ../tests/server ../tests/testlib --server=${{ matrix.server }} --max-runs=3
+
+ test_php_ubuntu_zts:
+ name: Ubuntu ZTS php-${{ matrix.php_version }} ${{ matrix.server }} ${{ matrix.arch == '' && 'x86_64' || 'arm' }}
+ runs-on: ubuntu-24.04${{ matrix.arch }}
+ container:
+ image: ghcr.io/aikidosec/firewall-php-test-ubuntu-zts:${{ matrix.php_version }}-v2
+ options: --privileged
+ needs: [ build_deb ]
+ strategy:
+ matrix:
+ arch: ['', '-arm']
+ php_version: ['7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5']
+ server: ['nginx-php-fpm', 'php-built-in']
+ fail-fast: false
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Get Arch
+ run: echo "ARCH=$(uname -m)" >> $GITHUB_ENV
+
+ - name: Set env
+ run: |
+ AIKIDO_VERSION=$(grep '#define PHP_AIKIDO_VERSION' lib/php-extension/include/php_aikido.h | awk -F'"' '{print $2}')
+ echo $AIKIDO_VERSION
+ echo "AIKIDO_VERSION=$AIKIDO_VERSION" >> $GITHUB_ENV
+ echo "AIKIDO_DEB=aikido-php-firewall.${{ env.ARCH }}.deb" >> $GITHUB_ENV
+
+ - name: Verify ZTS is enabled
+ run: |
+ php -v | grep -q "ZTS" || (echo "ERROR: ZTS not enabled!" && exit 1)
+ php -v
+
+ - name: Download artifacts
+ uses: actions/download-artifact@v4
+ with:
+ pattern: |
+ ${{ env.AIKIDO_DEB }}
+
+ - name: Prepare php-fpm
+ if: matrix.server == 'nginx-php-fpm'
+ run: |
+ # Verify ZTS-built PHP-FPM exists and is ZTS-enabled
+ /usr/local/sbin/php-fpm -v
+ /usr/local/sbin/php-fpm -i | grep -q "Thread Safety => enabled" || (echo "ERROR: PHP-FPM not built with ZTS!" && exit 1)
+ # Create symlink for nginx to find php-fpm
+ ln -sf /usr/local/sbin/php-fpm /usr/sbin/php-fpm || true
+ # Verify symlink works
+ php-fpm -i | grep -q "Thread Safety => enabled" || (echo "ERROR: php-fpm symlink not working or not ZTS!" && exit 1)
+
+ - name: Start MariaDB (background)
+ run: |
+ start-mariadb & # provided by the image
+ sleep 5
+ mysql -u root -ppwd -e "SELECT 1" || (echo "MySQL not up" && exit 1)
- name: Install DEB
run: |
@@ -453,11 +600,239 @@ jobs:
- name: Run CLI tests
run: |
+ export TEST_PHP_EXECUTABLE=/usr/local/bin/php
+ cd lib/php-extension/
+ phpize
+ cd ../../
php lib/php-extension/run-tests.php ./tests/cli
- name: Run ${{ matrix.server }} server tests
run: |
- . /etc/apache2/envvars
+ cd tools
+ python3 run_server_tests.py ../tests/server ../tests/testlib --server=${{ matrix.server }} --max-runs=3
+
+ test_php_centos_zts:
+ name: CentOS ZTS php-${{ matrix.php_version }} ${{ matrix.server }} ${{ matrix.arch == '' && 'x86_64' || 'arm' }}
+ runs-on: ubuntu-24.04${{ matrix.arch }}
+ container:
+ image: ghcr.io/aikidosec/firewall-php-test-centos-zts:${{ matrix.php_version }}-v2
+ options: --privileged
+ needs: [ build_rpm ]
+ strategy:
+ matrix:
+ php_version: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5']
+ server: ['nginx-php-fpm', 'php-built-in']
+ arch: ['', '-arm']
+ fail-fast: false
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Setup
+ run: |
+ uname -a
+ cat /etc/centos-release || cat /etc/redhat-release || echo "CentOS/Stream detected"
+ php -v
+ nginx -v || true
+ which php-fpm && php-fpm -v || true
+
+ - name: Verify ZTS is enabled
+ run: |
+ php -v | grep -q "ZTS" || (echo "ERROR: ZTS not enabled!" && exit 1)
+ php -v
+
+ - name: Install and start MySQL
+ run: |
+ mkdir -p /var/lib/mysql
+ mysqld --initialize-insecure --datadir=/var/lib/mysql
+ mysqld -u root --datadir=/var/lib/mysql --socket=/var/lib/mysql/mysql.sock &
+ sleep 10
+ mysql -u root -e "CREATE DATABASE IF NOT EXISTS db;"
+ mysql -u root -e "ALTER USER 'root'@'localhost' IDENTIFIED BY 'pwd'; FLUSH PRIVILEGES;"
+
+ - name: Test MySQL connection with mysqli
+ run: |
+ php -r '
+ $mysqli = new mysqli("localhost", "root", "pwd", "db");
+ if ($mysqli->connect_error) {
+ echo "MySQL connection failed: " . $mysqli->connect_error . "\n";
+ exit(1);
+ } else {
+ echo "MySQL connection successful\n";
+ $mysqli->close();
+ }
+ '
+
+ - name: Get Arch
+ run: echo "ARCH=$(uname -m)" >> $GITHUB_ENV
+
+ - name: Check PHP setup
+ run: |
+ uname -m
+ php -v
+ php -i | head -20
+
+ - name: Get Aikido version
+ run: |
+ AIKIDO_VERSION=$(grep '#define PHP_AIKIDO_VERSION' lib/php-extension/include/php_aikido.h | awk -F'"' '{print $2}')
+ echo $AIKIDO_VERSION
+ echo "AIKIDO_VERSION=$AIKIDO_VERSION" >> $GITHUB_ENV
+ echo "AIKIDO_RPM=aikido-php-firewall.${{ env.ARCH }}.rpm" >> $GITHUB_ENV
+
+ - name: Download artifacts
+ uses: actions/download-artifact@v4
+ with:
+ pattern: |
+ ${{ env.AIKIDO_RPM }}
+
+ - name: Install RPM
+ run: |
+ rpm -Uvh --oldpackage ${{ env.AIKIDO_RPM }}/${{ env.AIKIDO_RPM }}
+
+ - name: Run CLI tests
+ run: |
+ export TEST_PHP_EXECUTABLE=/usr/local/bin/php
+ cd lib/php-extension/
+ phpize
+ cd ../../
+ php lib/php-extension/run-tests.php ./tests/cli
+
+ - name: Run ${{ matrix.server }} server tests
+ run: |
+ cd tools
+ python3 run_server_tests.py ../tests/server ../tests/testlib --server=${{ matrix.server }} --max-runs=3
+
+ test_php_frankenphp:
+ name: CentOS FrankenPHP php-${{ matrix.php_version }} ${{ matrix.server }} ${{ matrix.arch == '' && 'x86_64' || 'arm' }}
+ runs-on: ubuntu-24.04${{ matrix.arch }}
+ container:
+ image: ghcr.io/aikidosec/firewall-php-test-centos-zts:${{ matrix.php_version }}-v2
+ options: --privileged
+ needs: [ build_rpm ]
+ strategy:
+ matrix:
+ php_version: ['8.2', '8.3', '8.4', '8.5']
+ server: ['frankenphp-worker', 'frankenphp-classic']
+ arch: ['', '-arm']
+ fail-fast: false
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Setup
+ run: |
+ uname -a
+ cat /etc/centos-release || cat /etc/redhat-release || echo "CentOS/Stream detected"
+ php -v
+ command -v frankenphp && frankenphp -v || (echo "ERROR: FrankenPHP not found!" && exit 1)
+
+ - name: Verify ZTS is enabled
+ run: |
+ php -v | grep -q "ZTS" || (echo "ERROR: ZTS not enabled!" && exit 1)
+ php -v
+
+ - name: Install and start MySQL
+ run: |
+ mkdir -p /var/lib/mysql
+ mysqld --initialize-insecure --datadir=/var/lib/mysql
+ mysqld -u root --datadir=/var/lib/mysql --socket=/var/lib/mysql/mysql.sock &
+ sleep 10
+ mysql -u root -e "CREATE DATABASE IF NOT EXISTS db;"
+ mysql -u root -e "ALTER USER 'root'@'localhost' IDENTIFIED BY 'pwd'; FLUSH PRIVILEGES;"
+
+ - name: Test MySQL connection with mysqli
+ run: |
+ php -r '
+ $mysqli = new mysqli("localhost", "root", "pwd", "db");
+ if ($mysqli->connect_error) {
+ echo "MySQL connection failed: " . $mysqli->connect_error . "\n";
+ exit(1);
+ } else {
+ echo "MySQL connection successful\n";
+ $mysqli->close();
+ }
+ '
+
+ - name: Get Arch
+ run: echo "ARCH=$(uname -m)" >> $GITHUB_ENV
+
+ - name: Get Aikido version
+ run: |
+ AIKIDO_VERSION=$(grep '#define PHP_AIKIDO_VERSION' lib/php-extension/include/php_aikido.h | awk -F'"' '{print $2}')
+ echo $AIKIDO_VERSION
+ echo "AIKIDO_VERSION=$AIKIDO_VERSION" >> $GITHUB_ENV
+ echo "AIKIDO_RPM=aikido-php-firewall.${{ env.ARCH }}.rpm" >> $GITHUB_ENV
+
+ - name: Download artifacts
+ uses: actions/download-artifact@v4
+ with:
+ pattern: |
+ ${{ env.AIKIDO_RPM }}
+
+ - name: Install RPM
+ run: |
+ rpm -Uvh --oldpackage ${{ env.AIKIDO_RPM }}/${{ env.AIKIDO_RPM }}
+
+ - name: Run ${{ matrix.server }} server tests
+ run: |
+ cd tools
+ python3 run_server_tests.py ../tests/server ../tests/testlib --server=${{ matrix.server }} --max-runs=3
+
+ test_php_frankenphp_ubuntu:
+ name: Ubuntu FrankenPHP php-${{ matrix.php_version }} ${{ matrix.server }} ${{ matrix.arch == '' && 'x86_64' || 'arm' }}
+ runs-on: ubuntu-24.04${{ matrix.arch }}
+ container:
+ image: ghcr.io/aikidosec/firewall-php-test-ubuntu-zts:${{ matrix.php_version }}-v2
+ options: --privileged
+ needs: [ build_deb ]
+ strategy:
+ matrix:
+ php_version: ['8.2', '8.3', '8.4', '8.5']
+ server: ['frankenphp-worker', 'frankenphp-classic']
+ arch: ['', '-arm']
+ fail-fast: false
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Get Arch
+ run: echo "ARCH=$(uname -m)" >> $GITHUB_ENV
+
+ - name: Set env
+ run: |
+ AIKIDO_VERSION=$(grep '#define PHP_AIKIDO_VERSION' lib/php-extension/include/php_aikido.h | awk -F'"' '{print $2}')
+ echo $AIKIDO_VERSION
+ echo "AIKIDO_VERSION=$AIKIDO_VERSION" >> $GITHUB_ENV
+ echo "AIKIDO_DEB=aikido-php-firewall.${{ env.ARCH }}.deb" >> $GITHUB_ENV
+
+ - name: Verify ZTS is enabled
+ run: |
+ php -v | grep -q "ZTS" || (echo "ERROR: ZTS not enabled!" && exit 1)
+ php -v
+
+ - name: Verify FrankenPHP is installed
+ run: |
+ command -v frankenphp && frankenphp -v || (echo "ERROR: FrankenPHP not found!" && exit 1)
+
+ - name: Start MariaDB (background)
+ run: |
+ start-mariadb & # provided by the image
+ sleep 5
+ mysql -u root -ppwd -e "SELECT 1" || (echo "MySQL not up" && exit 1)
+
+ - name: Download artifacts
+ uses: actions/download-artifact@v4
+ with:
+ pattern: |
+ ${{ env.AIKIDO_DEB }}
+
+ - name: Install DEB
+ run: |
+ dpkg -i -E ${{ env.AIKIDO_DEB }}/${{ env.AIKIDO_DEB }}
+
+ - name: Run ${{ matrix.server }} server tests
+ run: |
cd tools
python3 run_server_tests.py ../tests/server ../tests/testlib --server=${{ matrix.server }} --max-runs=3
diff --git a/lib/agent/aikido_types/init_data.go b/lib/agent/aikido_types/init_data.go
index 98c374e26..cb302dfa4 100644
--- a/lib/agent/aikido_types/init_data.go
+++ b/lib/agent/aikido_types/init_data.go
@@ -215,6 +215,10 @@ type ServerData struct {
// Got some request info passed via gRPC to the Agent
GotTraffic uint32
+ // Tracks if the "started" event has been sent for this server
+ // In multi-worker mode (e.g., frankenphp-worker), only one worker should send it
+ SentStartedEvent uint32
+
// Last time this server established a gRPC connection
LastConnectionTime int64
diff --git a/lib/agent/aikido_types/stats.go b/lib/agent/aikido_types/stats.go
index 9fd059ee1..2c9072671 100644
--- a/lib/agent/aikido_types/stats.go
+++ b/lib/agent/aikido_types/stats.go
@@ -48,6 +48,7 @@ type RateLimitingValue struct {
UserCounts map[string]*SlidingWindow
IpCounts map[string]*SlidingWindow
RateLimitGroupCounts map[string]*SlidingWindow
+ Mutex sync.Mutex
}
type RateLimitingWildcardValue struct {
diff --git a/lib/agent/attack-wave-detection/attackWaveDetector.go b/lib/agent/attack-wave-detection/attackWaveDetector.go
index 857baa4d5..f9d9be27a 100644
--- a/lib/agent/attack-wave-detection/attackWaveDetector.go
+++ b/lib/agent/attack-wave-detection/attackWaveDetector.go
@@ -19,9 +19,9 @@ func AdvanceAttackWaveQueues(server *ServerData) {
}
}
-func Init(server *ServerData) {
+// StartAttackWaveTicker starts the attack wave detection ticker
+func StartAttackWaveTicker(server *ServerData) {
utils.StartPollingRoutine(server.PollingData.AttackWaveChannel, server.PollingData.AttackWaveTicker, AdvanceAttackWaveQueues, server)
- AdvanceAttackWaveQueues(server)
}
func Uninit(server *ServerData) {
diff --git a/lib/agent/cloud/cloud.go b/lib/agent/cloud/cloud.go
index 140749192..d304e4725 100644
--- a/lib/agent/cloud/cloud.go
+++ b/lib/agent/cloud/cloud.go
@@ -11,8 +11,8 @@ func Init(server *ServerData) {
CheckConfigUpdatedAt(server)
- utils.StartPollingRoutine(server.PollingData.HeartbeatRoutineChannel, server.PollingData.HeartbeatTicker, SendHeartbeatEvent, server)
utils.StartPollingRoutine(server.PollingData.ConfigPollingRoutineChannel, server.PollingData.ConfigPollingTicker, CheckConfigUpdatedAt, server)
+ utils.StartPollingRoutine(server.PollingData.HeartbeatRoutineChannel, server.PollingData.HeartbeatTicker, SendHeartbeatEvent, server)
}
func Uninit(server *ServerData) {
diff --git a/lib/agent/cloud/common.go b/lib/agent/cloud/common.go
index 0d6112dd9..e939364b1 100644
--- a/lib/agent/cloud/common.go
+++ b/lib/agent/cloud/common.go
@@ -35,6 +35,11 @@ func GetAgentInfo(server *ServerData) AgentInfo {
}
func ResetHeartbeatTicker(server *ServerData) {
+ // HeartbeatTicker is created on first request, so it may be nil during initial config fetch
+ if server.PollingData.HeartbeatTicker == nil {
+ return
+ }
+
if !server.CloudConfig.ReceivedAnyStats {
log.Info(server.Logger, "Resetting HeartbeatTicker to 1m!")
server.PollingData.HeartbeatTicker.Reset(1 * time.Minute)
diff --git a/lib/agent/cloud/event_started.go b/lib/agent/cloud/event_started.go
index 85e713352..f40c798f5 100644
--- a/lib/agent/cloud/event_started.go
+++ b/lib/agent/cloud/event_started.go
@@ -4,9 +4,17 @@ import (
. "main/aikido_types"
"main/constants"
"main/utils"
+ "sync/atomic"
)
func SendStartEvent(server *ServerData) {
+ // In multi-worker mode (e.g., frankenphp-worker), ensure only one worker sends the started event
+ // Use atomic compare-and-swap to guarantee exactly-once semantics
+ if !atomic.CompareAndSwapUint32(&server.SentStartedEvent, 0, 1) {
+ // Another worker already sent the started event
+ return
+ }
+
startedEvent := Started{
Type: "started",
Agent: GetAgentInfo(server),
diff --git a/lib/agent/grpc/request.go b/lib/agent/grpc/request.go
index f659f1e8a..98dea0c37 100644
--- a/lib/agent/grpc/request.go
+++ b/lib/agent/grpc/request.go
@@ -176,20 +176,6 @@ func incrementSlidingWindowEntry(m map[string]*SlidingWindow, key string) *Slidi
return entry
}
-func updateRateLimitingCounts(server *ServerData, method string, route string, routeParsed string, user string, ip string, rateLimitGroup string) {
- server.RateLimitingMutex.Lock()
- defer server.RateLimitingMutex.Unlock()
-
- rateLimitingDataForEndpoint := getRateLimitingDataForEndpoint(server, method, route, routeParsed)
- if rateLimitingDataForEndpoint == nil {
- return
- }
-
- incrementSlidingWindowEntry(rateLimitingDataForEndpoint.UserCounts, user)
- incrementSlidingWindowEntry(rateLimitingDataForEndpoint.IpCounts, ip)
- incrementSlidingWindowEntry(rateLimitingDataForEndpoint.RateLimitGroupCounts, rateLimitGroup)
-}
-
func isRateLimitingThresholdExceeded(config *RateLimitingConfig, countsMap map[string]*SlidingWindow, key string) bool {
counts, exists := countsMap[key]
if !exists {
@@ -321,31 +307,38 @@ func getRateLimitingStatus(server *ServerData, method, route, routeParsed, user,
}
server.RateLimitingMutex.RLock()
- defer server.RateLimitingMutex.RUnlock()
-
rateLimitingDataMatch := getRateLimitingDataForEndpoint(server, method, route, routeParsed)
+
if rateLimitingDataMatch == nil {
+ server.RateLimitingMutex.RUnlock()
return &protos.RateLimitingStatus{Block: false}
}
+ rateLimitingDataMatch.Mutex.Lock()
+ server.RateLimitingMutex.RUnlock()
+ defer rateLimitingDataMatch.Mutex.Unlock()
+
if rateLimitGroup != "" {
// If the rate limit group exists, we only try to rate limit by rate limit group
if isRateLimitingThresholdExceeded(&rateLimitingDataMatch.Config, rateLimitingDataMatch.RateLimitGroupCounts, rateLimitGroup) {
log.Infof(server.Logger, "Rate limited request for group %s - %s %s - %v", rateLimitGroup, method, routeParsed, rateLimitingDataMatch.RateLimitGroupCounts[rateLimitGroup])
return &protos.RateLimitingStatus{Block: true, Trigger: "group"}
}
+ incrementSlidingWindowEntry(rateLimitingDataMatch.RateLimitGroupCounts, rateLimitGroup)
} else if user != "" {
// Otherwise, if the user exists, we try to rate limit by user
if isRateLimitingThresholdExceeded(&rateLimitingDataMatch.Config, rateLimitingDataMatch.UserCounts, user) {
log.Infof(server.Logger, "Rate limited request for user %s - %s %s - %v", user, method, routeParsed, rateLimitingDataMatch.UserCounts[user])
return &protos.RateLimitingStatus{Block: true, Trigger: "user"}
}
+ incrementSlidingWindowEntry(rateLimitingDataMatch.UserCounts, user)
} else {
// Otherwise, we try to rate limit by ip
if isRateLimitingThresholdExceeded(&rateLimitingDataMatch.Config, rateLimitingDataMatch.IpCounts, ip) {
log.Infof(server.Logger, "Rate limited request for ip %s - %s %s - %v", ip, method, routeParsed, rateLimitingDataMatch.IpCounts[ip])
return &protos.RateLimitingStatus{Block: true, Trigger: "ip"}
}
+ incrementSlidingWindowEntry(rateLimitingDataMatch.IpCounts, ip)
}
return &protos.RateLimitingStatus{Block: false}
diff --git a/lib/agent/grpc/server.go b/lib/agent/grpc/server.go
index 983e75105..e3a848fb8 100644
--- a/lib/agent/grpc/server.go
+++ b/lib/agent/grpc/server.go
@@ -33,13 +33,26 @@ func (s *GrpcServer) OnConfig(ctx context.Context, req *protos.Config) (*emptypb
return &emptypb.Empty{}, nil
}
- server := globals.GetServer(ServerKey{Token: token, ServerPID: req.GetServerPid()})
- if server != nil {
+ serverKey := ServerKey{Token: token, ServerPID: req.GetServerPid()}
+
+ globals.ServersMutex.Lock()
+ server, exists := globals.Servers[serverKey]
+ if exists {
+ globals.ServersMutex.Unlock()
log.Debugf(server.Logger, "Server \"AIK_RUNTIME_***%s\" already exists, skipping config update (request processor PID: %d, server PID: %d)", utils.AnonymizeToken(token), req.GetRequestProcessorPid(), req.GetServerPid())
return &emptypb.Empty{}, nil
}
- server_utils.Register(ServerKey{Token: token, ServerPID: req.GetServerPid()}, req.GetRequestProcessorPid(), req)
+ log.Infof(log.MainLogger, "Client (request processor PID: %d) connected. Registering server \"AIK_RUNTIME_***%s\" (server PID: %d)...", req.GetRequestProcessorPid(), utils.AnonymizeToken(serverKey.Token), serverKey.ServerPID)
+ server = NewServerData()
+
+ server_utils.InitializeServerLogger(server, req)
+
+ globals.Servers[serverKey] = server
+ globals.ServersMutex.Unlock()
+
+ server_utils.CompleteServerConfiguration(server, serverKey, req)
+
return &emptypb.Empty{}, nil
}
@@ -57,6 +70,7 @@ func (s *GrpcServer) OnDomain(ctx context.Context, req *protos.Domain) (*emptypb
if server == nil {
return &emptypb.Empty{}, nil
}
+
log.Debugf(server.Logger, "Received domain: %s:%d", req.GetDomain(), req.GetPort())
storeDomain(server, req.GetDomain(), req.GetPort())
return &emptypb.Empty{}, nil
@@ -67,6 +81,7 @@ func (s *GrpcServer) GetRateLimitingStatus(ctx context.Context, req *protos.Rate
if server == nil {
return &protos.RateLimitingStatus{Block: false}, nil
}
+
log.Debugf(server.Logger, "Received rate limiting info: %s %s %s %s %s %s", req.GetMethod(), req.GetRoute(), req.GetRouteParsed(), req.GetUser(), req.GetIp(), req.GetRateLimitGroup())
return getRateLimitingStatus(server, req.GetMethod(), req.GetRoute(), req.GetRouteParsed(), req.GetUser(), req.GetIp(), req.GetRateLimitGroup()), nil
}
@@ -76,15 +91,14 @@ func (s *GrpcServer) OnRequestShutdown(ctx context.Context, req *protos.RequestM
if server == nil {
return &emptypb.Empty{}, nil
}
+
log.Debugf(server.Logger, "Received request metadata: %s %s %d %s %s %v", req.GetMethod(), req.GetRouteParsed(), req.GetStatusCode(), req.GetUser(), req.GetIp(), req.GetApiSpec())
if req.GetShouldDiscoverRoute() || req.GetRateLimited() {
go storeTotalStats(server, req.GetRateLimited())
go storeRoute(server, req.GetMethod(), req.GetRouteParsed(), req.GetApiSpec(), req.GetRateLimited())
- go updateRateLimitingCounts(server, req.GetMethod(), req.GetRoute(), req.GetRouteParsed(), req.GetUser(), req.GetIp(), req.GetRateLimitGroup())
}
go updateAttackWaveCountsAndDetect(server, req.GetIsWebScanner(), req.GetIp(), req.GetUser(), req.GetUserAgent(), req.GetMethod(), req.GetUrl())
- atomic.StoreUint32(&server.GotTraffic, 1)
return &emptypb.Empty{}, nil
}
@@ -109,6 +123,7 @@ func (s *GrpcServer) OnUser(ctx context.Context, req *protos.User) (*emptypb.Emp
if server == nil {
return &emptypb.Empty{}, nil
}
+
log.Debugf(server.Logger, "Received user event: %s", req.GetId())
go onUserEvent(server, req.GetId(), req.GetUsername(), req.GetIp())
return &emptypb.Empty{}, nil
@@ -119,6 +134,7 @@ func (s *GrpcServer) OnAttackDetected(ctx context.Context, req *protos.AttackDet
if server == nil {
return &emptypb.Empty{}, nil
}
+
cloud.SendAttackDetectedEvent(server, req, "detected_attack")
storeAttackStats(server, req)
return &emptypb.Empty{}, nil
@@ -129,6 +145,7 @@ func (s *GrpcServer) OnMonitoredSinkStats(ctx context.Context, req *protos.Monit
if server == nil {
return &emptypb.Empty{}, nil
}
+
storeSinkStats(server, req)
return &emptypb.Empty{}, nil
}
@@ -138,6 +155,8 @@ func (s *GrpcServer) OnMiddlewareInstalled(ctx context.Context, req *protos.Midd
if server == nil {
return &emptypb.Empty{}, nil
}
+
+ // Note: Don't start tickers here - this is an initialization event, not runtime traffic
log.Debugf(server.Logger, "Received MiddlewareInstalled")
atomic.StoreUint32(&server.MiddlewareInstalled, 1)
return &emptypb.Empty{}, nil
@@ -148,6 +167,7 @@ func (s *GrpcServer) OnMonitoredIpMatch(ctx context.Context, req *protos.Monitor
if server == nil {
return &emptypb.Empty{}, nil
}
+
log.Debugf(server.Logger, "Received MonitoredIpMatch: %v", req.GetLists())
server.StatsData.StatsMutex.Lock()
@@ -162,6 +182,7 @@ func (s *GrpcServer) OnMonitoredUserAgentMatch(ctx context.Context, req *protos.
if server == nil {
return &emptypb.Empty{}, nil
}
+
log.Debugf(server.Logger, "Received MonitoredUserAgentMatch: %v", req.GetLists())
server.StatsData.StatsMutex.Lock()
diff --git a/lib/agent/rate_limiting/rate_limiting.go b/lib/agent/rate_limiting/rate_limiting.go
index a33ca3210..66ad28236 100644
--- a/lib/agent/rate_limiting/rate_limiting.go
+++ b/lib/agent/rate_limiting/rate_limiting.go
@@ -6,19 +6,25 @@ import (
)
func AdvanceRateLimitingQueues(server *ServerData) {
- server.RateLimitingMutex.Lock()
- defer server.RateLimitingMutex.Unlock()
-
+ server.RateLimitingMutex.RLock()
+ endpoints := make([]*RateLimitingValue, 0, len(server.RateLimitingMap))
for _, endpoint := range server.RateLimitingMap {
+ endpoints = append(endpoints, endpoint)
+ }
+ server.RateLimitingMutex.RUnlock()
+
+ for _, endpoint := range endpoints {
+ endpoint.Mutex.Lock()
AdvanceSlidingWindowMap(endpoint.UserCounts, endpoint.Config.WindowSizeInMinutes)
AdvanceSlidingWindowMap(endpoint.IpCounts, endpoint.Config.WindowSizeInMinutes)
AdvanceSlidingWindowMap(endpoint.RateLimitGroupCounts, endpoint.Config.WindowSizeInMinutes)
+ endpoint.Mutex.Unlock()
}
}
-func Init(server *ServerData) {
+// StartRateLimitingTicker starts the rate limiting ticker
+func StartRateLimitingTicker(server *ServerData) {
utils.StartPollingRoutine(server.PollingData.RateLimitingChannel, server.PollingData.RateLimitingTicker, AdvanceRateLimitingQueues, server)
- AdvanceRateLimitingQueues(server)
}
func Uninit(server *ServerData) {
diff --git a/lib/agent/server_utils/server.go b/lib/agent/server_utils/server.go
index a767099d2..9916a4f30 100644
--- a/lib/agent/server_utils/server.go
+++ b/lib/agent/server_utils/server.go
@@ -28,26 +28,38 @@ func storeConfig(server *ServerData, req *protos.Config) {
server.AikidoConfig.CollectApiSchema = req.GetCollectApiSchema()
}
-func Register(serverKey ServerKey, requestProcessorPID int32, req *protos.Config) {
- log.Infof(log.MainLogger, "Client (request processor PID: %d) connected. Registering server \"AIK_RUNTIME_***%s\" (server PID: %d)...", requestProcessorPID, utils.AnonymizeToken(serverKey.Token), serverKey.ServerPID)
-
- server := globals.CreateServer(serverKey)
+func InitializeServerLogger(server *ServerData, req *protos.Config) {
storeConfig(server, req)
+ serverKey := ServerKey{Token: req.GetToken(), ServerPID: req.GetServerPid()}
server.Logger = log.CreateLogger(utils.AnonymizeToken(serverKey.Token), server.AikidoConfig.LogLevel, server.AikidoConfig.DiskLogs)
+ atomic.StoreInt64(&server.LastConnectionTime, utils.GetTime())
+}
+func CompleteServerConfiguration(server *ServerData, serverKey ServerKey, req *protos.Config) {
log.InfofMainAndServer(server.Logger, "Server \"AIK_RUNTIME_***%s\" (server PID: %d) registered successfully!", utils.AnonymizeToken(serverKey.Token), serverKey.ServerPID)
- atomic.StoreInt64(&server.LastConnectionTime, utils.GetTime())
-
cloud.Init(server)
+ rate_limiting.StartRateLimitingTicker(server)
+ attack_wave_detection.StartAttackWaveTicker(server)
+
if globals.IsPastDeletedServer(serverKey) {
log.InfofMainAndServer(server.Logger, "Server \"AIK_RUNTIME_***%s\" (server PID: %d) was registered before for this server PID, but deleted due to inactivity! Skipping start event as it was sent before...", utils.AnonymizeToken(serverKey.Token), serverKey.ServerPID)
} else {
cloud.SendStartEvent(server)
}
+}
+
+func ConfigureServer(server *ServerData, req *protos.Config) {
+ serverKey := ServerKey{Token: req.GetToken(), ServerPID: req.GetServerPid()}
+ InitializeServerLogger(server, req)
+ CompleteServerConfiguration(server, serverKey, req)
+}
- rate_limiting.Init(server)
- attack_wave_detection.Init(server)
+func Register(serverKey ServerKey, requestProcessorPID int32, req *protos.Config) {
+ log.Infof(log.MainLogger, "Client (request processor PID: %d) connected. Registering server \"AIK_RUNTIME_***%s\" (server PID: %d)...", requestProcessorPID, utils.AnonymizeToken(serverKey.Token), serverKey.ServerPID)
+
+ server := globals.CreateServer(serverKey)
+ ConfigureServer(server, req)
}
func Unregister(serverKey ServerKey) {
diff --git a/lib/php-extension/Action.cpp b/lib/php-extension/Action.cpp
index 13c0976f1..997d5b296 100644
--- a/lib/php-extension/Action.cpp
+++ b/lib/php-extension/Action.cpp
@@ -1,12 +1,12 @@
#include "Includes.h"
-Action action;
-
ACTION_STATUS Action::executeThrow(json &event) {
int _code = event["code"].get();
std::string _message = event["message"].get();
- zend_throw_exception(GetFirewallDefaultExceptionCe(), _message.c_str(), _code);
+
CallPhpFunctionWithOneParam("http_response_code", _code);
+ zend_throw_exception(GetFirewallDefaultExceptionCe(), _message.c_str(), _code);
+
return BLOCK;
}
@@ -38,7 +38,7 @@ ACTION_STATUS Action::executeStore(json &event) {
}
ACTION_STATUS Action::executeBypassIp(json &event) {
- isIpBypassed = true;
+ AIKIDO_GLOBAL(isIpBypassed) = true;
return CONTINUE;
}
diff --git a/lib/php-extension/Aikido.cpp b/lib/php-extension/Aikido.cpp
index acbe25252..ebc426195 100644
--- a/lib/php-extension/Aikido.cpp
+++ b/lib/php-extension/Aikido.cpp
@@ -4,7 +4,14 @@
ZEND_DECLARE_MODULE_GLOBALS(aikido)
PHP_MINIT_FUNCTION(aikido) {
- LoadEnvironment();
+ // For FrankenPHP: Set sapi_name but skip rest of LoadEnvironment during MINIT
+ // Full environment will be loaded in RINIT when Caddyfile env vars are available
+ if (std::string(sapi_module.name) == "frankenphp") {
+ AIKIDO_GLOBAL(sapi_name) = sapi_module.name;
+ } else {
+ // For other SAPIs: Load environment during MINIT as normal
+ LoadEnvironment();
+ }
AIKIDO_GLOBAL(logger).Init();
AIKIDO_LOG_INFO("MINIT started!\n");
@@ -16,14 +23,14 @@ PHP_MINIT_FUNCTION(aikido) {
return SUCCESS;
}
- phpLifecycle.HookAll();
+ AIKIDO_GLOBAL(phpLifecycle).HookAll();
/* If SAPI name is "cli" run in "simple" mode */
if (AIKIDO_GLOBAL(sapi_name) == "cli") {
AIKIDO_LOG_INFO("MINIT finished earlier because we run in CLI mode!\n");
return SUCCESS;
}
- phpLifecycle.ModuleInit();
+ AIKIDO_GLOBAL(phpLifecycle).ModuleInit();
AIKIDO_LOG_INFO("MINIT finished!\n");
return SUCCESS;
}
@@ -41,7 +48,7 @@ PHP_MSHUTDOWN_FUNCTION(aikido) {
/* If SAPI name is "cli" run in "simple" mode */
if (AIKIDO_GLOBAL(sapi_name) == "cli") {
AIKIDO_LOG_INFO("MSHUTDOWN finished earlier because we run in CLI mode!\n");
- phpLifecycle.UnhookAll();
+ AIKIDO_GLOBAL(phpLifecycle).UnhookAll();
return SUCCESS;
}
@@ -52,15 +59,23 @@ PHP_MSHUTDOWN_FUNCTION(aikido) {
The same does not apply for CLI mode, where the MSHUTDOWN is called only once.
*/
- phpLifecycle.ModuleShutdown();
+ AIKIDO_GLOBAL(phpLifecycle).ModuleShutdown();
AIKIDO_LOG_DEBUG("MSHUTDOWN finished!\n");
return SUCCESS;
}
PHP_RINIT_FUNCTION(aikido) {
ScopedTimer scopedTimer("request_init", "request_op");
+
+ if (std::string(sapi_module.name) == "frankenphp") {
+ if (GetEnvBool("FRANKENPHP_WORKER", false)) {
+ AIKIDO_LOG_INFO("RINIT: Skipping FrankenPHP warm-up request\n");
+ return SUCCESS;
+ }
+ }
+
+ AIKIDO_GLOBAL(phpLifecycle).RequestInit();
- phpLifecycle.RequestInit();
AIKIDO_LOG_DEBUG("RINIT finished!\n");
return SUCCESS;
}
@@ -76,7 +91,7 @@ PHP_RSHUTDOWN_FUNCTION(aikido) {
}
DestroyAstToClean();
- phpLifecycle.RequestShutdown();
+ AIKIDO_GLOBAL(phpLifecycle).RequestShutdown();
AIKIDO_LOG_DEBUG("RSHUTDOWN finished!\n");
return SUCCESS;
}
@@ -97,6 +112,63 @@ static const zend_function_entry ext_functions[] = {
ZEND_FE_END
};
+PHP_GINIT_FUNCTION(aikido) {
+ aikido_globals->environment_loaded = false;
+ aikido_globals->log_level = 0;
+ aikido_globals->blocking = false;
+ aikido_globals->disable = false;
+ aikido_globals->disk_logs = false;
+ aikido_globals->collect_api_schema = false;
+ aikido_globals->trust_proxy = false;
+ aikido_globals->localhost_allowed_by_default = false;
+ aikido_globals->report_stats_interval_to_agent = 0;
+ aikido_globals->currentRequestStart = std::chrono::high_resolution_clock::time_point{};
+ aikido_globals->totalOverheadForCurrentRequest = 0;
+ aikido_globals->laravelEnvLoaded = false;
+ aikido_globals->checkedAutoBlock = false;
+ aikido_globals->checkedShouldBlockRequest = false;
+ aikido_globals->isIpBypassed = false;
+ aikido_globals->global_ast_to_clean = nullptr;
+ aikido_globals->original_ast_process = nullptr;
+#ifdef ZTS
+ new (&aikido_globals->log_level_str) std::string();
+ new (&aikido_globals->sapi_name) std::string();
+ new (&aikido_globals->token) std::string();
+ new (&aikido_globals->endpoint) std::string();
+ new (&aikido_globals->config_endpoint) std::string();
+ new (&aikido_globals->logger) Log();
+ new (&aikido_globals->agent) Agent();
+ new (&aikido_globals->server) Server();
+ new (&aikido_globals->requestProcessor) RequestProcessor();
+ new (&aikido_globals->action) Action();
+ new (&aikido_globals->requestCache) RequestCache();
+ new (&aikido_globals->eventCache) EventCache();
+ new (&aikido_globals->phpLifecycle) PhpLifecycle();
+ new (&aikido_globals->stats) std::unordered_map();
+ new (&aikido_globals->laravelEnv) std::unordered_map();
+#endif
+}
+
+PHP_GSHUTDOWN_FUNCTION(aikido) {
+#ifdef ZTS
+ aikido_globals->laravelEnv.~unordered_map();
+ aikido_globals->phpLifecycle.~PhpLifecycle();
+ aikido_globals->action.~Action();
+ aikido_globals->requestProcessor.~RequestProcessor();
+ aikido_globals->stats.~unordered_map();
+ aikido_globals->server.~Server();
+ aikido_globals->logger.~Log();
+ aikido_globals->agent.~Agent();
+ aikido_globals->eventCache.~EventCache();
+ aikido_globals->requestCache.~RequestCache();
+ aikido_globals->config_endpoint.~string();
+ aikido_globals->endpoint.~string();
+ aikido_globals->token.~string();
+ aikido_globals->sapi_name.~string();
+ aikido_globals->log_level_str.~string();
+#endif
+}
+
zend_module_entry aikido_module_entry = {
STANDARD_MODULE_HEADER,
"aikido", /* Extension name */
@@ -108,8 +180,8 @@ zend_module_entry aikido_module_entry = {
PHP_MINFO(aikido), /* PHP_MINFO - Module info */
PHP_AIKIDO_VERSION, /* Version */
PHP_MODULE_GLOBALS(aikido), /* Module globals */
- NULL, /* PHP_GINIT – Globals initialization */
- NULL, /* PHP_GSHUTDOWN – Globals shutdown */
+ PHP_GINIT(aikido), /* PHP_GINIT – Globals initialization */
+ PHP_GSHUTDOWN(aikido), /* PHP_GSHUTDOWN – Globals shutdown */
NULL,
STANDARD_MODULE_PROPERTIES_EX,
};
diff --git a/lib/php-extension/Cache.cpp b/lib/php-extension/Cache.cpp
index 323d233bf..60c569d6d 100644
--- a/lib/php-extension/Cache.cpp
+++ b/lib/php-extension/Cache.cpp
@@ -1,8 +1,5 @@
#include "Includes.h"
-RequestCache requestCache;
-EventCache eventCache;
-
void RequestCache::Reset() {
*this = RequestCache();
}
diff --git a/lib/php-extension/Environment.cpp b/lib/php-extension/Environment.cpp
index 264f2558a..cbf53a240 100644
--- a/lib/php-extension/Environment.cpp
+++ b/lib/php-extension/Environment.cpp
@@ -33,15 +33,13 @@ std::string GetSystemEnvVariable(const std::string& env_key) {
return env_value;
}
-std::unordered_map laravelEnv;
-bool laravelEnvLoaded = false;
bool LoadLaravelEnvFile() {
- if (laravelEnvLoaded) {
+ if (AIKIDO_GLOBAL(laravelEnvLoaded)) {
return true;
}
- std::string docRoot = server.GetVar("DOCUMENT_ROOT");
+ std::string docRoot = AIKIDO_GLOBAL(server).GetVar("DOCUMENT_ROOT");
AIKIDO_LOG_DEBUG("Trying to load .env file, starting with DOCUMENT_ROOT: %s\n", docRoot.c_str());
if (docRoot.empty()) {
AIKIDO_LOG_DEBUG("DOCUMENT_ROOT is empty!\n");
@@ -89,36 +87,74 @@ bool LoadLaravelEnvFile() {
(value.front() == '\'' && value.back() == '\''))) {
value = value.substr(1, value.length() - 2);
}
- laravelEnv[key] = value;
+ AIKIDO_GLOBAL(laravelEnv)[key] = value;
}
}
}
- laravelEnvLoaded = true;
+ AIKIDO_GLOBAL(laravelEnvLoaded) = true;
AIKIDO_LOG_DEBUG("Loaded Laravel env file: %s\n", laravelEnvPath.c_str());
return true;
}
+
+/*
+ FrankenPHP's Caddyfile env directive only populates $_SERVER, not the process environment.
+ This function reads environment variables from $_SERVER for FrankenPHP compatibility.
+*/
+std::string GetFrankenEnvVariable(const std::string& env_key) {
+ if (std::string(sapi_module.name) != "frankenphp") {
+ return "";
+ }
+
+ // Force $_SERVER autoglobal to be initialized (it's lazily loaded in PHP)
+ // This is CRITICAL in ZTS mode to ensure each thread gets request-specific $_SERVER values
+ zend_is_auto_global_str(ZEND_STRL("_SERVER"));
+
+ if (Z_TYPE(PG(http_globals)[TRACK_VARS_SERVER]) != IS_ARRAY) {
+ AIKIDO_LOG_DEBUG("franken_env[%s] = (empty - $_SERVER not an array)\n", env_key.c_str());
+ return "";
+ }
+
+ std::string env_value = AIKIDO_GLOBAL(server).GetVar(env_key.c_str());
+ if (!env_value.empty()) {
+ if (env_key == "AIKIDO_TOKEN") {
+ AIKIDO_LOG_DEBUG("franken_env[%s] = %s\n", env_key.c_str(), AnonymizeToken(env_value).c_str());
+ } else {
+ AIKIDO_LOG_DEBUG("franken_env[%s] = %s\n", env_key.c_str(), env_value.c_str());
+ }
+ }
+ return env_value;
+}
+
std::string GetLaravelEnvVariable(const std::string& env_key) {
+ const auto& laravelEnv = AIKIDO_GLOBAL(laravelEnv);
if (laravelEnv.find(env_key) != laravelEnv.end()) {
if (env_key == "AIKIDO_TOKEN") {
- AIKIDO_LOG_DEBUG("laravel_env[%s] = %s\n", env_key.c_str(), AnonymizeToken(laravelEnv[env_key]).c_str());
+ AIKIDO_LOG_DEBUG("laravel_env[%s] = %s\n", env_key.c_str(), AnonymizeToken(laravelEnv.at(env_key)).c_str());
} else {
- AIKIDO_LOG_DEBUG("laravel_env[%s] = %s\n", env_key.c_str(), laravelEnv[env_key].c_str());
+ AIKIDO_LOG_DEBUG("laravel_env[%s] = %s\n", env_key.c_str(), laravelEnv.at(env_key).c_str());
}
- return laravelEnv[env_key];
+ return laravelEnv.at(env_key);
}
return "";
}
/*
- Load env variables from the following sources (in this order):
+ Load env variables from the following sources (priority order):
- System environment variables
- - PHP environment variables
+ - FrankenPHP environment variables ($_SERVER - request-specific, thread-safe)
+ - PHP environment variables
- Laravel environment variables
+
+ Order is critical: In multithreaded environments (FrankenPHP worker/classic, ZTS),
+ getenv() returns cached process-level values that may belong to a different request.
+ $_SERVER must be checked first to get fresh, request-specific environment data.
*/
+
using EnvGetterFn = std::string(*)(const std::string&);
EnvGetterFn envGetters[] = {
&GetSystemEnvVariable,
+ &GetFrankenEnvVariable,
&GetPhpEnvVariable,
&GetLaravelEnvVariable
};
@@ -168,12 +204,14 @@ unsigned int GetEnvNumber(const std::string& env_key, unsigned int default_value
}
void LoadEnvironment() {
+ auto& logLevelStr = AIKIDO_GLOBAL(log_level_str);
+ auto& logLevel = AIKIDO_GLOBAL(log_level);
if (GetEnvBool("AIKIDO_DEBUG", false)) {
- AIKIDO_GLOBAL(log_level_str) = "DEBUG";
- AIKIDO_GLOBAL(log_level) = AIKIDO_LOG_LEVEL_DEBUG;
+ logLevelStr = "DEBUG";
+ logLevel = AIKIDO_LOG_LEVEL_DEBUG;
} else {
- AIKIDO_GLOBAL(log_level_str) = GetEnvString("AIKIDO_LOG_LEVEL", "WARN");
- AIKIDO_GLOBAL(log_level) = Log::ToLevel(AIKIDO_GLOBAL(log_level_str));
+ logLevelStr = GetEnvString("AIKIDO_LOG_LEVEL", "WARN");
+ logLevel = Log::ToLevel(logLevelStr);
}
AIKIDO_GLOBAL(blocking) = GetEnvBool("AIKIDO_BLOCK", false) || GetEnvBool("AIKIDO_BLOCKING", false);;
diff --git a/lib/php-extension/GoWrappers.cpp b/lib/php-extension/GoWrappers.cpp
index 06541eeae..c5e95b9c1 100644
--- a/lib/php-extension/GoWrappers.cpp
+++ b/lib/php-extension/GoWrappers.cpp
@@ -1,11 +1,13 @@
#include "Includes.h"
GoString GoCreateString(const std::string& s) {
- return GoString{s.c_str(), s.length()};
+ return GoString{ s.c_str(), static_cast(s.size()) };
}
GoSlice GoCreateSlice(const std::vector& v) {
- return GoSlice{ (void*)v.data(), v.size(), v.capacity() };
+ return GoSlice{ static_cast(const_cast(v.data())),
+ static_cast(v.size()),
+ static_cast(v.capacity()) };
}
/*
Callback wrapper called by the RequestProcessor (GO) whenever it needs data from PHP (C++ extension).
@@ -14,6 +16,10 @@ char* GoContextCallback(int callbackId) {
std::string ctx;
std::string ret;
+ auto& server = AIKIDO_GLOBAL(server);
+ const auto& requestCache = AIKIDO_GLOBAL(requestCache);
+ const auto& eventCache = AIKIDO_GLOBAL(eventCache);
+
try {
switch (callbackId) {
case CONTEXT_REMOTE_ADDRESS:
diff --git a/lib/php-extension/Handle.cpp b/lib/php-extension/Handle.cpp
index 2fb08637a..358f2ce73 100644
--- a/lib/php-extension/Handle.cpp
+++ b/lib/php-extension/Handle.cpp
@@ -6,11 +6,15 @@ ACTION_STATUS aikido_process_event(EVENT_ID& eventId, std::string& sink) {
return CONTINUE;
}
+ auto& requestProcessor = AIKIDO_GLOBAL(requestProcessor);
+ auto& action = AIKIDO_GLOBAL(action);
+ auto& statsMap = AIKIDO_GLOBAL(stats);
+
std::string outputEvent;
requestProcessor.SendEvent(eventId, outputEvent);
if (action.IsDetection(outputEvent)) {
- stats[sink].IncrementAttacksDetected();
+ statsMap[sink].IncrementAttacksDetected();
}
if (!requestProcessor.IsBlockingEnabled()) {
@@ -19,7 +23,7 @@ ACTION_STATUS aikido_process_event(EVENT_ID& eventId, std::string& sink) {
ACTION_STATUS action_status = action.Execute(outputEvent);
if (action_status == BLOCK) {
- stats[sink].IncrementAttacksBlocked();
+ statsMap[sink].IncrementAttacksBlocked();
}
return action_status;
}
@@ -36,6 +40,7 @@ ZEND_NAMED_FUNCTION(aikido_generic_handler) {
std::string outputEvent;
bool caughtException = false;
+ auto& eventCache = AIKIDO_GLOBAL(eventCache);
eventCache.Reset();
try {
diff --git a/lib/php-extension/HandleBypassedIp.cpp b/lib/php-extension/HandleBypassedIp.cpp
index 90906055e..da0611086 100644
--- a/lib/php-extension/HandleBypassedIp.cpp
+++ b/lib/php-extension/HandleBypassedIp.cpp
@@ -1,26 +1,25 @@
#include "Includes.h"
-// This variable is used to check if the request is bypassed,
-// if true, all blocking checks will be skipped.
-bool isIpBypassed = false;
+// The isIpBypassed module global variable is used to store whether the current IP is bypassed.
+// If true, all blocking checks will be skipped.
+// Accessed via AIKIDO_GLOBAL(isIpBypassed).
void InitIpBypassCheck() {
- // Reset state for new request
- isIpBypassed = false;
-
+ // Reset state for new request (so it's not cached from previous request)
+ AIKIDO_GLOBAL(isIpBypassed) = false;
+
ScopedTimer scopedTimer("check_ip_bypass", "aikido_op");
try {
std::string output;
- requestProcessor.SendEvent(EVENT_GET_IS_IP_BYPASSED, output);
- action.Execute(output);
+ AIKIDO_GLOBAL(requestProcessor).SendEvent(EVENT_GET_IS_IP_BYPASSED, output);
+ AIKIDO_GLOBAL(action).Execute(output);
} catch (const std::exception &e) {
AIKIDO_LOG_ERROR("Exception encountered in processing IP bypass check event: %s\n", e.what());
}
}
-
bool IsAikidoDisabledOrBypassed() {
- return AIKIDO_GLOBAL(disable) == true || isIpBypassed;
+ return AIKIDO_GLOBAL(disable) == true || AIKIDO_GLOBAL(isIpBypassed);
}
diff --git a/lib/php-extension/HandleFileCompilation.cpp b/lib/php-extension/HandleFileCompilation.cpp
index c8faad1a3..e37e9005b 100644
--- a/lib/php-extension/HandleFileCompilation.cpp
+++ b/lib/php-extension/HandleFileCompilation.cpp
@@ -1,6 +1,7 @@
#include "Includes.h"
zend_op_array* handle_file_compilation(zend_file_handle* file_handle, int type) {
+ auto& eventCache = AIKIDO_GLOBAL(eventCache);
eventCache.Reset();
switch (type) {
case ZEND_INCLUDE:
diff --git a/lib/php-extension/HandlePathAccess.cpp b/lib/php-extension/HandlePathAccess.cpp
index f621ff97c..3e0c4e207 100644
--- a/lib/php-extension/HandlePathAccess.cpp
+++ b/lib/php-extension/HandlePathAccess.cpp
@@ -27,6 +27,7 @@ void helper_handle_pre_file_path_access(char *filename, EVENT_ID &eventId) {
filenameString = get_resource_or_original_from_php_filter(filenameString);
// if filename starts with http:// or https://, it's a URL so we treat it as an outgoing request
+ auto& eventCache = AIKIDO_GLOBAL(eventCache);
if (StartsWith(filenameString, "http://", false) ||
StartsWith(filenameString, "https://", false)) {
eventId = EVENT_PRE_OUTGOING_REQUEST;
@@ -39,13 +40,14 @@ void helper_handle_pre_file_path_access(char *filename, EVENT_ID &eventId) {
/* Helper for handle post file path access */
void helper_handle_post_file_path_access(EVENT_ID &eventId) {
+ auto& eventCache = AIKIDO_GLOBAL(eventCache);
if (!eventCache.outgoingRequestUrl.empty()) {
// If the pre handler for path access determined this was actually an URL,
// we need to notify that the request finished.
eventId = EVENT_POST_OUTGOING_REQUEST;
// As we cannot extract the effective URL for these fopen wrappers,
- // we will just assume it's the same as the initial URL.
+ // we will assume it's the same as the initial URL.
eventCache.outgoingRequestEffectiveUrl = eventCache.outgoingRequestUrl;
}
}
@@ -92,7 +94,7 @@ AIKIDO_HANDLER_FUNCTION(handle_pre_file_path_access_2) {
helper_handle_pre_file_path_access(ZSTR_VAL(filename), eventId);
if (filename2) {
- eventCache.filename2 = ZSTR_VAL(filename2);
+ AIKIDO_GLOBAL(eventCache).filename2 = ZSTR_VAL(filename2);
}
}
diff --git a/lib/php-extension/HandleQueries.cpp b/lib/php-extension/HandleQueries.cpp
index 00e674a00..ae80b7041 100644
--- a/lib/php-extension/HandleQueries.cpp
+++ b/lib/php-extension/HandleQueries.cpp
@@ -24,6 +24,7 @@ AIKIDO_HANDLER_FUNCTION(handle_pre_pdo_query) {
}
eventId = EVENT_PRE_SQL_QUERY_EXECUTED;
+ auto& eventCache = AIKIDO_GLOBAL(eventCache);
eventCache.moduleName = "PDO";
eventCache.sqlQuery = ZSTR_VAL(query);
eventCache.sqlDialect = GetSqlDialectFromPdo(pdo_object);
@@ -47,6 +48,7 @@ AIKIDO_HANDLER_FUNCTION(handle_pre_pdo_exec) {
}
eventId = EVENT_PRE_SQL_QUERY_EXECUTED;
+ auto& eventCache = AIKIDO_GLOBAL(eventCache);
eventCache.moduleName = "PDO";
eventCache.sqlQuery = ZSTR_VAL(query);
eventCache.sqlDialect = GetSqlDialectFromPdo(pdo_object);
@@ -66,6 +68,7 @@ AIKIDO_HANDLER_FUNCTION(handle_pre_pdostatement_execute) {
}
eventId = EVENT_PRE_SQL_QUERY_EXECUTED;
+ auto& eventCache = AIKIDO_GLOBAL(eventCache);
eventCache.moduleName = "PDOStatement";
eventCache.sqlQuery = PHP_GET_CHAR_PTR(stmt->query_string);
@@ -116,6 +119,7 @@ AIKIDO_HANDLER_FUNCTION(handle_pre_mysqli_query){
scopedTimer.SetSink(sink, "sql_op");
eventId = EVENT_PRE_SQL_QUERY_EXECUTED;
+ auto& eventCache = AIKIDO_GLOBAL(eventCache);
eventCache.moduleName = "mysqli";
eventCache.sqlQuery = query;
eventCache.sqlDialect = "mysql";
diff --git a/lib/php-extension/HandleRateLimitGroup.cpp b/lib/php-extension/HandleRateLimitGroup.cpp
index 9d17d329c..c9ed80a4a 100644
--- a/lib/php-extension/HandleRateLimitGroup.cpp
+++ b/lib/php-extension/HandleRateLimitGroup.cpp
@@ -17,10 +17,11 @@ ZEND_FUNCTION(set_rate_limit_group) {
RETURN_BOOL(false);
}
+ auto& requestCache = AIKIDO_GLOBAL(requestCache);
requestCache.rateLimitGroup = std::string(group, groupLength);
std::string outputEvent;
- requestProcessor.SendEvent(EVENT_SET_RATE_LIMIT_GROUP, outputEvent);
+ AIKIDO_GLOBAL(requestProcessor).SendEvent(EVENT_SET_RATE_LIMIT_GROUP, outputEvent);
AIKIDO_LOG_DEBUG("Set rate limit group to %s\n", requestCache.rateLimitGroup.c_str());
RETURN_BOOL(true);
diff --git a/lib/php-extension/HandleRegisterParamMatcher.cpp b/lib/php-extension/HandleRegisterParamMatcher.cpp
index cd7c3ec23..3a3d10385 100644
--- a/lib/php-extension/HandleRegisterParamMatcher.cpp
+++ b/lib/php-extension/HandleRegisterParamMatcher.cpp
@@ -22,10 +22,13 @@ ZEND_FUNCTION(register_param_matcher) {
RETURN_BOOL(false);
}
+ auto& eventCache = AIKIDO_GLOBAL(eventCache);
eventCache.paramMatcherParam = std::string(param, paramLength);
eventCache.paramMatcherRegex = std::string(regex, regexLength);
try {
+ auto& requestProcessor = AIKIDO_GLOBAL(requestProcessor);
+ auto& action = AIKIDO_GLOBAL(action);
std::string outputEvent;
requestProcessor.SendEvent(EVENT_REGISTER_PARAM_MATCHER, outputEvent);
if (action.Execute(outputEvent) == WARNING_MESSAGE) {
diff --git a/lib/php-extension/HandleSetToken.cpp b/lib/php-extension/HandleSetToken.cpp
index 8e8941039..86a06b87e 100644
--- a/lib/php-extension/HandleSetToken.cpp
+++ b/lib/php-extension/HandleSetToken.cpp
@@ -19,6 +19,6 @@ ZEND_FUNCTION(set_token) {
RETURN_BOOL(false);
}
- requestProcessor.LoadConfigWithTokenFromPHPSetToken(std::string(token, tokenLength));
+ AIKIDO_GLOBAL(requestProcessor).LoadConfigWithTokenFromPHPSetToken(std::string(token, tokenLength));
RETURN_BOOL(true);
}
diff --git a/lib/php-extension/HandleShellExecution.cpp b/lib/php-extension/HandleShellExecution.cpp
index e780820ba..d9abcf3e0 100644
--- a/lib/php-extension/HandleShellExecution.cpp
+++ b/lib/php-extension/HandleShellExecution.cpp
@@ -1,7 +1,7 @@
#include "Includes.h"
void helper_handle_pre_shell_execution(std::string cmd, EVENT_ID &eventId) {
- eventCache.cmd = cmd;
+ AIKIDO_GLOBAL(eventCache).cmd = cmd;
eventId = EVENT_PRE_SHELL_EXECUTED;
}
@@ -24,6 +24,8 @@ AIKIDO_HANDLER_FUNCTION(handle_shell_execution) {
AIKIDO_HANDLER_FUNCTION(handle_shell_execution_with_array) {
+ scopedTimer.SetSink(sink, "exec_op");
+
zval *cmdVal = nullptr;
ZEND_PARSE_PARAMETERS_START(0, -1)
diff --git a/lib/php-extension/HandleShouldBlockRequest.cpp b/lib/php-extension/HandleShouldBlockRequest.cpp
index eb9b27c19..ee517a294 100644
--- a/lib/php-extension/HandleShouldBlockRequest.cpp
+++ b/lib/php-extension/HandleShouldBlockRequest.cpp
@@ -2,13 +2,13 @@
zend_class_entry *blockingStatusClass = nullptr;
-// This variable is used to check if auto_block_request function has already been called,
-// in order to avoid multiple calls to this function.
-bool checkedAutoBlock = false;
+// The checkedAutoBlock module global variable is used to check if auto_block_request function
+// has already been called, in order to avoid multiple calls to this function.
+// Accessed via AIKIDO_GLOBAL(checkedAutoBlock).
-// This variable is used to check if should_block_request function has already been called,
-// in order to avoid multiple calls to this function.
-bool checkedShouldBlockRequest = false;
+// The checkedShouldBlockRequest module global variable is used to check if should_block_request
+// function has already been called, in order to avoid multiple calls to this function.
+// Accessed via AIKIDO_GLOBAL(checkedShouldBlockRequest).
bool CheckBlocking(EVENT_ID eventId, bool& checkedBlocking) {
if (checkedBlocking) {
@@ -18,6 +18,8 @@ bool CheckBlocking(EVENT_ID eventId, bool& checkedBlocking) {
ScopedTimer scopedTimer("check_blocking", "aikido_op");
try {
+ auto& requestProcessor = AIKIDO_GLOBAL(requestProcessor);
+ auto& action = AIKIDO_GLOBAL(action);
std::string output;
requestProcessor.SendEvent(eventId, output);
action.Execute(output);
@@ -43,7 +45,7 @@ ZEND_FUNCTION(should_block_request) {
return;
}
- if (!CheckBlocking(EVENT_GET_BLOCKING_STATUS, checkedShouldBlockRequest)) {
+ if (!CheckBlocking(EVENT_GET_BLOCKING_STATUS, AIKIDO_GLOBAL(checkedShouldBlockRequest))) {
return;
}
@@ -56,6 +58,7 @@ ZEND_FUNCTION(should_block_request) {
#else
zval *obj = return_value;
#endif
+ auto& action = AIKIDO_GLOBAL(action);
zend_update_property_bool(blockingStatusClass, obj, "block", sizeof("block") - 1, action.Block());
zend_update_property_string(blockingStatusClass, obj, "type", sizeof("type") - 1, action.Type());
zend_update_property_string(blockingStatusClass, obj, "trigger", sizeof("trigger") - 1, action.Trigger());
@@ -74,7 +77,7 @@ ZEND_FUNCTION(auto_block_request) {
return;
}
- CheckBlocking(EVENT_GET_AUTO_BLOCKING_STATUS, checkedAutoBlock);
+ CheckBlocking(EVENT_GET_AUTO_BLOCKING_STATUS, AIKIDO_GLOBAL(checkedAutoBlock));
}
void RegisterAikidoBlockRequestStatusClass() {
diff --git a/lib/php-extension/HandleUrls.cpp b/lib/php-extension/HandleUrls.cpp
index 977d91d95..8b3969062 100644
--- a/lib/php-extension/HandleUrls.cpp
+++ b/lib/php-extension/HandleUrls.cpp
@@ -13,6 +13,9 @@ AIKIDO_HANDLER_FUNCTION(handle_pre_curl_exec) {
#endif
ZEND_PARSE_PARAMETERS_END();
+ auto& eventCache = AIKIDO_GLOBAL(eventCache);
+ auto& requestCache = AIKIDO_GLOBAL(requestCache);
+
eventCache.outgoingRequestUrl = CallPhpFunctionCurlGetInfo(curlHandle, CURLINFO_EFFECTIVE_URL);
eventCache.outgoingRequestPort = CallPhpFunctionCurlGetInfo(curlHandle, CURLINFO_PRIMARY_PORT);
@@ -56,6 +59,9 @@ AIKIDO_HANDLER_FUNCTION(handle_post_curl_exec) {
eventId = EVENT_POST_OUTGOING_REQUEST;
+ auto& eventCache = AIKIDO_GLOBAL(eventCache);
+ auto& requestCache = AIKIDO_GLOBAL(requestCache);
+
eventCache.moduleName = "curl";
eventCache.outgoingRequestEffectiveUrl = CallPhpFunctionCurlGetInfo(curlHandle, CURLINFO_EFFECTIVE_URL);
eventCache.outgoingRequestEffectiveUrlPort = CallPhpFunctionCurlGetInfo(curlHandle, CURLINFO_PRIMARY_PORT);
diff --git a/lib/php-extension/HandleUsers.cpp b/lib/php-extension/HandleUsers.cpp
index b1c1ff317..28e863f57 100644
--- a/lib/php-extension/HandleUsers.cpp
+++ b/lib/php-extension/HandleUsers.cpp
@@ -1,10 +1,13 @@
#include "Includes.h"
bool SendUserEvent(std::string id, std::string username) {
+ auto& requestCache = AIKIDO_GLOBAL(requestCache);
requestCache.userId = id;
requestCache.userName = username;
try {
+ auto& requestProcessor = AIKIDO_GLOBAL(requestProcessor);
+ auto& action = AIKIDO_GLOBAL(action);
std::string output;
requestProcessor.SendEvent(EVENT_SET_USER, output);
action.Execute(output);
diff --git a/lib/php-extension/HookAst.cpp b/lib/php-extension/HookAst.cpp
index c78815496..aeb1f400a 100644
--- a/lib/php-extension/HookAst.cpp
+++ b/lib/php-extension/HookAst.cpp
@@ -1,8 +1,5 @@
#include "Includes.h"
-HashTable *global_ast_to_clean;
-ZEND_API void (*original_ast_process)(zend_ast *ast) = nullptr;
-
/*
This is a custom destructor, used for cleaning the allocated ast hashtable.
This is needed because the ast hashtable is not cleaned by the zend_ast_process function.
@@ -13,15 +10,17 @@ void ast_to_clean_dtor(zval *zv) {
}
void ensure_ast_hashtable_initialized() {
- if (!global_ast_to_clean) {
- ALLOC_HASHTABLE(global_ast_to_clean);
- zend_hash_init(global_ast_to_clean, 8, NULL, ast_to_clean_dtor, 1);
+ auto& globalAstToClean = AIKIDO_GLOBAL(global_ast_to_clean);
+ if (!globalAstToClean) {
+ ALLOC_HASHTABLE(globalAstToClean);
+ zend_hash_init(globalAstToClean, 8, NULL, ast_to_clean_dtor, 0);
}
}
zend_ast *create_ast_call(const char *name) {
ensure_ast_hashtable_initialized();
+ auto& globalAstToClean = AIKIDO_GLOBAL(global_ast_to_clean);
zend_ast *call;
zend_ast_zval *name_var;
zend_ast_list *arg_list;
@@ -31,14 +30,14 @@ zend_ast *create_ast_call(const char *name) {
name_var->kind = ZEND_AST_ZVAL;
ZVAL_STRING(&name_var->val, name);
name_var->val.u2.lineno = 0;
- zend_hash_next_index_insert_ptr(global_ast_to_clean, name_var);
+ zend_hash_next_index_insert_ptr(globalAstToClean, name_var);
// Create empty argument list
arg_list = (zend_ast_list*)emalloc(sizeof(zend_ast_list));
arg_list->kind = ZEND_AST_ARG_LIST;
arg_list->lineno = 0;
arg_list->children = 0;
- zend_hash_next_index_insert_ptr(global_ast_to_clean, arg_list);
+ zend_hash_next_index_insert_ptr(globalAstToClean, arg_list);
// Create function call node
call = (zend_ast*)emalloc(sizeof(zend_ast) + sizeof(zend_ast*));
@@ -46,7 +45,7 @@ zend_ast *create_ast_call(const char *name) {
call->lineno = 0;
call->child[0] = (zend_ast*)name_var;
call->child[1] = (zend_ast*)arg_list;
- zend_hash_next_index_insert_ptr(global_ast_to_clean, call);
+ zend_hash_next_index_insert_ptr(globalAstToClean, call);
return call;
}
@@ -108,7 +107,8 @@ void insert_call_to_ast(zend_ast *ast) {
block->children = 2;
block->child[0] = call;
block->child[1] = stmt_list->child[insertion_point];
- zend_hash_next_index_insert_ptr(global_ast_to_clean, block);
+ auto& globalAstToClean = AIKIDO_GLOBAL(global_ast_to_clean);
+ zend_hash_next_index_insert_ptr(globalAstToClean, block);
stmt_list->child[insertion_point] = (zend_ast*)block;
}
@@ -116,39 +116,43 @@ void insert_call_to_ast(zend_ast *ast) {
void aikido_ast_process(zend_ast *ast) {
insert_call_to_ast(ast);
- if(original_ast_process){
- original_ast_process(ast);
+ auto& originalAstProcess = AIKIDO_GLOBAL(original_ast_process);
+ if(originalAstProcess){
+ originalAstProcess(ast);
}
}
void HookAstProcess() {
- if (original_ast_process) {
- AIKIDO_LOG_WARN("\"zend_ast_process\" already hooked (original handler %p)!\n", original_ast_process);
+ auto& originalAstProcess = AIKIDO_GLOBAL(original_ast_process);
+ if (originalAstProcess) {
+ AIKIDO_LOG_WARN("\"zend_ast_process\" already hooked (original handler %p)!\n", originalAstProcess);
return;
}
- original_ast_process = zend_ast_process;
+ originalAstProcess = zend_ast_process;
zend_ast_process = aikido_ast_process;
- AIKIDO_LOG_INFO("Hooked \"zend_ast_process\" (original handler %p)!\n", original_ast_process);
+ AIKIDO_LOG_INFO("Hooked \"zend_ast_process\" (original handler %p)!\n", originalAstProcess);
}
void UnhookAstProcess() {
- AIKIDO_LOG_INFO("Unhooked \"zend_ast_process\" (original handler %p)!\n", original_ast_process);
+ auto& originalAstProcess = AIKIDO_GLOBAL(original_ast_process);
+ AIKIDO_LOG_INFO("Unhooked \"zend_ast_process\" (original handler %p)!\n", originalAstProcess);
// As it's not mandatory to have a zend_ast_process installed, we need to ensure UnhookAstProcess() restores zend_ast_process even if the original was NULL
// Only unhook if the current handler is still ours, avoiding clobbering others
if (zend_ast_process == aikido_ast_process){
- zend_ast_process = original_ast_process;
+ zend_ast_process = originalAstProcess;
}
- original_ast_process = nullptr;
+ originalAstProcess = nullptr;
}
void DestroyAstToClean() {
- if (global_ast_to_clean) {
- zend_hash_destroy(global_ast_to_clean);
- FREE_HASHTABLE(global_ast_to_clean);
- global_ast_to_clean = nullptr;
+ auto& globalAstToClean = AIKIDO_GLOBAL(global_ast_to_clean);
+ if (globalAstToClean) {
+ zend_hash_destroy(globalAstToClean);
+ FREE_HASHTABLE(globalAstToClean);
+ globalAstToClean = nullptr;
}
}
\ No newline at end of file
diff --git a/lib/php-extension/Log.cpp b/lib/php-extension/Log.cpp
index be4c04209..4838cd8f6 100644
--- a/lib/php-extension/Log.cpp
+++ b/lib/php-extension/Log.cpp
@@ -37,7 +37,7 @@ void Log::Write(AIKIDO_LOG_LEVEL level, const char* format, ...) {
return;
}
- fprintf(logFile, "[AIKIDO][%s][%d][%s] ", ToString(level).c_str(), getpid(), GetTime().c_str());
+ fprintf(logFile, "[AIKIDO][%s][%d][%lu][%s] ", ToString(level).c_str(), getpid(), GetThreadID(), GetTime().c_str());
va_list args;
va_start(args, format);
diff --git a/lib/php-extension/Packages.cpp b/lib/php-extension/Packages.cpp
index b1e0386ee..19662ff72 100644
--- a/lib/php-extension/Packages.cpp
+++ b/lib/php-extension/Packages.cpp
@@ -2,11 +2,13 @@
std::string GetPhpPackageVersion(const std::string& packageName) {
zval return_value;
+ std::string result = "";
CallPhpFunctionWithOneParam("phpversion", packageName, &return_value);
if (Z_TYPE(return_value) == IS_STRING) {
- return Z_STRVAL(return_value);
+ result = Z_STRVAL(return_value);
}
- return "";
+ zval_ptr_dtor(&return_value);
+ return result;
}
unordered_map GetPhpPackages() {
@@ -58,7 +60,7 @@ std::string GetComposerPackageVersion(const std::string& version) {
unordered_map GetComposerPackages() {
unordered_map packages;
- std::string docRoot = server.GetVar("DOCUMENT_ROOT");
+ std::string docRoot = AIKIDO_GLOBAL(server).GetVar("DOCUMENT_ROOT");
if (docRoot.empty()) {
return packages;
}
diff --git a/lib/php-extension/PhpLifecycle.cpp b/lib/php-extension/PhpLifecycle.cpp
index db4e18c87..e6629ed49 100644
--- a/lib/php-extension/PhpLifecycle.cpp
+++ b/lib/php-extension/PhpLifecycle.cpp
@@ -11,19 +11,24 @@ void PhpLifecycle::ModuleInit() {
}
void PhpLifecycle::RequestInit() {
- action.Reset();
- requestCache.Reset();
- requestProcessor.RequestInit();
- checkedAutoBlock = false;
- checkedShouldBlockRequest = false;
+ AIKIDO_GLOBAL(action).Reset();
+ AIKIDO_GLOBAL(requestCache).Reset();
+
+ AIKIDO_GLOBAL(requestProcessor).RequestInit();
+ AIKIDO_GLOBAL(checkedAutoBlock) = false;
+ AIKIDO_GLOBAL(checkedShouldBlockRequest) = false;
InitIpBypassCheck();
}
void PhpLifecycle::RequestShutdown() {
- requestProcessor.RequestShutdown();
+ AIKIDO_GLOBAL(requestProcessor).RequestShutdown();
}
void PhpLifecycle::ModuleShutdown() {
+#ifdef ZTS
+ AIKIDO_LOG_INFO("ZTS mode: Uninitializing Aikido Request Processor to stop background goroutines...\n");
+ AIKIDO_GLOBAL(requestProcessor).Uninit();
+#else
if (this->mainPID == getpid()) {
AIKIDO_LOG_INFO("Module shutdown called on main PID.\n");
AIKIDO_LOG_INFO("Unhooking functions...\n");
@@ -32,8 +37,9 @@ void PhpLifecycle::ModuleShutdown() {
UnhookAll();
} else {
AIKIDO_LOG_INFO("Module shutdown NOT called on main PID. Uninitializing Aikido Request Processor...\n");
- requestProcessor.Uninit();
+ AIKIDO_GLOBAL(requestProcessor).Uninit();
}
+#endif
}
void PhpLifecycle::HookAll() {
@@ -49,5 +55,3 @@ void PhpLifecycle::UnhookAll() {
UnhookFileCompilation();
UnhookAstProcess();
}
-
-PhpLifecycle phpLifecycle;
\ No newline at end of file
diff --git a/lib/php-extension/PhpWrappers.cpp b/lib/php-extension/PhpWrappers.cpp
index d516cbbba..446fff300 100644
--- a/lib/php-extension/PhpWrappers.cpp
+++ b/lib/php-extension/PhpWrappers.cpp
@@ -36,12 +36,18 @@ bool CallPhpFunction(std::string function_name, unsigned int params_number, zval
int _result = call_user_function(EG(function_table), object, &_function_name, _return_value, params_number, params);
- zend_string_release(_function_name_str);
+ zval_dtor(&_function_name);
+
if (!return_value) {
zval_ptr_dtor(&_temp_return_value);
}
- return _result == SUCCESS;
+
+ if (_result != SUCCESS) {
+ return false;
+ }
+
+ return true;
}
bool CallPhpFunctionWithOneParam(std::string function_name, long first_param, zval* return_value, zval* object) {
@@ -60,7 +66,8 @@ bool CallPhpFunctionWithOneParam(std::string function_name, std::string first_pa
bool ret = CallPhpFunction(function_name, 1, _params, return_value, object);
- zend_string_release(_first_param);
+ // Clean up the zval properly - this will handle the string refcount
+ zval_dtor(&_params[0]);
return ret;
}
@@ -90,3 +97,4 @@ std::string CallPhpFunctionCurlGetInfo(zval* curl_handle, int curl_info_option)
return result;
}
+
diff --git a/lib/php-extension/RequestProcessor.cpp b/lib/php-extension/RequestProcessor.cpp
index 8c7d8c63c..c23918ec0 100644
--- a/lib/php-extension/RequestProcessor.cpp
+++ b/lib/php-extension/RequestProcessor.cpp
@@ -1,18 +1,17 @@
#include "Includes.h"
-RequestProcessor requestProcessor;
-
-std::string RequestProcessor::GetInitData(const std::string& token) {
+std::string RequestProcessor::GetInitData(const std::string& userProvidedToken) {
LoadLaravelEnvFile();
LoadEnvironment();
- if (!token.empty()) {
- AIKIDO_GLOBAL(token) = token;
+ auto& globalToken = AIKIDO_GLOBAL(token);
+ if (!userProvidedToken.empty()) {
+ globalToken = userProvidedToken;
}
unordered_map packages = GetPackages();
AIKIDO_GLOBAL(uses_symfony_http_foundation) = packages.find("symfony/http-foundation") != packages.end();
json initData = {
- {"token", AIKIDO_GLOBAL(token)},
+ {"token", globalToken},
{"platform_name", AIKIDO_GLOBAL(sapi_name)},
{"platform_version", PHP_VERSION},
{"endpoint", AIKIDO_GLOBAL(endpoint)},
@@ -28,20 +27,20 @@ std::string RequestProcessor::GetInitData(const std::string& token) {
}
bool RequestProcessor::ContextInit() {
- if (!this->requestInitialized || this->requestProcessorContextInitFn == nullptr) {
+ if (!this->requestInitialized || this->requestProcessorContextInitFn == nullptr || this->requestProcessorInstance == nullptr) {
return false;
}
- return this->requestProcessorContextInitFn(GoContextCallback);
+ return this->requestProcessorContextInitFn(this->requestProcessorInstance, GoContextCallback);
}
bool RequestProcessor::SendEvent(EVENT_ID eventId, std::string& output) {
- if (!this->requestInitialized || this->requestProcessorOnEventFn == nullptr) {
+ if (!this->requestInitialized || this->requestProcessorOnEventFn == nullptr || this->requestProcessorInstance == nullptr) {
return false;
}
AIKIDO_LOG_DEBUG("Sending event %s...\n", GetEventName(eventId));
- char* charPtr = this->requestProcessorOnEventFn(eventId);
+ char* charPtr = this->requestProcessorOnEventFn(this->requestProcessorInstance, eventId);
if (!charPtr) {
AIKIDO_LOG_DEBUG("Got event reply: nullptr\n");
return true;
@@ -58,7 +57,7 @@ void RequestProcessor::SendPreRequestEvent() {
try {
std::string outputEvent;
SendEvent(EVENT_PRE_REQUEST, outputEvent);
- action.Execute(outputEvent);
+ AIKIDO_GLOBAL(action).Execute(outputEvent);
} catch (const std::exception& e) {
AIKIDO_LOG_ERROR("Exception encountered in processing request init metadata: %s\n", e.what());
}
@@ -68,7 +67,7 @@ void RequestProcessor::SendPostRequestEvent() {
try {
std::string outputEvent;
SendEvent(EVENT_POST_REQUEST, outputEvent);
- action.Execute(outputEvent);
+ AIKIDO_GLOBAL(action).Execute(outputEvent);
} catch (const std::exception& e) {
AIKIDO_LOG_ERROR("Exception encountered in processing request shutdown metadata: %s\n", e.what());
}
@@ -79,10 +78,10 @@ void RequestProcessor::SendPostRequestEvent() {
Otherwise, return the env variable AIKIDO_BLOCK.
*/
bool RequestProcessor::IsBlockingEnabled() {
- if (!this->requestInitialized || this->requestProcessorGetBlockingModeFn == nullptr) {
+ if (!this->requestInitialized || this->requestProcessorGetBlockingModeFn == nullptr || this->requestProcessorInstance == nullptr) {
return false;
}
- int ret = this->requestProcessorGetBlockingModeFn();
+ int ret = this->requestProcessorGetBlockingModeFn(this->requestProcessorInstance);
if (ret == -1) {
ret = AIKIDO_GLOBAL(blocking);
}
@@ -98,11 +97,24 @@ bool RequestProcessor::ReportStats() {
}
AIKIDO_LOG_INFO("Reporting stats to Aikido Request Processor...\n");
- for (const auto& [sink, sinkStats] : stats) {
+ auto& statsMap = AIKIDO_GLOBAL(stats);
+ for (std::unordered_map::const_iterator it = statsMap.begin(); it != statsMap.end(); ++it) {
+ const std::string& sink = it->first;
+ const SinkStats& sinkStats = it->second;
AIKIDO_LOG_INFO("Reporting stats for sink \"%s\" to Aikido Request Processor...\n", sink.c_str());
- this->requestProcessorReportStatsFn(GoCreateString(sink), GoCreateString(sinkStats.kind), sinkStats.attacksDetected, sinkStats.attacksBlocked, sinkStats.interceptorThrewError, sinkStats.withoutContext, sinkStats.timings.size(), GoCreateSlice(sinkStats.timings));
+ requestProcessorReportStatsFn(
+ this->requestProcessorInstance,
+ GoCreateString(sink),
+ GoCreateString(sinkStats.kind),
+ sinkStats.attacksDetected,
+ sinkStats.attacksBlocked,
+ sinkStats.interceptorThrewError,
+ sinkStats.withoutContext,
+ static_cast(sinkStats.timings.size()),
+ GoCreateSlice(sinkStats.timings)
+ );
}
- stats.clear();
+ statsMap.clear();
return true;
}
@@ -116,27 +128,31 @@ bool RequestProcessor::Init() {
}
std::string initDataString = this->GetInitData();
- if (AIKIDO_GLOBAL(disable) == true && AIKIDO_GLOBAL(sapi_name) != "apache2handler") {
+ if (AIKIDO_GLOBAL(disable) == true && AIKIDO_GLOBAL(sapi_name) != "apache2handler" && AIKIDO_GLOBAL(sapi_name) != "frankenphp") {
/*
- As you can set AIKIDO_DISABLE per site, in an apache-mod-php setup, as a process can serve multiple sites,
+ As you can set AIKIDO_DISABLE per site, in an apache-mod-php or frankenphp setup, as a process can serve multiple sites,
we can't just not initialize the request processor, as it can be disabled for one site but not for another.
When subsequent requests come in for the non-disabled sites, the request processor needs to be initialized.
- For non-apache-mod-php SAPI, we can just not initialize the request processor if AIKIDO_DISABLE is set to 1.
+ For non-apache-mod-php/frankenphp SAPI, we can just not initialize the request processor if AIKIDO_DISABLE is set to 1.
*/
- AIKIDO_LOG_INFO("Request Processor initialization skipped because AIKIDO_DISABLE is set to 1 and SAPI is not apache2handler!\n");
+ AIKIDO_LOG_INFO("Request Processor initialization skipped because AIKIDO_DISABLE is set to 1 and SAPI is not apache2handler or frankenphp!\n");
return false;
}
std::string requestProcessorLibPath = "/opt/aikido-" + std::string(PHP_AIKIDO_VERSION) + "/aikido-request-processor.so";
this->libHandle = dlopen(requestProcessorLibPath.c_str(), RTLD_LAZY);
if (!this->libHandle) {
- AIKIDO_LOG_ERROR("Error loading the Aikido Request Processor library from %s: %s!\n", requestProcessorLibPath.c_str(), dlerror());
+ const char* err = dlerror();
+ AIKIDO_LOG_ERROR("Error loading the Aikido Request Processor library from %s: %s!\n", requestProcessorLibPath.c_str(), err);
this->initFailed = true;
return false;
}
AIKIDO_LOG_INFO("Initializing Aikido Request Processor...\n");
+ this->createInstanceFn = (CreateInstanceFn)dlsym(libHandle, "CreateInstance");
+ this->destroyInstanceFn = (DestroyInstanceFn)dlsym(libHandle, "DestroyInstance");
+
RequestProcessorInitFn requestProcessorInitFn = (RequestProcessorInitFn)dlsym(libHandle, "RequestProcessorInit");
this->requestProcessorContextInitFn = (RequestProcessorContextInitFn)dlsym(libHandle, "RequestProcessorContextInit");
this->requestProcessorConfigUpdateFn = (RequestProcessorConfigUpdateFn)dlsym(libHandle, "RequestProcessorConfigUpdate");
@@ -144,7 +160,9 @@ bool RequestProcessor::Init() {
this->requestProcessorGetBlockingModeFn = (RequestProcessorGetBlockingModeFn)dlsym(libHandle, "RequestProcessorGetBlockingMode");
this->requestProcessorReportStatsFn = (RequestProcessorReportStats)dlsym(libHandle, "RequestProcessorReportStats");
this->requestProcessorUninitFn = (RequestProcessorUninitFn)dlsym(libHandle, "RequestProcessorUninit");
- if (!requestProcessorInitFn ||
+ if (!this->createInstanceFn ||
+ !this->destroyInstanceFn ||
+ !requestProcessorInitFn ||
!this->requestProcessorContextInitFn ||
!this->requestProcessorConfigUpdateFn ||
!this->requestProcessorOnEventFn ||
@@ -156,15 +174,11 @@ bool RequestProcessor::Init() {
return false;
}
- if (!requestProcessorInitFn(GoCreateString(initDataString))) {
- AIKIDO_LOG_ERROR("Failed to initialize Aikido Request Processor library: %s!\n", dlerror());
- this->initFailed = true;
- return false;
- }
+ this->requestProcessorInitFn = requestProcessorInitFn;
AIKIDO_GLOBAL(logger).Init();
- AIKIDO_LOG_INFO("Aikido Request Processor initialized successfully (SAPI: %s)!\n", AIKIDO_GLOBAL(sapi_name).c_str());
+ AIKIDO_LOG_INFO("Aikido Request Processor library loaded successfully (SAPI: %s)!\n", AIKIDO_GLOBAL(sapi_name).c_str());
return true;
}
@@ -173,13 +187,42 @@ bool RequestProcessor::RequestInit() {
AIKIDO_LOG_ERROR("Failed to initialize the request processor: %s!\n", dlerror());
return false;
}
+ if (this->requestProcessorInstance == nullptr && this->createInstanceFn != nullptr) {
+ uint64_t threadId = GetThreadID();
+ #ifdef ZTS
+ bool isZTS = true;
+ #else
+ bool isZTS = false;
+ #endif
+ this->requestProcessorInstance = this->createInstanceFn(threadId, isZTS);
+ if (this->requestProcessorInstance == nullptr) {
+ AIKIDO_LOG_ERROR("Failed to create Go RequestProcessorInstance!\n");
+ return false;
+ }
+ AIKIDO_LOG_INFO("Created Go RequestProcessorInstance (threadId: %lu, isZTS: %d)\n", threadId, isZTS);
+
+ if (this->requestProcessorInitFn == nullptr) {
+ AIKIDO_LOG_ERROR("RequestProcessorInitFn is not loaded!\n");
+ return false;
+ }
+
+
+ std::string initDataString = this->GetInitData();
+ if (!this->requestProcessorInitFn(this->requestProcessorInstance, GoCreateString(initDataString))) {
+ AIKIDO_LOG_ERROR("Failed to initialize Aikido Request Processor!\n");
+ return false;
+ }
+ AIKIDO_LOG_INFO("RequestProcessorInit called successfully\n");
+ }
- if (AIKIDO_GLOBAL(sapi_name) == "apache2handler") {
- // Apache-mod-php can serve multiple sites per process
+
+ std::string sapiName = sapi_module.name;
+ if (sapiName == "apache2handler" || sapiName == "frankenphp") {
+ // Apache-mod-php and FrankenPHP can serve multiple sites per process
// We need to reload config each request to detect token changes
this->LoadConfigFromEnvironment();
} else {
- // Server APIs that are not apache-mod-php (like php-fpm, cli-server, ...)
+ // Server APIs that are not apache-mod-php/frankenphp (like php-fpm, cli-server, ...)
// can only serve one site per process, so the config should be loaded at the first request.
// If the token is not set at the first request, we try to reload it until we get a valid token.
// The user can update .env file via zero downtime deployments after the PHP server is started.
@@ -191,11 +234,6 @@ bool RequestProcessor::RequestInit() {
AIKIDO_LOG_DEBUG("RINIT started!\n");
- if (AIKIDO_GLOBAL(disable) == true) {
- AIKIDO_LOG_INFO("Request Processor initialization skipped because AIKIDO_DISABLE is set to 1!\n");
- return true;
- }
-
this->requestInitialized = true;
this->numberOfRequests++;
@@ -203,15 +241,16 @@ bool RequestProcessor::RequestInit() {
SendPreRequestEvent();
if ((this->numberOfRequests % AIKIDO_GLOBAL(report_stats_interval_to_agent)) == 0) {
- requestProcessor.ReportStats();
+ AIKIDO_GLOBAL(requestProcessor).ReportStats();
}
return true;
}
void RequestProcessor::LoadConfig(const std::string& previousToken, const std::string& currentToken) {
- if (this->requestProcessorConfigUpdateFn == nullptr) {
+ if (this->requestProcessorConfigUpdateFn == nullptr || this->requestProcessorInstance == nullptr) {
return;
}
+
if (currentToken.empty()) {
AIKIDO_LOG_INFO("Current token is empty, skipping config reload...!\n");
return;
@@ -223,13 +262,17 @@ void RequestProcessor::LoadConfig(const std::string& previousToken, const std::s
AIKIDO_LOG_INFO("Reloading Aikido config...\n");
std::string initJson = this->GetInitData(currentToken);
- this->requestProcessorConfigUpdateFn(GoCreateString(initJson));
+ this->requestProcessorConfigUpdateFn(this->requestProcessorInstance, GoCreateString(initJson));
}
void RequestProcessor::LoadConfigFromEnvironment() {
- std::string previousToken = AIKIDO_GLOBAL(token);
+ auto& globalToken = AIKIDO_GLOBAL(token);
+ std::string previousToken = globalToken;
+
LoadEnvironment();
- std::string currentToken = AIKIDO_GLOBAL(token);
+
+ std::string currentToken = globalToken;
+
LoadConfig(previousToken, currentToken);
}
@@ -246,15 +289,16 @@ void RequestProcessor::Uninit() {
if (!this->libHandle) {
return;
}
- if (!this->initFailed && this->requestProcessorUninitFn) {
+ if (!this->initFailed && this->requestProcessorUninitFn && this->requestProcessorInstance != nullptr) {
AIKIDO_LOG_INFO("Reporting final stats to Aikido Request Processor...\n");
this->ReportStats();
AIKIDO_LOG_INFO("Calling uninit for Aikido Request Processor...\n");
- this->requestProcessorUninitFn();
+ this->requestProcessorUninitFn(this->requestProcessorInstance);
}
dlclose(this->libHandle);
this->libHandle = nullptr;
+ this->requestProcessorInstance = nullptr;
AIKIDO_LOG_INFO("Aikido Request Processor unloaded!\n");
}
diff --git a/lib/php-extension/Server.cpp b/lib/php-extension/Server.cpp
index c825c05a9..f40a67ebf 100644
--- a/lib/php-extension/Server.cpp
+++ b/lib/php-extension/Server.cpp
@@ -6,8 +6,6 @@
return ""; \
}
-Server server;
-
/* Always load the current "_SERVER" variable from PHP,
so we make sure it's always available and it's the correct one */
zval* Server::GetServerVar() {
@@ -23,7 +21,7 @@ zval* Server::GetServerVar() {
}
/* Get the "_SERVER" PHP global variable */
- return &PG(http_globals)[TRACK_VARS_SERVER];
+ return &PG(http_globals)[TRACK_VARS_SERVER];
}
std::string Server::GetVar(const char* var) {
@@ -127,7 +125,9 @@ std::string Server::GetBody() {
stream = php_stream_open_wrapper("php://input", "rb", 0 | REPORT_ERRORS, NULL);
if ((contents = php_stream_copy_to_mem(stream, maxlen, 0)) != NULL) {
php_stream_close(stream);
- return std::string(ZSTR_VAL(contents));
+ std::string result = std::string(ZSTR_VAL(contents), ZSTR_LEN(contents));
+ zend_string_release(contents);
+ return result;
}
php_stream_close(stream);
return "";
@@ -179,8 +179,8 @@ std::string Server::GetHeaders() {
ZEND_HASH_FOREACH_END();
json headers_json;
- for (auto const& [key, val] : headers) {
- headers_json[key] = val;
+ for (std::map::const_iterator it = headers.begin(); it != headers.end(); ++it) {
+ headers_json[it->first] = it->second;
}
return NormalizeAndDumpJson(headers_json);
}
diff --git a/lib/php-extension/Stats.cpp b/lib/php-extension/Stats.cpp
index 67647f291..986f68886 100644
--- a/lib/php-extension/Stats.cpp
+++ b/lib/php-extension/Stats.cpp
@@ -1,29 +1,23 @@
#include "Includes.h"
-std::unordered_map stats;
-
-std::chrono::high_resolution_clock::time_point currentRequestStart = std::chrono::high_resolution_clock::time_point{};
-
-uint64_t totalOverheadForCurrentRequest = 0;
-
inline void AddToStats(const std::string& key, const std::string& kind, uint64_t duration) {
- SinkStats& sinkStats = stats[key];
+ SinkStats& sinkStats = AIKIDO_GLOBAL(stats)[key];
sinkStats.kind = kind;
sinkStats.timings.push_back(duration);
}
inline void AddRequestTotalToStats() {
- if (currentRequestStart == std::chrono::high_resolution_clock::time_point{}) {
+ if (AIKIDO_GLOBAL(currentRequestStart) == std::chrono::high_resolution_clock::time_point{}) {
return;
}
- uint64_t totalOverhead = std::chrono::duration_cast(std::chrono::high_resolution_clock::now() - currentRequestStart).count();
+ uint64_t totalOverhead = std::chrono::duration_cast(std::chrono::high_resolution_clock::now() - AIKIDO_GLOBAL(currentRequestStart)).count();
AddToStats("request_total", "request_op", totalOverhead);
- currentRequestStart = std::chrono::high_resolution_clock::time_point{};
+ AIKIDO_GLOBAL(currentRequestStart) = std::chrono::high_resolution_clock::time_point{};
}
inline void AddRequestTotalOverheadToStats() {
- AddToStats("request_total_overhead", "request_op", totalOverheadForCurrentRequest);
- totalOverheadForCurrentRequest = 0;
+ AddToStats("request_total_overhead", "request_op", AIKIDO_GLOBAL(totalOverheadForCurrentRequest));
+ AIKIDO_GLOBAL(totalOverheadForCurrentRequest) = 0;
}
ScopedTimer::ScopedTimer() {
@@ -42,7 +36,7 @@ void ScopedTimer::SetSink(std::string key, std::string kind) {
void ScopedTimer::Start() {
this->start = std::chrono::high_resolution_clock::now();
if (this->key == "request_init") {
- currentRequestStart = this->start;
+ AIKIDO_GLOBAL(currentRequestStart) = this->start;
}
}
@@ -59,7 +53,7 @@ ScopedTimer::~ScopedTimer() {
return;
}
this->Stop();
- totalOverheadForCurrentRequest += this->duration;
+ AIKIDO_GLOBAL(totalOverheadForCurrentRequest) += this->duration;
if (key == "request_shutdown") {
AddRequestTotalOverheadToStats();
AddRequestTotalToStats();
diff --git a/lib/php-extension/Utils.cpp b/lib/php-extension/Utils.cpp
index 1a7b46cc5..b2f34d690 100644
--- a/lib/php-extension/Utils.cpp
+++ b/lib/php-extension/Utils.cpp
@@ -33,6 +33,9 @@ std::string GetDateTime() {
return time_str;
}
+uint64_t GetThreadID() {
+ return (uint64_t)pthread_self();
+}
const char* GetEventName(EVENT_ID event) {
switch (event) {
case EVENT_PRE_REQUEST:
@@ -105,12 +108,14 @@ std::string GetSqlDialectFromPdo(zval *pdo_object) {
}
zval retval;
+ std::string result = "unknown";
if (CallPhpFunctionWithOneParam("getAttribute", PDO_ATTR_DRIVER_NAME, &retval, pdo_object)) {
if (Z_TYPE(retval) == IS_STRING) {
- return Z_STRVAL(retval);
+ result = Z_STRVAL(retval);
}
}
- return "unknown";
+ zval_ptr_dtor(&retval);
+ return result;
}
#if PHP_VERSION_ID >= 80500
@@ -141,9 +146,9 @@ json CallPhpFunctionParseUrl(const std::string& url) {
}
zval retval;
+ json result_json;
if (CallPhpFunctionWithOneParam("parse_url", url, &retval)) {
if (Z_TYPE(retval) == IS_ARRAY) {
- json result_json;
zval* host = zend_hash_str_find(Z_ARRVAL(retval), "host", sizeof("host") - 1);
if (host && Z_TYPE_P(host) == IS_STRING) {
result_json["host"] = Z_STRVAL_P(host);
@@ -166,10 +171,10 @@ json CallPhpFunctionParseUrl(const std::string& url) {
}
}
}
- return result_json;
}
}
- return json();
+ zval_ptr_dtor(&retval);
+ return result_json;
}
std::string AnonymizeToken(const std::string& str) {
diff --git a/lib/php-extension/include/Action.h b/lib/php-extension/include/Action.h
index dbb3c4945..e0abf2c90 100644
--- a/lib/php-extension/include/Action.h
+++ b/lib/php-extension/include/Action.h
@@ -46,5 +46,3 @@ class Action {
char* Ip();
char* UserAgent();
};
-
-extern Action action;
diff --git a/lib/php-extension/include/Cache.h b/lib/php-extension/include/Cache.h
index e67105495..508352622 100644
--- a/lib/php-extension/include/Cache.h
+++ b/lib/php-extension/include/Cache.h
@@ -37,6 +37,3 @@ class EventCache {
EventCache() = default;
void Reset();
};
-
-extern RequestCache requestCache;
-extern EventCache eventCache;
diff --git a/lib/php-extension/include/Environment.h b/lib/php-extension/include/Environment.h
index 88ee812ea..2c0e0685f 100644
--- a/lib/php-extension/include/Environment.h
+++ b/lib/php-extension/include/Environment.h
@@ -5,3 +5,7 @@ void LoadEnvironment();
bool LoadLaravelEnvFile();
bool GetBoolFromString(const std::string& env, bool default_value);
+
+bool GetEnvBool(const std::string& env_key, bool default_value);
+
+std::string GetEnvString(const std::string& env_key, const std::string default_value);
diff --git a/lib/php-extension/include/HandleBypassedIp.h b/lib/php-extension/include/HandleBypassedIp.h
index e8d86ee8f..b3c0e59ea 100644
--- a/lib/php-extension/include/HandleBypassedIp.h
+++ b/lib/php-extension/include/HandleBypassedIp.h
@@ -1,13 +1,5 @@
#pragma once
-// This variable is used to check if the request is bypassed,
-// if true, all blocking checks will be skipped.
-extern bool isIpBypassed;
-
-// Initialize the IP bypass check at request start.
-// Resets state and checks if the current IP should be bypassed.
-// This should be called during request initialization.
void InitIpBypassCheck();
-// Check if Aikido is disabled or the current IP is bypassed.
bool IsAikidoDisabledOrBypassed();
diff --git a/lib/php-extension/include/HookAst.h b/lib/php-extension/include/HookAst.h
index df678c7c2..61ec5828f 100644
--- a/lib/php-extension/include/HookAst.h
+++ b/lib/php-extension/include/HookAst.h
@@ -1,12 +1,5 @@
#pragma once
-
-extern HashTable *global_ast_to_clean;
-extern ZEND_API void (*original_ast_process)(zend_ast *ast);
-
-extern bool checkedAutoBlock;
-extern bool checkedShouldBlockRequest;
-
void HookAstProcess();
void UnhookAstProcess();
void DestroyAstToClean();
diff --git a/lib/php-extension/include/Includes.h b/lib/php-extension/include/Includes.h
index ce6e90d5f..1b73ef7b7 100644
--- a/lib/php-extension/include/Includes.h
+++ b/lib/php-extension/include/Includes.h
@@ -12,6 +12,9 @@
#include
#include
#include
+#include
+#include
+#include
#include
#include
@@ -39,18 +42,11 @@ using json = nlohmann::json;
#include "GoWrappers.h"
#include "../../API.h"
-#include "Log.h"
-#include "Agent.h"
#include "php_aikido.h"
#include "Environment.h"
-#include "Action.h"
-#include "Cache.h"
#include "Stats.h"
#include "Hooks.h"
#include "PhpWrappers.h"
-#include "Server.h"
-#include "RequestProcessor.h"
-#include "PhpLifecycle.h"
#include "Packages.h"
#include "Utils.h"
diff --git a/lib/php-extension/include/PhpLifecycle.h b/lib/php-extension/include/PhpLifecycle.h
index 6efcf49c4..78e8c3de0 100644
--- a/lib/php-extension/include/PhpLifecycle.h
+++ b/lib/php-extension/include/PhpLifecycle.h
@@ -20,5 +20,3 @@ class PhpLifecycle {
void UnhookAll();
};
-
-extern PhpLifecycle phpLifecycle;
diff --git a/lib/php-extension/include/RequestProcessor.h b/lib/php-extension/include/RequestProcessor.h
index de3cd2763..aebd7d91e 100644
--- a/lib/php-extension/include/RequestProcessor.h
+++ b/lib/php-extension/include/RequestProcessor.h
@@ -1,19 +1,29 @@
#pragma once
-typedef GoUint8 (*RequestProcessorInitFn)(GoString initJson);
-typedef GoUint8 (*RequestProcessorContextInitFn)(ContextCallback);
-typedef GoUint8 (*RequestProcessorConfigUpdateFn)(GoString initJson);
-typedef char* (*RequestProcessorOnEventFn)(GoInt eventId);
-typedef int (*RequestProcessorGetBlockingModeFn)();
-typedef void (*RequestProcessorReportStats)(GoString, GoString, GoInt32, GoInt32, GoInt32, GoInt32, GoInt32, GoSlice);
-typedef void (*RequestProcessorUninitFn)();
+typedef void* (*CreateInstanceFn)(uint64_t threadId, bool isZTS);
+typedef void (*DestroyInstanceFn)(uint64_t threadId);
+
+// Updated typedefs with instance pointer as first parameter
+typedef GoUint8 (*RequestProcessorInitFn)(void* instancePtr, GoString initJson);
+typedef GoUint8 (*RequestProcessorContextInitFn)(void* instancePtr, ContextCallback);
+typedef GoUint8 (*RequestProcessorConfigUpdateFn)(void* instancePtr, GoString initJson);
+typedef char* (*RequestProcessorOnEventFn)(void* instancePtr, GoInt eventId);
+typedef int (*RequestProcessorGetBlockingModeFn)(void* instancePtr);
+typedef void (*RequestProcessorReportStats)(void* instancePtr, GoString, GoString, GoInt32, GoInt32, GoInt32, GoInt32, GoInt32, GoSlice);
+typedef void (*RequestProcessorUninitFn)(void* instancePtr);
class RequestProcessor {
private:
bool initFailed = false;
bool requestInitialized = false;
void* libHandle = nullptr;
+ void* requestProcessorInstance = nullptr;
uint64_t numberOfRequests = 0;
+
+ // Function pointers to Go-exported functions
+ CreateInstanceFn createInstanceFn = nullptr;
+ DestroyInstanceFn destroyInstanceFn = nullptr;
+ RequestProcessorInitFn requestProcessorInitFn = nullptr;
RequestProcessorContextInitFn requestProcessorContextInitFn = nullptr;
RequestProcessorConfigUpdateFn requestProcessorConfigUpdateFn = nullptr;
RequestProcessorOnEventFn requestProcessorOnEventFn = nullptr;
@@ -22,7 +32,7 @@ class RequestProcessor {
RequestProcessorUninitFn requestProcessorUninitFn = nullptr;
private:
- std::string GetInitData(const std::string& token = "");
+ std::string GetInitData(const std::string& userProvidedToken = "");
bool ContextInit();
void SendPreRequestEvent();
void SendPostRequestEvent();
@@ -43,5 +53,3 @@ class RequestProcessor {
~RequestProcessor();
};
-
-extern RequestProcessor requestProcessor;
diff --git a/lib/php-extension/include/Server.h b/lib/php-extension/include/Server.h
index 48487e0a8..718ad0217 100644
--- a/lib/php-extension/include/Server.h
+++ b/lib/php-extension/include/Server.h
@@ -31,5 +31,3 @@ class Server {
~Server() = default;
};
-
-extern Server server;
diff --git a/lib/php-extension/include/Stats.h b/lib/php-extension/include/Stats.h
index 5835affc3..f12a21082 100644
--- a/lib/php-extension/include/Stats.h
+++ b/lib/php-extension/include/Stats.h
@@ -31,4 +31,3 @@ class SinkStats {
void IncrementWithoutContext();
};
-extern std::unordered_map stats;
diff --git a/lib/php-extension/include/Utils.h b/lib/php-extension/include/Utils.h
index 6529aaab6..ffdf98ff2 100644
--- a/lib/php-extension/include/Utils.h
+++ b/lib/php-extension/include/Utils.h
@@ -12,6 +12,8 @@ std::string GetTime();
std::string GetDateTime();
+uint64_t GetThreadID();
+
const char* GetEventName(EVENT_ID event);
std::string NormalizeAndDumpJson(const json& jsonStr);
diff --git a/lib/php-extension/include/php_aikido.h b/lib/php-extension/include/php_aikido.h
index 79d1bee9f..864606a04 100644
--- a/lib/php-extension/include/php_aikido.h
+++ b/lib/php-extension/include/php_aikido.h
@@ -1,5 +1,21 @@
#pragma once
+#ifdef HAVE_CONFIG_H
+#include "config.h"
+#endif
+
+#include
+#include
+#include "php.h"
+#include "Log.h"
+#include "Agent.h"
+#include "Server.h"
+#include "RequestProcessor.h"
+#include "Action.h"
+#include "Cache.h"
+#include "PhpLifecycle.h"
+#include "Stats.h"
+
extern zend_module_entry aikido_module_entry;
#define phpext_aikido_ptr &aikido_module_entry
@@ -20,13 +36,32 @@ bool trust_proxy;
bool localhost_allowed_by_default;
bool uses_symfony_http_foundation; // If true, method override is supported using X-HTTP-METHOD-OVERRIDE or _method query param
unsigned int report_stats_interval_to_agent; // Report once every X requests the collected stats to Agent
+std::chrono::high_resolution_clock::time_point currentRequestStart;
+uint64_t totalOverheadForCurrentRequest;
+bool laravelEnvLoaded;
+bool checkedAutoBlock;
+bool checkedShouldBlockRequest;
+bool isIpBypassed;
+HashTable *global_ast_to_clean;
+void (*original_ast_process)(zend_ast *ast);
+// IMPORTANT: The order of these objects MUST NOT be changed due to object interdependencies.
+// This ensures proper construction/destruction order in both ZTS and non-ZTS modes.
+// Objects are constructed in this order and destroyed in reverse order.
std::string log_level_str;
std::string sapi_name;
std::string token;
std::string endpoint;
std::string config_endpoint;
-Log logger;
+RequestCache requestCache;
+EventCache eventCache;
Agent agent;
+Log logger;
+Server server;
+std::unordered_map stats;
+RequestProcessor requestProcessor;
+Action action;
+PhpLifecycle phpLifecycle;
+std::unordered_map laravelEnv;
ZEND_END_MODULE_GLOBALS(aikido)
ZEND_EXTERN_MODULE_GLOBALS(aikido)
diff --git a/lib/php-extension/run-tests.php b/lib/php-extension/run-tests.php
index b185844b5..d0befa373 100644
--- a/lib/php-extension/run-tests.php
+++ b/lib/php-extension/run-tests.php
@@ -7,7 +7,7 @@
| This source file is subject to version 3.01 of the PHP license, |
| that is bundled with this package in the file LICENSE, and is |
| available through the world-wide-web at the following url: |
- | https://php.net/license/3_01.txt |
+ | https://www.php.net/license/3_01.txt |
| If you did not receive a copy of the PHP license and are unable to |
| obtain it through the world-wide-web, please send a note to |
| license@php.net so we can mail you a copy immediately. |
@@ -23,12 +23,10 @@
+----------------------------------------------------------------------+
*/
-/* $Id: 32de2a11d1b29ffcf67a7e4dfab6d2190160aaf7 $ */
-
/* Let there be no top-level code beyond this point:
* Only functions and classes, thanks!
*
- * Minimum required PHP version: 7.1.0
+ * Minimum required PHP version: 8.0.0
*/
function show_usage(): void
@@ -51,7 +49,7 @@ function show_usage(): void
-w Write a list of all failed tests to .
- -a Same as -w but append rather then truncating .
+ -a Same as -w but append rather than truncating .
-W Write a list of all tests and their result status to .
@@ -77,9 +75,11 @@ function show_usage(): void
-s Write output to .
- -x Sets 'SKIP_SLOW_TESTS' environmental variable.
+ -x Sets 'SKIP_SLOW_TESTS' environment variable.
+
+ --online Prevents setting the 'SKIP_ONLINE_TESTS' environment variable.
- --offline Sets 'SKIP_ONLINE_TESTS' environmental variable.
+ --offline Sets 'SKIP_ONLINE_TESTS' environment variable (default).
--verbose
-v Verbose mode.
@@ -90,7 +90,7 @@ function show_usage(): void
--temp-source --temp-target [--temp-urlbase ]
Write temporary files to by replacing from the
filenames to generate with . In general you want to make
- the path to your source files and some patch in
+ the path to your source files and some path in
your web page hierarchy with pointing to .
--keep-[all|php|skip|clean]
@@ -121,6 +121,14 @@ function show_usage(): void
--color
--no-color Do/Don't colorize the result type in the test result.
+ --progress
+ --no-progress Do/Don't show the current progress.
+
+ --repeat [n]
+ Run the tests multiple times in the same process and check the
+ output of the last execution (CLI SAPI only).
+
+ --bless Bless failed tests using scripts/dev/bless_tests.php.
HELP;
}
@@ -138,20 +146,24 @@ function main(): void
* looks like it doesn't belong, it probably doesn't; cull at will.
*/
global $DETAILED, $PHP_FAILED_TESTS, $SHOW_ONLY_GROUPS, $argc, $argv, $cfg,
- $cfgfiles, $cfgtypes, $conf_passed, $end_time, $environment,
+ $end_time, $environment,
$exts_skipped, $exts_tested, $exts_to_test, $failed_tests_file,
- $ignored_by_ext, $ini_overwrites, $is_switch, $colorize,
- $just_save_results, $log_format, $matches, $no_clean, $no_file_cache,
- $optionals, $output_file, $pass_option_n, $pass_options,
- $pattern_match, $php, $php_cgi, $phpdbg, $preload, $redir_tests,
- $repeat, $result_tests_file, $slow_min_ms, $start_time, $switch,
- $temp_source, $temp_target, $test_cnt, $test_dirs,
- $test_files, $test_idx, $test_list, $test_results, $testfile,
- $user_tests, $valgrind, $sum_results, $shuffle, $file_cache;
+ $ignored_by_ext, $ini_overwrites, $colorize,
+ $log_format, $no_clean, $no_file_cache,
+ $pass_options, $php, $php_cgi, $preload,
+ $result_tests_file, $slow_min_ms, $start_time,
+ $temp_source, $temp_target, $test_cnt,
+ $test_files, $test_idx, $test_results, $testfile,
+ $valgrind, $sum_results, $shuffle, $file_cache, $num_repeats,
+ $show_progress;
// Parallel testing
global $workers, $workerID;
global $context_line_count;
+ // Temporary for the duration of refactoring
+ /** @var JUnit $junit */
+ global $junit;
+
define('IS_WINDOWS', substr(PHP_OS, 0, 3) == "WIN");
$workerID = 0;
@@ -212,81 +224,18 @@ function main(): void
// fail to reattach to the OpCache because it will be using the
// wrong path.
die("TEMP environment is NOT set");
- } else {
- if (count($environment) == 1) {
- // Not having other environment variables, only having TEMP, is
- // probably ok, but strange and may make a difference in the
- // test pass rate, so warn the user.
- echo "WARNING: Only 1 environment variable will be available to tests(TEMP environment variable)" . PHP_EOL;
- }
- }
- }
-
- if (IS_WINDOWS && empty($environment["SystemRoot"])) {
- $environment["SystemRoot"] = getenv("SystemRoot");
- }
-
- $php = null;
- $php_cgi = null;
- $phpdbg = null;
-
- if (getenv('TEST_PHP_EXECUTABLE')) {
- $php = getenv('TEST_PHP_EXECUTABLE');
-
- if ($php == 'auto') {
- $php = TEST_PHP_SRCDIR . '/sapi/cli/php';
- putenv("TEST_PHP_EXECUTABLE=$php");
-
- if (!getenv('TEST_PHP_CGI_EXECUTABLE')) {
- $php_cgi = TEST_PHP_SRCDIR . '/sapi/cgi/php-cgi';
-
- if (file_exists($php_cgi)) {
- putenv("TEST_PHP_CGI_EXECUTABLE=$php_cgi");
- } else {
- $php_cgi = null;
- }
- }
- }
- $environment['TEST_PHP_EXECUTABLE'] = $php;
- }
-
- if (getenv('TEST_PHP_CGI_EXECUTABLE')) {
- $php_cgi = getenv('TEST_PHP_CGI_EXECUTABLE');
-
- if ($php_cgi == 'auto') {
- $php_cgi = TEST_PHP_SRCDIR . '/sapi/cgi/php-cgi';
- putenv("TEST_PHP_CGI_EXECUTABLE=$php_cgi");
}
- $environment['TEST_PHP_CGI_EXECUTABLE'] = $php_cgi;
- }
-
- if (!getenv('TEST_PHPDBG_EXECUTABLE')) {
- if (IS_WINDOWS && file_exists(dirname($php) . "/phpdbg.exe")) {
- $phpdbg = realpath(dirname($php) . "/phpdbg.exe");
- } elseif (file_exists(dirname($php) . "/../../sapi/phpdbg/phpdbg")) {
- $phpdbg = realpath(dirname($php) . "/../../sapi/phpdbg/phpdbg");
- } elseif (file_exists("./sapi/phpdbg/phpdbg")) {
- $phpdbg = realpath("./sapi/phpdbg/phpdbg");
- } elseif (file_exists(dirname($php) . "/phpdbg")) {
- $phpdbg = realpath(dirname($php) . "/phpdbg");
- } else {
- $phpdbg = null;
- }
- if ($phpdbg) {
- putenv("TEST_PHPDBG_EXECUTABLE=$phpdbg");
+ if (count($environment) == 1) {
+ // Not having other environment variables, only having TEMP, is
+ // probably ok, but strange and may make a difference in the
+ // test pass rate, so warn the user.
+ echo "WARNING: Only 1 environment variable will be available to tests(TEMP environment variable)" , PHP_EOL;
}
}
- if (getenv('TEST_PHPDBG_EXECUTABLE')) {
- $phpdbg = getenv('TEST_PHPDBG_EXECUTABLE');
-
- if ($phpdbg == 'auto') {
- $phpdbg = TEST_PHP_SRCDIR . '/sapi/phpdbg/phpdbg';
- putenv("TEST_PHPDBG_EXECUTABLE=$phpdbg");
- }
-
- $environment['TEST_PHPDBG_EXECUTABLE'] = $phpdbg;
+ if (IS_WINDOWS && empty($environment["SystemRoot"])) {
+ $environment["SystemRoot"] = getenv("SystemRoot");
}
if (getenv('TEST_PHP_LOG_FORMAT')) {
@@ -302,7 +251,7 @@ function main(): void
$DETAILED = 0;
}
- junit_init();
+ $junit = new JUnit($environment, $workerID);
if (getenv('SHOW_ONLY_GROUPS')) {
$SHOW_ONLY_GROUPS = explode(",", getenv('SHOW_ONLY_GROUPS'));
@@ -311,10 +260,9 @@ function main(): void
}
// Check whether user test dirs are requested.
+ $user_tests = [];
if (getenv('TEST_PHP_USER')) {
$user_tests = explode(',', getenv('TEST_PHP_USER'));
- } else {
- $user_tests = [];
}
$exts_to_test = [];
@@ -324,12 +272,12 @@ function main(): void
'disable_functions=',
'output_buffering=Off',
'error_reporting=' . E_ALL,
+ 'fatal_error_backtraces=Off',
'display_errors=1',
'display_startup_errors=1',
'log_errors=0',
'html_errors=0',
'track_errors=0',
- 'report_memleaks=1',
'report_zend_debug=0',
'docref_root=',
'docref_ext=.html',
@@ -341,7 +289,6 @@ function main(): void
'precision=14',
'serialize_precision=-1',
'memory_limit=128M',
- 'log_errors_max_len=0',
'opcache.fast_shutdown=0',
'opcache.file_update_protection=0',
'opcache.revalidate_freq=0',
@@ -349,6 +296,10 @@ function main(): void
'opcache.jit_hot_func=1',
'opcache.jit_hot_return=1',
'opcache.jit_hot_side_exit=1',
+ 'opcache.jit_max_root_traces=100000',
+ 'opcache.jit_max_side_traces=100000',
+ 'opcache.jit_max_exit_counters=100000',
+ 'opcache.protect_memory=1',
'zend.assertions=1',
'zend.exception_ignore_args=0',
'zend.exception_string_param_max_len=15',
@@ -357,11 +308,6 @@ function main(): void
$no_file_cache = '-d opcache.file_cache= -d opcache.file_cache_only=0';
- define('PHP_QA_EMAIL', 'qa-reports@lists.php.net');
- define('QA_SUBMISSION_PAGE', 'http://qa.php.net/buildtest-process.php');
- define('QA_REPORTS_PAGE', 'http://qa.php.net/reports');
- define('TRAVIS_CI', (bool) getenv('TRAVIS'));
-
// Determine the tests to be run.
$test_files = [];
@@ -395,7 +341,7 @@ function main(): void
if (function_exists('sapi_windows_vt100_support') && !sapi_windows_vt100_support(STDOUT, true)) {
$colorize = false;
}
- if (array_key_exists('NO_COLOR', $_ENV)) {
+ if (array_key_exists('NO_COLOR', $environment)) {
$colorize = false;
}
$selected_tests = false;
@@ -403,8 +349,13 @@ function main(): void
$preload = false;
$file_cache = null;
$shuffle = false;
+ $bless = false;
$workers = null;
$context_line_count = 3;
+ $num_repeats = 1;
+ $show_progress = true;
+ $ignored_by_ext = [];
+ $online = null;
$cfgtypes = ['show', 'keep'];
$cfgfiles = ['skip', 'php', 'clean', 'out', 'diff', 'exp', 'mem'];
@@ -440,15 +391,13 @@ function main(): void
$is_switch = true;
- if ($repeat) {
- foreach ($cfgtypes as $type) {
- if (strpos($switch, '--' . $type) === 0) {
- foreach ($cfgfiles as $file) {
- if ($switch == '--' . $type . '-' . $file) {
- $cfg[$type][$file] = true;
- $is_switch = false;
- break;
- }
+ foreach ($cfgtypes as $type) {
+ if (strpos($switch, '--' . $type) === 0) {
+ foreach ($cfgfiles as $file) {
+ if ($switch == '--' . $type . '-' . $file) {
+ $cfg[$type][$file] = true;
+ $is_switch = false;
+ break;
}
}
}
@@ -464,7 +413,7 @@ function main(): void
switch ($switch) {
case 'j':
$workers = substr($argv[$i], 2);
- if (!preg_match('/^\d+$/', $workers) || $workers == 0) {
+ if ($workers == 0 || !preg_match('/^\d+$/', $workers)) {
error("'$workers' is not a valid number of workers, try e.g. -j16 for 16 workers");
}
$workers = intval($workers, 10);
@@ -481,10 +430,8 @@ function main(): void
$matches = [];
if (preg_match('/^#.*\[(.*)\]\:\s+(.*)$/', $test, $matches)) {
$redir_tests[] = [$matches[1], $matches[2]];
- } else {
- if (strlen($test)) {
- $test_files[] = trim($test);
- }
+ } elseif (strlen($test)) {
+ $test_files[] = trim($test);
}
}
}
@@ -511,13 +458,11 @@ function main(): void
case 'g':
$SHOW_ONLY_GROUPS = explode(",", $argv[++$i]);
break;
- //case 'h'
case '--keep-all':
foreach ($cfgfiles as $file) {
$cfg['keep'][$file] = true;
}
break;
- //case 'l'
case 'm':
$valgrind = new RuntestsValgrind($environment);
break;
@@ -535,6 +480,7 @@ function main(): void
break;
case '--preload':
$preload = true;
+ $environment['SKIP_PRELOAD'] = 1;
break;
case '--file-cache-prime':
$file_cache = 'prime';
@@ -565,7 +511,6 @@ function main(): void
putenv('NO_INTERACTION=1');
$environment['NO_INTERACTION'] = 1;
break;
- //case 'r'
case 's':
$output_file = $argv[++$i];
$just_save_results = true;
@@ -609,8 +554,11 @@ function main(): void
case 'x':
$environment['SKIP_SLOW_TESTS'] = 1;
break;
+ case '--online':
+ $online = true;
+ break;
case '--offline':
- $environment['SKIP_ONLINE_TESTS'] = 1;
+ $online = false;
break;
case '--shuffle':
$shuffle = true;
@@ -623,6 +571,7 @@ function main(): void
$environment['SKIP_PERF_SENSITIVE'] = 1;
if ($switch === '--msan') {
$environment['SKIP_MSAN'] = 1;
+ $environment['MSAN_OPTIONS'] = 'intercept_tls_get_addr=0';
}
$lsanSuppressions = __DIR__ . '/.github/lsan-suppressions.txt';
@@ -631,7 +580,13 @@ function main(): void
. ':print_suppressions=0';
}
break;
- //case 'w'
+ case '--repeat':
+ $num_repeats = (int) $argv[++$i];
+ $environment['SKIP_REPEAT'] = 1;
+ break;
+ case '--bless':
+ $bless = true;
+ break;
case '-':
// repeat check with full switch
$switch = $argv[$i];
@@ -639,8 +594,14 @@ function main(): void
$repeat = true;
}
break;
+ case '--progress':
+ $show_progress = true;
+ break;
+ case '--no-progress':
+ $show_progress = false;
+ break;
case '--version':
- echo '$Id: 32de2a11d1b29ffcf67a7e4dfab6d2190160aaf7 $' . "\n";
+ echo '$Id$' . "\n";
exit(1);
default:
@@ -661,44 +622,64 @@ function main(): void
if (!$testfile && strpos($argv[$i], '*') !== false && function_exists('glob')) {
if (substr($argv[$i], -5) == '.phpt') {
$pattern_match = glob($argv[$i]);
+ } elseif (preg_match("/\*$/", $argv[$i])) {
+ $pattern_match = glob($argv[$i] . '.phpt');
} else {
- if (preg_match("/\*$/", $argv[$i])) {
- $pattern_match = glob($argv[$i] . '.phpt');
- } else {
- die('Cannot find test file "' . $argv[$i] . '".' . PHP_EOL);
- }
+ die('Cannot find test file "' . $argv[$i] . '".' . PHP_EOL);
}
if (is_array($pattern_match)) {
$test_files = array_merge($test_files, $pattern_match);
}
+ } elseif (is_dir($testfile)) {
+ find_files($testfile);
+ } elseif (substr($testfile, -5) == '.phpt') {
+ $test_files[] = $testfile;
} else {
- if (is_dir($testfile)) {
- find_files($testfile);
- } else {
- if (substr($testfile, -5) == '.phpt') {
- $test_files[] = $testfile;
- } else {
- die('Cannot find test file "' . $argv[$i] . '".' . PHP_EOL);
- }
- }
+ die('Cannot find test file "' . $argv[$i] . '".' . PHP_EOL);
}
}
}
+ if ($online === null && !isset($environment['SKIP_ONLINE_TESTS'])) {
+ $online = false;
+ }
+ if ($online !== null) {
+ $environment['SKIP_ONLINE_TESTS'] = $online ? '0' : '1';
+ }
+
+ if (!defined('STDIN') || !stream_isatty(STDIN)
+ || !defined('STDOUT') || !stream_isatty(STDOUT)
+ || !defined('STDERR') || !stream_isatty(STDERR)) {
+ $environment['SKIP_IO_CAPTURE_TESTS'] = '1';
+ }
+
if ($selected_tests && count($test_files) === 0) {
echo "No tests found.\n";
return;
}
- // Default to PHP_BINARY as executable
- if (!isset($environment['TEST_PHP_EXECUTABLE'])) {
- $php = PHP_BINARY;
- putenv("TEST_PHP_EXECUTABLE=$php");
- $environment['TEST_PHP_EXECUTABLE'] = $php;
+ if (!$php) {
+ $php = getenv('TEST_PHP_EXECUTABLE') ?: PHP_BINARY;
}
- if (strlen($conf_passed)) {
+ $php_cgi = getenv('TEST_PHP_CGI_EXECUTABLE') ?: get_binary($php, 'php-cgi', 'sapi/cgi/php-cgi');
+ $phpdbg = getenv('TEST_PHPDBG_EXECUTABLE') ?: get_binary($php, 'phpdbg', 'sapi/phpdbg/phpdbg');
+
+ putenv("TEST_PHP_EXECUTABLE=$php");
+ $environment['TEST_PHP_EXECUTABLE'] = $php;
+ putenv("TEST_PHP_EXECUTABLE_ESCAPED=" . escapeshellarg($php));
+ $environment['TEST_PHP_EXECUTABLE_ESCAPED'] = escapeshellarg($php);
+ putenv("TEST_PHP_CGI_EXECUTABLE=$php_cgi");
+ $environment['TEST_PHP_CGI_EXECUTABLE'] = $php_cgi;
+ putenv("TEST_PHP_CGI_EXECUTABLE_ESCAPED=" . escapeshellarg($php_cgi ?? ''));
+ $environment['TEST_PHP_CGI_EXECUTABLE_ESCAPED'] = escapeshellarg($php_cgi ?? '');
+ putenv("TEST_PHPDBG_EXECUTABLE=$phpdbg");
+ $environment['TEST_PHPDBG_EXECUTABLE'] = $phpdbg;
+ putenv("TEST_PHPDBG_EXECUTABLE_ESCAPED=" . escapeshellarg($phpdbg ?? ''));
+ $environment['TEST_PHPDBG_EXECUTABLE_ESCAPED'] = escapeshellarg($phpdbg ?? '');
+
+ if ($conf_passed !== null) {
if (IS_WINDOWS) {
$pass_options .= " -c " . escapeshellarg($conf_passed);
} else {
@@ -712,19 +693,23 @@ function main(): void
// Run selected tests.
$test_cnt = count($test_files);
- verify_config();
- write_information();
+ if ($test_cnt === 1) {
+ $cfg['show']['diff'] = true;
+ }
+
+ verify_config($php);
+ write_information($user_tests, $phpdbg);
if ($test_cnt) {
putenv('NO_INTERACTION=1');
usort($test_files, "test_sort");
- $start_time = time();
+ $start_time = hrtime(true);
echo "Running selected tests.\n";
$test_idx = 0;
run_all_tests($test_files, $environment);
- $end_time = time();
+ $end_time = hrtime(true);
if ($failed_tests_file) {
fclose($failed_tests_file);
@@ -744,33 +729,21 @@ function main(): void
echo get_summary(false);
if ($output_file != '' && $just_save_results) {
- save_or_mail_results();
+ save_results($output_file, /* prompt_to_save_results: */ false);
}
} else {
// Compile a list of all test files (*.phpt).
$test_files = [];
- $exts_tested = count($exts_to_test);
- $exts_skipped = 0;
- $ignored_by_ext = 0;
+ $exts_tested = $exts_to_test;
+ $exts_skipped = [];
sort($exts_to_test);
- $test_dirs = [];
- $optionals = ['Zend', 'tests', 'ext', 'sapi'];
- foreach ($optionals as $dir) {
+ foreach (['Zend', 'tests', 'ext', 'sapi'] as $dir) {
if (is_dir($dir)) {
- $test_dirs[] = $dir;
+ find_files(TEST_PHP_SRCDIR . "/{$dir}", $dir == 'ext');
}
}
- // Convert extension names to lowercase
- foreach ($exts_to_test as $key => $val) {
- $exts_to_test[$key] = strtolower($val);
- }
-
- foreach ($test_dirs as $dir) {
- find_files(TEST_PHP_SRCDIR . "/{$dir}", $dir == 'ext');
- }
-
foreach ($user_tests as $dir) {
find_files($dir, $dir == 'ext');
}
@@ -778,13 +751,14 @@ function main(): void
$test_files = array_unique($test_files);
usort($test_files, "test_sort");
- $start_time = time();
- show_start($start_time);
+ $start_timestamp = time();
+ $start_time = hrtime(true);
+ show_start($start_timestamp);
$test_cnt = count($test_files);
$test_idx = 0;
run_all_tests($test_files, $environment);
- $end_time = time();
+ $end_time = hrtime(true);
if ($failed_tests_file) {
fclose($failed_tests_file);
@@ -803,40 +777,24 @@ function main(): void
compute_summary();
- show_end($end_time);
+ show_end($start_timestamp, $start_time, $end_time);
show_summary();
- save_or_mail_results();
+ save_results($output_file, /* prompt_to_save_results: */ true);
}
- junit_save_xml();
+ $junit->saveXML();
+ if ($bless) {
+ bless_failed_tests($PHP_FAILED_TESTS['FAILED']);
+ }
if (getenv('REPORT_EXIT_STATUS') !== '0' && getenv('REPORT_EXIT_STATUS') !== 'no' &&
($sum_results['FAILED'] || $sum_results['BORKED'] || $sum_results['LEAKED'])) {
exit(1);
}
}
-if (!function_exists("hrtime")) {
- /**
- * @return array|float|int
- */
- function hrtime(bool $as_num = false)
- {
- $t = microtime(true);
-
- if ($as_num) {
- return $t * 1000000000;
- }
-
- $s = floor($t);
- return [0 => $s, 1 => ($t - $s) * 1000000000];
- }
-}
-
-function verify_config(): void
+function verify_config(string $php): void
{
- global $php;
-
if (empty($php) || !file_exists($php)) {
error('environment variable TEST_PHP_EXECUTABLE must be set to specify PHP executable!');
}
@@ -846,9 +804,13 @@ function verify_config(): void
}
}
-function write_information(): void
+/**
+ * @param string[] $user_tests
+ */
+function write_information(array $user_tests, $phpdbg): void
{
- global $php, $php_cgi, $phpdbg, $php_info, $user_tests, $ini_overwrites, $pass_options, $exts_to_test, $valgrind, $no_file_cache;
+ global $php, $php_cgi, $php_info, $ini_overwrites, $pass_options, $exts_to_test, $valgrind, $no_file_cache;
+ $php_escaped = escapeshellarg($php);
// Get info from php
$info_file = __DIR__ . '/run-test-info.php';
@@ -864,11 +826,12 @@ function write_information(): void
$info_params = [];
settings2array($ini_overwrites, $info_params);
$info_params = settings2params($info_params);
- $php_info = `$php $pass_options $info_params $no_file_cache "$info_file"`;
- define('TESTED_PHP_VERSION', `$php -n -r "echo PHP_VERSION;"`);
+ $php_info = shell_exec("$php_escaped $pass_options $info_params $no_file_cache \"$info_file\"");
+ define('TESTED_PHP_VERSION', shell_exec("$php_escaped -n -r \"echo PHP_VERSION;\""));
if ($php_cgi && $php != $php_cgi) {
- $php_info_cgi = `$php_cgi $pass_options $info_params $no_file_cache -q "$info_file"`;
+ $php_cgi_escaped = escapeshellarg($php_cgi);
+ $php_info_cgi = shell_exec("$php_cgi_escaped $pass_options $info_params $no_file_cache -q \"$info_file\"");
$php_info_sep = "\n---------------------------------------------------------------------";
$php_cgi_info = "$php_info_sep\nPHP : $php_cgi $php_info_cgi$php_info_sep";
} else {
@@ -876,7 +839,8 @@ function write_information(): void
}
if ($phpdbg) {
- $phpdbg_info = `$phpdbg $pass_options $info_params $no_file_cache -qrr "$info_file"`;
+ $phpdbg_escaped = escapeshellarg($phpdbg);
+ $phpdbg_info = shell_exec("$phpdbg_escaped $pass_options $info_params $no_file_cache -qrr \"$info_file\"");
$php_info_sep = "\n---------------------------------------------------------------------";
$phpdbg_info = "$php_info_sep\nPHP : $phpdbg $phpdbg_info$php_info_sep";
} else {
@@ -888,17 +852,28 @@ function write_information(): void
}
@unlink($info_file);
- // load list of enabled extensions
- save_text($info_file,
- '');
- $exts_to_test = explode(',', `$php $pass_options $info_params $no_file_cache "$info_file"`);
+ // load list of enabled and loadable extensions
+ save_text($info_file, <<<'PHP'
+ ['session.auto_start=0'],
'tidy' => ['tidy.clean_output=0'],
'zlib' => ['zlib.output_compression=Off'],
'xdebug' => ['xdebug.mode=off'],
- 'mbstring' => ['mbstring.func_overload=0'],
];
foreach ($info_params_ex as $ext => $ini_overwrites_ex) {
@@ -927,134 +902,118 @@ function write_information(): void
";
}
-function save_or_mail_results(): void
+function save_results(string $output_file, bool $prompt_to_save_results): void
{
- global $sum_results, $just_save_results, $failed_test_summary,
- $PHP_FAILED_TESTS, $php, $output_file;
+ global $sum_results, $failed_test_summary, $PHP_FAILED_TESTS, $php;
- /* We got failed Tests, offer the user to send an e-mail to QA team, unless NO_INTERACTION is set */
- if (!getenv('NO_INTERACTION') && !TRAVIS_CI) {
+ if (getenv('NO_INTERACTION')) {
+ return;
+ }
+
+ if ($prompt_to_save_results) {
+ /* We got failed Tests, offer the user to save a QA report */
$fp = fopen("php://stdin", "r+");
if ($sum_results['FAILED'] || $sum_results['BORKED'] || $sum_results['WARNED'] || $sum_results['LEAKED']) {
echo "\nYou may have found a problem in PHP.";
}
- echo "\nThis report can be automatically sent to the PHP QA team at\n";
- echo QA_REPORTS_PAGE . " and http://news.php.net/php.qa.reports\n";
+ echo "\nThis report can be saved and used to open an issue on the bug tracker at\n";
+ echo "https://github.com/php/php-src/issues\n";
echo "This gives us a better understanding of PHP's behavior.\n";
- echo "If you don't want to send the report immediately you can choose\n";
- echo "option \"s\" to save it. You can then email it to " . PHP_QA_EMAIL . " later.\n";
- echo "Do you want to send this report now? [Yns]: ";
+ echo "Do you want to save this report in a file? [Yn]: ";
flush();
$user_input = fgets($fp, 10);
- $just_save_results = (!empty($user_input) && strtolower($user_input[0]) === 's');
- }
-
- if ($just_save_results || !getenv('NO_INTERACTION') || TRAVIS_CI) {
- if ($just_save_results || TRAVIS_CI || strlen(trim($user_input)) == 0 || strtolower($user_input[0]) == 'y') {
- /*
- * Collect information about the host system for our report
- * Fetch phpinfo() output so that we can see the PHP environment
- * Make an archive of all the failed tests
- * Send an email
- */
- if ($just_save_results) {
- $user_input = 's';
- }
-
- /* Ask the user to provide an email address, so that QA team can contact the user */
- if (TRAVIS_CI) {
- $user_email = 'travis at php dot net';
- } elseif (!strncasecmp($user_input, 'y', 1) || strlen(trim($user_input)) == 0) {
- echo "\nPlease enter your email address.\n(Your address will be mangled so that it will not go out on any\nmailinglist in plain text): ";
- flush();
- $user_email = trim(fgets($fp, 1024));
- $user_email = str_replace("@", " at ", str_replace(".", " dot ", $user_email));
- }
-
- $failed_tests_data = '';
- $sep = "\n" . str_repeat('=', 80) . "\n";
- $failed_tests_data .= $failed_test_summary . "\n";
- $failed_tests_data .= get_summary(true) . "\n";
-
- if ($sum_results['FAILED']) {
- foreach ($PHP_FAILED_TESTS['FAILED'] as $test_info) {
- $failed_tests_data .= $sep . $test_info['name'] . $test_info['info'];
- $failed_tests_data .= $sep . file_get_contents(realpath($test_info['output']));
- $failed_tests_data .= $sep . file_get_contents(realpath($test_info['diff']));
- $failed_tests_data .= $sep . "\n\n";
- }
- $status = "failed";
- } else {
- $status = "success";
- }
-
- $failed_tests_data .= "\n" . $sep . 'BUILD ENVIRONMENT' . $sep;
- $failed_tests_data .= "OS:\n" . PHP_OS . " - " . php_uname() . "\n\n";
- $ldd = $autoconf = $sys_libtool = $libtool = $compiler = 'N/A';
-
- if (!IS_WINDOWS) {
- /* If PHP_AUTOCONF is set, use it; otherwise, use 'autoconf'. */
- if (getenv('PHP_AUTOCONF')) {
- $autoconf = shell_exec(getenv('PHP_AUTOCONF') . ' --version');
- } else {
- $autoconf = shell_exec('autoconf --version');
- }
-
- /* Always use the generated libtool - Mac OSX uses 'glibtool' */
- $libtool = shell_exec(INIT_DIR . '/libtool --version');
+ fclose($fp);
+ if (!(strlen(trim($user_input)) == 0 || strtolower($user_input[0]) == 'y')) {
+ return;
+ }
+ }
+ /**
+ * Collect information about the host system for our report
+ * Fetch phpinfo() output so that we can see the PHP environment
+ * Make an archive of all the failed tests
+ */
+ $failed_tests_data = '';
+ $sep = "\n" . str_repeat('=', 80) . "\n";
+ $failed_tests_data .= $failed_test_summary . "\n";
+ $failed_tests_data .= get_summary(true) . "\n";
+
+ if ($sum_results['FAILED']) {
+ foreach ($PHP_FAILED_TESTS['FAILED'] as $test_info) {
+ $failed_tests_data .= $sep . $test_info['name'] . $test_info['info'];
+ $failed_tests_data .= $sep . file_get_contents(realpath($test_info['output']));
+ $failed_tests_data .= $sep . file_get_contents(realpath($test_info['diff']));
+ $failed_tests_data .= $sep . "\n\n";
+ }
+ }
- /* Use shtool to find out if there is glibtool present (MacOSX) */
- $sys_libtool_path = shell_exec(__DIR__ . '/build/shtool path glibtool libtool');
+ $failed_tests_data .= "\n" . $sep . 'BUILD ENVIRONMENT' . $sep;
+ $failed_tests_data .= "OS:\n" . PHP_OS . " - " . php_uname() . "\n\n";
+ $ldd = $autoconf = $sys_libtool = $libtool = $compiler = 'N/A';
- if ($sys_libtool_path) {
- $sys_libtool = shell_exec(str_replace("\n", "", $sys_libtool_path) . ' --version');
- }
+ if (!IS_WINDOWS) {
+ /* If PHP_AUTOCONF is set, use it; otherwise, use 'autoconf'. */
+ if (getenv('PHP_AUTOCONF')) {
+ $autoconf = shell_exec(getenv('PHP_AUTOCONF') . ' --version');
+ } else {
+ $autoconf = shell_exec('autoconf --version');
+ }
- /* Try the most common flags for 'version' */
- $flags = ['-v', '-V', '--version'];
- $cc_status = 0;
+ /* Always use the generated libtool - Mac OSX uses 'glibtool' */
+ $libtool = shell_exec(INIT_DIR . '/libtool --version');
- foreach ($flags as $flag) {
- system(getenv('CC') . " $flag >/dev/null 2>&1", $cc_status);
- if ($cc_status == 0) {
- $compiler = shell_exec(getenv('CC') . " $flag 2>&1");
- break;
- }
- }
+ /* Use shtool to find out if there is glibtool present (MacOSX) */
+ $sys_libtool_path = shell_exec(__DIR__ . '/build/shtool path glibtool libtool');
- $ldd = shell_exec("ldd $php 2>/dev/null");
- }
+ if ($sys_libtool_path) {
+ $sys_libtool = shell_exec(str_replace("\n", "", $sys_libtool_path) . ' --version');
+ }
- $failed_tests_data .= "Autoconf:\n$autoconf\n";
- $failed_tests_data .= "Bundled Libtool:\n$libtool\n";
- $failed_tests_data .= "System Libtool:\n$sys_libtool\n";
- $failed_tests_data .= "Compiler:\n$compiler\n";
- $failed_tests_data .= "Bison:\n" . shell_exec('bison --version 2>/dev/null') . "\n";
- $failed_tests_data .= "Libraries:\n$ldd\n";
- $failed_tests_data .= "\n";
+ /* Try the most common flags for 'version' */
+ $flags = ['-v', '-V', '--version'];
+ $cc_status = 0;
- if (isset($user_email)) {
- $failed_tests_data .= "User's E-mail: " . $user_email . "\n\n";
+ foreach ($flags as $flag) {
+ system(getenv('CC') . " $flag >/dev/null 2>&1", $cc_status);
+ if ($cc_status == 0) {
+ $compiler = shell_exec(getenv('CC') . " $flag 2>&1");
+ break;
}
+ }
- $failed_tests_data .= $sep . "PHPINFO" . $sep;
- $failed_tests_data .= shell_exec($php . ' -ddisplay_errors=stderr -dhtml_errors=0 -i 2> /dev/null');
+ $ldd = shell_exec("ldd $php 2>/dev/null");
+ }
- if (($just_save_results || !mail_qa_team($failed_tests_data, $status)) && !TRAVIS_CI) {
- file_put_contents($output_file, $failed_tests_data);
+ $failed_tests_data .= "Autoconf:\n$autoconf\n";
+ $failed_tests_data .= "Bundled Libtool:\n$libtool\n";
+ $failed_tests_data .= "System Libtool:\n$sys_libtool\n";
+ $failed_tests_data .= "Compiler:\n$compiler\n";
+ $failed_tests_data .= "Bison:\n" . shell_exec('bison --version 2>/dev/null') . "\n";
+ $failed_tests_data .= "Libraries:\n$ldd\n";
+ $failed_tests_data .= "\n";
+ $failed_tests_data .= $sep . "PHPINFO" . $sep;
+ $failed_tests_data .= shell_exec($php . ' -ddisplay_errors=stderr -dhtml_errors=0 -i 2> /dev/null');
- if (!$just_save_results) {
- echo "\nThe test script was unable to automatically send the report to PHP's QA Team\n";
- }
+ file_put_contents($output_file, $failed_tests_data);
+ echo "Report saved to: ", $output_file, "\n";
+}
- echo "Please send " . $output_file . " to " . PHP_QA_EMAIL . " manually, thank you.\n";
- } elseif (!getenv('NO_INTERACTION') && !TRAVIS_CI) {
- fwrite($fp, "\nThank you for helping to make PHP better.\n");
- fclose($fp);
- }
- }
+function get_binary(string $php, string $sapi, string $sapi_path): ?string
+{
+ $dir = dirname($php);
+ if (IS_WINDOWS && file_exists("$dir/$sapi.exe")) {
+ return realpath("$dir/$sapi.exe");
+ }
+ // Sources tree
+ if (file_exists("$dir/../../$sapi_path")) {
+ return realpath("$dir/../../$sapi_path");
+ }
+ // Installation tree, preserve command prefix/suffix
+ $inst = str_replace('php', $sapi, basename($php));
+ if (file_exists("$dir/$inst")) {
+ return realpath("$dir/$inst");
}
+ return null;
}
function find_files(string $dir, bool $is_ext_dir = false, bool $ignore = false): void
@@ -1065,9 +1024,9 @@ function find_files(string $dir, bool $is_ext_dir = false, bool $ignore = false)
while (($name = readdir($o)) !== false) {
if (is_dir("{$dir}/{$name}") && !in_array($name, ['.', '..', '.svn'])) {
- $skip_ext = ($is_ext_dir && !in_array(strtolower($name), $exts_to_test));
+ $skip_ext = ($is_ext_dir && !in_array($name, $exts_to_test));
if ($skip_ext) {
- $exts_skipped++;
+ $exts_skipped[] = $name;
}
find_files("{$dir}/{$name}", false, $ignore || $skip_ext);
}
@@ -1079,11 +1038,13 @@ function find_files(string $dir, bool $is_ext_dir = false, bool $ignore = false)
}
// Otherwise we're only interested in *.phpt files.
- if (substr($name, -5) == '.phpt') {
+ // (but not those starting with a dot, which are hidden on
+ // many platforms)
+ if (substr($name, -5) == '.phpt' && substr($name, 0, 1) !== '.') {
+ $testfile = realpath("{$dir}/{$name}");
if ($ignore) {
- $ignored_by_ext++;
+ $ignored_by_ext[] = $testfile;
} else {
- $testfile = realpath("{$dir}/{$name}");
$test_files[] = $testfile;
}
}
@@ -1099,9 +1060,9 @@ function test_name($name): string
{
if (is_array($name)) {
return $name[0] . ':' . $name[1];
- } else {
- return $name;
}
+
+ return $name;
}
/**
* @param array|string $a
@@ -1119,69 +1080,21 @@ function test_sort($a, $b): int
if ($ta == $tb) {
return strcmp($a, $b);
- } else {
- return $tb - $ta;
- }
-}
-
-//
-// Send Email to QA Team
-//
-
-function mail_qa_team(string $data, bool $status = false): bool
-{
- $url_bits = parse_url(QA_SUBMISSION_PAGE);
-
- if ($proxy = getenv('http_proxy')) {
- $proxy = parse_url($proxy);
- $path = $url_bits['host'] . $url_bits['path'];
- $host = $proxy['host'];
- if (empty($proxy['port'])) {
- $proxy['port'] = 80;
- }
- $port = $proxy['port'];
- } else {
- $path = $url_bits['path'];
- $host = $url_bits['host'];
- $port = empty($url_bits['port']) ? 80 : $port = $url_bits['port'];
- }
-
- $data = "php_test_data=" . urlencode(base64_encode(str_replace("\00", '[0x0]', $data)));
- $data_length = strlen($data);
-
- $fs = fsockopen($host, $port, $errno, $errstr, 10);
-
- if (!$fs) {
- return false;
}
- $php_version = urlencode(TESTED_PHP_VERSION);
-
- echo "\nPosting to " . QA_SUBMISSION_PAGE . "\n";
- fwrite($fs, "POST " . $path . "?status=$status&version=$php_version HTTP/1.1\r\n");
- fwrite($fs, "Host: " . $host . "\r\n");
- fwrite($fs, "User-Agent: QA Browser 0.1\r\n");
- fwrite($fs, "Content-Type: application/x-www-form-urlencoded\r\n");
- fwrite($fs, "Content-Length: " . $data_length . "\r\n\r\n");
- fwrite($fs, $data);
- fwrite($fs, "\r\n\r\n");
- fclose($fs);
-
- return true;
+ return $tb - $ta;
}
//
-// Write the given text to a temporary file, and return the filename.
+// Write the given text to a temporary file.
//
function save_text(string $filename, string $text, ?string $filename_copy = null): void
{
global $DETAILED;
- if ($filename_copy && $filename_copy != $filename) {
- if (file_put_contents($filename_copy, $text) === false) {
- error("Cannot open file '" . $filename_copy . "' (save_text)");
- }
+ if ($filename_copy && $filename_copy != $filename && file_put_contents($filename_copy, $text) === false) {
+ error("Cannot open file '" . $filename_copy . "' (save_text)");
}
if (file_put_contents($filename, $text) === false) {
@@ -1231,6 +1144,13 @@ function system_with_timeout(
) {
global $valgrind;
+ // when proc_open cmd is passed as a string (without bypass_shell=true option) the cmd goes thru shell
+ // and on Windows quotes are discarded, this is a fix to honor the quotes and allow values containing
+ // spaces like '"C:\Program Files\PHP\php.exe"' to be passed as 1 argument correctly
+ if (IS_WINDOWS) {
+ $commandline = 'start "" /b /wait ' . $commandline . ' & exit';
+ }
+
$data = '';
$bin_env = [];
@@ -1263,6 +1183,10 @@ function system_with_timeout(
}
$timeout = $valgrind ? 300 : ($env['TEST_TIMEOUT'] ?? 60);
+ /* ASAN can cause a ~2-3x slowdown. */
+ if (isset($env['SKIP_ASAN'])) {
+ $timeout *= 3;
+ }
while (true) {
/* hide errors from interrupted syscalls */
@@ -1274,12 +1198,16 @@ function system_with_timeout(
if ($n === false) {
break;
- } elseif ($n === 0) {
+ }
+
+ if ($n === 0) {
/* timed out */
$data .= "\n ** ERROR: process timed out **\n";
proc_terminate($proc, 9);
return $data;
- } elseif ($n > 0) {
+ }
+
+ if ($n > 0) {
if ($captureStdOut) {
$line = fread($pipes[1], 8192);
} elseif ($captureStdErr) {
@@ -1311,23 +1239,35 @@ function system_with_timeout(
return $data;
}
-/**
- * @param string|array|null $redir_tested
- */
-function run_all_tests(array $test_files, array $env, $redir_tested = null): void
+function run_all_tests(array $test_files, array $env, ?string $redir_tested = null): void
{
- global $test_results, $failed_tests_file, $result_tests_file, $php, $test_idx, $file_cache;
+ global $test_results, $failed_tests_file, $result_tests_file, $php, $test_idx, $file_cache, $shuffle;
+ global $preload;
// Parallel testing
global $PHP_FAILED_TESTS, $workers, $workerID, $workerSock;
- if ($file_cache !== null) {
- /* Automatically skip opcache tests in --file-cache mode,
- * because opcache generally doesn't expect those to run under file cache */
- $test_files = array_filter($test_files, function($test) {
- return !is_string($test) || false === strpos($test, 'ext/opcache');
+ if ($file_cache !== null || $preload) {
+ /* Automatically skip opcache tests in --file-cache and --preload mode,
+ * because opcache generally expects these to run under a default configuration. */
+ $test_files = array_filter($test_files, function($test) use($preload) {
+ if (!is_string($test)) {
+ return true;
+ }
+ if (false !== strpos($test, 'ext/opcache')) {
+ return false;
+ }
+ if ($preload && false !== strpos($test, 'ext/zend_test/tests/observer')) {
+ return false;
+ }
+ return true;
});
}
+ // To discover parallelization issues and order dependent tests it is useful to randomize the test order.
+ if ($shuffle) {
+ shuffle($test_files);
+ }
+
/* Ignore -jN if there is only one file to analyze. */
if ($workers !== null && count($test_files) > 1 && !$workerID) {
run_all_tests_parallel($test_files, $env, $redir_tested);
@@ -1382,12 +1322,11 @@ function run_all_tests(array $test_files, array $env, $redir_tested = null): voi
}
}
-/** The heart of parallel testing.
- * @param string|array|null $redir_tested
- */
-function run_all_tests_parallel(array $test_files, array $env, $redir_tested): void
+function run_all_tests_parallel(array $test_files, array $env, ?string $redir_tested): void
{
- global $workers, $test_idx, $test_cnt, $test_results, $failed_tests_file, $result_tests_file, $PHP_FAILED_TESTS, $shuffle, $SHOW_ONLY_GROUPS, $valgrind;
+ global $workers, $test_idx, $test_results, $failed_tests_file, $result_tests_file, $PHP_FAILED_TESTS, $shuffle, $valgrind, $show_progress;
+
+ global $junit;
// The PHP binary running run-tests.php, and run-tests.php itself
// This PHP executable is *not* necessarily the same as the tested version
@@ -1397,10 +1336,6 @@ function run_all_tests_parallel(array $test_files, array $env, $redir_tested): v
$workerProcs = [];
$workerSocks = [];
- echo "=====================================================================\n";
- echo "========= WELCOME TO THE FUTURE: run-tests PARALLEL EDITION =========\n";
- echo "=====================================================================\n";
-
// Each test may specify a list of conflict keys. While a test that conflicts with
// key K is running, no other test that conflicts with K may run. Conflict keys are
// specified either in the --CONFLICTS-- section, or CONFLICTS file inside a directory.
@@ -1438,11 +1373,8 @@ function run_all_tests_parallel(array $test_files, array $env, $redir_tested): v
// Some tests assume that they are executed in a certain order. We will be popping from
// $test_files, so reverse its order here. This makes sure that order is preserved at least
// for tests with a common conflict key.
- $test_files = array_reverse($test_files);
-
- // To discover parallelization issues it is useful to randomize the test order.
- if ($shuffle) {
- shuffle($test_files);
+ if (!$shuffle) {
+ $test_files = array_reverse($test_files);
}
// Don't start more workers than test files.
@@ -1467,11 +1399,11 @@ function run_all_tests_parallel(array $test_files, array $env, $redir_tested): v
$startTime = microtime(true);
for ($i = 1; $i <= $workers; $i++) {
$proc = proc_open(
- $thisPHP . ' ' . escapeshellarg($thisScript),
+ [$thisPHP, $thisScript],
[], // Inherit our stdin, stdout and stderr
$pipes,
null,
- $_ENV + [
+ $GLOBALS['environment'] + [
"TEST_PHP_WORKER" => $i,
"TEST_PHP_URI" => $sockUri,
],
@@ -1500,10 +1432,6 @@ function run_all_tests_parallel(array $test_files, array $env, $redir_tested): v
"constants" => [
"INIT_DIR" => INIT_DIR,
"TEST_PHP_SRCDIR" => TEST_PHP_SRCDIR,
- "PHP_QA_EMAIL" => PHP_QA_EMAIL,
- "QA_SUBMISSION_PAGE" => QA_SUBMISSION_PAGE,
- "QA_REPORTS_PAGE" => QA_REPORTS_PAGE,
- "TRAVIS_CI" => TRAVIS_CI
]
])) . "\n";
@@ -1555,6 +1483,10 @@ function run_all_tests_parallel(array $test_files, array $env, $redir_tested): v
kill_children($workerProcs);
error("Could not find worker stdout in array of worker stdouts, THIS SHOULD NOT HAPPEN.");
}
+ if (feof($workerSock)) {
+ kill_children($workerProcs);
+ error("Worker $i died unexpectedly");
+ }
while (false !== ($rawMessage = fgets($workerSock))) {
// work around fgets truncating things
if (($rawMessageBuffers[$i] ?? '') !== '') {
@@ -1587,9 +1519,7 @@ function run_all_tests_parallel(array $test_files, array $env, $redir_tested): v
}
}
}
- if (junit_enabled()) {
- junit_merge_results($message["junit"]);
- }
+ $junit->mergeResults($message["junit"]);
// no break
case "ready":
// Schedule sequential tests only once we are down to one worker.
@@ -1630,8 +1560,7 @@ function run_all_tests_parallel(array $test_files, array $env, $redir_tested): v
]);
} else {
proc_terminate($workerProcs[$i]);
- unset($workerProcs[$i]);
- unset($workerSocks[$i]);
+ unset($workerProcs[$i], $workerSocks[$i]);
goto escape;
}
break;
@@ -1642,13 +1571,13 @@ function run_all_tests_parallel(array $test_files, array $env, $redir_tested): v
}
$test_idx++;
- if (!$SHOW_ONLY_GROUPS) {
+ if ($show_progress) {
clear_show_test();
}
echo $resultText;
- if (!$SHOW_ONLY_GROUPS) {
+ if ($show_progress) {
show_test($test_idx, count($workerProcs) . "/$workers concurrent test workers running");
}
@@ -1681,7 +1610,6 @@ function run_all_tests_parallel(array $test_files, array $env, $redir_tested): v
'E_USER_ERROR',
'E_USER_WARNING',
'E_USER_NOTICE',
- 'E_STRICT', // TODO Cleanup when removed from Zend Engine.
'E_RECOVERABLE_ERROR',
'E_DEPRECATED',
'E_USER_DEPRECATED'
@@ -1698,7 +1626,7 @@ function run_all_tests_parallel(array $test_files, array $env, $redir_tested): v
}
}
- if (!$SHOW_ONLY_GROUPS) {
+ if ($show_progress) {
clear_show_test();
}
@@ -1709,11 +1637,47 @@ function run_all_tests_parallel(array $test_files, array $env, $redir_tested): v
}
}
+/**
+ * Calls fwrite and retries when network writes fail with errors such as "Resource temporarily unavailable"
+ *
+ * @param resource $stream the stream to fwrite to
+ * @param string $data
+ * @return int|false
+ */
+function safe_fwrite($stream, string $data)
+{
+ // safe_fwrite was tested by adding $message['unused'] = str_repeat('a', 20_000_000); in send_message()
+ // fwrites on tcp sockets can return false or less than strlen if the recipient is busy.
+ // (e.g. fwrite(): Send of 577 bytes failed with errno=35 Resource temporarily unavailable)
+ $bytes_written = 0;
+ while ($bytes_written < strlen($data)) {
+ $n = @fwrite($stream, substr($data, $bytes_written));
+ if ($n === false) {
+ $write_streams = [$stream];
+ $read_streams = [];
+ $except_streams = [];
+ /* Wait for up to 10 seconds for the stream to be ready to write again. */
+ $result = stream_select($read_streams, $write_streams, $except_streams, 10);
+ if (!$result) {
+ echo "ERROR: send_message() stream_select() failed\n";
+ return false;
+ }
+ $n = @fwrite($stream, substr($data, $bytes_written));
+ if ($n === false) {
+ echo "ERROR: send_message() Failed to write chunk after stream_select: " . error_get_last()['message'] . "\n";
+ return false;
+ }
+ }
+ $bytes_written += $n;
+ }
+ return $bytes_written;
+}
+
function send_message($stream, array $message): void
{
$blocking = stream_get_meta_data($stream)["blocked"];
stream_set_blocking($stream, true);
- fwrite($stream, base64_encode(serialize($message)) . "\n");
+ safe_fwrite($stream, base64_encode(serialize($message)) . "\n");
stream_set_blocking($stream, $blocking);
}
@@ -1730,6 +1694,8 @@ function run_worker(): void
{
global $workerID, $workerSock;
+ global $junit;
+
$sockUri = getenv("TEST_PHP_URI");
$workerSock = stream_socket_client($sockUri, $_, $_, 5) or error("Couldn't connect to $sockUri");
@@ -1776,9 +1742,9 @@ function run_worker(): void
run_all_tests($command["test_files"], $command["env"], $command["redir_tested"]);
send_message($workerSock, [
"type" => "tests_finished",
- "junit" => junit_enabled() ? $GLOBALS['JUNIT'] : null,
+ "junit" => $junit->isEnabled() ? $junit : null,
]);
- junit_init();
+ $junit->clear();
break;
default:
send_message($workerSock, [
@@ -1814,6 +1780,16 @@ function show_file_block(string $file, string $block, ?string $section = null):
}
}
+function skip_test(string $tested, string $tested_file, string $shortname, string $reason): string
+{
+ global $junit;
+
+ show_result('SKIP', $tested, $tested_file, "reason: $reason");
+ $junit->initSuite($junit->getSuiteName($shortname));
+ $junit->markTestAs('SKIP', $shortname, $tested, 0, $reason);
+ return 'SKIPPED';
+}
+
//
// Run an individual test case.
//
@@ -1830,19 +1806,32 @@ function run_test(string $php, $file, array $env): string
global $no_file_cache;
global $slow_min_ms;
global $preload, $file_cache;
+ global $num_repeats;
// Parallel testing
global $workerID;
- $temp_filenames = null;
- $org_file = $file;
+ global $show_progress;
- if (isset($env['TEST_PHP_CGI_EXECUTABLE'])) {
- $php_cgi = $env['TEST_PHP_CGI_EXECUTABLE'];
- }
+ // Temporary
+ /** @var JUnit $junit */
+ global $junit;
- if (isset($env['TEST_PHPDBG_EXECUTABLE'])) {
- $phpdbg = $env['TEST_PHPDBG_EXECUTABLE'];
+ static $skipCache;
+ if (!$skipCache) {
+ $enableSkipCache = !($env['DISABLE_SKIP_CACHE'] ?? '0');
+ $skipCache = new SkipCache($enableSkipCache, $cfg['keep']['skip']);
}
+ $orig_php = $php;
+ $php = escapeshellarg($php);
+
+ $retried = false;
+retry:
+
+ $org_file = $file;
+
+ $php_cgi = $env['TEST_PHP_CGI_EXECUTABLE'] ?? null;
+ $phpdbg = $env['TEST_PHPDBG_EXECUTABLE'] ?? null;
+
if (is_array($file)) {
$file = $file[0];
}
@@ -1854,136 +1843,38 @@ function run_test(string $php, $file, array $env): string
";
}
- // Load the sections of the test file.
- $section_text = ['TEST' => ''];
-
- $fp = fopen($file, "rb") or error("Cannot open test file: $file");
-
- $bork_info = null;
-
- if (!feof($fp)) {
- $line = fgets($fp);
-
- if ($line === false) {
- $bork_info = "cannot read test";
- }
- } else {
- $bork_info = "empty test [$file]";
- }
- if ($bork_info === null && strncmp('--TEST--', $line, 8)) {
- $bork_info = "tests must start with --TEST-- [$file]";
- }
-
- $section = 'TEST';
- $secfile = false;
- $secdone = false;
-
- while (!feof($fp)) {
- $line = fgets($fp);
-
- if ($line === false) {
- break;
- }
-
- // Match the beginning of a section.
- if (preg_match('/^--([_A-Z]+)--/', $line, $r)) {
- $section = (string) $r[1];
-
- if (isset($section_text[$section]) && $section_text[$section]) {
- $bork_info = "duplicated $section section";
- }
-
- // check for unknown sections
- if (!in_array($section, [
- 'EXPECT', 'EXPECTF', 'EXPECTREGEX', 'EXPECTREGEX_EXTERNAL', 'EXPECT_EXTERNAL', 'EXPECTF_EXTERNAL', 'EXPECTHEADERS',
- 'POST', 'POST_RAW', 'GZIP_POST', 'DEFLATE_POST', 'PUT', 'GET', 'COOKIE', 'ARGS',
- 'FILE', 'FILEEOF', 'FILE_EXTERNAL', 'REDIRECTTEST',
- 'CAPTURE_STDIO', 'STDIN', 'CGI', 'PHPDBG',
- 'INI', 'ENV', 'EXTENSIONS',
- 'SKIPIF', 'XFAIL', 'XLEAK', 'CLEAN',
- 'CREDITS', 'DESCRIPTION', 'CONFLICTS', 'WHITESPACE_SENSITIVE',
- ])) {
- $bork_info = 'Unknown section "' . $section . '"';
- }
-
- $section_text[$section] = '';
- $secfile = $section == 'FILE' || $section == 'FILEEOF' || $section == 'FILE_EXTERNAL';
- $secdone = false;
- continue;
- }
-
- // Add to the section text.
- if (!$secdone) {
- $section_text[$section] .= $line;
- }
-
- // End of actual test?
- if ($secfile && preg_match('/^===DONE===\s*$/', $line)) {
- $secdone = true;
- }
- }
-
- // the redirect section allows a set of tests to be reused outside of
- // a given test dir
- if ($bork_info === null) {
- if (isset($section_text['REDIRECTTEST'])) {
- if ($IN_REDIRECT) {
- $bork_info = "Can't redirect a test from within a redirected test";
- }
- } else {
- if (!isset($section_text['PHPDBG']) && isset($section_text['FILE']) + isset($section_text['FILEEOF']) + isset($section_text['FILE_EXTERNAL']) != 1) {
- $bork_info = "missing section --FILE--";
- }
-
- if (isset($section_text['FILEEOF'])) {
- $section_text['FILE'] = preg_replace("/[\r\n]+$/", '', $section_text['FILEEOF']);
- unset($section_text['FILEEOF']);
- }
-
- foreach (['FILE', 'EXPECT', 'EXPECTF', 'EXPECTREGEX'] as $prefix) {
- $key = $prefix . '_EXTERNAL';
-
- if (isset($section_text[$key])) {
- // don't allow tests to retrieve files from anywhere but this subdirectory
- $section_text[$key] = dirname($file) . '/' . trim(str_replace('..', '', $section_text[$key]));
-
- if (file_exists($section_text[$key])) {
- $section_text[$prefix] = file_get_contents($section_text[$key]);
- unset($section_text[$key]);
- } else {
- $bork_info = "could not load --" . $key . "-- " . dirname($file) . '/' . trim($section_text[$key]);
- }
- }
- }
-
- if ((isset($section_text['EXPECT']) + isset($section_text['EXPECTF']) + isset($section_text['EXPECTREGEX'])) != 1) {
- $bork_info = "missing section --EXPECT--, --EXPECTF-- or --EXPECTREGEX--";
- }
- }
- }
- fclose($fp);
-
$shortname = str_replace(TEST_PHP_SRCDIR . '/', '', $file);
$tested_file = $shortname;
- if ($bork_info !== null) {
- show_result("BORK", $bork_info, $tested_file);
+ try {
+ $test = new TestFile($file, (bool)$IN_REDIRECT);
+ } catch (BorkageException $ex) {
+ show_result("BORK", $ex->getMessage(), $tested_file);
$PHP_FAILED_TESTS['BORKED'][] = [
'name' => $file,
'test_name' => '',
'output' => '',
'diff' => '',
- 'info' => "$bork_info [$file]",
+ 'info' => "{$ex->getMessage()} [$file]",
];
- junit_mark_test_as('BORK', $shortname, $tested_file, 0, $bork_info);
+ $junit->markTestAs('BORK', $shortname, $tested_file, 0, $ex->getMessage());
return 'BORKED';
}
- if (isset($section_text['CAPTURE_STDIO'])) {
- $captureStdIn = stripos($section_text['CAPTURE_STDIO'], 'STDIN') !== false;
- $captureStdOut = stripos($section_text['CAPTURE_STDIO'], 'STDOUT') !== false;
- $captureStdErr = stripos($section_text['CAPTURE_STDIO'], 'STDERR') !== false;
+ $tested = $test->getName();
+
+ if ($test->hasSection('FILE_EXTERNAL')) {
+ if ($num_repeats > 1) {
+ return skip_test($tested, $tested_file, $shortname, 'Test with FILE_EXTERNAL might not be repeatable');
+ }
+ }
+
+ if ($test->hasSection('CAPTURE_STDIO')) {
+ $capture = $test->getSection('CAPTURE_STDIO');
+ $captureStdIn = stripos($capture, 'STDIN') !== false;
+ $captureStdOut = stripos($capture, 'STDOUT') !== false;
+ $captureStdErr = stripos($capture, 'STDERR') !== false;
} else {
$captureStdIn = true;
$captureStdOut = true;
@@ -1995,55 +1886,45 @@ function run_test(string $php, $file, array $env): string
$cmdRedirect = '';
}
- $tested = trim($section_text['TEST']);
-
/* For GET/POST/PUT tests, check if cgi sapi is available and if it is, use it. */
- if (array_key_exists('CGI', $section_text) || !empty($section_text['GET']) || !empty($section_text['POST']) || !empty($section_text['GZIP_POST']) || !empty($section_text['DEFLATE_POST']) || !empty($section_text['POST_RAW']) || !empty($section_text['PUT']) || !empty($section_text['COOKIE']) || !empty($section_text['EXPECTHEADERS'])) {
- if (isset($php_cgi)) {
- $php = $php_cgi . ' -C ';
- } elseif (IS_WINDOWS && file_exists(dirname($php) . "/php-cgi.exe")) {
- $php = realpath(dirname($php) . "/php-cgi.exe") . ' -C ';
- } else {
- if (file_exists(dirname($php) . "/../../sapi/cgi/php-cgi")) {
- $php = realpath(dirname($php) . "/../../sapi/cgi/php-cgi") . ' -C ';
- } elseif (file_exists("./sapi/cgi/php-cgi")) {
- $php = realpath("./sapi/cgi/php-cgi") . ' -C ';
- } elseif (file_exists(dirname($php) . "/php-cgi")) {
- $php = realpath(dirname($php) . "/php-cgi") . ' -C ';
- } else {
- show_result('SKIP', $tested, $tested_file, "reason: CGI not available");
-
- junit_init_suite(junit_get_suitename_for($shortname));
- junit_mark_test_as('SKIP', $shortname, $tested, 0, 'CGI not available');
- return 'SKIPPED';
- }
+ $uses_cgi = false;
+ if ($test->isCGI()) {
+ if (!$php_cgi) {
+ return skip_test($tested, $tested_file, $shortname, 'CGI not available');
}
+ $php = escapeshellarg($php_cgi) . ' -C ';
$uses_cgi = true;
+ if ($num_repeats > 1) {
+ return skip_test($tested, $tested_file, $shortname, 'CGI does not support --repeat');
+ }
}
/* For phpdbg tests, check if phpdbg sapi is available and if it is, use it. */
$extra_options = '';
- if (array_key_exists('PHPDBG', $section_text)) {
- if (!isset($section_text['STDIN'])) {
- $section_text['STDIN'] = $section_text['PHPDBG'] . "\n";
- }
-
+ if ($test->hasSection('PHPDBG')) {
if (isset($phpdbg)) {
- $php = $phpdbg . ' -qIb';
+ $php = escapeshellarg($phpdbg) . ' -qIb';
// Additional phpdbg command line options for sections that need to
// be run straight away. For example, EXTENSIONS, SKIPIF, CLEAN.
$extra_options = '-rr';
} else {
- show_result('SKIP', $tested, $tested_file, "reason: phpdbg not available");
+ return skip_test($tested, $tested_file, $shortname, 'phpdbg not available');
+ }
+ if ($num_repeats > 1) {
+ return skip_test($tested, $tested_file, $shortname, 'phpdbg does not support --repeat');
+ }
+ }
- junit_init_suite(junit_get_suitename_for($shortname));
- junit_mark_test_as('SKIP', $shortname, $tested, 0, 'phpdbg not available');
- return 'SKIPPED';
+ foreach (['CLEAN', 'STDIN', 'CAPTURE_STDIO'] as $section) {
+ if ($test->hasSection($section)) {
+ if ($num_repeats > 1) {
+ return skip_test($tested, $tested_file, $shortname, "Test with $section might not be repeatable");
+ }
}
}
- if (!$SHOW_ONLY_GROUPS && !$workerID) {
+ if ($show_progress && !$workerID) {
show_test($test_idx, $shortname);
}
@@ -2062,6 +1943,7 @@ function run_test(string $php, $file, array $env): string
$diff_filename = $temp_dir . DIRECTORY_SEPARATOR . $main_file_name . 'diff';
$log_filename = $temp_dir . DIRECTORY_SEPARATOR . $main_file_name . 'log';
$exp_filename = $temp_dir . DIRECTORY_SEPARATOR . $main_file_name . 'exp';
+ $stdin_filename = $temp_dir . DIRECTORY_SEPARATOR . $main_file_name . 'stdin';
$output_filename = $temp_dir . DIRECTORY_SEPARATOR . $main_file_name . 'out';
$memcheck_filename = $temp_dir . DIRECTORY_SEPARATOR . $main_file_name . 'mem';
$sh_filename = $temp_dir . DIRECTORY_SEPARATOR . $main_file_name . 'sh';
@@ -2079,32 +1961,19 @@ function run_test(string $php, $file, array $env): string
$temp_skipif .= 's';
$temp_file .= 's';
$temp_clean .= 's';
- $copy_file = $temp_dir . DIRECTORY_SEPARATOR . basename(is_array($file) ? $file[1] : $file) . '.phps';
+ $copy_file = $temp_dir . DIRECTORY_SEPARATOR . basename($file) . '.phps';
if (!is_dir(dirname($copy_file))) {
mkdir(dirname($copy_file), 0777, true) or error("Cannot create output directory - " . dirname($copy_file));
}
- if (isset($section_text['FILE'])) {
- save_text($copy_file, $section_text['FILE']);
+ if ($test->hasSection('FILE')) {
+ save_text($copy_file, $test->getSection('FILE'));
}
-
- $temp_filenames = [
- 'file' => $copy_file,
- 'diff' => $diff_filename,
- 'log' => $log_filename,
- 'exp' => $exp_filename,
- 'out' => $output_filename,
- 'mem' => $memcheck_filename,
- 'sh' => $sh_filename,
- 'php' => $temp_file,
- 'skip' => $temp_skipif,
- 'clean' => $temp_clean
- ];
}
if (is_array($IN_REDIRECT)) {
- $tested = $IN_REDIRECT['prefix'] . ' ' . trim($section_text['TEST']);
+ $tested = $IN_REDIRECT['prefix'] . ' ' . $tested;
$tested_file = $tmp_relative_file;
$shortname = str_replace(TEST_PHP_SRCDIR . '/', '', $tested_file);
}
@@ -2113,6 +1982,7 @@ function run_test(string $php, $file, array $env): string
@unlink($diff_filename);
@unlink($log_filename);
@unlink($exp_filename);
+ @unlink($stdin_filename);
@unlink($output_filename);
@unlink($memcheck_filename);
@unlink($sh_filename);
@@ -2135,8 +2005,9 @@ function run_test(string $php, $file, array $env): string
$env['CONTENT_LENGTH'] = '';
$env['TZ'] = '';
- if (!empty($section_text['ENV'])) {
- foreach (explode("\n", trim($section_text['ENV'])) as $e) {
+ if ($test->sectionNotEmpty('ENV')) {
+ $env_str = str_replace('{PWD}', dirname($file), $test->getSection('ENV'));
+ foreach (explode("\n", $env_str) as $e) {
$e = explode('=', trim($e), 2);
if (!empty($e[0]) && isset($e[1])) {
@@ -2149,23 +2020,41 @@ function run_test(string $php, $file, array $env): string
$ini_settings = $workerID ? ['opcache.cache_id' => "worker$workerID"] : [];
// Additional required extensions
- if (array_key_exists('EXTENSIONS', $section_text)) {
+ $extensions = [];
+ if ($test->hasSection('EXTENSIONS')) {
+ $extensions = preg_split("/[\n\r]+/", trim($test->getSection('EXTENSIONS')));
+ }
+ if (is_array($IN_REDIRECT) && $IN_REDIRECT['EXTENSIONS'] != []) {
+ $extensions = array_merge($extensions, $IN_REDIRECT['EXTENSIONS']);
+ }
+
+ /* Load required extensions */
+ if ($extensions != []) {
$ext_params = [];
settings2array($ini_overwrites, $ext_params);
$ext_params = settings2params($ext_params);
- $ext_dir = `$php $pass_options $extra_options $ext_params $no_file_cache -d display_errors=0 -r "echo ini_get('extension_dir');"`;
- $extensions = preg_split("/[\n\r]+/", trim($section_text['EXTENSIONS']));
- $loaded = explode(",", `$php $pass_options $extra_options $ext_params $no_file_cache -d display_errors=0 -r "echo implode(',', get_loaded_extensions());"`);
+ [$ext_dir, $loaded] = $skipCache->getExtensions("$orig_php $pass_options $extra_options $ext_params $no_file_cache");
$ext_prefix = IS_WINDOWS ? "php_" : "";
+ $missing = [];
foreach ($extensions as $req_ext) {
- if (!in_array($req_ext, $loaded)) {
- if ($req_ext == 'opcache') {
- $ini_settings['zend_extension'][] = $ext_dir . DIRECTORY_SEPARATOR . $ext_prefix . $req_ext . '.' . PHP_SHLIB_SUFFIX;
+ if (!in_array($req_ext, $loaded, true)) {
+ if ($req_ext == 'opcache' || $req_ext == 'xdebug') {
+ $ext_file = $ext_dir . DIRECTORY_SEPARATOR . $ext_prefix . $req_ext . '.' . PHP_SHLIB_SUFFIX;
+ $ini_settings['zend_extension'][] = $ext_file;
} else {
- $ini_settings['extension'][] = $ext_dir . DIRECTORY_SEPARATOR . $ext_prefix . $req_ext . '.' . PHP_SHLIB_SUFFIX;
+ $ext_file = $ext_dir . DIRECTORY_SEPARATOR . $ext_prefix . $req_ext . '.' . PHP_SHLIB_SUFFIX;
+ $ini_settings['extension'][] = $ext_file;
+ }
+ if (!is_readable($ext_file)) {
+ $missing[] = $req_ext;
}
}
}
+ if ($missing) {
+ $message = 'Required extension' . (count($missing) > 1 ? 's' : '')
+ . ' missing: ' . implode(', ', $missing);
+ return skip_test($tested, $tested_file, $shortname, $message);
+ }
}
// additional ini overwrites
@@ -2185,16 +2074,38 @@ function run_test(string $php, $file, array $env): string
// even though all the files are re-created.
$ini_settings['opcache.validate_timestamps'] = '0';
}
+ } else if ($num_repeats > 1) {
+ // Make sure warnings still show up on the second run.
+ $ini_settings['opcache.record_warnings'] = '1';
}
// Any special ini settings
// these may overwrite the test defaults...
- if (array_key_exists('INI', $section_text)) {
- $section_text['INI'] = str_replace('{PWD}', dirname($file), $section_text['INI']);
- $section_text['INI'] = str_replace('{TMP}', sys_get_temp_dir(), $section_text['INI']);
+ if ($test->hasSection('INI')) {
+ $ini = str_replace('{PWD}', dirname($file), $test->getSection('INI'));
+ $ini = str_replace('{TMP}', sys_get_temp_dir(), $ini);
$replacement = IS_WINDOWS ? '"' . PHP_BINARY . ' -r \"while ($in = fgets(STDIN)) echo $in;\" > $1"' : 'tee $1 >/dev/null';
- $section_text['INI'] = preg_replace('/{MAIL:(\S+)}/', $replacement, $section_text['INI']);
- settings2array(preg_split("/[\n\r]+/", $section_text['INI']), $ini_settings);
+ $ini = preg_replace('/{MAIL:(\S+)}/', $replacement, $ini);
+ $skip = false;
+ $ini = preg_replace_callback('/{ENV:(\S+)}/', function ($m) use (&$skip) {
+ $name = $m[1];
+ $value = getenv($name);
+ if ($value === false) {
+ $skip = sprintf('Environment variable %s is not set', $name);
+ return '';
+ }
+ return $value;
+ }, $ini);
+ if ($skip !== false) {
+ return skip_test($tested, $tested_file, $shortname, $skip);
+ }
+ settings2array(preg_split("/[\n\r]+/", $ini), $ini_settings);
+
+ if (isset($ini_settings['opcache.opt_debug_level'])) {
+ if ($num_repeats > 1) {
+ return skip_test($tested, $tested_file, $shortname, 'opt_debug_level tests are not repeatable');
+ }
+ }
}
$ini_settings = settings2params($ini_settings);
@@ -2205,85 +2116,95 @@ function run_test(string $php, $file, array $env): string
$info = '';
$warn = false;
- if (array_key_exists('SKIPIF', $section_text)) {
- if (trim($section_text['SKIPIF'])) {
- show_file_block('skip', $section_text['SKIPIF']);
- save_text($test_skipif, $section_text['SKIPIF'], $temp_skipif);
- $extra = !IS_WINDOWS ?
- "unset REQUEST_METHOD; unset QUERY_STRING; unset PATH_TRANSLATED; unset SCRIPT_FILENAME; unset REQUEST_METHOD;" : "";
+ if ($test->sectionNotEmpty('SKIPIF')) {
+ show_file_block('skip', $test->getSection('SKIPIF'));
+ $extra = !IS_WINDOWS ?
+ "unset REQUEST_METHOD; unset QUERY_STRING; unset PATH_TRANSLATED; unset SCRIPT_FILENAME; unset REQUEST_METHOD;" : "";
- if ($valgrind) {
- $env['USE_ZEND_ALLOC'] = '0';
- $env['ZEND_DONT_UNLOAD_MODULES'] = 1;
- }
+ if ($valgrind) {
+ $env['USE_ZEND_ALLOC'] = '0';
+ $env['ZEND_DONT_UNLOAD_MODULES'] = 1;
+ }
- junit_start_timer($shortname);
+ $junit->startTimer($shortname);
- $output = system_with_timeout("$extra $php $pass_options $extra_options -q $orig_ini_settings $no_file_cache -d display_errors=1 -d display_startup_errors=0 \"$test_skipif\"", $env);
- $output = trim($output);
+ $startTime = microtime(true);
+ $commandLine = "$extra $php $pass_options $extra_options -q $orig_ini_settings $no_file_cache -d display_errors=1 -d display_startup_errors=0";
+ $output = $skipCache->checkSkip($commandLine, $test->getSection('SKIPIF'), $test_skipif, $temp_skipif, $env);
- junit_finish_timer($shortname);
+ $time = microtime(true) - $startTime;
+ $junit->stopTimer($shortname);
- if (!$cfg['keep']['skip']) {
- @unlink($test_skipif);
- }
+ if ($time > $slow_min_ms / 1000) {
+ $PHP_FAILED_TESTS['SLOW'][] = [
+ 'name' => $file,
+ 'test_name' => 'SKIPIF of ' . $tested . " [$tested_file]",
+ 'output' => '',
+ 'diff' => '',
+ 'info' => $time,
+ ];
+ }
- if (!strncasecmp('skip', $output, 4)) {
- if (preg_match('/^skip\s*(.+)/i', $output, $m)) {
- show_result('SKIP', $tested, $tested_file, "reason: $m[1]", $temp_filenames);
- } else {
- show_result('SKIP', $tested, $tested_file, '', $temp_filenames);
- }
+ if (!$cfg['keep']['skip']) {
+ @unlink($test_skipif);
+ }
- if (!$cfg['keep']['skip']) {
- @unlink($test_skipif);
- }
+ if (!strncasecmp('skip', $output, 4)) {
+ if (preg_match('/^skip\s*(.+)/i', $output, $m)) {
+ show_result('SKIP', $tested, $tested_file, "reason: $m[1]");
+ } else {
+ show_result('SKIP', $tested, $tested_file, '');
+ }
- $message = !empty($m[1]) ? $m[1] : '';
- junit_mark_test_as('SKIP', $shortname, $tested, null, $message);
- return 'SKIPPED';
- }
-
- if (!strncasecmp('info', $output, 4) && preg_match('/^info\s*(.+)/i', $output, $m)) {
- $info = " (info: $m[1])";
- } elseif (!strncasecmp('warn', $output, 4) && preg_match('/^warn\s+(.+)/i', $output, $m)) {
- $warn = true; /* only if there is a reason */
- $info = " (warn: $m[1])";
- } elseif (!strncasecmp('xfail', $output, 5)) {
- // Pretend we have an XFAIL section
- $section_text['XFAIL'] = ltrim(substr($output, 5));
- } elseif ($output !== '') {
- show_result("BORK", $output, $tested_file, 'reason: invalid output from SKIPIF', $temp_filenames);
- $PHP_FAILED_TESTS['BORKED'][] = [
- 'name' => $file,
- 'test_name' => '',
- 'output' => '',
- 'diff' => '',
- 'info' => "$output [$file]",
- ];
+ $message = !empty($m[1]) ? $m[1] : '';
+ $junit->markTestAs('SKIP', $shortname, $tested, null, $message);
+ return 'SKIPPED';
+ }
- junit_mark_test_as('BORK', $shortname, $tested, null, $output);
- return 'BORKED';
- }
+ if (!strncasecmp('info', $output, 4) && preg_match('/^info\s*(.+)/i', $output, $m)) {
+ $info = " (info: $m[1])";
+ } elseif (!strncasecmp('warn', $output, 4) && preg_match('/^warn\s+(.+)/i', $output, $m)) {
+ $warn = true; /* only if there is a reason */
+ $info = " (warn: $m[1])";
+ } elseif (!strncasecmp('xfail', $output, 5)) {
+ // Pretend we have an XFAIL section
+ $test->setSection('XFAIL', ltrim(substr($output, 5)));
+ } elseif (!strncasecmp('xleak', $output, 5)) {
+ // Pretend we have an XLEAK section
+ $test->setSection('XLEAK', ltrim(substr($output, 5)));
+ } elseif (!strncasecmp('flaky', $output, 5)) {
+ // Pretend we have a FLAKY section
+ $test->setSection('FLAKY', ltrim(substr($output, 5)));
+ } elseif ($output !== '') {
+ show_result("BORK", $output, $tested_file, 'reason: invalid output from SKIPIF');
+ $PHP_FAILED_TESTS['BORKED'][] = [
+ 'name' => $file,
+ 'test_name' => '',
+ 'output' => '',
+ 'diff' => '',
+ 'info' => "$output [$file]",
+ ];
+
+ $junit->markTestAs('BORK', $shortname, $tested, null, $output);
+ return 'BORKED';
}
}
- if (!extension_loaded("zlib")
- && (array_key_exists("GZIP_POST", $section_text)
- || array_key_exists("DEFLATE_POST", $section_text))) {
+ if (!extension_loaded("zlib") && $test->hasAnySections("GZIP_POST", "DEFLATE_POST")) {
$message = "ext/zlib required";
- show_result('SKIP', $tested, $tested_file, "reason: $message", $temp_filenames);
- junit_mark_test_as('SKIP', $shortname, $tested, null, $message);
+ show_result('SKIP', $tested, $tested_file, "reason: $message");
+ $junit->markTestAs('SKIP', $shortname, $tested, null, $message);
return 'SKIPPED';
}
- if (isset($section_text['REDIRECTTEST'])) {
+ if ($test->hasSection('REDIRECTTEST')) {
$test_files = [];
- $IN_REDIRECT = eval($section_text['REDIRECTTEST']);
+ $IN_REDIRECT = eval($test->getSection('REDIRECTTEST'));
$IN_REDIRECT['via'] = "via [$shortname]\n\t";
$IN_REDIRECT['dir'] = realpath(dirname($file));
- $IN_REDIRECT['prefix'] = trim($section_text['TEST']);
+ $IN_REDIRECT['prefix'] = $tested;
+ $IN_REDIRECT['EXTENSIONS'] = $extensions;
if (!empty($IN_REDIRECT['TESTS'])) {
if (is_array($org_file)) {
@@ -2313,28 +2234,28 @@ function run_test(string $php, $file, array $env): string
// a redirected test never fails
$IN_REDIRECT = false;
- junit_mark_test_as('PASS', $shortname, $tested);
+ $junit->markTestAs('PASS', $shortname, $tested);
return 'REDIR';
- } else {
- $bork_info = "Redirect info must contain exactly one TEST string to be used as redirect directory.";
- show_result("BORK", $bork_info, '', '', $temp_filenames);
- $PHP_FAILED_TESTS['BORKED'][] = [
- 'name' => $file,
- 'test_name' => '',
- 'output' => '',
- 'diff' => '',
- 'info' => "$bork_info [$file]",
- ];
}
+
+ $bork_info = "Redirect info must contain exactly one TEST string to be used as redirect directory.";
+ show_result("BORK", $bork_info, '', '');
+ $PHP_FAILED_TESTS['BORKED'][] = [
+ 'name' => $file,
+ 'test_name' => '',
+ 'output' => '',
+ 'diff' => '',
+ 'info' => "$bork_info [$file]",
+ ];
}
- if (is_array($org_file) || isset($section_text['REDIRECTTEST'])) {
+ if (is_array($org_file) || $test->hasSection('REDIRECTTEST')) {
if (is_array($org_file)) {
$file = $org_file[0];
}
$bork_info = "Redirected test did not contain redirection info";
- show_result("BORK", $bork_info, '', '', $temp_filenames);
+ show_result("BORK", $bork_info, '', '');
$PHP_FAILED_TESTS['BORKED'][] = [
'name' => $file,
'test_name' => '',
@@ -2343,21 +2264,21 @@ function run_test(string $php, $file, array $env): string
'info' => "$bork_info [$file]",
];
- junit_mark_test_as('BORK', $shortname, $tested, null, $bork_info);
+ $junit->markTestAs('BORK', $shortname, $tested, null, $bork_info);
return 'BORKED';
}
// We've satisfied the preconditions - run the test!
- if (isset($section_text['FILE'])) {
- show_file_block('php', $section_text['FILE'], 'TEST');
- save_text($test_file, $section_text['FILE'], $temp_file);
+ if ($test->hasSection('FILE')) {
+ show_file_block('php', $test->getSection('FILE'), 'TEST');
+ save_text($test_file, $test->getSection('FILE'), $temp_file);
} else {
- $test_file = $temp_file = "";
+ $test_file = "";
}
- if (array_key_exists('GET', $section_text)) {
- $query_string = trim($section_text['GET']);
+ if ($test->hasSection('GET')) {
+ $query_string = trim($test->getSection('GET'));
} else {
$query_string = '';
}
@@ -2373,13 +2294,13 @@ function run_test(string $php, $file, array $env): string
$env['SCRIPT_FILENAME'] = $test_file;
}
- if (array_key_exists('COOKIE', $section_text)) {
- $env['HTTP_COOKIE'] = trim($section_text['COOKIE']);
+ if ($test->hasSection('COOKIE')) {
+ $env['HTTP_COOKIE'] = trim($test->getSection('COOKIE'));
} else {
$env['HTTP_COOKIE'] = '';
}
- $args = isset($section_text['ARGS']) ? ' -- ' . $section_text['ARGS'] : '';
+ $args = $test->hasSection('ARGS') ? ' -- ' . $test->getSection('ARGS') : '';
if ($preload && !empty($test_file)) {
save_text($preload_filename, "sectionNotEmpty('POST_RAW')) {
+ $post = trim($test->getSection('POST_RAW'));
$raw_lines = explode("\n", $post);
$request = '';
@@ -2411,17 +2332,19 @@ function run_test(string $php, $file, array $env): string
}
$env['CONTENT_LENGTH'] = strlen($request);
- $env['REQUEST_METHOD'] = 'POST';
+ if (empty($env['REQUEST_METHOD'])) {
+ $env['REQUEST_METHOD'] = 'POST';
+ }
if (empty($request)) {
- junit_mark_test_as('BORK', $shortname, $tested, null, 'empty $request');
+ $junit->markTestAs('BORK', $shortname, $tested, null, 'empty $request');
return 'BORKED';
}
save_text($tmp_post, $request);
$cmd = "$php $pass_options $ini_settings -f \"$test_file\"$cmdRedirect < \"$tmp_post\"";
- } elseif (array_key_exists('PUT', $section_text) && !empty($section_text['PUT'])) {
- $post = trim($section_text['PUT']);
+ } elseif ($test->sectionNotEmpty('PUT')) {
+ $post = trim($test->getSection('PUT'));
$raw_lines = explode("\n", $post);
$request = '';
@@ -2445,14 +2368,14 @@ function run_test(string $php, $file, array $env): string
$env['REQUEST_METHOD'] = 'PUT';
if (empty($request)) {
- junit_mark_test_as('BORK', $shortname, $tested, null, 'empty $request');
+ $junit->markTestAs('BORK', $shortname, $tested, null, 'empty $request');
return 'BORKED';
}
save_text($tmp_post, $request);
$cmd = "$php $pass_options $ini_settings -f \"$test_file\"$cmdRedirect < \"$tmp_post\"";
- } elseif (array_key_exists('POST', $section_text) && !empty($section_text['POST'])) {
- $post = trim($section_text['POST']);
+ } elseif ($test->sectionNotEmpty('POST')) {
+ $post = trim($test->getSection('POST'));
$content_length = strlen($post);
save_text($tmp_post, $post);
@@ -2466,8 +2389,8 @@ function run_test(string $php, $file, array $env): string
}
$cmd = "$php $pass_options $ini_settings -f \"$test_file\"$cmdRedirect < \"$tmp_post\"";
- } elseif (array_key_exists('GZIP_POST', $section_text) && !empty($section_text['GZIP_POST'])) {
- $post = trim($section_text['GZIP_POST']);
+ } elseif ($test->sectionNotEmpty('GZIP_POST')) {
+ $post = trim($test->getSection('GZIP_POST'));
$post = gzencode($post, 9, FORCE_GZIP);
$env['HTTP_CONTENT_ENCODING'] = 'gzip';
@@ -2479,8 +2402,8 @@ function run_test(string $php, $file, array $env): string
$env['CONTENT_LENGTH'] = $content_length;
$cmd = "$php $pass_options $ini_settings -f \"$test_file\"$cmdRedirect < \"$tmp_post\"";
- } elseif (array_key_exists('DEFLATE_POST', $section_text) && !empty($section_text['DEFLATE_POST'])) {
- $post = trim($section_text['DEFLATE_POST']);
+ } elseif ($test->sectionNotEmpty('DEFLATE_POST')) {
+ $post = trim($test->getSection('DEFLATE_POST'));
$post = gzcompress($post, 9);
$env['HTTP_CONTENT_ENCODING'] = 'deflate';
save_text($tmp_post, $post);
@@ -2496,9 +2419,11 @@ function run_test(string $php, $file, array $env): string
$env['CONTENT_TYPE'] = '';
$env['CONTENT_LENGTH'] = '';
- $cmd = "$php $pass_options $ini_settings -f \"$test_file\" $args$cmdRedirect";
+ $repeat_option = $num_repeats > 1 ? "--repeat $num_repeats" : "";
+ $cmd = "$php $pass_options $repeat_option $ini_settings -f \"$test_file\" $args$cmdRedirect";
}
+ $orig_cmd = $cmd;
if ($valgrind) {
$env['USE_ZEND_ALLOC'] = '0';
$env['ZEND_DONT_UNLOAD_MODULES'] = 1;
@@ -2506,6 +2431,16 @@ function run_test(string $php, $file, array $env): string
$cmd = $valgrind->wrapCommand($cmd, $memcheck_filename, strpos($test_file, "pcre") !== false);
}
+ if ($test->hasSection('XLEAK')) {
+ $env['ZEND_ALLOC_PRINT_LEAKS'] = '0';
+ if (isset($env['SKIP_ASAN'])) {
+ // $env['LSAN_OPTIONS'] = 'detect_leaks=0';
+ /* For unknown reasons, LSAN_OPTIONS=detect_leaks=0 would occasionally not be picked up
+ * in CI. Skip the test with ASAN, as it's not worth investegating. */
+ return skip_test($tested, $tested_file, $shortname, 'xleak does not work with asan');
+ }
+ }
+
if ($DETAILED) {
echo "
CONTENT_LENGTH = " . $env['CONTENT_LENGTH'] . "
@@ -2520,44 +2455,43 @@ function run_test(string $php, $file, array $env): string
";
}
- junit_start_timer($shortname);
+ $junit->startTimer($shortname);
$hrtime = hrtime();
$startTime = $hrtime[0] * 1000000000 + $hrtime[1];
- $out = system_with_timeout($cmd, $env, $section_text['STDIN'] ?? null, $captureStdIn, $captureStdOut, $captureStdErr);
+ $stdin = $test->hasSection('STDIN') ? $test->getSection('STDIN') : null;
+ $out = system_with_timeout($cmd, $env, $stdin, $captureStdIn, $captureStdOut, $captureStdErr);
- junit_finish_timer($shortname);
+ $junit->stopTimer($shortname);
$hrtime = hrtime();
$time = $hrtime[0] * 1000000000 + $hrtime[1] - $startTime;
if ($time >= $slow_min_ms * 1000000) {
$PHP_FAILED_TESTS['SLOW'][] = [
'name' => $file,
- 'test_name' => (is_array($IN_REDIRECT) ? $IN_REDIRECT['via'] : '') . $tested . " [$tested_file]",
+ 'test_name' => $tested . " [$tested_file]",
'output' => '',
'diff' => '',
'info' => $time / 1000000000,
];
}
- if (array_key_exists('CLEAN', $section_text) && (!$no_clean || $cfg['keep']['clean'])) {
- if (trim($section_text['CLEAN'])) {
- show_file_block('clean', $section_text['CLEAN']);
- save_text($test_clean, trim($section_text['CLEAN']), $temp_clean);
+ // Remember CLEAN output to report borked test if it otherwise passes.
+ $clean_output = null;
+ if ((!$no_clean || $cfg['keep']['clean']) && $test->sectionNotEmpty('CLEAN')) {
+ show_file_block('clean', $test->getSection('CLEAN'));
+ save_text($test_clean, trim($test->getSection('CLEAN')), $temp_clean);
- if (!$no_clean) {
- $extra = !IS_WINDOWS ?
- "unset REQUEST_METHOD; unset QUERY_STRING; unset PATH_TRANSLATED; unset SCRIPT_FILENAME; unset REQUEST_METHOD;" : "";
- system_with_timeout("$extra $php $pass_options $extra_options -q $orig_ini_settings $no_file_cache \"$test_clean\"", $env);
- }
+ if (!$no_clean) {
+ $extra = !IS_WINDOWS ?
+ "unset REQUEST_METHOD; unset QUERY_STRING; unset PATH_TRANSLATED; unset SCRIPT_FILENAME; unset REQUEST_METHOD;" : "";
+ $clean_output = system_with_timeout("$extra $orig_php $pass_options -q $orig_ini_settings $no_file_cache \"$test_clean\"", $env);
+ }
- if (!$cfg['keep']['clean']) {
- @unlink($test_clean);
- }
+ if (!$cfg['keep']['clean']) {
+ @unlink($test_clean);
}
}
- @unlink($preload_filename);
-
$leaked = false;
$passed = false;
@@ -2569,13 +2503,32 @@ function run_test(string $php, $file, array $env): string
}
}
+ if ($num_repeats > 1) {
+ // In repeat mode, retain the output before the first execution,
+ // and of the last execution. Do this early, because the trimming below
+ // makes the newline handling complicated.
+ $separator1 = "Executing for the first time...\n";
+ $separator1_pos = strpos($out, $separator1);
+ if ($separator1_pos !== false) {
+ $separator2 = "Finished execution, repeating...\n";
+ $separator2_pos = strrpos($out, $separator2);
+ if ($separator2_pos !== false) {
+ $out = substr($out, 0, $separator1_pos)
+ . substr($out, $separator2_pos + strlen($separator2));
+ } else {
+ $out = substr($out, 0, $separator1_pos)
+ . substr($out, $separator1_pos + strlen($separator1));
+ }
+ }
+ }
+
// Does the output match what is expected?
$output = preg_replace("/\r\n/", "\n", trim($out));
/* when using CGI, strip the headers from the output */
$headers = [];
- if (!empty($uses_cgi) && preg_match("/^(.*?)\r?\n\r?\n(.*)/s", $out, $match)) {
+ if ($uses_cgi && preg_match("/^(.*?)\r?\n\r?\n(.*)/s", $out, $match)) {
$output = trim($match[2]);
$rh = preg_split("/[\n\r]+/", $match[1]);
@@ -2587,12 +2540,14 @@ function run_test(string $php, $file, array $env): string
}
}
+ $wanted_headers = null;
+ $output_headers = null;
$failed_headers = false;
- if (isset($section_text['EXPECTHEADERS'])) {
+ if ($test->hasSection('EXPECTHEADERS')) {
$want = [];
$wanted_headers = [];
- $lines = preg_split("/[\n\r]+/", $section_text['EXPECTHEADERS']);
+ $lines = preg_split("/[\n\r]+/", $test->getSection('EXPECTHEADERS'));
foreach ($lines as $line) {
if (strpos($line, ':') !== false) {
@@ -2614,9 +2569,7 @@ function run_test(string $php, $file, array $env): string
}
}
- ksort($wanted_headers);
$wanted_headers = implode("\n", $wanted_headers);
- ksort($output_headers);
$output_headers = implode("\n", $output_headers);
}
@@ -2626,111 +2579,79 @@ function run_test(string $php, $file, array $env): string
$output = trim(preg_replace("/\n?Warning: Can't preload [^\n]*\n?/", "", $output));
}
- if (isset($section_text['EXPECTF']) || isset($section_text['EXPECTREGEX'])) {
- if (isset($section_text['EXPECTF'])) {
- $wanted = trim($section_text['EXPECTF']);
+ if ($test->hasAnySections('EXPECTF', 'EXPECTREGEX')) {
+ if ($test->hasSection('EXPECTF')) {
+ $wanted = trim($test->getSection('EXPECTF'));
} else {
- $wanted = trim($section_text['EXPECTREGEX']);
+ $wanted = trim($test->getSection('EXPECTREGEX'));
}
show_file_block('exp', $wanted);
$wanted_re = preg_replace('/\r\n/', "\n", $wanted);
- if (isset($section_text['EXPECTF'])) {
- // do preg_quote, but miss out any %r delimited sections
- $temp = "";
- $r = "%r";
- $startOffset = 0;
- $length = strlen($wanted_re);
- while ($startOffset < $length) {
- $start = strpos($wanted_re, $r, $startOffset);
- if ($start !== false) {
- // we have found a start tag
- $end = strpos($wanted_re, $r, $start + 2);
- if ($end === false) {
- // unbalanced tag, ignore it.
- $end = $start = $length;
- }
- } else {
- // no more %r sections
- $start = $end = $length;
- }
- // quote a non re portion of the string
- $temp .= preg_quote(substr($wanted_re, $startOffset, $start - $startOffset), '/');
- // add the re unquoted.
- if ($end > $start) {
- $temp .= '(' . substr($wanted_re, $start + 2, $end - $start - 2) . ')';
- }
- $startOffset = $end + 2;
- }
- $wanted_re = $temp;
-
- // Stick to basics
- $wanted_re = str_replace('%e', '\\' . DIRECTORY_SEPARATOR, $wanted_re);
- $wanted_re = str_replace('%s', '[^\r\n]+', $wanted_re);
- $wanted_re = str_replace('%S', '[^\r\n]*', $wanted_re);
- $wanted_re = str_replace('%a', '.+', $wanted_re);
- $wanted_re = str_replace('%A', '.*', $wanted_re);
- $wanted_re = str_replace('%w', '\s*', $wanted_re);
- $wanted_re = str_replace('%i', '[+-]?\d+', $wanted_re);
- $wanted_re = str_replace('%d', '\d+', $wanted_re);
- $wanted_re = str_replace('%x', '[0-9a-fA-F]+', $wanted_re);
- $wanted_re = str_replace('%f', '[+-]?\.?\d+\.?\d*(?:[Ee][+-]?\d+)?', $wanted_re);
- $wanted_re = str_replace('%c', '.', $wanted_re);
- // %f allows two points "-.0.0" but that is the best *simple* expression
- }
-
- if (preg_match("/^$wanted_re\$/s", $output)) {
+ if ($test->hasSection('EXPECTF')) {
+ $wanted_re = expectf_to_regex($wanted_re);
+ }
+
+ if (preg_match('/^' . $wanted_re . '$/s', $output)) {
$passed = true;
- if (!$cfg['keep']['php']) {
- @unlink($test_file);
- }
- @unlink($tmp_post);
-
- if (!$leaked && !$failed_headers) {
- if (isset($section_text['XFAIL'])) {
- $warn = true;
- $info = " (warn: XFAIL section but test passes)";
- } elseif (isset($section_text['XLEAK'])) {
- $warn = true;
- $info = " (warn: XLEAK section but test passes)";
- } else {
- show_result("PASS", $tested, $tested_file, '', $temp_filenames);
- junit_mark_test_as('PASS', $shortname, $tested);
- return 'PASSED';
- }
- }
}
} else {
- $wanted = trim($section_text['EXPECT']);
+ $wanted = trim($test->getSection('EXPECT'));
$wanted = preg_replace('/\r\n/', "\n", $wanted);
show_file_block('exp', $wanted);
// compare and leave on success
if (!strcmp($output, $wanted)) {
$passed = true;
+ }
+
+ $wanted_re = null;
+ }
+ if (!$passed && !$retried && error_may_be_retried($test, $output)) {
+ $retried = true;
+ goto retry;
+ }
+
+ if ($passed) {
+ if (!$cfg['keep']['php'] && !$leaked) {
+ @unlink($test_file);
+ @unlink($preload_filename);
+ }
+ @unlink($tmp_post);
+
+ if (!$leaked && !$failed_headers) {
+ // If the test passed and CLEAN produced output, report test as borked.
+ if ($clean_output) {
+ show_result("BORK", $output, $tested_file, 'reason: invalid output from CLEAN');
+ $PHP_FAILED_TESTS['BORKED'][] = [
+ 'name' => $file,
+ 'test_name' => '',
+ 'output' => '',
+ 'diff' => '',
+ 'info' => "$clean_output [$file]",
+ ];
- if (!$cfg['keep']['php']) {
- @unlink($test_file);
+ $junit->markTestAs('BORK', $shortname, $tested, null, $clean_output);
+ return 'BORKED';
}
- @unlink($tmp_post);
- if (!$leaked && !$failed_headers) {
- if (isset($section_text['XFAIL'])) {
- $warn = true;
- $info = " (warn: XFAIL section but test passes)";
- } elseif (isset($section_text['XLEAK'])) {
- $warn = true;
- $info = " (warn: XLEAK section but test passes)";
- } else {
- show_result("PASS", $tested, $tested_file, '', $temp_filenames);
- junit_mark_test_as('PASS', $shortname, $tested);
- return 'PASSED';
- }
+ if ($test->hasSection('XFAIL')) {
+ $warn = true;
+ $info = " (warn: XFAIL section but test passes)";
+ } elseif ($test->hasSection('XLEAK') && $valgrind) {
+ // XLEAK with ASAN completely disables LSAN so the test is expected to pass
+ $warn = true;
+ $info = " (warn: XLEAK section but test passes)";
+ } elseif ($retried) {
+ $warn = true;
+ $info = " (warn: Test passed on retry attempt)";
+ } else {
+ show_result("PASS", $tested, $tested_file, '');
+ $junit->markTestAs('PASS', $shortname, $tested);
+ return 'PASSED';
}
}
-
- $wanted_re = null;
}
// Test failed so we need to report details.
@@ -2744,8 +2665,10 @@ function run_test(string $php, $file, array $env): string
}
}
+ $restype = [];
+
if ($leaked) {
- $restype[] = isset($section_text['XLEAK']) ?
+ $restype[] = $test->hasSection('XLEAK') ?
'XLEAK' : 'LEAK';
}
@@ -2754,12 +2677,13 @@ function run_test(string $php, $file, array $env): string
}
if (!$passed) {
- if (isset($section_text['XFAIL'])) {
+ if ($test->hasSection('XFAIL')) {
$restype[] = 'XFAIL';
- $info = ' XFAIL REASON: ' . rtrim($section_text['XFAIL']);
- } elseif (isset($section_text['XLEAK'])) {
+ $info = ' XFAIL REASON: ' . rtrim($test->getSection('XFAIL'));
+ } elseif ($test->hasSection('XLEAK') && $valgrind) {
+ // XLEAK with ASAN completely disables LSAN so the test is expected to pass
$restype[] = 'XLEAK';
- $info = ' XLEAK REASON: ' . rtrim($section_text['XLEAK']);
+ $info = ' XLEAK REASON: ' . rtrim($test->getSection('XLEAK'));
} else {
$restype[] = 'FAIL';
}
@@ -2777,38 +2701,78 @@ function run_test(string $php, $file, array $env): string
}
// write .diff
- $diff = generate_diff($wanted, $wanted_re, $output);
+ if (!empty($environment['TEST_PHP_DIFF_CMD'])) {
+ $diff = generate_diff_external($environment['TEST_PHP_DIFF_CMD'], $exp_filename, $output_filename);
+ } else {
+ $diff = generate_diff($wanted, $wanted_re, $output);
+ }
+
+ // write .stdin
+ if ($test->hasSection('STDIN') || $test->hasSection('PHPDBG')) {
+ $stdin = $test->hasSection('STDIN')
+ ? $test->getSection('STDIN')
+ : $test->getSection('PHPDBG') . "\n";
+ if (file_put_contents($stdin_filename, $stdin) === false) {
+ error("Cannot create test stdin - $stdin_filename");
+ }
+ }
+
if (is_array($IN_REDIRECT)) {
$orig_shortname = str_replace(TEST_PHP_SRCDIR . '/', '', $file);
$diff = "# original source file: $orig_shortname\n" . $diff;
}
- show_file_block('diff', $diff);
+ if (!$SHOW_ONLY_GROUPS || array_intersect($restype, $SHOW_ONLY_GROUPS)) {
+ show_file_block('diff', $diff);
+ }
if (strpos($log_format, 'D') !== false && file_put_contents($diff_filename, $diff) === false) {
error("Cannot create test diff - $diff_filename");
}
+ // write .log
+ if (strpos($log_format, 'L') !== false && file_put_contents($log_filename, "
+---- EXPECTED OUTPUT
+$wanted
+---- ACTUAL OUTPUT
+$output
+---- FAILED
+") === false) {
+ error("Cannot create test log - $log_filename");
+ error_report($file, $log_filename, $tested);
+ }
+ }
+
+ if (!$passed || $leaked) {
// write .sh
if (strpos($log_format, 'S') !== false) {
- $env_lines = [];
+ // Unset all environment variables so that we don't inherit extra
+ // ones from the parent process.
+ $env_lines = ['unset $(env | cut -d= -f1)'];
foreach ($env as $env_var => $env_val) {
+ if (strval($env_val) === '') {
+ // proc_open does not pass empty env vars
+ continue;
+ }
$env_lines[] = "export $env_var=" . escapeshellarg($env_val ?? "");
}
- $exported_environment = $env_lines ? "\n" . implode("\n", $env_lines) . "\n" : "";
+ $exported_environment = "\n" . implode("\n", $env_lines) . "\n";
$sh_script = <<', $diff);
- junit_mark_test_as($restype, $shortname, $tested, null, $info, $diff);
+ $junit->markTestAs($restype, $shortname, $tested, null, $info, $diff);
return $restype[0] . 'ED';
}
-/**
- * @return bool|int
- */
-function comp_line(string $l1, string $l2, bool $is_reg)
+function is_flaky(TestFile $test): bool
{
- if ($is_reg) {
- return preg_match('/^' . $l1 . '$/s', $l2);
- } else {
- return !strcmp($l1, $l2);
- }
-}
-
-function count_array_diff(
- array $ar1,
- array $ar2,
- bool $is_reg,
- array $w,
- int $idx1,
- int $idx2,
- int $cnt1,
- int $cnt2,
- int $steps
-): int {
- $equal = 0;
-
- while ($idx1 < $cnt1 && $idx2 < $cnt2 && comp_line($ar1[$idx1], $ar2[$idx2], $is_reg)) {
- $idx1++;
- $idx2++;
- $equal++;
- $steps--;
+ if ($test->hasSection('FLAKY')) {
+ return true;
}
- if (--$steps > 0) {
- $eq1 = 0;
- $st = $steps / 2;
-
- for ($ofs1 = $idx1 + 1; $ofs1 < $cnt1 && $st-- > 0; $ofs1++) {
- $eq = @count_array_diff($ar1, $ar2, $is_reg, $w, $ofs1, $idx2, $cnt1, $cnt2, $st);
-
- if ($eq > $eq1) {
- $eq1 = $eq;
- }
- }
-
- $eq2 = 0;
- $st = $steps;
-
- for ($ofs2 = $idx2 + 1; $ofs2 < $cnt2 && $st-- > 0; $ofs2++) {
- $eq = @count_array_diff($ar1, $ar2, $is_reg, $w, $idx1, $ofs2, $cnt1, $cnt2, $st);
- if ($eq > $eq2) {
- $eq2 = $eq;
- }
- }
-
- if ($eq1 > $eq2) {
- $equal += $eq1;
- } elseif ($eq2 > 0) {
- $equal += $eq2;
+ if ($test->hasSection('SKIPIF')) {
+ if (strpos($test->getSection('SKIPIF'), 'SKIP_PERF_SENSITIVE') !== false) {
+ return true;
}
}
+ if (!$test->hasSection('FILE')) {
+ return false;
+ }
+ $file = $test->getSection('FILE');
+ $flaky_functions = [
+ 'disk_free_space',
+ 'hrtime',
+ 'microtime',
+ 'sleep',
+ 'usleep',
+ ];
+ $regex = '(\b(' . implode('|', $flaky_functions) . ')\()i';
+ return preg_match($regex, $file) === 1;
+}
- return $equal;
+function is_flaky_output(string $output): bool
+{
+ $messages = [
+ '404: page not found',
+ 'address already in use',
+ 'connection refused',
+ 'deadlock',
+ 'mailbox already exists',
+ 'timed out',
+ ];
+ $regex = '(\b(' . implode('|', $messages) . ')\b)i';
+ return preg_match($regex, $output) === 1;
}
-function generate_array_diff(array $ar1, array $ar2, bool $is_reg, array $w): array
+function error_may_be_retried(TestFile $test, string $output): bool
{
- global $context_line_count;
- $idx1 = 0;
- $cnt1 = @count($ar1);
- $idx2 = 0;
- $cnt2 = @count($ar2);
- $diff = [];
- $old1 = [];
- $old2 = [];
- $number_len = max(3, strlen((string)max($cnt1 + 1, $cnt2 + 1)));
- $line_number_spec = '%0' . $number_len . 'd';
-
- /** Mapping from $idx2 to $idx1, including indexes of idx2 that are identical to idx1 as well as entries that don't have matches */
- $mapping = [];
-
- while ($idx1 < $cnt1 && $idx2 < $cnt2) {
- $mapping[$idx2] = $idx1;
- if (comp_line($ar1[$idx1], $ar2[$idx2], $is_reg)) {
- $idx1++;
- $idx2++;
- continue;
- } else {
- $c1 = @count_array_diff($ar1, $ar2, $is_reg, $w, $idx1 + 1, $idx2, $cnt1, $cnt2, 10);
- $c2 = @count_array_diff($ar1, $ar2, $is_reg, $w, $idx1, $idx2 + 1, $cnt1, $cnt2, 10);
+ return is_flaky_output($output)
+ || is_flaky($test);
+}
- if ($c1 > $c2) {
- $old1[$idx1] = sprintf("{$line_number_spec}- ", $idx1 + 1) . $w[$idx1++];
- } elseif ($c2 > 0) {
- $old2[$idx2] = sprintf("{$line_number_spec}+ ", $idx2 + 1) . $ar2[$idx2++];
- } else {
- $old1[$idx1] = sprintf("{$line_number_spec}- ", $idx1 + 1) . $w[$idx1++];
- $old2[$idx2] = sprintf("{$line_number_spec}+ ", $idx2 + 1) . $ar2[$idx2++];
+function expectf_to_regex(?string $wanted): string
+{
+ $wanted_re = $wanted ?? '';
+
+ $wanted_re = preg_replace('/\r\n/', "\n", $wanted_re);
+
+ // do preg_quote, but miss out any %r delimited sections
+ $temp = "";
+ $r = "%r";
+ $startOffset = 0;
+ $length = strlen($wanted_re);
+ while ($startOffset < $length) {
+ $start = strpos($wanted_re, $r, $startOffset);
+ if ($start !== false) {
+ // we have found a start tag
+ $end = strpos($wanted_re, $r, $start + 2);
+ if ($end === false) {
+ // unbalanced tag, ignore it.
+ $end = $start = $length;
}
- $last_printed_context_line = $idx1;
+ } else {
+ // no more %r sections
+ $start = $end = $length;
}
- }
- $mapping[$idx2] = $idx1;
-
- reset($old1);
- $k1 = key($old1);
- $l1 = -2;
- reset($old2);
- $k2 = key($old2);
- $l2 = -2;
- $old_k1 = -1;
- $add_context_lines = function (int $new_k1) use (&$old_k1, &$diff, $w, $context_line_count, $number_len) {
- if ($old_k1 >= $new_k1 || !$context_line_count) {
- return;
+ // quote a non re portion of the string
+ $temp .= preg_quote(substr($wanted_re, $startOffset, $start - $startOffset), '/');
+ // add the re unquoted.
+ if ($end > $start) {
+ $temp .= '(' . substr($wanted_re, $start + 2, $end - $start - 2) . ')';
}
- $end = $new_k1 - 1;
- $range_end = min($end, $old_k1 + $context_line_count);
- if ($old_k1 >= 0) {
- while ($old_k1 < $range_end) {
- $diff[] = str_repeat(' ', $number_len + 2) . $w[$old_k1++];
- }
- }
- if ($end - $context_line_count > $old_k1) {
- $old_k1 = $end - $context_line_count;
- if ($old_k1 > 0) {
- // Add a '--' to mark sections where the common areas were truncated
- $diff[] = '--';
- }
- }
- $old_k1 = max($old_k1, 0);
- while ($old_k1 < $end) {
- $diff[] = str_repeat(' ', $number_len + 2) . $w[$old_k1++];
- }
- $old_k1 = $new_k1;
- };
-
- while ($k1 !== null || $k2 !== null) {
- if ($k1 == $l1 + 1 || $k2 === null) {
- $add_context_lines($k1);
- $l1 = $k1;
- $diff[] = current($old1);
- $old_k1 = $k1;
- $k1 = next($old1) ? key($old1) : null;
- } elseif ($k2 == $l2 + 1 || $k1 === null) {
- $add_context_lines($mapping[$k2]);
- $l2 = $k2;
- $diff[] = current($old2);
- $k2 = next($old2) ? key($old2) : null;
- } elseif ($k1 < $mapping[$k2]) {
- $add_context_lines($k1);
- $l1 = $k1;
- $diff[] = current($old1);
- $k1 = next($old1) ? key($old1) : null;
- } else {
- $add_context_lines($mapping[$k2]);
- $l2 = $k2;
- $diff[] = current($old2);
- $k2 = next($old2) ? key($old2) : null;
+ $startOffset = $end + 2;
+ }
+ $wanted_re = $temp;
+
+ return strtr($wanted_re, [
+ '%e' => preg_quote(DIRECTORY_SEPARATOR, '/'),
+ '%s' => '[^\r\n]+',
+ '%S' => '[^\r\n]*',
+ '%a' => '.+?',
+ '%A' => '.*?',
+ '%w' => '\s*',
+ '%i' => '[+-]?\d+',
+ '%d' => '\d+',
+ '%x' => '[0-9a-fA-F]+',
+ '%f' => '[+-]?(?:\d+|(?=\.\d))(?:\.\d+)?(?:[Ee][+-]?\d+)?',
+ '%c' => '.',
+ '%0' => '\x00',
+ ]);
+}
+/**
+ * Map "Zend OPcache" to "opcache" and convert all ext names to lowercase.
+ */
+function remap_loaded_extensions_names(array $names): array
+{
+ $exts = [];
+ foreach ($names as $name) {
+ if ($name === 'Core') {
+ continue;
}
+ $exts[] = ['Zend OPcache' => 'opcache'][$name] ?? strtolower($name);
}
- while ($idx1 < $cnt1) {
- $add_context_lines($idx1 + 1);
- $diff[] = sprintf("{$line_number_spec}- ", $idx1 + 1) . $w[$idx1++];
- }
+ return $exts;
+}
- while ($idx2 < $cnt2) {
- if (isset($mapping[$idx2])) {
- $add_context_lines($mapping[$idx2] + 1);
- }
- $diff[] = sprintf("{$line_number_spec}+ ", $idx2 + 1) . $ar2[$idx2++];
- }
- $add_context_lines(min($old_k1 + $context_line_count + 1, $cnt1 + 1));
- if ($context_line_count && $old_k1 < $cnt1 + 1) {
- // Add a '--' to mark sections where the common areas were truncated
- $diff[] = '--';
- }
+function generate_diff_external(string $diff_cmd, string $exp_file, string $output_file): string
+{
+ $retval = shell_exec("{$diff_cmd} {$exp_file} {$output_file}");
- return $diff;
+ return is_string($retval) ? $retval : 'Could not run external diff tool set through TEST_PHP_DIFF_CMD environment variable';
}
function generate_diff(string $wanted, ?string $wanted_re, string $output): string
{
$w = explode("\n", $wanted);
$o = explode("\n", $output);
- $r = is_null($wanted_re) ? $w : explode("\n", $wanted_re);
- $diff = generate_array_diff($r, $o, !is_null($wanted_re), $w);
+ $is_regex = $wanted_re !== null;
- return implode(PHP_EOL, $diff);
+ $differ = new Differ(function ($expected, $new) use ($is_regex) {
+ if (!$is_regex) {
+ return $expected === $new;
+ }
+ $regex = '/^' . expectf_to_regex($expected). '$/s';
+ return preg_match($regex, $new);
+ });
+ return $differ->diff($w, $o);
}
function error(string $message): void
@@ -3049,7 +2945,7 @@ function error(string $message): void
exit(1);
}
-function settings2array(array $settings, &$ini_settings): void
+function settings2array(array $settings, array &$ini_settings): void
{
foreach ($settings as $setting) {
if (strpos($setting, '=') !== false) {
@@ -3104,7 +3000,7 @@ function compute_summary(): void
global $n_total, $test_results, $ignored_by_ext, $sum_results, $percent_results;
$n_total = count($test_results);
- $n_total += $ignored_by_ext;
+ $n_total += count($ignored_by_ext);
$sum_results = [
'PASSED' => 0,
'WARNED' => 0,
@@ -3120,7 +3016,7 @@ function compute_summary(): void
$sum_results[$v]++;
}
- $sum_results['SKIPPED'] += $ignored_by_ext;
+ $sum_results['SKIPPED'] += count($ignored_by_ext);
$percent_results = [];
foreach ($sum_results as $v => $n) {
@@ -3152,43 +3048,43 @@ function get_summary(bool $show_ext_summary): string
=====================================================================
TEST RESULT SUMMARY
---------------------------------------------------------------------
-Exts skipped : ' . sprintf('%4d', $exts_skipped) . '
-Exts tested : ' . sprintf('%4d', $exts_tested) . '
+Exts skipped : ' . sprintf('%5d', count($exts_skipped)) . ($exts_skipped ? ' (' . implode(', ', $exts_skipped) . ')' : '') . '
+Exts tested : ' . sprintf('%5d', count($exts_tested)) . '
---------------------------------------------------------------------
';
}
$summary .= '
-Number of tests : ' . sprintf('%4d', $n_total) . ' ' . sprintf('%8d', $x_total);
+Number of tests : ' . sprintf('%5d', $n_total) . ' ' . sprintf('%8d', $x_total);
if ($sum_results['BORKED']) {
$summary .= '
-Tests borked : ' . sprintf('%4d (%5.1f%%)', $sum_results['BORKED'], $percent_results['BORKED']) . ' --------';
+Tests borked : ' . sprintf('%5d (%5.1f%%)', $sum_results['BORKED'], $percent_results['BORKED']) . ' --------';
}
$summary .= '
-Tests skipped : ' . sprintf('%4d (%5.1f%%)', $sum_results['SKIPPED'], $percent_results['SKIPPED']) . ' --------
-Tests warned : ' . sprintf('%4d (%5.1f%%)', $sum_results['WARNED'], $percent_results['WARNED']) . ' ' . sprintf('(%5.1f%%)', $x_warned) . '
-Tests failed : ' . sprintf('%4d (%5.1f%%)', $sum_results['FAILED'], $percent_results['FAILED']) . ' ' . sprintf('(%5.1f%%)', $x_failed);
+Tests skipped : ' . sprintf('%5d (%5.1f%%)', $sum_results['SKIPPED'], $percent_results['SKIPPED']) . ' --------
+Tests warned : ' . sprintf('%5d (%5.1f%%)', $sum_results['WARNED'], $percent_results['WARNED']) . ' ' . sprintf('(%5.1f%%)', $x_warned) . '
+Tests failed : ' . sprintf('%5d (%5.1f%%)', $sum_results['FAILED'], $percent_results['FAILED']) . ' ' . sprintf('(%5.1f%%)', $x_failed);
if ($sum_results['XFAILED']) {
$summary .= '
-Expected fail : ' . sprintf('%4d (%5.1f%%)', $sum_results['XFAILED'], $percent_results['XFAILED']) . ' ' . sprintf('(%5.1f%%)', $x_xfailed);
+Expected fail : ' . sprintf('%5d (%5.1f%%)', $sum_results['XFAILED'], $percent_results['XFAILED']) . ' ' . sprintf('(%5.1f%%)', $x_xfailed);
}
if ($valgrind) {
$summary .= '
-Tests leaked : ' . sprintf('%4d (%5.1f%%)', $sum_results['LEAKED'], $percent_results['LEAKED']) . ' ' . sprintf('(%5.1f%%)', $x_leaked);
+Tests leaked : ' . sprintf('%5d (%5.1f%%)', $sum_results['LEAKED'], $percent_results['LEAKED']) . ' ' . sprintf('(%5.1f%%)', $x_leaked);
if ($sum_results['XLEAKED']) {
$summary .= '
-Expected leak : ' . sprintf('%4d (%5.1f%%)', $sum_results['XLEAKED'], $percent_results['XLEAKED']) . ' ' . sprintf('(%5.1f%%)', $x_xleaked);
+Expected leak : ' . sprintf('%5d (%5.1f%%)', $sum_results['XLEAKED'], $percent_results['XLEAKED']) . ' ' . sprintf('(%5.1f%%)', $x_xleaked);
}
}
$summary .= '
-Tests passed : ' . sprintf('%4d (%5.1f%%)', $sum_results['PASSED'], $percent_results['PASSED']) . ' ' . sprintf('(%5.1f%%)', $x_passed) . '
+Tests passed : ' . sprintf('%5d (%5.1f%%)', $sum_results['PASSED'], $percent_results['PASSED']) . ' ' . sprintf('(%5.1f%%)', $x_passed) . '
---------------------------------------------------------------------
-Time taken : ' . sprintf('%4d seconds', $end_time - $start_time) . '
+Time taken : ' . sprintf('%5.3f seconds', ($end_time - $start_time) / 1e9) . '
=====================================================================
';
$failed_test_summary = '';
@@ -3209,18 +3105,6 @@ function get_summary(bool $show_ext_summary): string
$failed_test_summary .= "=====================================================================\n";
}
- if (count($PHP_FAILED_TESTS['XFAILED'])) {
- $failed_test_summary .= '
-=====================================================================
-EXPECTED FAILED TEST SUMMARY
----------------------------------------------------------------------
-';
- foreach ($PHP_FAILED_TESTS['XFAILED'] as $failed_test_data) {
- $failed_test_summary .= $failed_test_data['test_name'] . $failed_test_data['info'] . "\n";
- }
- $failed_test_summary .= "=====================================================================\n";
- }
-
if (count($PHP_FAILED_TESTS['BORKED'])) {
$failed_test_summary .= '
=====================================================================
@@ -3271,19 +3155,6 @@ function get_summary(bool $show_ext_summary): string
$failed_test_summary .= "=====================================================================\n";
}
- if (count($PHP_FAILED_TESTS['XLEAKED'])) {
- $failed_test_summary .= '
-=====================================================================
-EXPECTED LEAK TEST SUMMARY
----------------------------------------------------------------------
-';
- foreach ($PHP_FAILED_TESTS['XLEAKED'] as $failed_test_data) {
- $failed_test_summary .= $failed_test_data['test_name'] . $failed_test_data['info'] . "\n";
- }
-
- $failed_test_summary .= "=====================================================================\n";
- }
-
if ($failed_test_summary && !getenv('NO_PHPTEST_SUMMARY')) {
$summary .= $failed_test_summary;
}
@@ -3291,14 +3162,14 @@ function get_summary(bool $show_ext_summary): string
return $summary;
}
-function show_start($start_time): void
+function show_start(int $start_timestamp): void
{
- echo "TIME START " . date('Y-m-d H:i:s', $start_time) . "\n=====================================================================\n";
+ echo "TIME START " . date('Y-m-d H:i:s', $start_timestamp) . "\n=====================================================================\n";
}
-function show_end($end_time): void
+function show_end(int $start_timestamp, int|float $start_time, int|float $end_time): void
{
- echo "=====================================================================\nTIME END " . date('Y-m-d H:i:s', $end_time) . "\n";
+ echo "=====================================================================\nTIME END " . date('Y-m-d H:i:s', $start_timestamp + (int)(($end_time - $start_time)/1e9)) . "\n";
}
function show_summary(): void
@@ -3308,22 +3179,22 @@ function show_summary(): void
function show_redirect_start(string $tests, string $tested, string $tested_file): void
{
- global $SHOW_ONLY_GROUPS;
+ global $SHOW_ONLY_GROUPS, $show_progress;
if (!$SHOW_ONLY_GROUPS || in_array('REDIRECT', $SHOW_ONLY_GROUPS)) {
echo "REDIRECT $tests ($tested [$tested_file]) begin\n";
- } else {
+ } elseif ($show_progress) {
clear_show_test();
}
}
function show_redirect_ends(string $tests, string $tested, string $tested_file): void
{
- global $SHOW_ONLY_GROUPS;
+ global $SHOW_ONLY_GROUPS, $show_progress;
if (!$SHOW_ONLY_GROUPS || in_array('REDIRECT', $SHOW_ONLY_GROUPS)) {
echo "REDIRECT $tests ($tested [$tested_file]) done\n";
- } else {
+ } elseif ($show_progress) {
clear_show_test();
}
}
@@ -3345,7 +3216,7 @@ function clear_show_test(): void
// Parallel testing
global $workerID;
- if (!$workerID) {
+ if (!$workerID && isset($line_length)) {
// Write over the last line to avoid random trailing chars on next echo
echo str_repeat(" ", $line_length), "\r";
}
@@ -3362,10 +3233,9 @@ function show_result(
string $result,
string $tested,
string $tested_file,
- string $extra = '',
- ?array $temp_filenames = null
+ string $extra = ''
): void {
- global $SHOW_ONLY_GROUPS, $colorize;
+ global $SHOW_ONLY_GROUPS, $colorize, $show_progress;
if (!$SHOW_ONLY_GROUPS || in_array($result, $SHOW_ONLY_GROUPS)) {
if ($colorize) {
@@ -3387,324 +3257,378 @@ function show_result(
} else {
echo "$result $tested [$tested_file] $extra\n";
}
- } elseif (!$SHOW_ONLY_GROUPS) {
+ } elseif ($show_progress) {
clear_show_test();
}
+}
+class BorkageException extends Exception
+{
}
-function junit_init(): void
+class JUnit
{
- // Check whether a junit log is wanted.
- global $workerID;
- $JUNIT = getenv('TEST_PHP_JUNIT');
- if (empty($JUNIT)) {
- $GLOBALS['JUNIT'] = false;
- return;
- }
- if ($workerID) {
- $fp = null;
- } elseif (!$fp = fopen($JUNIT, 'w')) {
- error("Failed to open $JUNIT for writing.");
- }
- $GLOBALS['JUNIT'] = [
- 'fp' => $fp,
- 'name' => 'PHP',
+ private bool $enabled = true;
+ private $fp = null;
+ private array $suites = [];
+ private array $rootSuite = self::EMPTY_SUITE + ['name' => 'php'];
+
+ private const EMPTY_SUITE = [
'test_total' => 0,
'test_pass' => 0,
'test_fail' => 0,
'test_error' => 0,
'test_skip' => 0,
'test_warn' => 0,
+ 'files' => [],
'execution_time' => 0,
- 'suites' => [],
- 'files' => []
];
-}
-function junit_save_xml(): void
-{
- global $JUNIT;
- if (!junit_enabled()) {
- return;
+ /**
+ * @throws Exception
+ */
+ public function __construct(array $env, int $workerID)
+ {
+ // Check whether a junit log is wanted.
+ $fileName = $env['TEST_PHP_JUNIT'] ?? null;
+ if (empty($fileName)) {
+ $this->enabled = false;
+ return;
+ }
+ if (!$workerID && !$this->fp = fopen($fileName, 'w')) {
+ throw new Exception("Failed to open $fileName for writing.");
+ }
}
- $xml = '<' . '?' . 'xml version="1.0" encoding="UTF-8"' . '?' . '>' . PHP_EOL;
- $xml .= sprintf(
- '' . PHP_EOL,
- $JUNIT['name'],
- $JUNIT['test_total'],
- $JUNIT['test_fail'],
- $JUNIT['test_error'],
- $JUNIT['test_skip'],
- $JUNIT['execution_time']
- );
- $xml .= junit_get_suite_xml();
- $xml .= '';
- fwrite($JUNIT['fp'], $xml);
-}
+ public function isEnabled(): bool
+ {
+ return $this->enabled;
+ }
-function junit_get_suite_xml(string $suite_name = ''): string
-{
- global $JUNIT;
-
- $result = "";
-
- foreach ($JUNIT['suites'] as $suite_name => $suite) {
- $result .= sprintf(
- '' . PHP_EOL,
- $suite['name'],
- $suite['test_total'],
- $suite['test_fail'],
- $suite['test_error'],
- $suite['test_skip'],
- $suite['execution_time']
- );
+ public function clear(): void
+ {
+ $this->rootSuite = self::EMPTY_SUITE + ['name' => 'php'];
+ $this->suites = [];
+ }
- if (!empty($suite_name)) {
- foreach ($suite['files'] as $file) {
- $result .= $JUNIT['files'][$file]['xml'];
- }
+ public function saveXML(): void
+ {
+ if (!$this->enabled) {
+ return;
}
- $result .= '' . PHP_EOL;
+ $xml = '<' . '?' . 'xml version="1.0" encoding="UTF-8"' . '?' . '>' . PHP_EOL;
+ $xml .= sprintf(
+ '' . PHP_EOL,
+ $this->rootSuite['name'],
+ $this->rootSuite['test_total'],
+ $this->rootSuite['test_fail'],
+ $this->rootSuite['test_error'],
+ $this->rootSuite['test_skip'],
+ $this->rootSuite['execution_time']
+ );
+ $xml .= $this->getSuitesXML();
+ $xml .= '';
+ fwrite($this->fp, $xml);
}
- return $result;
-}
+ private function getSuitesXML(): string
+ {
+ $result = '';
+
+ foreach ($this->suites as $suite_name => $suite) {
+ $result .= sprintf(
+ '' . PHP_EOL,
+ $suite['name'],
+ $suite['test_total'],
+ $suite['test_fail'],
+ $suite['test_error'],
+ $suite['test_skip'],
+ $suite['execution_time']
+ );
+
+ if (!empty($suite_name)) {
+ foreach ($suite['files'] as $file) {
+ $result .= $this->rootSuite['files'][$file]['xml'];
+ }
+ }
-function junit_enabled(): bool
-{
- global $JUNIT;
- return !empty($JUNIT);
-}
+ $result .= '' . PHP_EOL;
+ }
-/**
- * @param array|string $type
- */
-function junit_mark_test_as(
- $type,
- string $file_name,
- string $test_name,
- ?float $time = null,
- string $message = '',
- string $details = ''
-): void {
- global $JUNIT;
- if (!junit_enabled()) {
- return;
+ return $result;
}
- $suite = junit_get_suitename_for($file_name);
-
- junit_suite_record($suite, 'test_total');
+ public function markTestAs(
+ $type,
+ string $file_name,
+ string $test_name,
+ ?int $time = null,
+ string $message = '',
+ string $details = ''
+ ): void {
+ if (!$this->enabled) {
+ return;
+ }
- $time = $time ?? junit_get_timer($file_name);
- junit_suite_record($suite, 'execution_time', $time);
+ $suite = $this->getSuiteName($file_name);
- $escaped_details = htmlspecialchars($details, ENT_QUOTES, 'UTF-8');
- $escaped_details = preg_replace_callback('/[\0-\x08\x0B\x0C\x0E-\x1F]/', function (array $c): string {
- return sprintf('[[0x%02x]]', ord($c[0]));
- }, $escaped_details);
- $escaped_message = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
+ $this->record($suite, 'test_total');
- $escaped_test_name = htmlspecialchars($file_name . ' (' . $test_name . ')', ENT_QUOTES);
- $JUNIT['files'][$file_name]['xml'] = "\n";
+ $time = $time ?? $this->getTimer($file_name);
+ $this->record($suite, 'execution_time', $time);
- if (is_array($type)) {
- $output_type = $type[0] . 'ED';
- $temp = array_intersect(['XFAIL', 'XLEAK', 'FAIL', 'WARN'], $type);
- $type = reset($temp);
- } else {
- $output_type = $type . 'ED';
- }
-
- if ('PASS' == $type || 'XFAIL' == $type || 'XLEAK' == $type) {
- junit_suite_record($suite, 'test_pass');
- } elseif ('BORK' == $type) {
- junit_suite_record($suite, 'test_error');
- $JUNIT['files'][$file_name]['xml'] .= "\n";
- } elseif ('SKIP' == $type) {
- junit_suite_record($suite, 'test_skip');
- $JUNIT['files'][$file_name]['xml'] .= "$escaped_message\n";
- } elseif ('WARN' == $type) {
- junit_suite_record($suite, 'test_warn');
- $JUNIT['files'][$file_name]['xml'] .= "$escaped_message\n";
- } elseif ('FAIL' == $type) {
- junit_suite_record($suite, 'test_fail');
- $JUNIT['files'][$file_name]['xml'] .= "$escaped_details\n";
- } else {
- junit_suite_record($suite, 'test_error');
- $JUNIT['files'][$file_name]['xml'] .= "$escaped_details\n";
- }
+ $escaped_details = htmlspecialchars($details, ENT_QUOTES, 'UTF-8');
+ $escaped_details = preg_replace_callback('/[\0-\x08\x0B\x0C\x0E-\x1F]/', function ($c) {
+ return sprintf('[[0x%02x]]', ord($c[0]));
+ }, $escaped_details);
+ $escaped_message = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
- $JUNIT['files'][$file_name]['xml'] .= "\n";
-}
+ $escaped_test_name = htmlspecialchars($file_name . ' (' . $test_name . ')', ENT_QUOTES);
+ $this->rootSuite['files'][$file_name]['xml'] = "\n";
-function junit_suite_record(string $suite, string $param, float $value = 1): void
-{
- global $JUNIT;
+ if (is_array($type)) {
+ $output_type = $type[0] . 'ED';
+ $temp = array_intersect(['XFAIL', 'XLEAK', 'FAIL', 'WARN'], $type);
+ $type = reset($temp);
+ } else {
+ $output_type = $type . 'ED';
+ }
- $JUNIT[$param] += $value;
- $JUNIT['suites'][$suite][$param] += $value;
-}
+ if ('PASS' == $type || 'XFAIL' == $type || 'XLEAK' == $type) {
+ $this->record($suite, 'test_pass');
+ } elseif ('BORK' == $type) {
+ $this->record($suite, 'test_error');
+ $this->rootSuite['files'][$file_name]['xml'] .= "\n";
+ } elseif ('SKIP' == $type) {
+ $this->record($suite, 'test_skip');
+ $this->rootSuite['files'][$file_name]['xml'] .= "$escaped_message\n";
+ } elseif ('WARN' == $type) {
+ $this->record($suite, 'test_warn');
+ $this->rootSuite['files'][$file_name]['xml'] .= "$escaped_message\n";
+ } elseif ('FAIL' == $type) {
+ $this->record($suite, 'test_fail');
+ $this->rootSuite['files'][$file_name]['xml'] .= "$escaped_details\n";
+ } else {
+ $this->record($suite, 'test_error');
+ $this->rootSuite['files'][$file_name]['xml'] .= "$escaped_details\n";
+ }
-function junit_get_timer(string $file_name): float
-{
- global $JUNIT;
- if (!junit_enabled()) {
- return 0;
+ $this->rootSuite['files'][$file_name]['xml'] .= "\n";
}
- if (isset($JUNIT['files'][$file_name]['total'])) {
- return number_format($JUNIT['files'][$file_name]['total'], 4);
+ private function record(string $suite, string $param, $value = 1): void
+ {
+ $this->rootSuite[$param] += $value;
+ $this->suites[$suite][$param] += $value;
}
- return 0;
-}
+ private function getTimer(string $file_name)
+ {
+ if (!$this->enabled) {
+ return 0;
+ }
-function junit_start_timer(string $file_name): void
-{
- global $JUNIT;
- if (!junit_enabled()) {
- return;
+ if (isset($this->rootSuite['files'][$file_name]['total'])) {
+ return number_format($this->rootSuite['files'][$file_name]['total'], 4);
+ }
+
+ return 0;
}
- if (!isset($JUNIT['files'][$file_name]['start'])) {
- $JUNIT['files'][$file_name]['start'] = microtime(true);
+ public function startTimer(string $file_name): void
+ {
+ if (!$this->enabled) {
+ return;
+ }
+
+ if (!isset($this->rootSuite['files'][$file_name]['start'])) {
+ $this->rootSuite['files'][$file_name]['start'] = microtime(true);
- $suite = junit_get_suitename_for($file_name);
- junit_init_suite($suite);
- $JUNIT['suites'][$suite]['files'][$file_name] = $file_name;
+ $suite = $this->getSuiteName($file_name);
+ $this->initSuite($suite);
+ $this->suites[$suite]['files'][$file_name] = $file_name;
+ }
}
-}
-function junit_get_suitename_for(string $file_name): string
-{
- return junit_path_to_classname(dirname($file_name));
-}
+ public function getSuiteName(string $file_name): string
+ {
+ return $this->pathToClassName(dirname($file_name));
+ }
-function junit_path_to_classname(string $file_name): string
-{
- global $JUNIT;
+ private function pathToClassName(string $file_name): string
+ {
+ if (!$this->enabled) {
+ return '';
+ }
- if (!junit_enabled()) {
- return '';
- }
+ $ret = $this->rootSuite['name'];
+ $_tmp = [];
- $ret = $JUNIT['name'];
- $_tmp = [];
+ // lookup whether we're in the PHP source checkout
+ $max = 5;
+ if (is_file($file_name)) {
+ $dir = dirname(realpath($file_name));
+ } else {
+ $dir = realpath($file_name);
+ }
+ do {
+ array_unshift($_tmp, basename($dir));
+ $chk = $dir . DIRECTORY_SEPARATOR . "main" . DIRECTORY_SEPARATOR . "php_version.h";
+ $dir = dirname($dir);
+ } while (!file_exists($chk) && --$max > 0);
+ if (file_exists($chk)) {
+ if ($max) {
+ array_shift($_tmp);
+ }
+ foreach ($_tmp as $p) {
+ $ret .= "." . preg_replace(",[^a-z0-9]+,i", ".", $p);
+ }
+ return $ret;
+ }
- // lookup whether we're in the PHP source checkout
- $max = 5;
- if (is_file($file_name)) {
- $dir = dirname(realpath($file_name));
- } else {
- $dir = realpath($file_name);
+ return $this->rootSuite['name'] . '.' . str_replace([DIRECTORY_SEPARATOR, '-'], '.', $file_name);
}
- do {
- array_unshift($_tmp, basename($dir));
- $chk = $dir . DIRECTORY_SEPARATOR . "main" . DIRECTORY_SEPARATOR . "php_version.h";
- $dir = dirname($dir);
- } while (!file_exists($chk) && --$max > 0);
- if (file_exists($chk)) {
- if ($max) {
- array_shift($_tmp);
+
+ public function initSuite(string $suite_name): void
+ {
+ if (!$this->enabled) {
+ return;
}
- foreach ($_tmp as $p) {
- $ret .= "." . preg_replace(",[^a-z0-9]+,i", ".", $p);
+
+ if (!empty($this->suites[$suite_name])) {
+ return;
}
- return $ret;
+
+ $this->suites[$suite_name] = self::EMPTY_SUITE + ['name' => $suite_name];
}
- return $JUNIT['name'] . '.' . str_replace([DIRECTORY_SEPARATOR, '-'], '.', $file_name);
-}
+ /**
+ * @throws Exception
+ */
+ public function stopTimer(string $file_name): void
+ {
+ if (!$this->enabled) {
+ return;
+ }
-function junit_init_suite(string $suite_name): void
-{
- global $JUNIT;
- if (!junit_enabled()) {
- return;
+ if (!isset($this->rootSuite['files'][$file_name]['start'])) {
+ throw new Exception("Timer for $file_name was not started!");
+ }
+
+ if (!isset($this->rootSuite['files'][$file_name]['total'])) {
+ $this->rootSuite['files'][$file_name]['total'] = 0;
+ }
+
+ $start = $this->rootSuite['files'][$file_name]['start'];
+ $this->rootSuite['files'][$file_name]['total'] += microtime(true) - $start;
+ unset($this->rootSuite['files'][$file_name]['start']);
}
- if (!empty($JUNIT['suites'][$suite_name])) {
- return;
+ public function mergeResults(?JUnit $other): void
+ {
+ if (!$this->enabled || !$other) {
+ return;
+ }
+
+ $this->mergeSuites($this->rootSuite, $other->rootSuite);
+ foreach ($other->suites as $name => $suite) {
+ if (!isset($this->suites[$name])) {
+ $this->suites[$name] = $suite;
+ continue;
+ }
+
+ $this->mergeSuites($this->suites[$name], $suite);
+ }
}
- $JUNIT['suites'][$suite_name] = [
- 'name' => $suite_name,
- 'test_total' => 0,
- 'test_pass' => 0,
- 'test_fail' => 0,
- 'test_error' => 0,
- 'test_skip' => 0,
- 'test_warn' => 0,
- 'files' => [],
- 'execution_time' => 0,
- ];
+ private function mergeSuites(array &$dest, array $source): void
+ {
+ $dest['test_total'] += $source['test_total'];
+ $dest['test_pass'] += $source['test_pass'];
+ $dest['test_fail'] += $source['test_fail'];
+ $dest['test_error'] += $source['test_error'];
+ $dest['test_skip'] += $source['test_skip'];
+ $dest['test_warn'] += $source['test_warn'];
+ $dest['execution_time'] += $source['execution_time'];
+ $dest['files'] += $source['files'];
+ }
}
-function junit_finish_timer(string $file_name): void
+class SkipCache
{
- global $JUNIT;
- if (!junit_enabled()) {
- return;
- }
+ private bool $enable;
+ private bool $keepFile;
- if (!isset($JUNIT['files'][$file_name]['start'])) {
- error("Timer for $file_name was not started!");
- }
+ private array $skips = [];
+ private array $extensions = [];
+
+ private int $hits = 0;
+ private int $misses = 0;
+ private int $extHits = 0;
+ private int $extMisses = 0;
- if (!isset($JUNIT['files'][$file_name]['total'])) {
- $JUNIT['files'][$file_name]['total'] = 0;
+ public function __construct(bool $enable, bool $keepFile)
+ {
+ $this->enable = $enable;
+ $this->keepFile = $keepFile;
}
- $start = $JUNIT['files'][$file_name]['start'];
- $JUNIT['files'][$file_name]['total'] += microtime(true) - $start;
- unset($JUNIT['files'][$file_name]['start']);
-}
+ public function checkSkip(string $php, string $code, string $checkFile, string $tempFile, array $env): string
+ {
+ // Extension tests frequently use something like $dir";
+
+ if (isset($this->skips[$key][$code])) {
+ $this->hits++;
+ if ($this->keepFile) {
+ save_text($checkFile, $code, $tempFile);
+ }
+ return $this->skips[$key][$code];
+ }
-function junit_merge_results(array $junit): void
-{
- global $JUNIT;
- $JUNIT['test_total'] += $junit['test_total'];
- $JUNIT['test_pass'] += $junit['test_pass'];
- $JUNIT['test_fail'] += $junit['test_fail'];
- $JUNIT['test_error'] += $junit['test_error'];
- $JUNIT['test_skip'] += $junit['test_skip'];
- $JUNIT['test_warn'] += $junit['test_warn'];
- $JUNIT['execution_time'] += $junit['execution_time'];
- $JUNIT['files'] += $junit['files'];
- foreach ($junit['suites'] as $name => $suite) {
- if (!isset($JUNIT['suites'][$name])) {
- $JUNIT['suites'][$name] = $suite;
- continue;
+ save_text($checkFile, $code, $tempFile);
+ $result = trim(system_with_timeout("$php \"$checkFile\"", $env));
+ if (strpos($result, 'nocache') === 0) {
+ $result = '';
+ } else if ($this->enable) {
+ $this->skips[$key][$code] = $result;
}
+ $this->misses++;
+
+ if (!$this->keepFile) {
+ @unlink($checkFile);
+ }
+
+ return $result;
+ }
+
+ public function getExtensions(string $php): array
+ {
+ if (isset($this->extensions[$php])) {
+ $this->extHits++;
+ return $this->extensions[$php];
+ }
+
+ $extDir = shell_exec("$php -d display_errors=0 -r \"echo ini_get('extension_dir');\"");
+ $extensionsNames = explode(",", shell_exec("$php -d display_errors=0 -r \"echo implode(',', get_loaded_extensions());\""));
+ $extensions = remap_loaded_extensions_names($extensionsNames);
+
+ $result = [$extDir, $extensions];
+ $this->extensions[$php] = $result;
+ $this->extMisses++;
- $SUITE =& $JUNIT['suites'][$name];
- $SUITE['test_total'] += $suite['test_total'];
- $SUITE['test_pass'] += $suite['test_pass'];
- $SUITE['test_fail'] += $suite['test_fail'];
- $SUITE['test_error'] += $suite['test_error'];
- $SUITE['test_skip'] += $suite['test_skip'];
- $SUITE['test_warn'] += $suite['test_warn'];
- $SUITE['execution_time'] += $suite['execution_time'];
- $SUITE['files'] += $suite['files'];
+ return $result;
}
}
class RuntestsValgrind
{
- protected $version = '';
- protected $header = '';
- protected $version_3_3_0 = false;
- protected $version_3_8_0 = false;
- protected $tool = null;
-
- public function getVersion(): string
- {
- return $this->version;
- }
+ protected string $header;
+ protected bool $version_3_8_0;
+ protected string $tool;
public function getHeader(): string
{
@@ -3717,17 +3641,14 @@ public function __construct(array $environment, string $tool = 'memcheck')
$header = system_with_timeout("valgrind --tool={$this->tool} --version", $environment);
if (!$header) {
error("Valgrind returned no version info for {$this->tool}, cannot proceed.\n".
- "Please check if Valgrind is installed and the tool is named correctly.");
+ "Please check if Valgrind is installed and the tool is named correctly.");
}
$count = 0;
$version = preg_replace("/valgrind-(\d+)\.(\d+)\.(\d+)([.\w_-]+)?(\s+)/", '$1.$2.$3', $header, 1, $count);
if ($count != 1) {
error("Valgrind returned invalid version info (\"{$header}\") for {$this->tool}, cannot proceed.");
}
- $this->version = $version;
- $this->header = sprintf(
- "%s (%s)", trim($header), $this->tool);
- $this->version_3_3_0 = version_compare($version, '3.3.0', '>=');
+ $this->header = sprintf("%s (%s)", trim($header), $this->tool);
$this->version_3_8_0 = version_compare($version, '3.8.0', '>=');
}
@@ -3740,12 +3661,208 @@ public function wrapCommand(string $cmd, string $memcheck_filename, bool $check_
/* --vex-iropt-register-updates=allregs-at-mem-access is necessary for phpdbg watchpoint tests */
if ($this->version_3_8_0) {
- /* valgrind 3.3.0+ doesn't have --log-file-exactly option */
return "$vcmd --vex-iropt-register-updates=allregs-at-mem-access --log-file=$memcheck_filename $cmd";
- } elseif ($this->version_3_3_0) {
- return "$vcmd --vex-iropt-precise-memory-exns=yes --log-file=$memcheck_filename $cmd";
+ }
+ return "$vcmd --vex-iropt-precise-memory-exns=yes --log-file=$memcheck_filename $cmd";
+ }
+}
+
+class TestFile
+{
+ private string $fileName;
+
+ private array $sections = ['TEST' => ''];
+
+ private const ALLOWED_SECTIONS = [
+ 'EXPECT', 'EXPECTF', 'EXPECTREGEX', 'EXPECTREGEX_EXTERNAL', 'EXPECT_EXTERNAL', 'EXPECTF_EXTERNAL', 'EXPECTHEADERS',
+ 'POST', 'POST_RAW', 'GZIP_POST', 'DEFLATE_POST', 'PUT', 'GET', 'COOKIE', 'ARGS',
+ 'FILE', 'FILEEOF', 'FILE_EXTERNAL', 'REDIRECTTEST',
+ 'CAPTURE_STDIO', 'STDIN', 'CGI', 'PHPDBG',
+ 'INI', 'ENV', 'EXTENSIONS',
+ 'SKIPIF', 'XFAIL', 'XLEAK', 'CLEAN',
+ 'CREDITS', 'DESCRIPTION', 'CONFLICTS', 'WHITESPACE_SENSITIVE',
+ 'FLAKY',
+ ];
+
+ /**
+ * @throws BorkageException
+ */
+ public function __construct(string $fileName, bool $inRedirect)
+ {
+ $this->fileName = $fileName;
+
+ $this->readFile();
+ $this->validateAndProcess($inRedirect);
+ }
+
+ public function hasSection(string $name): bool
+ {
+ return isset($this->sections[$name]);
+ }
+
+ public function hasAnySections(string ...$names): bool
+ {
+ foreach ($names as $section) {
+ if (isset($this->sections[$section])) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public function sectionNotEmpty(string $name): bool
+ {
+ return !empty($this->sections[$name]);
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function getSection(string $name): string
+ {
+ if (!isset($this->sections[$name])) {
+ throw new Exception("Section $name not found");
+ }
+ return $this->sections[$name];
+ }
+
+ public function getName(): string
+ {
+ return trim($this->getSection('TEST'));
+ }
+
+ public function isCGI(): bool
+ {
+ return $this->hasSection('CGI')
+ || $this->sectionNotEmpty('GET')
+ || $this->sectionNotEmpty('POST')
+ || $this->sectionNotEmpty('GZIP_POST')
+ || $this->sectionNotEmpty('DEFLATE_POST')
+ || $this->sectionNotEmpty('POST_RAW')
+ || $this->sectionNotEmpty('PUT')
+ || $this->sectionNotEmpty('COOKIE')
+ || $this->sectionNotEmpty('EXPECTHEADERS');
+ }
+
+ /**
+ * TODO Refactor to make it not needed
+ */
+ public function setSection(string $name, string $value): void
+ {
+ $this->sections[$name] = $value;
+ }
+
+ /**
+ * Load the sections of the test file
+ * @throws BorkageException
+ */
+ private function readFile(): void
+ {
+ $fp = fopen($this->fileName, "rb") or error("Cannot open test file: {$this->fileName}");
+
+ if (!feof($fp)) {
+ $line = fgets($fp);
+
+ if ($line === false) {
+ throw new BorkageException("cannot read test");
+ }
} else {
- return "$vcmd --vex-iropt-precise-memory-exns=yes --log-file-exactly=$memcheck_filename $cmd";
+ throw new BorkageException("empty test [{$this->fileName}]");
+ }
+ if (strncmp('--TEST--', $line, 8)) {
+ throw new BorkageException("tests must start with --TEST-- [{$this->fileName}]");
+ }
+
+ $section = 'TEST';
+ $secfile = false;
+ $secdone = false;
+
+ while (!feof($fp)) {
+ $line = fgets($fp);
+
+ if ($line === false) {
+ break;
+ }
+
+ // Match the beginning of a section.
+ if (preg_match('/^--([_A-Z]+)--/', $line, $r)) {
+ $section = $r[1];
+
+ if (isset($this->sections[$section]) && $this->sections[$section]) {
+ throw new BorkageException("duplicated $section section");
+ }
+
+ // check for unknown sections
+ if (!in_array($section, self::ALLOWED_SECTIONS)) {
+ throw new BorkageException('Unknown section "' . $section . '"');
+ }
+
+ $this->sections[$section] = '';
+ $secfile = $section == 'FILE' || $section == 'FILEEOF' || $section == 'FILE_EXTERNAL';
+ $secdone = false;
+ continue;
+ }
+
+ // Add to the section text.
+ if (!$secdone) {
+ $this->sections[$section] .= $line;
+ }
+
+ // End of actual test?
+ if ($secfile && preg_match('/^===DONE===\s*$/', $line)) {
+ $secdone = true;
+ }
+ }
+
+ fclose($fp);
+ }
+
+ /**
+ * @throws BorkageException
+ */
+ private function validateAndProcess(bool $inRedirect): void
+ {
+ // the redirect section allows a set of tests to be reused outside of
+ // a given test dir
+ if ($this->hasSection('REDIRECTTEST')) {
+ if ($inRedirect) {
+ throw new BorkageException("Can't redirect a test from within a redirected test");
+ }
+ return;
+ }
+ if (!$this->hasSection('PHPDBG') && $this->hasSection('FILE') + $this->hasSection('FILEEOF') + $this->hasSection('FILE_EXTERNAL') != 1) {
+ throw new BorkageException("missing section --FILE--");
+ }
+
+ if ($this->hasSection('FILEEOF')) {
+ $this->sections['FILE'] = preg_replace("/[\r\n]+$/", '', $this->sections['FILEEOF']);
+ unset($this->sections['FILEEOF']);
+ }
+
+ foreach (['FILE', 'EXPECT', 'EXPECTF', 'EXPECTREGEX'] as $prefix) {
+ // For grepping: FILE_EXTERNAL, EXPECT_EXTERNAL, EXPECTF_EXTERNAL, EXPECTREGEX_EXTERNAL
+ $key = $prefix . '_EXTERNAL';
+
+ if ($this->hasSection($key)) {
+ // don't allow tests to retrieve files from anywhere but this subdirectory
+ $dir = dirname($this->fileName);
+ $fileName = $dir . '/' . trim(str_replace('..', '', $this->getSection($key)));
+
+ if (file_exists($fileName)) {
+ $this->sections[$prefix] = file_get_contents($fileName);
+ } else {
+ throw new BorkageException("could not load --" . $key . "-- " . $dir . '/' . trim($fileName));
+ }
+ }
+ }
+
+ if (($this->hasSection('EXPECT') + $this->hasSection('EXPECTF') + $this->hasSection('EXPECTREGEX')) != 1) {
+ throw new BorkageException("missing section --EXPECT--, --EXPECTF-- or --EXPECTREGEX--");
+ }
+
+ if ($this->hasSection('PHPDBG') && !$this->hasSection('STDIN')) {
+ $this->sections['STDIN'] = $this->sections['PHPDBG'] . "\n";
}
}
}
@@ -3777,4 +3894,277 @@ function check_proc_open_function_exists(): void
}
}
-main();
\ No newline at end of file
+function bless_failed_tests(array $failedTests): void
+{
+ if (empty($failedTests)) {
+ return;
+ }
+ $args = [
+ PHP_BINARY,
+ __DIR__ . '/scripts/dev/bless_tests.php',
+ ];
+ foreach ($failedTests as $test) {
+ $args[] = $test['name'];
+ }
+ proc_open($args, [], $pipes);
+}
+
+/*
+ * BSD 3-Clause License
+ *
+ * Copyright (c) 2002-2023, Sebastian Bergmann
+ * All rights reserved.
+ *
+ * This file is part of sebastian/diff.
+ * https://github.com/sebastianbergmann/diff
+ */
+
+final class Differ
+{
+ public const OLD = 0;
+ public const ADDED = 1;
+ public const REMOVED = 2;
+ private DiffOutputBuilder $outputBuilder;
+ private $isEqual;
+
+ public function __construct(callable $isEqual)
+ {
+ $this->outputBuilder = new DiffOutputBuilder;
+ $this->isEqual = $isEqual;
+ }
+
+ public function diff(array $from, array $to): string
+ {
+ $diff = $this->diffToArray($from, $to);
+
+ return $this->outputBuilder->getDiff($diff);
+ }
+
+ public function diffToArray(array $from, array $to): array
+ {
+ $fromLine = 1;
+ $toLine = 1;
+
+ [$from, $to, $start, $end] = $this->getArrayDiffParted($from, $to);
+
+ $common = $this->calculateCommonSubsequence(array_values($from), array_values($to));
+ $diff = [];
+
+ foreach ($start as $token) {
+ $diff[] = [$token, self::OLD];
+ $fromLine++;
+ $toLine++;
+ }
+
+ reset($from);
+ reset($to);
+
+ foreach ($common as $token) {
+ while (!empty($from) && !($this->isEqual)(reset($from), $token)) {
+ $diff[] = [array_shift($from), self::REMOVED, $fromLine++];
+ }
+
+ while (!empty($to) && !($this->isEqual)($token, reset($to))) {
+ $diff[] = [array_shift($to), self::ADDED, $toLine++];
+ }
+
+ $diff[] = [$token, self::OLD];
+ $fromLine++;
+ $toLine++;
+
+ array_shift($from);
+ array_shift($to);
+ }
+
+ while (($token = array_shift($from)) !== null) {
+ $diff[] = [$token, self::REMOVED, $fromLine++];
+ }
+
+ while (($token = array_shift($to)) !== null) {
+ $diff[] = [$token, self::ADDED, $toLine++];
+ }
+
+ foreach ($end as $token) {
+ $diff[] = [$token, self::OLD];
+ }
+
+ return $diff;
+ }
+
+ private function getArrayDiffParted(array &$from, array &$to): array
+ {
+ $start = [];
+ $end = [];
+
+ reset($to);
+
+ foreach ($from as $k => $v) {
+ $toK = key($to);
+
+ if (($this->isEqual)($toK, $k) && ($this->isEqual)($v, $to[$k])) {
+ $start[$k] = $v;
+
+ unset($from[$k], $to[$k]);
+ } else {
+ break;
+ }
+ }
+
+ end($from);
+ end($to);
+
+ do {
+ $fromK = key($from);
+ $toK = key($to);
+
+ if (null === $fromK || null === $toK || !($this->isEqual)(current($from), current($to))) {
+ break;
+ }
+
+ prev($from);
+ prev($to);
+
+ $end = [$fromK => $from[$fromK]] + $end;
+ unset($from[$fromK], $to[$toK]);
+ } while (true);
+
+ return [$from, $to, $start, $end];
+ }
+
+ public function calculateCommonSubsequence(array $from, array $to): array
+ {
+ $cFrom = count($from);
+ $cTo = count($to);
+
+ if ($cFrom === 0) {
+ return [];
+ }
+
+ if ($cFrom === 1) {
+ foreach ($to as $toV) {
+ if (($this->isEqual)($from[0], $toV)) {
+ return [$toV];
+ }
+ }
+
+ return [];
+ }
+
+ $i = (int) ($cFrom / 2);
+ $fromStart = array_slice($from, 0, $i);
+ $fromEnd = array_slice($from, $i);
+ $llB = $this->commonSubsequenceLength($fromStart, $to);
+ $llE = $this->commonSubsequenceLength(array_reverse($fromEnd), array_reverse($to));
+ $jMax = 0;
+ $max = 0;
+
+ for ($j = 0; $j <= $cTo; $j++) {
+ $m = $llB[$j] + $llE[$cTo - $j];
+
+ if ($m >= $max) {
+ $max = $m;
+ $jMax = $j;
+ }
+ }
+
+ $toStart = array_slice($to, 0, $jMax);
+ $toEnd = array_slice($to, $jMax);
+
+ return array_merge(
+ $this->calculateCommonSubsequence($fromStart, $toStart),
+ $this->calculateCommonSubsequence($fromEnd, $toEnd)
+ );
+ }
+
+ private function commonSubsequenceLength(array $from, array $to): array
+ {
+ $current = array_fill(0, count($to) + 1, 0);
+ $cFrom = count($from);
+ $cTo = count($to);
+
+ for ($i = 0; $i < $cFrom; $i++) {
+ $prev = $current;
+
+ for ($j = 0; $j < $cTo; $j++) {
+ if (($this->isEqual)($from[$i], $to[$j])) {
+ $current[$j + 1] = $prev[$j] + 1;
+ } else {
+ $current[$j + 1] = max($current[$j], $prev[$j + 1]);
+ }
+ }
+ }
+
+ return $current;
+ }
+}
+
+class DiffOutputBuilder
+{
+ public function getDiff(array $diffs): string
+ {
+ global $context_line_count;
+ $i = 0;
+ $number_len = max(3, strlen((string)count($diffs)));
+ $line_number_spec = '%0' . $number_len . 'd';
+ $buffer = fopen('php://memory', 'r+b');
+ while ($i < count($diffs)) {
+ // Find next difference
+ $next = $i;
+ while ($next < count($diffs)) {
+ if ($diffs[$next][1] !== Differ::OLD) {
+ break;
+ }
+ $next++;
+ }
+ // Found no more differentiating rows, we're done
+ if ($next === count($diffs)) {
+ if (($i - 1) < count($diffs)) {
+ fwrite($buffer, "--\n");
+ }
+ break;
+ }
+ // Print separator if necessary
+ if ($i < ($next - $context_line_count)) {
+ fwrite($buffer, "--\n");
+ $i = $next - $context_line_count;
+ }
+ // Print leading context
+ while ($i < $next) {
+ fwrite($buffer, str_repeat(' ', $number_len + 2));
+ fwrite($buffer, $diffs[$i][0]);
+ fwrite($buffer, "\n");
+ $i++;
+ }
+ // Print differences
+ while ($i < count($diffs) && $diffs[$i][1] !== Differ::OLD) {
+ fwrite($buffer, sprintf($line_number_spec, $diffs[$i][2]));
+ switch ($diffs[$i][1]) {
+ case Differ::ADDED:
+ fwrite($buffer, '+ ');
+ break;
+ case Differ::REMOVED:
+ fwrite($buffer, '- ');
+ break;
+ }
+ fwrite($buffer, $diffs[$i][0]);
+ fwrite($buffer, "\n");
+ $i++;
+ }
+ // Print trailing context
+ $afterContext = min($i + $context_line_count, count($diffs));
+ while ($i < $afterContext && $diffs[$i][1] === Differ::OLD) {
+ fwrite($buffer, str_repeat(' ', $number_len + 2));
+ fwrite($buffer, $diffs[$i][0]);
+ fwrite($buffer, "\n");
+ $i++;
+ }
+ }
+
+ $diff = stream_get_contents($buffer, -1, 0);
+ fclose($buffer);
+
+ return $diff;
+ }
+}
+
+main();
diff --git a/lib/request-processor/aikido_types/handle.go b/lib/request-processor/aikido_types/handle.go
index 7177ebdc5..e14f23eb3 100644
--- a/lib/request-processor/aikido_types/handle.go
+++ b/lib/request-processor/aikido_types/handle.go
@@ -1,28 +1,6 @@
package aikido_types
-import "main/ipc/protos"
-
-type HandlerFunction func() string
-
type Method struct {
ClassName string
MethodName string
}
-
-type RequestShutdownParams struct {
- Server *ServerData
- Method string
- Route string
- RouteParsed string
- StatusCode int
- User string
- UserAgent string
- IP string
- Url string
- RateLimitGroup string
- APISpec *protos.APISpec
- RateLimited bool
- QueryParsed map[string]interface{}
- IsWebScanner bool
- ShouldDiscoverRoute bool
-}
diff --git a/lib/request-processor/api_discovery/getApiAuthType.go b/lib/request-processor/api_discovery/getApiAuthType.go
index f95ba514b..8f55e82d6 100644
--- a/lib/request-processor/api_discovery/getApiAuthType.go
+++ b/lib/request-processor/api_discovery/getApiAuthType.go
@@ -2,6 +2,7 @@ package api_discovery
import (
"main/context"
+ "main/instance"
"main/ipc/protos"
"slices"
"strings"
@@ -30,10 +31,10 @@ var commonAuthCookieNames = append([]string{
// GetApiAuthType returns the authentication type of the API request.
// Returns nil if the authentication type could not be determined.
-func GetApiAuthType() []*protos.APIAuthType {
+func GetApiAuthType(inst *instance.RequestProcessorInstance) []*protos.APIAuthType {
var result []*protos.APIAuthType
- headers := context.GetHeadersParsed()
+ headers := context.GetHeadersParsed(inst)
// Check the Authorization header
authHeader, authHeaderExists := headers["authorization"].(string)
@@ -44,7 +45,7 @@ func GetApiAuthType() []*protos.APIAuthType {
}
}
- result = append(result, findApiKeys()...)
+ result = append(result, findApiKeys(inst)...)
return result
}
@@ -81,11 +82,11 @@ func getPhpHttpHeaderEquivalent(apiKey string) string {
}
// findApiKeys searches for API keys in headers and cookies.
-func findApiKeys() []*protos.APIAuthType {
+func findApiKeys(inst *instance.RequestProcessorInstance) []*protos.APIAuthType {
var result []*protos.APIAuthType
- headers := context.GetHeadersParsed()
- cookies := context.GetCookiesParsed()
+ headers := context.GetHeadersParsed(inst)
+ cookies := context.GetCookiesParsed(inst)
for header_index, header := range commonApiKeyHeaderNames {
if value, exists := headers[getPhpHttpHeaderEquivalent(header)]; exists && value != "" {
result = append(result, &protos.APIAuthType{
diff --git a/lib/request-processor/api_discovery/getApiAuthType_test.go b/lib/request-processor/api_discovery/getApiAuthType_test.go
index 6e9c7f304..1a736ba98 100644
--- a/lib/request-processor/api_discovery/getApiAuthType_test.go
+++ b/lib/request-processor/api_discovery/getApiAuthType_test.go
@@ -12,34 +12,34 @@ import (
func TestDetectAuthorizationHeader(t *testing.T) {
assert := assert.New(t)
- context.LoadForUnitTests(map[string]string{
+ inst := context.LoadForUnitTests(map[string]string{
"headers": context.GetJsonString(map[string]interface{}{
"authorization": "Bearer token",
}),
})
assert.Equal([]*protos.APIAuthType{
{Type: "http", Scheme: "bearer"},
- }, GetApiAuthType())
+ }, GetApiAuthType(inst))
context.UnloadForUnitTests()
- context.LoadForUnitTests(map[string]string{
+ inst = context.LoadForUnitTests(map[string]string{
"headers": context.GetJsonString(map[string]interface{}{
"authorization": "Basic base64",
}),
})
assert.Equal([]*protos.APIAuthType{
{Type: "http", Scheme: "basic"},
- }, GetApiAuthType())
+ }, GetApiAuthType(inst))
context.UnloadForUnitTests()
- context.LoadForUnitTests(map[string]string{
+ inst = context.LoadForUnitTests(map[string]string{
"headers": context.GetJsonString(map[string]interface{}{
"authorization": "custom",
}),
})
assert.Equal([]*protos.APIAuthType{
{Type: "apiKey", In: "header", Name: "Authorization"},
- }, GetApiAuthType())
+ }, GetApiAuthType(inst))
context.UnloadForUnitTests()
}
@@ -47,24 +47,24 @@ func TestDetectAuthorizationHeader(t *testing.T) {
func TestDetectApiKeys(t *testing.T) {
assert := assert.New(t)
- context.LoadForUnitTests(map[string]string{
+ inst := context.LoadForUnitTests(map[string]string{
"headers": context.GetJsonString(map[string]interface{}{
"x_api_key": "token",
}),
})
assert.Equal([]*protos.APIAuthType{
{Type: "apiKey", In: ("header"), Name: ("x-api-key")},
- }, GetApiAuthType())
+ }, GetApiAuthType(inst))
context.UnloadForUnitTests()
- context.LoadForUnitTests(map[string]string{
+ inst = context.LoadForUnitTests(map[string]string{
"headers": context.GetJsonString(map[string]interface{}{
"api_key": "token",
}),
})
assert.Equal([]*protos.APIAuthType{
{Type: "apiKey", In: ("header"), Name: ("api-key")},
- }, GetApiAuthType())
+ }, GetApiAuthType(inst))
context.UnloadForUnitTests()
}
@@ -72,20 +72,20 @@ func TestDetectApiKeys(t *testing.T) {
func TestDetectAuthCookies(t *testing.T) {
assert := assert.New(t)
- context.LoadForUnitTests(map[string]string{
+ inst := context.LoadForUnitTests(map[string]string{
"cookies": "api-key=token",
})
assert.Equal([]*protos.APIAuthType{
{Type: "apiKey", In: ("cookie"), Name: ("api-key")},
- }, GetApiAuthType())
+ }, GetApiAuthType(inst))
context.UnloadForUnitTests()
- context.LoadForUnitTests(map[string]string{
+ inst = context.LoadForUnitTests(map[string]string{
"cookies": "session=test",
})
assert.Equal([]*protos.APIAuthType{
{Type: "apiKey", In: ("cookie"), Name: ("session")},
- }, GetApiAuthType())
+ }, GetApiAuthType(inst))
context.UnloadForUnitTests()
}
@@ -93,21 +93,21 @@ func TestDetectAuthCookies(t *testing.T) {
func TestNoAuth(t *testing.T) {
assert := assert.New(t)
- context.LoadForUnitTests(map[string]string{})
- assert.Empty(GetApiAuthType())
+ inst := context.LoadForUnitTests(map[string]string{})
+ assert.Empty(GetApiAuthType(inst))
context.UnloadForUnitTests()
- context.LoadForUnitTests(map[string]string{
+ inst = context.LoadForUnitTests(map[string]string{
"headers": context.GetJsonString(map[string]interface{}{}),
})
- assert.Empty(GetApiAuthType())
+ assert.Empty(GetApiAuthType(inst))
context.UnloadForUnitTests()
- context.LoadForUnitTests(map[string]string{
+ inst = context.LoadForUnitTests(map[string]string{
"headers": context.GetJsonString(map[string]interface{}{
"authorization": "",
}),
})
- assert.Empty(GetApiAuthType())
+ assert.Empty(GetApiAuthType(inst))
context.UnloadForUnitTests()
}
diff --git a/lib/request-processor/api_discovery/getApiInfo.go b/lib/request-processor/api_discovery/getApiInfo.go
index f308426ea..8f40bc9db 100644
--- a/lib/request-processor/api_discovery/getApiInfo.go
+++ b/lib/request-processor/api_discovery/getApiInfo.go
@@ -3,29 +3,30 @@ package api_discovery
import (
. "main/aikido_types"
"main/context"
+ "main/instance"
"main/ipc/protos"
"main/log"
"reflect"
)
-func GetApiInfo(server *ServerData) *protos.APISpec {
+func GetApiInfo(inst *instance.RequestProcessorInstance, server *ServerData) *protos.APISpec {
if !server.AikidoConfig.CollectApiSchema {
- log.Debug("AIKIDO_FEATURE_COLLECT_API_SCHEMA is not enabled -> no API schema!")
+ log.Debug(inst, "AIKIDO_FEATURE_COLLECT_API_SCHEMA is not enabled -> no API schema!")
return nil
}
var bodyInfo *protos.APIBodyInfo
var queryInfo *protos.DataSchema
- body := context.GetBodyParsed()
- headers := context.GetHeadersParsed()
- query := context.GetQueryParsed()
+ body := context.GetBodyParsed(inst)
+ headers := context.GetHeadersParsed(inst)
+ query := context.GetQueryParsed(inst)
// Check body data
if body != nil && isObject(body) && len(body) > 0 {
bodyType := getBodyDataType(headers)
if bodyType == Undefined {
- log.Debug("Body type is undefined -> no API schema!")
+ log.Debug(inst, "Body type is undefined -> no API schema!")
return nil
}
@@ -43,10 +44,10 @@ func GetApiInfo(server *ServerData) *protos.APISpec {
}
// Get Auth Info
- authInfo := GetApiAuthType()
+ authInfo := GetApiAuthType(inst)
if bodyInfo == nil && queryInfo == nil && authInfo == nil {
- log.Debug("All sub-schemas are empty -> no API schema!")
+ log.Debug(inst, "All sub-schemas are empty -> no API schema!")
return nil
}
diff --git a/lib/request-processor/attack/attack.go b/lib/request-processor/attack/attack.go
index a647c994a..9d365dd48 100644
--- a/lib/request-processor/attack/attack.go
+++ b/lib/request-processor/attack/attack.go
@@ -5,8 +5,8 @@ import (
"fmt"
"html"
"main/context"
- "main/globals"
"main/grpc"
+ "main/instance"
"main/ipc/protos"
"main/utils"
)
@@ -21,9 +21,9 @@ func GetMetadataProto(metadata map[string]string) []*protos.Metadata {
}
/* Convert headers map to protobuf structure to be sent via gRPC to the Agent */
-func GetHeadersProto() []*protos.Header {
+func GetHeadersProto(inst *instance.RequestProcessorInstance) []*protos.Header {
var headersProto []*protos.Header
- for key, value := range context.GetHeadersParsed() {
+ for key, value := range context.GetHeadersParsed(inst) {
valueStr, ok := value.(string)
if ok {
headersProto = append(headersProto, &protos.Header{Key: key, Value: valueStr})
@@ -33,31 +33,32 @@ func GetHeadersProto() []*protos.Header {
}
/* Construct the AttackDetected protobuf structure to be sent via gRPC to the Agent */
-func GetAttackDetectedProto(res utils.InterceptorResult) *protos.AttackDetected {
+func GetAttackDetectedProto(res utils.InterceptorResult, inst *instance.RequestProcessorInstance) *protos.AttackDetected {
+ serverPID := context.GetServerPID()
return &protos.AttackDetected{
- Token: globals.CurrentToken,
- ServerPid: globals.EnvironmentConfig.ServerPID,
+ Token: inst.GetCurrentToken(),
+ ServerPid: serverPID,
Request: &protos.Request{
- Method: context.GetMethod(),
- IpAddress: context.GetIp(),
- UserAgent: context.GetUserAgent(),
- Url: context.GetUrl(),
- Headers: GetHeadersProto(),
- Body: context.GetBodyRaw(),
+ Method: context.GetMethod(inst),
+ IpAddress: context.GetIp(inst),
+ UserAgent: context.GetUserAgent(inst),
+ Url: context.GetUrl(inst),
+ Headers: GetHeadersProto(inst),
+ Body: context.GetBodyRaw(inst),
Source: "php",
- Route: context.GetRoute(),
+ Route: context.GetRoute(inst),
},
Attack: &protos.Attack{
Kind: string(res.Kind),
Operation: res.Operation,
- Module: context.GetModule(),
- Blocked: utils.IsBlockingEnabled(globals.GetCurrentServer()),
+ Module: context.GetModule(inst),
+ Blocked: utils.IsBlockingEnabled(inst.GetCurrentServer()),
Source: res.Source,
Path: res.PathToPayload,
- Stack: context.GetStackTrace(),
+ Stack: context.GetStackTrace(inst),
Payload: res.Payload,
Metadata: GetMetadataProto(res.Metadata),
- UserId: context.GetUserId(),
+ UserId: context.GetUserId(inst),
},
}
}
@@ -87,11 +88,11 @@ func GetAttackDetectedAction(result utils.InterceptorResult) string {
return GetThrowAction(BuildAttackDetectedMessage(result), 500)
}
-func ReportAttackDetected(res *utils.InterceptorResult) string {
+func ReportAttackDetected(res *utils.InterceptorResult, inst *instance.RequestProcessorInstance) string {
if res == nil {
return ""
}
- grpc.OnAttackDetected(GetAttackDetectedProto(*res))
+ grpc.OnAttackDetected(inst, GetAttackDetectedProto(*res, inst))
return GetAttackDetectedAction(*res)
}
diff --git a/lib/request-processor/config/config.go b/lib/request-processor/config/config.go
index 7c327dacc..55c3a4d07 100644
--- a/lib/request-processor/config/config.go
+++ b/lib/request-processor/config/config.go
@@ -5,19 +5,30 @@ import (
"fmt"
. "main/aikido_types"
"main/globals"
+ "main/instance"
"main/log"
"main/utils"
"os"
)
-func UpdateToken(token string) bool {
- if token == globals.CurrentToken {
- log.Debugf("Token is the same as previous one, skipping config reload...")
+func UpdateToken(inst *instance.RequestProcessorInstance, token string) bool {
+ server := globals.GetServer(token)
+ if server == nil {
+ log.Debugf(inst, "Server not found for token \"AIK_RUNTIME_***%s\"", utils.AnonymizeToken(token))
return false
}
- globals.CurrentToken = token
- globals.CurrentServer = globals.GetServer(token)
- log.Infof("Token changed to \"AIK_RUNTIME_***%s\"", utils.AnonymizeToken(token))
+
+ if token == inst.GetCurrentToken() {
+ if inst.GetCurrentServer() == nil {
+ inst.SetCurrentServer(server)
+ }
+ log.Debugf(inst, "Token is the same as previous one, skipping config reload...")
+ return false
+ }
+
+ inst.SetCurrentToken(token)
+ inst.SetCurrentServer(server)
+ log.Infof(inst, "Token changed to \"AIK_RUNTIME_***%s\"", utils.AnonymizeToken(token))
return true
}
@@ -30,7 +41,7 @@ const (
ReloadWithPastSeenToken
)
-func ReloadAikidoConfig(conf *AikidoConfigData, initJson string) ReloadResult {
+func ReloadAikidoConfig(inst *instance.RequestProcessorInstance, conf *AikidoConfigData, initJson string) ReloadResult {
err := json.Unmarshal([]byte(initJson), conf)
if err != nil {
return ReloadError
@@ -45,18 +56,18 @@ func ReloadAikidoConfig(conf *AikidoConfigData, initJson string) ReloadResult {
}
if globals.ServerExists(conf.Token) {
- if !UpdateToken(conf.Token) {
+ if !UpdateToken(inst, conf.Token) {
return ReloadWithSameToken
}
return ReloadWithPastSeenToken
}
server := globals.CreateServer(conf.Token)
server.AikidoConfig = *conf
- UpdateToken(conf.Token)
+ UpdateToken(inst, conf.Token)
return ReloadWithNewToken
}
-func Init(initJson string) {
+func Init(inst *instance.RequestProcessorInstance, initJson string) {
err := json.Unmarshal([]byte(initJson), &globals.EnvironmentConfig)
if err != nil {
panic(fmt.Sprintf("Error parsing JSON to EnvironmentConfig: %s", err))
@@ -66,7 +77,7 @@ func Init(initJson string) {
globals.EnvironmentConfig.RequestProcessorPID = int32(os.Getpid())
conf := AikidoConfigData{}
- ReloadAikidoConfig(&conf, initJson)
+ ReloadAikidoConfig(inst, &conf, initJson)
log.Init(conf.DiskLogs)
}
diff --git a/lib/request-processor/context/cache.go b/lib/request-processor/context/cache.go
index 5eb5e4d2f..b22514bca 100644
--- a/lib/request-processor/context/cache.go
+++ b/lib/request-processor/context/cache.go
@@ -3,8 +3,8 @@ package context
// #include "../../API.h"
import "C"
import (
- "main/globals"
"main/helpers"
+ "main/instance"
"main/log"
"main/utils"
"strconv"
@@ -20,12 +20,17 @@ import (
type ParseFunction func(string) map[string]interface{}
-func ContextSetMap(contextId int, rawDataPtr **string, parsedPtr **map[string]interface{}, stringsPtr **map[string]string, parseFunc ParseFunction) {
+func ContextSetMap(inst *instance.RequestProcessorInstance, contextId int, rawDataPtr **string, parsedPtr **map[string]interface{}, stringsPtr **map[string]string, parseFunc ParseFunction) {
if stringsPtr != nil && *stringsPtr != nil {
return
}
- contextData := Context.Callback(contextId)
+ c := GetContext(inst)
+ if c.Callback == nil {
+ return
+ }
+
+ contextData := c.Callback(inst, contextId)
if rawDataPtr != nil {
*rawDataPtr = &contextData
}
@@ -39,201 +44,256 @@ func ContextSetMap(contextId int, rawDataPtr **string, parsedPtr **map[string]in
}
}
-func ContextSetString(context_id int, m **string) {
+func ContextSetString(inst *instance.RequestProcessorInstance, context_id int, m **string) {
if *m != nil {
return
}
- temp := Context.Callback(context_id)
+
+ c := GetContext(inst)
+ if c.Callback == nil {
+ return
+ }
+
+ temp := c.Callback(inst, context_id)
*m = &temp
}
-func ContextSetBody() {
- ContextSetMap(C.CONTEXT_BODY, &Context.BodyRaw, &Context.BodyParsed, &Context.BodyParsedFlattened, utils.ParseBody)
+func ContextSetBody(inst *instance.RequestProcessorInstance) {
+ c := GetContext(inst)
+ ContextSetMap(inst, C.CONTEXT_BODY, &c.BodyRaw, &c.BodyParsed, &c.BodyParsedFlattened, utils.ParseBody)
}
-func ContextSetQuery() {
- ContextSetMap(C.CONTEXT_QUERY, nil, &Context.QueryParsed, &Context.QueryParsedFlattened, utils.ParseQuery)
+func ContextSetQuery(inst *instance.RequestProcessorInstance) {
+ c := GetContext(inst)
+ ContextSetMap(inst, C.CONTEXT_QUERY, nil, &c.QueryParsed, &c.QueryParsedFlattened, utils.ParseQuery)
}
-func ContextSetCookies() {
- ContextSetMap(C.CONTEXT_COOKIES, nil, &Context.CookiesParsed, &Context.CookiesParsedFlattened, utils.ParseCookies)
+func ContextSetCookies(inst *instance.RequestProcessorInstance) {
+ c := GetContext(inst)
+ ContextSetMap(inst, C.CONTEXT_COOKIES, nil, &c.CookiesParsed, &c.CookiesParsedFlattened, utils.ParseCookies)
}
-func ContextSetHeaders() {
- ContextSetMap(C.CONTEXT_HEADERS, nil, &Context.HeadersParsed, &Context.HeadersParsedFlattened, utils.ParseHeaders)
+func ContextSetHeaders(inst *instance.RequestProcessorInstance) {
+ c := GetContext(inst)
+ ContextSetMap(inst, C.CONTEXT_HEADERS, nil, &c.HeadersParsed, &c.HeadersParsedFlattened, utils.ParseHeaders)
}
-func ContextSetRouteParams() {
- ContextSetMap(C.CONTEXT_ROUTE, &Context.RouteParamsRaw, &Context.RouteParamsParsed, &Context.RouteParamsParsedFlattened, utils.ParseRouteParams)
+func ContextSetRouteParams(inst *instance.RequestProcessorInstance) {
+ c := GetContext(inst)
+ ContextSetMap(inst, C.CONTEXT_ROUTE, &c.RouteParamsRaw, &c.RouteParamsParsed, &c.RouteParamsParsedFlattened, utils.ParseRouteParams)
}
-func ContextSetStatusCode() {
- if Context.StatusCode != nil {
+func ContextSetStatusCode(inst *instance.RequestProcessorInstance) {
+ c := GetContext(inst)
+ if c.StatusCode != nil {
+ return
+ }
+ if c.Callback == nil {
return
}
- status_code_str := Context.Callback(C.CONTEXT_STATUS_CODE)
+ status_code_str := c.Callback(inst, C.CONTEXT_STATUS_CODE)
status_code, err := strconv.Atoi(status_code_str)
if err != nil {
- log.Warnf("Error parsing status code %v: %v", status_code_str, err)
+ log.Warnf(inst, "Error parsing status code %v: %v", status_code_str, err)
return
}
- Context.StatusCode = &status_code
+ c.StatusCode = &status_code
}
-func ContextSetRoute() {
- ContextSetString(C.CONTEXT_ROUTE, &Context.Route)
+func ContextSetRoute(inst *instance.RequestProcessorInstance) {
+ c := GetContext(inst)
+ ContextSetString(inst, C.CONTEXT_ROUTE, &c.Route)
}
-func ContextSetParsedRoute() {
- parsedRoute := utils.BuildRouteFromURL(GetRoute())
- Context.RouteParsed = &parsedRoute
+func ContextSetParsedRoute(inst *instance.RequestProcessorInstance) {
+ parsedRoute := utils.BuildRouteFromURL(inst, GetRoute(inst))
+ c := GetContext(inst)
+ c.RouteParsed = &parsedRoute
}
-func ContextSetMethod() {
- ContextSetString(C.CONTEXT_METHOD, &Context.Method)
+func ContextSetMethod(inst *instance.RequestProcessorInstance) {
+ c := GetContext(inst)
+ ContextSetString(inst, C.CONTEXT_METHOD, &c.Method)
}
-func ContextSetUrl() {
- ContextSetString(C.CONTEXT_URL, &Context.URL)
+func ContextSetUrl(inst *instance.RequestProcessorInstance) {
+ c := GetContext(inst)
+ ContextSetString(inst, C.CONTEXT_URL, &c.URL)
}
-func ContextSetUserAgent() {
- ContextSetString(C.CONTEXT_HEADER_USER_AGENT, &Context.UserAgent)
+func ContextSetUserAgent(inst *instance.RequestProcessorInstance) {
+ c := GetContext(inst)
+ ContextSetString(inst, C.CONTEXT_HEADER_USER_AGENT, &c.UserAgent)
}
-func ContextSetIp() {
- if Context.IP != nil {
+func ContextSetIp(inst *instance.RequestProcessorInstance) {
+ c := GetContext(inst)
+ if c.IP != nil {
+ return
+ }
+ if c.Callback == nil {
return
}
- remoteAddress := Context.Callback(C.CONTEXT_REMOTE_ADDRESS)
- xForwardedFor := Context.Callback(C.CONTEXT_HEADER_X_FORWARDED_FOR)
- ip := utils.GetIpFromRequest(globals.GetCurrentServer(), remoteAddress, xForwardedFor)
- Context.IP = &ip
+ remoteAddress := c.Callback(inst, C.CONTEXT_REMOTE_ADDRESS)
+ xForwardedFor := c.Callback(inst, C.CONTEXT_HEADER_X_FORWARDED_FOR)
+
+ server := c.inst.GetCurrentServer()
+ ip := utils.GetIpFromRequest(server, remoteAddress, xForwardedFor)
+ c.IP = &ip
}
-func ContextSetIsIpBypassed() {
- if Context.IsIpBypassed != nil {
+func ContextSetIsIpBypassed(inst *instance.RequestProcessorInstance) {
+ c := GetContext(inst)
+ if c.IsIpBypassed != nil {
return
}
- isIpBypassed := utils.IsIpBypassed(globals.GetCurrentServer(), GetIp())
- Context.IsIpBypassed = &isIpBypassed
+ server := c.inst.GetCurrentServer()
+ isIpBypassed := utils.IsIpBypassed(inst, server, GetIp(inst))
+ c.IsIpBypassed = &isIpBypassed
}
-func ContextSetUserId() {
- ContextSetString(C.CONTEXT_USER_ID, &Context.UserId)
+func ContextSetUserId(inst *instance.RequestProcessorInstance) {
+ c := GetContext(inst)
+ ContextSetString(inst, C.CONTEXT_USER_ID, &c.UserId)
}
-func ContextSetUserName() {
- ContextSetString(C.CONTEXT_USER_NAME, &Context.UserName)
+func ContextSetUserName(inst *instance.RequestProcessorInstance) {
+ c := GetContext(inst)
+ ContextSetString(inst, C.CONTEXT_USER_NAME, &c.UserName)
}
-func ContextSetRateLimitGroup() {
- if Context.RateLimitGroup != nil {
+func ContextSetRateLimitGroup(inst *instance.RequestProcessorInstance) {
+ c := GetContext(inst)
+ if c.RateLimitGroup != nil {
+ return
+ }
+ if c.Callback == nil {
return
}
- rateLimitGroup := Context.Callback(C.CONTEXT_RATE_LIMIT_GROUP)
- Context.RateLimitGroup = &rateLimitGroup
+ rateLimitGroup := c.Callback(inst, C.CONTEXT_RATE_LIMIT_GROUP)
+ c.RateLimitGroup = &rateLimitGroup
}
-func ContextSetEndpointConfig() {
- if Context.EndpointConfig != nil {
+func ContextSetEndpointConfig(inst *instance.RequestProcessorInstance) {
+ c := GetContext(inst)
+ if c.EndpointConfig != nil {
+ return
+ }
+
+ // Per-thread isolation via sync.Map prevents context bleeding
+ server := c.inst.GetCurrentServer()
+ if server == nil {
return
}
- endpointConfig := utils.GetEndpointConfig(globals.GetCurrentServer(), GetMethod(), GetParsedRoute())
- Context.EndpointConfig = &endpointConfig
+ method := GetMethod(inst)
+ route := GetParsedRoute(inst)
+ endpointConfig := utils.GetEndpointConfig(server, method, route)
+ c.EndpointConfig = &endpointConfig
}
-func ContextSetWildcardEndpointsConfigs() {
- if Context.WildcardEndpointsConfigs != nil {
+func ContextSetWildcardEndpointsConfigs(inst *instance.RequestProcessorInstance) {
+ c := GetContext(inst)
+ if c.WildcardEndpointsConfigs != nil {
return
}
- wildcardEndpointsConfigs := utils.GetWildcardEndpointsConfigs(globals.GetCurrentServer(), GetMethod(), GetParsedRoute())
- Context.WildcardEndpointsConfigs = &wildcardEndpointsConfigs
+ // Per-thread isolation via sync.Map prevents context bleeding
+ server := c.inst.GetCurrentServer()
+ if server == nil {
+ return
+ }
+
+ wildcardEndpointsConfigs := utils.GetWildcardEndpointsConfigs(server, GetMethod(inst), GetParsedRoute(inst))
+ c.WildcardEndpointsConfigs = &wildcardEndpointsConfigs
}
-func ContextSetIsEndpointProtectionTurnedOff() {
- if Context.IsEndpointProtectionTurnedOff != nil {
+func ContextSetIsEndpointProtectionTurnedOff(inst *instance.RequestProcessorInstance) {
+ c := GetContext(inst)
+ if c.IsEndpointProtectionTurnedOff != nil {
return
}
isEndpointProtectionTurnedOff := false
- endpointConfig := GetEndpointConfig()
+ endpointConfig := GetEndpointConfig(inst)
if endpointConfig != nil {
isEndpointProtectionTurnedOff = endpointConfig.ForceProtectionOff
}
if !isEndpointProtectionTurnedOff {
- for _, wildcardEndpointConfig := range GetWildcardEndpointsConfig() {
+ for _, wildcardEndpointConfig := range GetWildcardEndpointsConfig(inst) {
if wildcardEndpointConfig.ForceProtectionOff {
isEndpointProtectionTurnedOff = true
break
}
}
}
- Context.IsEndpointProtectionTurnedOff = &isEndpointProtectionTurnedOff
+ c.IsEndpointProtectionTurnedOff = &isEndpointProtectionTurnedOff
}
-func ContextSetIsEndpointConfigured() {
- if Context.IsEndpointConfigured != nil {
+func ContextSetIsEndpointConfigured(inst *instance.RequestProcessorInstance) {
+ c := GetContext(inst)
+ if c.IsEndpointConfigured != nil {
return
}
IsEndpointConfigured := false
- endpointConfig := GetEndpointConfig()
+ endpointConfig := GetEndpointConfig(inst)
if endpointConfig != nil {
IsEndpointConfigured = true
}
if !IsEndpointConfigured {
- if len(GetWildcardEndpointsConfig()) != 0 {
+ if len(GetWildcardEndpointsConfig(inst)) != 0 {
IsEndpointConfigured = true
}
}
- Context.IsEndpointConfigured = &IsEndpointConfigured
+ c.IsEndpointConfigured = &IsEndpointConfigured
}
-func ContextSetIsEndpointRateLimitingEnabled() {
- if Context.IsEndpointRateLimitingEnabled != nil {
+func ContextSetIsEndpointRateLimitingEnabled(inst *instance.RequestProcessorInstance) {
+ c := GetContext(inst)
+ if c.IsEndpointRateLimitingEnabled != nil {
return
}
IsEndpointRateLimitingEnabled := false
- endpointConfig := GetEndpointConfig()
+ endpointConfig := GetEndpointConfig(inst)
if endpointConfig != nil {
IsEndpointRateLimitingEnabled = endpointConfig.RateLimiting.Enabled
}
if !IsEndpointRateLimitingEnabled {
- for _, wildcardEndpointConfig := range GetWildcardEndpointsConfig() {
+ for _, wildcardEndpointConfig := range GetWildcardEndpointsConfig(inst) {
if wildcardEndpointConfig.RateLimiting.Enabled {
IsEndpointRateLimitingEnabled = true
break
}
}
}
- Context.IsEndpointRateLimitingEnabled = &IsEndpointRateLimitingEnabled
+ c.IsEndpointRateLimitingEnabled = &IsEndpointRateLimitingEnabled
}
-func ContextSetIsEndpointIpAllowed() {
- if Context.IsEndpointIpAllowed != nil {
+func ContextSetIsEndpointIpAllowed(inst *instance.RequestProcessorInstance) {
+ c := GetContext(inst)
+ if c.IsEndpointIpAllowed != nil {
return
}
- ip := GetIp()
+ ip := GetIp(inst)
isEndpointIpAllowed := utils.NoConfig
- endpointConfig := GetEndpointConfig()
- if endpointConfig != nil {
- isEndpointIpAllowed = utils.IsIpAllowedOnEndpoint(globals.GetCurrentServer(), endpointConfig.AllowedIPAddresses, ip)
+ server := c.inst.GetCurrentServer()
+ endpointConfig := GetEndpointConfig(inst)
+ if endpointConfig != nil && server != nil {
+ isEndpointIpAllowed = utils.IsIpAllowedOnEndpoint(inst, server, endpointConfig.AllowedIPAddresses, ip)
}
- if isEndpointIpAllowed == utils.NoConfig {
- for _, wildcardEndpointConfig := range GetWildcardEndpointsConfig() {
- isEndpointIpAllowed = utils.IsIpAllowedOnEndpoint(globals.GetCurrentServer(), wildcardEndpointConfig.AllowedIPAddresses, ip)
+ if isEndpointIpAllowed == utils.NoConfig && server != nil {
+ for _, wildcardEndpointConfig := range GetWildcardEndpointsConfig(inst) {
+ isEndpointIpAllowed = utils.IsIpAllowedOnEndpoint(inst, server, wildcardEndpointConfig.AllowedIPAddresses, ip)
if isEndpointIpAllowed != utils.NoConfig {
break
}
@@ -242,9 +302,10 @@ func ContextSetIsEndpointIpAllowed() {
isEndpointIpAllowedBool := isEndpointIpAllowed != utils.NotFound
- Context.IsEndpointIpAllowed = &isEndpointIpAllowedBool
+ c.IsEndpointIpAllowed = &isEndpointIpAllowedBool
}
-func ContextSetIsEndpointRateLimited() {
- Context.IsEndpointRateLimited = true
+func ContextSetIsEndpointRateLimited(inst *instance.RequestProcessorInstance) {
+ c := GetContext(inst)
+ c.IsEndpointRateLimited = true
}
diff --git a/lib/request-processor/context/context_for_unit_tests.go b/lib/request-processor/context/context_for_unit_tests.go
index 51f8af7f0..3a4821d3f 100644
--- a/lib/request-processor/context/context_for_unit_tests.go
+++ b/lib/request-processor/context/context_for_unit_tests.go
@@ -1,15 +1,20 @@
package context
// #include "../../API.h"
+// #include
+// static unsigned long get_thread_id() { return (unsigned long)pthread_self(); }
import "C"
import (
"encoding/json"
"fmt"
+ . "main/aikido_types"
+ "main/instance"
)
var TestContext map[string]string
+var TestServer *ServerData // Test server for unit tests
-func UnitTestsCallback(context_id int) string {
+func UnitTestsCallback(inst *instance.RequestProcessorInstance, context_id int) string {
switch context_id {
case C.CONTEXT_REMOTE_ADDRESS:
return TestContext["remoteAddress"]
@@ -39,14 +44,51 @@ func UnitTestsCallback(context_id int) string {
return ""
}
-func LoadForUnitTests(context map[string]string) {
- Context.Callback = UnitTestsCallback
+func getThreadID() uint64 {
+ return uint64(C.get_thread_id())
+}
+
+func LoadForUnitTests(context map[string]string) *instance.RequestProcessorInstance {
+ tid := getThreadID()
+
+ mockInst := instance.NewRequestProcessorInstance(tid, false)
+ if TestServer != nil {
+ mockInst.SetCurrentServer(TestServer)
+ mockInst.SetCurrentToken(TestServer.AikidoConfig.Token)
+ }
+
+ ctx := &RequestContextData{
+ inst: mockInst,
+ Callback: UnitTestsCallback,
+ }
+ mockInst.SetRequestContext(ctx)
+ mockInst.SetContextInstance(nil)
+ mockInst.SetEventContext(&EventContextData{})
+
TestContext = context
+ return mockInst
}
func UnloadForUnitTests() {
- Context = RequestContextData{}
- EventContext = EventContextData{}
+ // Note: In the new design, contexts are stored per-instance, not globally
+ // The test instance will be garbage collected when no longer referenced
+ TestServer = nil
+ TestContext = nil
+}
+
+func SetTestServer(inst *instance.RequestProcessorInstance, server *ServerData) {
+ TestServer = server
+
+ c := GetContext(inst)
+ if c != nil && c.inst != nil && server != nil {
+ c.inst.SetCurrentServer(server)
+ c.inst.SetCurrentToken(server.AikidoConfig.Token)
+ }
+}
+
+// GetTestServer returns the current test server, or nil if not set
+func GetTestServer() *ServerData {
+ return TestServer
}
func GetJsonString(m map[string]interface{}) string {
diff --git a/lib/request-processor/context/data_sources.go b/lib/request-processor/context/data_sources.go
index 6aaae6972..49b254c9f 100644
--- a/lib/request-processor/context/data_sources.go
+++ b/lib/request-processor/context/data_sources.go
@@ -1,8 +1,10 @@
package context
+import "main/instance"
+
type Source struct {
Name string
- CacheGet func() map[string]string
+ CacheGet func(*instance.RequestProcessorInstance) map[string]string
}
var SOURCES = []Source{
diff --git a/lib/request-processor/context/event_context.go b/lib/request-processor/context/event_context.go
index 0ce9feebf..b4bed462d 100644
--- a/lib/request-processor/context/event_context.go
+++ b/lib/request-processor/context/event_context.go
@@ -3,6 +3,7 @@ package context
// #include "../../API.h"
import "C"
import (
+ "main/instance"
"main/utils"
)
@@ -15,10 +16,23 @@ type EventContextData struct {
CurrentSsrfInterceptorResult *utils.InterceptorResult
}
-var EventContext EventContextData
+func getEventContext(inst *instance.RequestProcessorInstance) *EventContextData {
+ if inst == nil {
+ return nil
+ }
-func ResetEventContext() bool {
- EventContext = EventContextData{}
+ ctx := inst.GetEventContext()
+ if ctx == nil {
+ return nil
+ }
+ return ctx.(*EventContextData)
+}
+
+func ResetEventContext(inst *instance.RequestProcessorInstance) bool {
+ if inst == nil {
+ return false
+ }
+ inst.SetEventContext(&EventContextData{})
return true
}
@@ -33,10 +47,17 @@ A partial interceptor result stores the payload that matched the user input, the
PHP function that was called, ..., basically the data needed for reporting if this actually turns into
a detection at a later stage.
*/
-func EventContextSetCurrentSsrfInterceptorResult(interceptorResult *utils.InterceptorResult) {
- EventContext.CurrentSsrfInterceptorResult = interceptorResult
+func EventContextSetCurrentSsrfInterceptorResult(inst *instance.RequestProcessorInstance, interceptorResult *utils.InterceptorResult) {
+ ctx := getEventContext(inst)
+ if ctx != nil {
+ ctx.CurrentSsrfInterceptorResult = interceptorResult
+ }
}
-func GetCurrentSsrfInterceptorResult() *utils.InterceptorResult {
- return EventContext.CurrentSsrfInterceptorResult
+func GetCurrentSsrfInterceptorResult(inst *instance.RequestProcessorInstance) *utils.InterceptorResult {
+ ctx := getEventContext(inst)
+ if ctx == nil {
+ return nil
+ }
+ return ctx.CurrentSsrfInterceptorResult
}
diff --git a/lib/request-processor/context/event_getters.go b/lib/request-processor/context/event_getters.go
index 0929666fb..9432f79ee 100644
--- a/lib/request-processor/context/event_getters.go
+++ b/lib/request-processor/context/event_getters.go
@@ -4,61 +4,64 @@ package context
import "C"
import (
"main/helpers"
+ "main/instance"
"net/url"
)
-func GetOutgoingRequestHostnameAndPort() (string, uint32) {
- return getHostNameAndPort(C.OUTGOING_REQUEST_URL, C.OUTGOING_REQUEST_PORT)
+func GetOutgoingRequestHostnameAndPort(inst *instance.RequestProcessorInstance) (string, uint32) {
+ return getHostNameAndPort(inst, C.OUTGOING_REQUEST_URL, C.OUTGOING_REQUEST_PORT)
}
-func GetOutgoingRequestEffectiveHostnameAndPort() (string, uint32) {
- return getHostNameAndPort(C.OUTGOING_REQUEST_EFFECTIVE_URL, C.OUTGOING_REQUEST_EFFECTIVE_URL_PORT)
+func GetOutgoingRequestEffectiveHostnameAndPort(inst *instance.RequestProcessorInstance) (string, uint32) {
+ return getHostNameAndPort(inst, C.OUTGOING_REQUEST_EFFECTIVE_URL, C.OUTGOING_REQUEST_EFFECTIVE_URL_PORT)
}
-func GetOutgoingRequestResolvedIp() string {
- return Context.Callback(C.OUTGOING_REQUEST_RESOLVED_IP)
+func GetOutgoingRequestResolvedIp(inst *instance.RequestProcessorInstance) string {
+ return GetContext(inst).Callback(inst, C.OUTGOING_REQUEST_RESOLVED_IP)
}
-func GetFunctionName() string {
- return Context.Callback(C.FUNCTION_NAME)
+func GetFunctionName(inst *instance.RequestProcessorInstance) string {
+ return GetContext(inst).Callback(inst, C.FUNCTION_NAME)
}
-func GetCmd() string {
- return Context.Callback(C.CMD)
+func GetCmd(inst *instance.RequestProcessorInstance) string {
+ return GetContext(inst).Callback(inst, C.CMD)
}
-func GetFilename() string {
- return Context.Callback(C.FILENAME)
+func GetFilename(inst *instance.RequestProcessorInstance) string {
+ return GetContext(inst).Callback(inst, C.FILENAME)
}
-func GetFilename2() string {
- return Context.Callback(C.FILENAME2)
+func GetFilename2(inst *instance.RequestProcessorInstance) string {
+ return GetContext(inst).Callback(inst, C.FILENAME2)
}
-func GetSqlQuery() string {
- return Context.Callback(C.SQL_QUERY)
+func GetSqlQuery(inst *instance.RequestProcessorInstance) string {
+ return GetContext(inst).Callback(inst, C.SQL_QUERY)
}
-func GetSqlDialect() string {
- return Context.Callback(C.SQL_DIALECT)
+func GetSqlDialect(inst *instance.RequestProcessorInstance) string {
+ return GetContext(inst).Callback(inst, C.SQL_DIALECT)
}
-func GetModule() string {
- return Context.Callback(C.MODULE)
+func GetModule(inst *instance.RequestProcessorInstance) string {
+ return GetContext(inst).Callback(inst, C.MODULE)
}
-func GetStackTrace() string {
- return Context.Callback(C.STACK_TRACE)
+func GetStackTrace(inst *instance.RequestProcessorInstance) string {
+ return GetContext(inst).Callback(inst, C.STACK_TRACE)
}
-func GetParamMatcher() (string, string) {
- param := Context.Callback(C.PARAM_MATCHER_PARAM)
- regex := Context.Callback(C.PARAM_MATCHER_REGEX)
+func GetParamMatcher(inst *instance.RequestProcessorInstance) (string, string) {
+ ctx := GetContext(inst)
+ param := ctx.Callback(inst, C.PARAM_MATCHER_PARAM)
+ regex := ctx.Callback(inst, C.PARAM_MATCHER_REGEX)
return param, regex
}
-func getHostNameAndPort(urlCallbackId int, portCallbackId int) (string, uint32) { // urlcallbackid is the type of data we request, eg C.OUTGOING_REQUEST_URL
- urlStr := Context.Callback(urlCallbackId)
+func getHostNameAndPort(inst *instance.RequestProcessorInstance, urlCallbackId int, portCallbackId int) (string, uint32) {
+ ctx := GetContext(inst)
+ urlStr := ctx.Callback(inst, urlCallbackId)
urlParsed, err := url.Parse(urlStr)
if err != nil {
return "", 0
@@ -66,7 +69,7 @@ func getHostNameAndPort(urlCallbackId int, portCallbackId int) (string, uint32)
hostname := urlParsed.Hostname()
portFromURL := helpers.GetPortFromURL(urlParsed)
- portStr := Context.Callback(portCallbackId)
+ portStr := ctx.Callback(inst, portCallbackId)
port := helpers.ParsePort(portStr)
if port == 0 {
port = portFromURL
diff --git a/lib/request-processor/context/request_context.go b/lib/request-processor/context/request_context.go
index 893783b32..ce1d6c4d2 100644
--- a/lib/request-processor/context/request_context.go
+++ b/lib/request-processor/context/request_context.go
@@ -4,14 +4,18 @@ package context
import "C"
import (
. "main/aikido_types"
+ "main/globals"
+ "main/instance"
"main/log"
+ "unsafe"
)
-type CallbackFunction func(int) string
+type CallbackFunction func(*instance.RequestProcessorInstance, int) string
/* Request level context cache (changes on each PHP request) */
type RequestContextData struct {
- Callback CallbackFunction // callback to access data from the PHP layer (C++ extension) about the current request and current event
+ inst *instance.RequestProcessorInstance // CACHED: Instance pointer for fast access
+ Callback CallbackFunction // callback to access data from the PHP layer (C++ extension) about the current request and current event
Method *string
Route *string
RouteParsed *string
@@ -44,143 +48,205 @@ type RequestContextData struct {
RouteParamsParsedFlattened *map[string]string
}
-var Context RequestContextData
+func GetServerPID() int32 {
+ return globals.EnvironmentConfig.ServerPID
+}
+
+func Init(instPtr unsafe.Pointer, callback CallbackFunction) bool {
+ inst := instance.GetInstance(instPtr)
+ if inst == nil {
+ return false
+ }
-func Init(callback CallbackFunction) bool {
- Context = RequestContextData{
+ inst.SetContextInstance(instPtr)
+
+ ctx := &RequestContextData{
+ inst: inst,
Callback: callback,
}
+ inst.SetRequestContext(ctx)
+
+ // Initialize EventContext upfront
+ inst.SetEventContext(&EventContextData{})
+
return true
}
-func Clear() bool {
- Context = RequestContextData{
- Callback: Context.Callback,
+func GetContext(inst *instance.RequestProcessorInstance) *RequestContextData {
+ if inst == nil {
+ return nil
+ }
+ ctx := inst.GetRequestContext()
+ if ctx == nil {
+ return nil
+ }
+ return ctx.(*RequestContextData)
+}
+
+func (ctx *RequestContextData) GetInstance() *instance.RequestProcessorInstance {
+ return ctx.inst
+}
+
+func Clear(inst *instance.RequestProcessorInstance) bool {
+ ctx := GetContext(inst)
+ *ctx = RequestContextData{
+ inst: inst,
+ Callback: ctx.Callback,
}
- ResetEventContext()
+ ResetEventContext(inst)
return true
}
-func GetFromCache[T any](fetchDataFn func(), s **T) T {
+func GetFromCache[T any](inst *instance.RequestProcessorInstance, fetchDataFn func(*instance.RequestProcessorInstance), s **T) T {
if fetchDataFn != nil {
- fetchDataFn()
+ fetchDataFn(inst)
}
if *s == nil {
var t T
- log.Warnf("Error getting from cache. Returning default value %v...", t)
+ c := GetContext(inst)
+ if c != nil && c.inst != nil && inst.GetCurrentServer() != nil {
+ log.Warnf(inst, "Error getting from cache. Returning default value %v...", t)
+ }
return t
}
return **s
}
-func GetIp() string {
- return GetFromCache(ContextSetIp, &Context.IP)
+func GetMethod(inst *instance.RequestProcessorInstance) string {
+ ctx := GetContext(inst)
+ return GetFromCache(inst, ContextSetMethod, &ctx.Method)
}
-func GetMethod() string {
- return GetFromCache(ContextSetMethod, &Context.Method)
+func GetRoute(inst *instance.RequestProcessorInstance) string {
+ ctx := GetContext(inst)
+ return GetFromCache(inst, ContextSetRoute, &ctx.Route)
}
-func GetRoute() string {
- return GetFromCache(ContextSetRoute, &Context.Route)
+func GetIp(inst *instance.RequestProcessorInstance) string {
+ ctx := GetContext(inst)
+ return GetFromCache(inst, ContextSetIp, &ctx.IP)
}
-func GetParsedRoute() string {
- return GetFromCache(ContextSetParsedRoute, &Context.RouteParsed)
+func GetUserId(inst *instance.RequestProcessorInstance) string {
+ ctx := GetContext(inst)
+ return GetFromCache(inst, ContextSetUserId, &ctx.UserId)
}
-func GetUrl() string {
- return GetFromCache(ContextSetUrl, &Context.URL)
+func GetUserAgent(inst *instance.RequestProcessorInstance) string {
+ ctx := GetContext(inst)
+ return GetFromCache(inst, ContextSetUserAgent, &ctx.UserAgent)
}
-func GetStatusCode() int {
- return GetFromCache(ContextSetStatusCode, &Context.StatusCode)
+func GetStatusCode(inst *instance.RequestProcessorInstance) int {
+ ctx := GetContext(inst)
+ return GetFromCache(inst, ContextSetStatusCode, &ctx.StatusCode)
}
-func IsIpBypassed() bool {
- return GetFromCache(ContextSetIsIpBypassed, &Context.IsIpBypassed)
+func GetUrl(inst *instance.RequestProcessorInstance) string {
+ ctx := GetContext(inst)
+ return GetFromCache(inst, ContextSetUrl, &ctx.URL)
}
-func GetBodyRaw() string {
- return GetFromCache(ContextSetBody, &Context.BodyRaw)
+func GetBodyRaw(inst *instance.RequestProcessorInstance) string {
+ ctx := GetContext(inst)
+ return GetFromCache(inst, ContextSetBody, &ctx.BodyRaw)
}
-func GetBodyParsed() map[string]interface{} {
- return GetFromCache(ContextSetBody, &Context.BodyParsed)
+func GetParsedRoute(inst *instance.RequestProcessorInstance) string {
+ ctx := GetContext(inst)
+ return GetFromCache(inst, ContextSetParsedRoute, &ctx.RouteParsed)
}
-func GetQueryParsed() map[string]interface{} {
- return GetFromCache(ContextSetQuery, &Context.QueryParsed)
+func GetRateLimitGroup(inst *instance.RequestProcessorInstance) string {
+ ctx := GetContext(inst)
+ return GetFromCache(inst, ContextSetRateLimitGroup, &ctx.RateLimitGroup)
}
-func GetCookiesParsed() map[string]interface{} {
- return GetFromCache(ContextSetCookies, &Context.CookiesParsed)
+func GetQueryParsed(inst *instance.RequestProcessorInstance) map[string]interface{} {
+ ctx := GetContext(inst)
+ return GetFromCache(inst, ContextSetQuery, &ctx.QueryParsed)
}
-func GetHeadersParsed() map[string]interface{} {
- return GetFromCache(ContextSetHeaders, &Context.HeadersParsed)
+func GetHeadersParsed(inst *instance.RequestProcessorInstance) map[string]interface{} {
+ ctx := GetContext(inst)
+ return GetFromCache(inst, ContextSetHeaders, &ctx.HeadersParsed)
}
-func GetBodyParsedFlattened() map[string]string {
- return GetFromCache(ContextSetBody, &Context.BodyParsedFlattened)
+func IsIpBypassed(inst *instance.RequestProcessorInstance) bool {
+ ctx := GetContext(inst)
+ return GetFromCache(inst, ContextSetIsIpBypassed, &ctx.IsIpBypassed)
}
-func GetQueryParsedFlattened() map[string]string {
- return GetFromCache(ContextSetQuery, &Context.QueryParsedFlattened)
+func IsEndpointRateLimited(inst *instance.RequestProcessorInstance) bool {
+ return GetContext(inst).IsEndpointRateLimited
}
-func GetCookiesParsedFlattened() map[string]string {
- return GetFromCache(ContextSetCookies, &Context.CookiesParsedFlattened)
+func IsEndpointProtectionTurnedOff(inst *instance.RequestProcessorInstance) bool {
+ ctx := GetContext(inst)
+ return GetFromCache(inst, ContextSetIsEndpointProtectionTurnedOff, &ctx.IsEndpointProtectionTurnedOff)
}
-func GetRouteParamsParsedFlattened() map[string]string {
- return GetFromCache(ContextSetRouteParams, &Context.RouteParamsParsedFlattened)
+func GetBodyParsed(inst *instance.RequestProcessorInstance) map[string]interface{} {
+ ctx := GetContext(inst)
+ return GetFromCache(inst, ContextSetBody, &ctx.BodyParsed)
}
-func GetHeadersParsedFlattened() map[string]string {
- return GetFromCache(ContextSetHeaders, &Context.HeadersParsedFlattened)
+func GetCookiesParsed(inst *instance.RequestProcessorInstance) map[string]interface{} {
+ ctx := GetContext(inst)
+ return GetFromCache(inst, ContextSetCookies, &ctx.CookiesParsed)
}
-func GetUserAgent() string {
- return GetFromCache(ContextSetUserAgent, &Context.UserAgent)
+func GetBodyParsedFlattened(inst *instance.RequestProcessorInstance) map[string]string {
+ ctx := GetContext(inst)
+ return GetFromCache(inst, ContextSetBody, &ctx.BodyParsedFlattened)
}
-func GetUserId() string {
- return GetFromCache(ContextSetUserId, &Context.UserId)
+func GetQueryParsedFlattened(inst *instance.RequestProcessorInstance) map[string]string {
+ ctx := GetContext(inst)
+ return GetFromCache(inst, ContextSetQuery, &ctx.QueryParsedFlattened)
}
-func GetUserName() string {
- return GetFromCache(ContextSetUserName, &Context.UserName)
+func GetCookiesParsedFlattened(inst *instance.RequestProcessorInstance) map[string]string {
+ ctx := GetContext(inst)
+ return GetFromCache(inst, ContextSetCookies, &ctx.CookiesParsedFlattened)
}
-func GetRateLimitGroup() string {
- return GetFromCache(ContextSetRateLimitGroup, &Context.RateLimitGroup)
+func GetRouteParamsParsedFlattened(inst *instance.RequestProcessorInstance) map[string]string {
+ ctx := GetContext(inst)
+ return GetFromCache(inst, ContextSetRouteParams, &ctx.RouteParamsParsedFlattened)
}
-func GetEndpointConfig() *EndpointData {
- return GetFromCache(ContextSetEndpointConfig, &Context.EndpointConfig)
+func GetHeadersParsedFlattened(inst *instance.RequestProcessorInstance) map[string]string {
+ ctx := GetContext(inst)
+ return GetFromCache(inst, ContextSetHeaders, &ctx.HeadersParsedFlattened)
}
-func GetWildcardEndpointsConfig() []EndpointData {
- return GetFromCache(ContextSetWildcardEndpointsConfigs, &Context.WildcardEndpointsConfigs)
+func GetUserName(inst *instance.RequestProcessorInstance) string {
+ ctx := GetContext(inst)
+ return GetFromCache(inst, ContextSetUserName, &ctx.UserName)
}
-func IsEndpointConfigured() bool {
- return GetFromCache(ContextSetIsEndpointConfigured, &Context.IsEndpointConfigured)
+func GetEndpointConfig(inst *instance.RequestProcessorInstance) *EndpointData {
+ ctx := GetContext(inst)
+ return GetFromCache(inst, ContextSetEndpointConfig, &ctx.EndpointConfig)
}
-func IsEndpointRateLimitingEnabled() bool {
- return GetFromCache(ContextSetIsEndpointRateLimitingEnabled, &Context.IsEndpointRateLimitingEnabled)
+func GetWildcardEndpointsConfig(inst *instance.RequestProcessorInstance) []EndpointData {
+ ctx := GetContext(inst)
+ return GetFromCache(inst, ContextSetWildcardEndpointsConfigs, &ctx.WildcardEndpointsConfigs)
}
-func IsEndpointIpAllowed() bool {
- return GetFromCache(ContextSetIsEndpointIpAllowed, &Context.IsEndpointIpAllowed)
+func IsEndpointConfigured(inst *instance.RequestProcessorInstance) bool {
+ ctx := GetContext(inst)
+ return GetFromCache(inst, ContextSetIsEndpointConfigured, &ctx.IsEndpointConfigured)
}
-func IsEndpointProtectionTurnedOff() bool {
- return GetFromCache(ContextSetIsEndpointProtectionTurnedOff, &Context.IsEndpointProtectionTurnedOff)
+func IsEndpointRateLimitingEnabled(inst *instance.RequestProcessorInstance) bool {
+ ctx := GetContext(inst)
+ return GetFromCache(inst, ContextSetIsEndpointRateLimitingEnabled, &ctx.IsEndpointRateLimitingEnabled)
}
-func IsEndpointRateLimited() bool {
- return Context.IsEndpointRateLimited
+func IsEndpointIpAllowed(inst *instance.RequestProcessorInstance) bool {
+ ctx := GetContext(inst)
+ return GetFromCache(inst, ContextSetIsEndpointIpAllowed, &ctx.IsEndpointIpAllowed)
}
diff --git a/lib/request-processor/globals/globals.go b/lib/request-processor/globals/globals.go
index af9de9dd6..d967081d7 100644
--- a/lib/request-processor/globals/globals.go
+++ b/lib/request-processor/globals/globals.go
@@ -1,16 +1,47 @@
package globals
import (
- . "main/aikido_types"
+ "log"
+ "os"
"regexp"
"sync"
+
+ . "main/aikido_types"
)
+// ===========================
+// Server Configuration
+// ===========================
+
var EnvironmentConfig EnvironmentConfigData
var Servers = make(map[string]*ServerData)
var ServersMutex sync.RWMutex
-var CurrentToken string = ""
-var CurrentServer *ServerData = nil
+
+// ===========================
+// Per-Thread Context Storage
+// ===========================
+
+// ===========================
+// Logging State
+// ===========================
+
+type LogLevel int
+
+const (
+ LogDebugLevel LogLevel = iota
+ LogInfoLevel
+ LogWarnLevel
+ LogErrorLevel
+)
+
+var (
+ CurrentLogLevel = LogErrorLevel
+ Logger = log.New(os.Stdout, "", 0)
+ CliLogging = true
+ LogFilePath = ""
+ LogMutex sync.RWMutex
+ LogFile *os.File
+)
func NewServerData() *ServerData {
return &ServerData{
@@ -24,10 +55,6 @@ func NewServerData() *ServerData {
}
}
-func GetCurrentServer() *ServerData {
- return CurrentServer
-}
-
func GetServer(token string) *ServerData {
if token == "" {
return nil
diff --git a/lib/request-processor/grpc/client.go b/lib/request-processor/grpc/client.go
index 5954672dc..8aa4c7cf8 100644
--- a/lib/request-processor/grpc/client.go
+++ b/lib/request-processor/grpc/client.go
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"main/globals"
+ "main/instance"
"main/log"
"main/utils"
"time"
@@ -16,6 +17,25 @@ import (
"google.golang.org/grpc/credentials/insecure"
)
+type RequestShutdownParams struct {
+ Inst *instance.RequestProcessorInstance
+ Method string
+ Route string
+ RouteParsed string
+ StatusCode int
+ User string
+ UserAgent string
+ IP string
+ Url string
+ RateLimitGroup string
+ APISpec *protos.APISpec
+ RateLimited bool
+ QueryParsed map[string]interface{}
+ IsWebScanner bool
+ ShouldDiscoverRoute bool
+ IsIpBypassed bool
+}
+
var conn *grpc.ClientConn
var client protos.AikidoClient
@@ -34,7 +54,7 @@ func Init() {
client = protos.NewAikidoClient(conn)
- log.Debugf("Current connection state: %s\n", conn.GetState().String())
+ log.Debugf(nil, "Current connection state: %s\n", conn.GetState().String())
}
func Uninit() {
@@ -68,29 +88,34 @@ func SendAikidoConfig(server *ServerData) {
RequestProcessorPid: globals.EnvironmentConfig.RequestProcessorPID,
})
if err != nil {
- log.Warnf("Could not send Aikido Config: %v", err)
+ log.Warnf(nil, "Could not send Aikido Config: %v", err)
return
}
- log.Debugf("Aikido config sent via socket!")
+ log.Debugf(nil, "Aikido config sent via socket!")
}
/* Send outgoing domain to Aikido Agent via gRPC */
-func OnDomain(server *ServerData, domain string, port uint32) {
+func OnDomain(inst *instance.RequestProcessorInstance, domain string, port uint32) {
if client == nil {
return
}
+ server := inst.GetCurrentServer()
+ if server == nil {
+ return
+ }
+
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
_, err := client.OnDomain(ctx, &protos.Domain{Token: server.AikidoConfig.Token, ServerPid: globals.EnvironmentConfig.ServerPID, Domain: domain, Port: port})
if err != nil {
- log.Warnf("Could not send domain %v: %v", domain, err)
+ log.Warnf(inst, "Could not send domain %v: %v", domain, err)
return
}
- log.Debugf("Domain sent via socket: %v:%v", domain, port)
+ log.Debugf(inst, "Domain sent via socket: %v:%v", domain, port)
}
/* Send packages to Aikido Agent via gRPC */
@@ -104,15 +129,15 @@ func OnPackages(server *ServerData, packages map[string]string) {
_, err := client.OnPackages(ctx, &protos.Packages{Token: server.AikidoConfig.Token, ServerPid: globals.EnvironmentConfig.ServerPID, Packages: packages})
if err != nil {
- log.Warnf("Could not send packages %v: %v", packages, err)
+ log.Warnf(nil, "Could not send packages %v: %v", packages, err)
return
}
- log.Debugf("Packages sent via socket!")
+ log.Debugf(nil, "Packages sent via socket!")
}
/* Send request metadata (route & method) to Aikido Agent via gRPC */
-func GetRateLimitingStatus(server *ServerData, method string, route string, routeParsed string, user string, ip string, rateLimitGroup string, timeout time.Duration) *protos.RateLimitingStatus {
+func GetRateLimitingStatus(inst *instance.RequestProcessorInstance, server *ServerData, method string, route string, routeParsed string, user string, ip string, rateLimitGroup string, timeout time.Duration) *protos.RateLimitingStatus {
if client == nil || server == nil {
return nil
}
@@ -122,11 +147,11 @@ func GetRateLimitingStatus(server *ServerData, method string, route string, rout
RateLimitingStatus, err := client.GetRateLimitingStatus(ctx, &protos.RateLimitingInfo{Token: server.AikidoConfig.Token, ServerPid: globals.EnvironmentConfig.ServerPID, Method: method, Route: route, RouteParsed: routeParsed, User: user, Ip: ip, RateLimitGroup: rateLimitGroup})
if err != nil {
- log.Warnf("Cannot get rate limiting status %v %v: %v", method, route, err)
+ log.Warnf(inst, "Cannot get rate limiting status %v %v: %v", method, route, err)
return nil
}
- log.Debugf("Rate limiting status for (%v %v) sent via socket and got reply (%v)", method, route, RateLimitingStatus)
+ log.Debugf(inst, "Rate limiting status for (%v %v) sent via socket and got reply (%v)", method, route, RateLimitingStatus)
return RateLimitingStatus
}
@@ -140,7 +165,7 @@ func OnRequestShutdown(params RequestShutdownParams) {
defer cancel()
_, err := client.OnRequestShutdown(ctx, &protos.RequestMetadataShutdown{
- Token: params.Server.AikidoConfig.Token,
+ Token: params.Inst.GetCurrentToken(),
ServerPid: globals.EnvironmentConfig.ServerPID,
Method: params.Method,
Route: params.Route,
@@ -157,11 +182,11 @@ func OnRequestShutdown(params RequestShutdownParams) {
ShouldDiscoverRoute: params.ShouldDiscoverRoute,
})
if err != nil {
- log.Warnf("Could not send request metadata %v %v %v: %v", params.Method, params.Route, params.StatusCode, err)
+ log.Warnf(nil, "Could not send request metadata %v %v %v: %v", params.Method, params.Route, params.StatusCode, err)
return
}
- log.Debugf("Request metadata sent via socket (%v %v %v)", params.Method, params.Route, params.StatusCode)
+ log.Debugf(nil, "Request metadata sent via socket (%v %v %v)", params.Method, params.Route, params.StatusCode)
}
/* Get latest cloud config from Aikido Agent via gRPC */
@@ -173,13 +198,25 @@ func GetCloudConfig(server *ServerData, timeout time.Duration) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
- cloudConfig, err := client.GetCloudConfig(ctx, &protos.CloudConfigUpdatedAt{Token: server.AikidoConfig.Token, ServerPid: globals.EnvironmentConfig.ServerPID, ConfigUpdatedAt: utils.GetCloudConfigUpdatedAt(server)})
+ cloudConfig, err := client.GetCloudConfig(ctx, &protos.CloudConfigUpdatedAt{
+ Token: server.AikidoConfig.Token,
+ ServerPid: globals.EnvironmentConfig.ServerPID,
+ ConfigUpdatedAt: utils.GetCloudConfigUpdatedAt(server),
+ })
+
if err != nil {
- log.Debugf("Could not get cloud config for server \"AIK_RUNTIME_***%s\": %v", utils.AnonymizeToken(server.AikidoConfig.Token), err)
+ log.Debugf(nil, "Could not get cloud config for server \"AIK_RUNTIME_***%s\": %v", utils.AnonymizeToken(server.AikidoConfig.Token), err)
return
}
- log.Debugf("Got cloud config for server \"AIK_RUNTIME_***%s\"!", utils.AnonymizeToken(server.AikidoConfig.Token))
+ if cloudConfig == nil {
+ log.Debugf(nil, "Cloud config not updated for server \"AIK_RUNTIME_***%s\"", utils.AnonymizeToken(server.AikidoConfig.Token))
+ return
+ }
+
+ fmt.Printf("[GetCloudConfig] Successfully received cloud config for token \"AIK_RUNTIME_***%s\", ConfigUpdatedAt=%d, endpoints=%d\n",
+ utils.AnonymizeToken(server.AikidoConfig.Token), cloudConfig.ConfigUpdatedAt, len(cloudConfig.Endpoints))
+ log.Debugf(nil, "Got cloud config for server \"AIK_RUNTIME_***%s\"!", utils.AnonymizeToken(server.AikidoConfig.Token))
setCloudConfig(server, cloudConfig)
}
@@ -189,24 +226,29 @@ func GetCloudConfigForAllServers(timeout time.Duration) {
}
}
-func OnUserEvent(server *ServerData, id string, username string, ip string) {
+func OnUserEvent(inst *instance.RequestProcessorInstance, id string, username string, ip string) {
if client == nil {
return
}
+ server := inst.GetCurrentServer()
+ if server == nil {
+ return
+ }
+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_, err := client.OnUser(ctx, &protos.User{Token: server.AikidoConfig.Token, ServerPid: globals.EnvironmentConfig.ServerPID, Id: id, Username: username, Ip: ip})
if err != nil {
- log.Warnf("Could not send user event %v %v %v: %v", id, username, ip, err)
+ log.Warnf(inst, "Could not send user event %v %v %v: %v", id, username, ip, err)
return
}
- log.Debugf("User event sent via socket (%v %v %v)", id, username, ip)
+ log.Debugf(inst, "User event sent via socket (%v %v %v)", id, username, ip)
}
-func OnAttackDetected(attackDetected *protos.AttackDetected) {
+func OnAttackDetected(inst *instance.RequestProcessorInstance, attackDetected *protos.AttackDetected) {
if client == nil {
return
}
@@ -216,14 +258,19 @@ func OnAttackDetected(attackDetected *protos.AttackDetected) {
_, err := client.OnAttackDetected(ctx, attackDetected)
if err != nil {
- log.Warnf("Could not send attack detected event")
+ log.Warnf(inst, "Could not send attack detected event")
return
}
- log.Debugf("Attack detected event sent via socket")
+ log.Debugf(inst, "Attack detected event sent via socket")
}
-func OnMonitoredSinkStats(server *ServerData, sink, kind string, attacksDetected, attacksBlocked, interceptorThrewError, withoutContext, total int32, timings []int64) {
- if client == nil || server == nil {
+func OnMonitoredSinkStats(inst *instance.RequestProcessorInstance, sink, kind string, attacksDetected, attacksBlocked, interceptorThrewError, withoutContext, total int32, timings []int64) {
+ if client == nil {
+ return
+ }
+
+ server := inst.GetCurrentServer()
+ if server == nil {
return
}
@@ -243,14 +290,19 @@ func OnMonitoredSinkStats(server *ServerData, sink, kind string, attacksDetected
Timings: timings,
})
if err != nil {
- log.Warnf("Could not send monitored sink stats event")
+ log.Warnf(inst, "Could not send monitored sink stats event")
return
}
- log.Debugf("Monitored sink stats for sink \"%s\" sent via socket", sink)
+ log.Debugf(inst, "Monitored sink stats for sink \"%s\" sent via socket", sink)
}
-func OnMiddlewareInstalled(server *ServerData) {
- if client == nil || server == nil {
+func OnMiddlewareInstalled(inst *instance.RequestProcessorInstance) {
+ if client == nil {
+ return
+ }
+
+ server := inst.GetCurrentServer()
+ if server == nil {
return
}
@@ -259,17 +311,22 @@ func OnMiddlewareInstalled(server *ServerData) {
_, err := client.OnMiddlewareInstalled(ctx, &protos.MiddlewareInstalledInfo{Token: server.AikidoConfig.Token, ServerPid: globals.EnvironmentConfig.ServerPID})
if err != nil {
- log.Warnf("Could not call OnMiddlewareInstalled")
+ log.Warnf(inst, "Could not call OnMiddlewareInstalled")
return
}
- log.Debugf("OnMiddlewareInstalled sent via socket")
+ log.Debugf(inst, "OnMiddlewareInstalled sent via socket")
}
-func OnMonitoredIpMatch(server *ServerData, lists []utils.IpListMatch) {
+func OnMonitoredIpMatch(inst *instance.RequestProcessorInstance, lists []utils.IpListMatch) {
if client == nil || len(lists) == 0 {
return
}
+ server := inst.GetCurrentServer()
+ if server == nil {
+ return
+ }
+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
@@ -280,24 +337,29 @@ func OnMonitoredIpMatch(server *ServerData, lists []utils.IpListMatch) {
_, err := client.OnMonitoredIpMatch(ctx, &protos.MonitoredIpMatch{Token: server.AikidoConfig.Token, ServerPid: globals.EnvironmentConfig.ServerPID, Lists: protosLists})
if err != nil {
- log.Warnf("Could not call OnMonitoredIpMatch")
+ log.Warnf(inst, "Could not call OnMonitoredIpMatch")
return
}
- log.Debugf("OnMonitoredIpMatch sent via socket")
+ log.Debugf(inst, "OnMonitoredIpMatch sent via socket")
}
-func OnMonitoredUserAgentMatch(server *ServerData, lists []string) {
+func OnMonitoredUserAgentMatch(inst *instance.RequestProcessorInstance, lists []string) {
if client == nil || len(lists) == 0 {
return
}
+ server := inst.GetCurrentServer()
+ if server == nil {
+ return
+ }
+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := client.OnMonitoredUserAgentMatch(ctx, &protos.MonitoredUserAgentMatch{Token: server.AikidoConfig.Token, ServerPid: globals.EnvironmentConfig.ServerPID, Lists: lists})
if err != nil {
- log.Warnf("Could not call OnMonitoredUserAgentMatch")
+ log.Warnf(inst, "Could not call OnMonitoredUserAgentMatch")
return
}
- log.Debugf("OnMonitoredUserAgentMatch sent via socket")
+ log.Debugf(inst, "OnMonitoredUserAgentMatch sent via socket")
}
diff --git a/lib/request-processor/grpc/config.go b/lib/request-processor/grpc/config.go
index 118f746d7..2fc2f455d 100644
--- a/lib/request-processor/grpc/config.go
+++ b/lib/request-processor/grpc/config.go
@@ -9,12 +9,15 @@ import (
"regexp"
"runtime"
"strings"
+ "sync"
"time"
)
var (
- stopChan chan struct{}
- cloudConfigTicker = time.NewTicker(1 * time.Minute)
+ stopChan chan struct{}
+ cloudConfigTicker = time.NewTicker(1 * time.Minute)
+ cloudConfigStarted bool
+ cloudConfigMutex sync.Mutex
)
func buildIpList(cloudIpList map[string]*protos.IpList) map[string]IpList {
@@ -22,7 +25,7 @@ func buildIpList(cloudIpList map[string]*protos.IpList) map[string]IpList {
for ipListKey, protoIpList := range cloudIpList {
ipSet, err := utils.BuildIpList(protoIpList.Description, protoIpList.Ips)
if err != nil {
- log.Errorf("Error building IP list: %s\n", err)
+ log.Errorf(nil, "Error building IP list: %s\n", err)
continue
}
ipList[ipListKey] = *ipSet
@@ -33,7 +36,7 @@ func buildIpList(cloudIpList map[string]*protos.IpList) map[string]IpList {
func getEndpointData(ep *protos.Endpoint) EndpointData {
allowedIPSet, err := utils.BuildIpSet(ep.AllowedIPAddresses)
if err != nil {
- log.Errorf("Error building allowed IP set: %s\n", err)
+ log.Errorf(nil, "Error building allowed IP set: %s\n", err)
}
endpointData := EndpointData{
ForceProtectionOff: ep.ForceProtectionOff,
@@ -69,7 +72,7 @@ func buildUserAgentsRegexpFromProto(userAgents string) *regexp.Regexp {
}
userAgentsRegexp, err := regexp.Compile("(?i)" + userAgents)
if err != nil {
- log.Errorf("Error compiling user agents regex: %s\n", err)
+ log.Errorf(nil, "Error compiling user agents regex: %s\n", err)
return nil
}
return userAgentsRegexp
@@ -112,7 +115,7 @@ func setCloudConfig(server *ServerData, cloudConfigFromAgent *protos.CloudConfig
bypassedIPSet, bypassedIPSetErr := utils.BuildIpSet(cloudConfigFromAgent.BypassedIps)
server.CloudConfig.BypassedIps = bypassedIPSet
if bypassedIPSet == nil {
- log.Errorf("Error building bypassed IP set: %s\n", bypassedIPSetErr)
+ log.Errorf(nil, "Error building bypassed IP set: %s\n", bypassedIPSetErr)
}
if cloudConfigFromAgent.Block {
@@ -144,6 +147,14 @@ func setCloudConfig(server *ServerData, cloudConfigFromAgent *protos.CloudConfig
}
func StartCloudConfigRoutine() {
+ cloudConfigMutex.Lock()
+ defer cloudConfigMutex.Unlock()
+
+ if cloudConfigStarted {
+ return
+ }
+ cloudConfigStarted = true
+
stopChan = make(chan struct{})
go func() {
@@ -160,7 +171,12 @@ func StartCloudConfigRoutine() {
}
func stopCloudConfigRoutine() {
+ cloudConfigMutex.Lock()
+ defer cloudConfigMutex.Unlock()
+
if stopChan != nil {
close(stopChan)
+ stopChan = nil
}
+ cloudConfigStarted = false
}
diff --git a/lib/request-processor/handle_blocking_request.go b/lib/request-processor/handle_blocking_request.go
index b76c21e69..3daeeea98 100644
--- a/lib/request-processor/handle_blocking_request.go
+++ b/lib/request-processor/handle_blocking_request.go
@@ -5,8 +5,8 @@ import (
"fmt"
"html"
"main/context"
- "main/globals"
"main/grpc"
+ "main/instance"
"main/log"
"main/utils"
"time"
@@ -29,41 +29,45 @@ func GetAction(actionHandling, actionType, trigger, description, data string, re
return string(actionJson)
}
-func OnGetBlockingStatus() string {
- log.Debugf("OnGetBlockingStatus called!")
+func OnGetBlockingStatus(inst *instance.RequestProcessorInstance) string {
+ log.Debugf(inst, "OnGetBlockingStatus called!")
- server := globals.GetCurrentServer()
+ server := inst.GetCurrentServer()
if server == nil {
return ""
}
if !server.MiddlewareInstalled {
- go grpc.OnMiddlewareInstalled(server)
+ go grpc.OnMiddlewareInstalled(inst)
server.MiddlewareInstalled = true
}
- userId := context.GetUserId()
+ userId := context.GetUserId(inst)
if utils.IsUserBlocked(server, userId) {
- log.Infof("User \"%s\" is blocked!", userId)
+ log.Infof(inst, "User \"%s\" is blocked!", userId)
return GetAction("store", "blocked", "user", "user blocked from config", userId, 403)
}
- autoBlockingStatus := OnGetAutoBlockingStatus()
+ autoBlockingStatus := OnGetAutoBlockingStatus(inst)
- if context.IsEndpointRateLimitingEnabled() {
+ if context.IsIpBypassed(inst) {
+ return ""
+ }
+
+ if context.IsEndpointRateLimitingEnabled(inst) {
// If request is monitored for rate limiting,
// do a sync call via gRPC to see if the request should be blocked or not
- method := context.GetMethod()
- route := context.GetRoute()
- ip := context.GetIp()
- rateLimitGroup := context.GetRateLimitGroup()
- routeParsed := context.GetParsedRoute()
+ method := context.GetMethod(inst)
+ route := context.GetRoute(inst)
+ ip := context.GetIp(inst)
+ rateLimitGroup := context.GetRateLimitGroup(inst)
+ routeParsed := context.GetParsedRoute(inst)
if method == "" || route == "" {
return ""
}
- rateLimitingStatus := grpc.GetRateLimitingStatus(server, method, route, routeParsed, userId, ip, rateLimitGroup, 10*time.Millisecond)
+ rateLimitingStatus := grpc.GetRateLimitingStatus(inst, server, method, route, routeParsed, userId, ip, rateLimitGroup, 10*time.Millisecond)
if rateLimitingStatus != nil && rateLimitingStatus.Block {
- context.ContextSetIsEndpointRateLimited()
- log.Infof("Request made from IP \"%s\" is ratelimited by \"%s\"!", ip, rateLimitingStatus.Trigger)
+ context.ContextSetIsEndpointRateLimited(inst)
+ log.Infof(inst, "Request made from IP \"%s\" is ratelimited by \"%s\"!", ip, rateLimitingStatus.Trigger)
return GetAction("store", "ratelimited", rateLimitingStatus.Trigger, "configured rate limit exceeded by current ip", ip, 429)
}
}
@@ -71,51 +75,56 @@ func OnGetBlockingStatus() string {
return autoBlockingStatus
}
-func OnGetAutoBlockingStatus() string {
- log.Debugf("OnGetAutoBlockingStatus called!")
+func OnGetAutoBlockingStatus(inst *instance.RequestProcessorInstance) string {
+ log.Debugf(inst, "OnGetAutoBlockingStatus called!")
- server := globals.GetCurrentServer()
+ server := inst.GetCurrentServer()
if server == nil {
return ""
}
- method := context.GetMethod()
- route := context.GetParsedRoute()
+ method := context.GetMethod(inst)
+ route := context.GetParsedRoute(inst)
if method == "" || route == "" {
return ""
}
- ip := context.GetIp()
- userAgent := context.GetUserAgent()
+ ip := context.GetIp(inst)
+ userAgent := context.GetUserAgent(inst)
- if !context.IsEndpointIpAllowed() {
- log.Infof("IP \"%s\" is not allowed to access this endpoint!", ip)
+ if !context.IsEndpointIpAllowed(inst) {
+ log.Infof(inst, "IP \"%s\" is not allowed to access this endpoint!", ip)
return GetAction("exit", "blocked", "ip", "not allowed by config to access this endpoint", ip, 403)
}
- if !utils.IsIpAllowed(server, ip) {
- log.Infof("IP \"%s\" is not found in allow lists!", ip)
+ if context.IsIpBypassed(inst) {
+ log.Infof(inst, "IP \"%s\" is bypassed! Skipping additional checks...", ip)
+ return ""
+ }
+
+ if !utils.IsIpAllowed(inst, server, ip) {
+ log.Infof(inst, "IP \"%s\" is not found in allow lists!", ip)
return GetAction("exit", "blocked", "ip", "not in allow lists", ip, 403)
}
- if ipMonitored, ipMonitoredMatches := utils.IsIpMonitored(server, ip); ipMonitored {
- log.Infof("IP \"%s\" found in monitored lists: %v!", ip, ipMonitoredMatches)
- go grpc.OnMonitoredIpMatch(server, ipMonitoredMatches)
+ if ipMonitored, ipMonitoredMatches := utils.IsIpMonitored(inst, server, ip); ipMonitored {
+ log.Infof(inst, "IP \"%s\" found in monitored lists: %v!", ip, ipMonitoredMatches)
+ go grpc.OnMonitoredIpMatch(inst, ipMonitoredMatches)
}
- if ipBlocked, ipBlockedMatches := utils.IsIpBlocked(server, ip); ipBlocked {
- log.Infof("IP \"%s\" found in blocked lists: %v!", ip, ipBlockedMatches)
- go grpc.OnMonitoredIpMatch(server, ipBlockedMatches)
+ if ipBlocked, ipBlockedMatches := utils.IsIpBlocked(inst, server, ip); ipBlocked {
+ log.Infof(inst, "IP \"%s\" found in blocked lists: %v!", ip, ipBlockedMatches)
+ go grpc.OnMonitoredIpMatch(inst, ipBlockedMatches)
return GetAction("exit", "blocked", "ip", ipBlockedMatches[0].Description, ip, 403)
}
if userAgentMonitored, userAgentMonitoredDescriptions := utils.IsUserAgentMonitored(server, userAgent); userAgentMonitored {
- log.Infof("User Agent \"%s\" found in monitored lists: %v!", userAgent, userAgentMonitoredDescriptions)
- go grpc.OnMonitoredUserAgentMatch(server, userAgentMonitoredDescriptions)
+ log.Infof(inst, "User Agent \"%s\" found in monitored lists: %v!", userAgent, userAgentMonitoredDescriptions)
+ go grpc.OnMonitoredUserAgentMatch(inst, userAgentMonitoredDescriptions)
}
if userAgentBlocked, userAgentBlockedDescriptions := utils.IsUserAgentBlocked(server, userAgent); userAgentBlocked {
- log.Infof("User Agent \"%s\" found in blocked lists: %v!", userAgent, userAgentBlockedDescriptions)
- go grpc.OnMonitoredUserAgentMatch(server, userAgentBlockedDescriptions)
+ log.Infof(inst, "User Agent \"%s\" found in blocked lists: %v!", userAgent, userAgentBlockedDescriptions)
+ go grpc.OnMonitoredUserAgentMatch(inst, userAgentBlockedDescriptions)
description := "unknown"
if len(userAgentBlockedDescriptions) > 0 {
@@ -138,9 +147,9 @@ func GetBypassAction() string {
return string(actionJson)
}
-func OnGetIsIpBypassed() string {
- log.Debugf("OnGetIsIpBypassed called!")
- if context.IsIpBypassed() {
+func OnGetIsIpBypassed(inst *instance.RequestProcessorInstance) string {
+ log.Debugf(inst, "OnGetIsIpBypassed called!")
+ if context.IsIpBypassed(inst) {
return GetBypassAction()
}
return ""
diff --git a/lib/request-processor/handle_path_traversal.go b/lib/request-processor/handle_path_traversal.go
index 5267066b9..aea107ec2 100644
--- a/lib/request-processor/handle_path_traversal.go
+++ b/lib/request-processor/handle_path_traversal.go
@@ -3,33 +3,34 @@ package main
import (
"main/attack"
"main/context"
+ "main/instance"
"main/log"
path_traversal "main/vulnerabilities/path-traversal"
)
-func OnPrePathAccessed() string {
- filename := context.GetFilename()
- filename2 := context.GetFilename2()
- operation := context.GetFunctionName()
+func OnPrePathAccessed(inst *instance.RequestProcessorInstance) string {
+ filename := context.GetFilename(inst)
+ filename2 := context.GetFilename2(inst)
+ operation := context.GetFunctionName(inst)
if filename == "" || operation == "" {
return ""
}
- if context.IsEndpointProtectionTurnedOff() {
- log.Infof("Protection is turned off -> will not run detection logic!")
+ if context.IsEndpointProtectionTurnedOff(inst) {
+ log.Infof(inst, "Protection is turned off -> will not run detection logic!")
return ""
}
- res := path_traversal.CheckContextForPathTraversal(filename, operation, true)
+ res := path_traversal.CheckContextForPathTraversal(inst, filename, operation, true)
if res != nil {
- return attack.ReportAttackDetected(res)
+ return attack.ReportAttackDetected(res, inst)
}
if filename2 != "" {
- res = path_traversal.CheckContextForPathTraversal(filename2, operation, true)
+ res = path_traversal.CheckContextForPathTraversal(inst, filename2, operation, true)
if res != nil {
- return attack.ReportAttackDetected(res)
+ return attack.ReportAttackDetected(res, inst)
}
}
return ""
diff --git a/lib/request-processor/handle_rate_limit_group_event.go b/lib/request-processor/handle_rate_limit_group_event.go
index 0fa7e7e96..56c88ed13 100644
--- a/lib/request-processor/handle_rate_limit_group_event.go
+++ b/lib/request-processor/handle_rate_limit_group_event.go
@@ -2,12 +2,13 @@ package main
import (
"main/context"
+ "main/instance"
"main/log"
)
-func OnRateLimitGroupEvent() string {
- context.ContextSetRateLimitGroup()
- group := context.GetRateLimitGroup()
- log.Infof("Got rate limit group: %s", group)
+func OnRateLimitGroupEvent(inst *instance.RequestProcessorInstance) string {
+ context.ContextSetRateLimitGroup(inst)
+ group := context.GetRateLimitGroup(inst)
+ log.Infof(inst, "Got rate limit group: %s", group)
return ""
}
diff --git a/lib/request-processor/handle_register_param_matcher.go b/lib/request-processor/handle_register_param_matcher.go
index 88efbdee3..6c8573460 100644
--- a/lib/request-processor/handle_register_param_matcher.go
+++ b/lib/request-processor/handle_register_param_matcher.go
@@ -3,13 +3,13 @@ package main
import (
"fmt"
"main/context"
- "main/globals"
+ "main/instance"
"main/log"
"main/utils"
)
-func OnRegisterParamMatcherEvent() string {
- param, regex := context.GetParamMatcher()
+func OnRegisterParamMatcherEvent(inst *instance.RequestProcessorInstance) string {
+ param, regex := context.GetParamMatcher(inst)
if param == "" || regex == "" {
return ""
}
@@ -18,7 +18,7 @@ func OnRegisterParamMatcherEvent() string {
return utils.GetMessageAction(fmt.Sprintf("Invalid param name: %s. Param names must match [a-zA-Z_]+", param))
}
- server := globals.GetCurrentServer()
+ server := inst.GetCurrentServer()
if server == nil {
return ""
}
@@ -32,6 +32,6 @@ func OnRegisterParamMatcherEvent() string {
return utils.GetMessageAction(fmt.Sprintf("Error compiling param matcher %s -> regex \"%s\": %s", param, regex, err.Error()))
}
server.ParamMatchers[param] = regexCompiled
- log.Infof("Registered param matcher %s -> %s", param, regex)
+ log.Infof(inst, "Registered param matcher %s -> %s", param, regex)
return ""
}
diff --git a/lib/request-processor/handle_request_metadata.go b/lib/request-processor/handle_request_metadata.go
index f880fa76e..b1578ec5e 100644
--- a/lib/request-processor/handle_request_metadata.go
+++ b/lib/request-processor/handle_request_metadata.go
@@ -1,62 +1,70 @@
package main
import (
- . "main/aikido_types"
"main/api_discovery"
"main/context"
- "main/globals"
"main/grpc"
+ "main/instance"
"main/log"
"main/utils"
webscanner "main/vulnerabilities/web-scanner"
)
-func OnPreRequest() string {
- context.Clear()
+func OnPreRequest(inst *instance.RequestProcessorInstance) string {
+ context.Clear(inst)
return ""
}
-func OnRequestShutdownReporting(params RequestShutdownParams) {
+func OnRequestShutdownReporting(params grpc.RequestShutdownParams) {
if params.Method == "" || params.Route == "" || params.StatusCode == 0 {
return
}
- log.Info("[RSHUTDOWN] Got request metadata: ", params.Method, " ", params.Route, " ", params.StatusCode)
-
- params.IsWebScanner = webscanner.IsWebScanner(params.Method, params.Route, params.QueryParsed)
-
+ log.Info(params.Inst, "[RSHUTDOWN] Got request metadata: ", params.Method, " ", params.Route, " ", params.StatusCode)
+ // Only detect web scanner activity for non-bypassed IPs
+ if !params.IsIpBypassed {
+ params.IsWebScanner = webscanner.IsWebScanner(params.Method, params.Route, params.QueryParsed)
+ }
params.ShouldDiscoverRoute = utils.ShouldDiscoverRoute(params.StatusCode, params.Route, params.Method)
+
if !params.RateLimited && !params.ShouldDiscoverRoute && !params.IsWebScanner {
return
}
- log.Info("[RSHUTDOWN] Got API spec: ", params.APISpec)
+ log.Info(params.Inst, "[RSHUTDOWN] Got API spec: ", params.APISpec)
grpc.OnRequestShutdown(params)
}
-func OnPostRequest() string {
- server := globals.GetCurrentServer()
+func OnPostRequest(inst *instance.RequestProcessorInstance) string {
+ server := inst.GetCurrentServer()
if server == nil {
return ""
}
- // Only send request metadata if the IP is not bypassed
- if !context.IsIpBypassed() {
- go OnRequestShutdownReporting(RequestShutdownParams{
- Server: server,
- Method: context.GetMethod(),
- Route: context.GetRoute(),
- RouteParsed: context.GetParsedRoute(),
- StatusCode: context.GetStatusCode(),
- User: context.GetUserId(),
- UserAgent: context.GetUserAgent(),
- IP: context.GetIp(),
- Url: context.GetUrl(),
- RateLimitGroup: context.GetRateLimitGroup(),
- APISpec: api_discovery.GetApiInfo(server),
- RateLimited: context.IsEndpointRateLimited(),
- QueryParsed: context.GetQueryParsed(),
- })
+
+ if !context.IsIpBypassed(inst) {
+ params := grpc.RequestShutdownParams{
+ Inst: inst,
+ Method: context.GetMethod(inst),
+ Route: context.GetRoute(inst),
+ RouteParsed: context.GetParsedRoute(inst),
+ StatusCode: context.GetStatusCode(inst),
+ User: context.GetUserId(inst),
+ UserAgent: context.GetUserAgent(inst),
+ IP: context.GetIp(inst),
+ Url: context.GetUrl(inst),
+ RateLimitGroup: context.GetRateLimitGroup(inst),
+ RateLimited: context.IsEndpointRateLimited(inst),
+ QueryParsed: context.GetQueryParsed(inst),
+ IsIpBypassed: context.IsIpBypassed(inst),
+ APISpec: api_discovery.GetApiInfo(inst, inst.GetCurrentServer()),
+ }
+
+ context.Clear(inst)
+
+ go func() {
+ OnRequestShutdownReporting(params)
+ }()
}
- context.Clear()
+
return ""
}
diff --git a/lib/request-processor/handle_shell_execution.go b/lib/request-processor/handle_shell_execution.go
index f120c555a..58a2c741b 100644
--- a/lib/request-processor/handle_shell_execution.go
+++ b/lib/request-processor/handle_shell_execution.go
@@ -3,27 +3,28 @@ package main
import (
"main/attack"
"main/context"
+ "main/instance"
"main/log"
shell_injection "main/vulnerabilities/shell-injection"
)
-func OnPreShellExecuted() string {
- cmd := context.GetCmd()
- operation := context.GetFunctionName()
+func OnPreShellExecuted(inst *instance.RequestProcessorInstance) string {
+ cmd := context.GetCmd(inst)
+ operation := context.GetFunctionName(inst)
if cmd == "" {
return ""
}
- log.Info("Got shell command: ", cmd)
+ log.Info(inst, "Got shell command: ", cmd)
- if context.IsEndpointProtectionTurnedOff() {
- log.Infof("Protection is turned off -> will not run detection logic!")
+ if context.IsEndpointProtectionTurnedOff(inst) {
+ log.Infof(inst, "Protection is turned off -> will not run detection logic!")
return ""
}
- res := shell_injection.CheckContextForShellInjection(cmd, operation)
+ res := shell_injection.CheckContextForShellInjection(inst, cmd, operation)
if res != nil {
- return attack.ReportAttackDetected(res)
+ return attack.ReportAttackDetected(res, inst)
}
return ""
}
diff --git a/lib/request-processor/handle_sql_queries.go b/lib/request-processor/handle_sql_queries.go
index a92f3bddf..45dc2cc75 100644
--- a/lib/request-processor/handle_sql_queries.go
+++ b/lib/request-processor/handle_sql_queries.go
@@ -3,26 +3,27 @@ package main
import (
"main/attack"
"main/context"
+ "main/instance"
"main/log"
sql_injection "main/vulnerabilities/sql-injection"
)
-func OnPreSqlQueryExecuted() string {
- query := context.GetSqlQuery()
- dialect := context.GetSqlDialect()
- operation := context.GetFunctionName()
+func OnPreSqlQueryExecuted(inst *instance.RequestProcessorInstance) string {
+ query := context.GetSqlQuery(inst)
+ dialect := context.GetSqlDialect(inst)
+ operation := context.GetFunctionName(inst)
if query == "" || dialect == "" {
return ""
}
- if context.IsEndpointProtectionTurnedOff() {
- log.Infof("Protection is turned off -> will not run detection logic!")
+ if context.IsEndpointProtectionTurnedOff(inst) {
+ log.Infof(inst, "Protection is turned off -> will not run detection logic!")
return ""
}
- res := sql_injection.CheckContextForSqlInjection(query, operation, dialect)
+ res := sql_injection.CheckContextForSqlInjection(inst, query, operation, dialect)
if res != nil {
- return attack.ReportAttackDetected(res)
+ return attack.ReportAttackDetected(res, inst)
}
return ""
}
diff --git a/lib/request-processor/handle_urls.go b/lib/request-processor/handle_urls.go
index e682d1c1a..3006f95c4 100644
--- a/lib/request-processor/handle_urls.go
+++ b/lib/request-processor/handle_urls.go
@@ -5,8 +5,8 @@ import (
"html"
"main/attack"
"main/context"
- "main/globals"
"main/grpc"
+ "main/instance"
"main/log"
ssrf "main/vulnerabilities/ssrf"
)
@@ -22,32 +22,30 @@ import (
All these checks first verify if the hostname was provided via user input.
Protects both curl and fopen wrapper functions (file_get_contents, etc...).
*/
-func OnPreOutgoingRequest() string {
- hostname, port := context.GetOutgoingRequestHostnameAndPort()
- operation := context.GetFunctionName()
+func OnPreOutgoingRequest(inst *instance.RequestProcessorInstance) string {
+ hostname, port := context.GetOutgoingRequestHostnameAndPort(inst)
+ operation := context.GetFunctionName(inst)
// Check if the domain is blocked based on cloud configuration
- if !context.IsIpBypassed() && ssrf.IsBlockedOutboundDomain(hostname) {
- server := globals.GetCurrentServer()
+ if !context.IsIpBypassed(inst) && ssrf.IsBlockedOutboundDomainWithInst(inst, hostname) {
// Blocked domains should also be reported to the agent.
- if server != nil {
- go grpc.OnDomain(server, hostname, port)
- }
+ go grpc.OnDomain(inst, hostname, port)
message := fmt.Sprintf("Aikido firewall has blocked an outbound connection: %s(...) to %s", operation, html.EscapeString(hostname))
return attack.GetThrowAction(message, 500)
}
- if context.IsEndpointProtectionTurnedOff() {
- log.Infof("Protection is turned off -> will not run detection logic!")
+ if context.IsEndpointProtectionTurnedOff(inst) {
+ log.Infof(inst, "Protection is turned off -> will not run detection logic!")
return ""
}
- res := ssrf.CheckContextForSSRF(hostname, port, operation)
+ res := ssrf.CheckContextForSSRF(inst, hostname, port, operation)
if res != nil {
- return attack.ReportAttackDetected(res)
+ return attack.ReportAttackDetected(res, inst)
}
- log.Info("[BEFORE] Got domain: ", hostname)
+ log.Info(inst, "[BEFORE] Got domain: ", hostname)
+ //TODO: check if domain is blacklisted
return ""
}
@@ -67,50 +65,47 @@ func OnPreOutgoingRequest() string {
All these checks first verify if the hostname was provided via user input.
Protects curl.
*/
-func OnPostOutgoingRequest() string {
- defer context.ResetEventContext()
+func OnPostOutgoingRequest(inst *instance.RequestProcessorInstance) string {
+ defer context.ResetEventContext(inst)
- hostname, port := context.GetOutgoingRequestHostnameAndPort()
- effectiveHostname, effectivePort := context.GetOutgoingRequestEffectiveHostnameAndPort()
- resolvedIp := context.GetOutgoingRequestResolvedIp()
+ hostname, port := context.GetOutgoingRequestHostnameAndPort(inst)
+ effectiveHostname, effectivePort := context.GetOutgoingRequestEffectiveHostnameAndPort(inst)
+ resolvedIp := context.GetOutgoingRequestResolvedIp(inst)
if hostname == "" {
return ""
}
- log.Info("[AFTER] Got domain: ", hostname, " port: ", port)
+ log.Info(inst, "[AFTER] Got domain: ", hostname, " port: ", port)
- server := globals.GetCurrentServer()
- if server != nil {
- go grpc.OnDomain(server, hostname, port)
- if effectiveHostname != hostname {
- go grpc.OnDomain(server, effectiveHostname, effectivePort)
- }
+ go grpc.OnDomain(inst, hostname, port)
+ if effectiveHostname != hostname {
+ go grpc.OnDomain(inst, effectiveHostname, effectivePort)
}
- if context.IsEndpointProtectionTurnedOff() {
- log.Infof("Protection is turned off -> will not run detection logic!")
+ if context.IsEndpointProtectionTurnedOff(inst) {
+ log.Infof(inst, "Protection is turned off -> will not run detection logic!")
return ""
}
- if ssrf.IsRequestToItself(effectiveHostname, effectivePort) {
- log.Infof("Request to itself detected -> will not run detection logic!")
+ if ssrf.IsRequestToItself(inst, effectiveHostname, effectivePort) {
+ log.Infof(inst, "Request to itself detected -> will not run detection logic!")
return ""
}
- res := ssrf.CheckResolvedIpForSSRF(resolvedIp)
+ res := ssrf.CheckResolvedIpForSSRF(inst, resolvedIp)
if effectiveHostname != hostname {
- log.Infof("EffectiveHostname \"%s\" is different than Hostname \"%s\"!", effectiveHostname, hostname)
+ log.Infof(inst, "EffectiveHostname \"%s\" is different than Hostname \"%s\"!", effectiveHostname, hostname)
// After the request was made, the effective hostname is different that the initially requested one (redirects)
if res == nil {
// We double check here for SSRF on the effective hostname because some sinks might not provide the resolved IP address
- res = ssrf.CheckEffectiveHostnameForSSRF(effectiveHostname)
+ res = ssrf.CheckEffectiveHostnameForSSRF(inst, effectiveHostname)
}
}
if res != nil {
/* Throw exception to PHP layer if blocking is enabled -> Response content is not returned to the PHP code */
- return attack.ReportAttackDetected(res)
+ return attack.ReportAttackDetected(res, inst)
}
return ""
}
diff --git a/lib/request-processor/handle_user_event.go b/lib/request-processor/handle_user_event.go
index f7a5e66c2..e371ec662 100644
--- a/lib/request-processor/handle_user_event.go
+++ b/lib/request-processor/handle_user_event.go
@@ -2,26 +2,22 @@ package main
import (
"main/context"
- "main/globals"
"main/grpc"
+ "main/instance"
"main/log"
)
-func OnUserEvent() string {
- id := context.GetUserId()
- username := context.GetUserName()
- ip := context.GetIp()
+func OnUserEvent(inst *instance.RequestProcessorInstance) string {
+ id := context.GetUserId(inst)
+ username := context.GetUserName(inst)
+ ip := context.GetIp(inst)
- log.Infof("Got user event!")
+ log.Infof(inst, "Got user event!")
if id == "" || ip == "" {
return ""
}
- server := globals.GetCurrentServer()
- if server == nil {
- return ""
- }
- go grpc.OnUserEvent(server, id, username, ip)
+ go grpc.OnUserEvent(inst, id, username, ip)
return ""
}
diff --git a/lib/request-processor/helpers/resolveHostname.go b/lib/request-processor/helpers/resolveHostname.go
index ba064e284..dc9eae057 100644
--- a/lib/request-processor/helpers/resolveHostname.go
+++ b/lib/request-processor/helpers/resolveHostname.go
@@ -2,6 +2,7 @@ package helpers
import (
"context"
+ "main/instance"
"main/log"
"net"
"time"
@@ -11,13 +12,13 @@ import (
This function tries to resolve the hostname to a private IP adress, if possible.
It does this by calling DNS resolution from the OS (getaddrinfo for Linux).
*/
-func ResolveHostname(hostname string) []string {
+func ResolveHostname(inst *instance.RequestProcessorInstance, hostname string) []string {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
resolvedIps, err := net.DefaultResolver.LookupHost(ctx, hostname)
if err != nil {
- log.Errorf("Failed to resolve hostname %s: %v", hostname, err)
+ log.Errorf(inst, "Failed to resolve hostname %s: %v", hostname, err)
// If timeout is reached or the OS lookup fail, return an emtpy list of resolved IPs
return []string{}
}
diff --git a/lib/request-processor/instance/manager.go b/lib/request-processor/instance/manager.go
new file mode 100644
index 000000000..211b5abfb
--- /dev/null
+++ b/lib/request-processor/instance/manager.go
@@ -0,0 +1,74 @@
+package instance
+
+import (
+ "runtime"
+ "sync"
+ "unsafe"
+)
+
+// Stores instances keyed by thread ID:
+// - NTS (standard PHP): threadID is always 0, single instance
+// - ZTS (FrankenPHP): threadID is pthread_self(), one per thread
+var (
+ instances = make(map[uint64]*RequestProcessorInstance)
+ instancesMutex sync.RWMutex
+ pinners = make(map[uint64]runtime.Pinner) // Prevents GC while C++ holds pointers
+)
+
+// CreateInstance creates or reuses an instance for the given thread.
+// Returns an unsafe.Pointer for C++ to store.
+func CreateInstance(threadID uint64, isZTS bool) unsafe.Pointer {
+ instancesMutex.Lock()
+ defer instancesMutex.Unlock()
+
+ if existingInstance, exists := instances[threadID]; exists {
+ return unsafe.Pointer(existingInstance)
+ }
+
+ instance := NewRequestProcessorInstance(threadID, isZTS)
+ instances[threadID] = instance
+
+ // Pin to prevent GC while C++ holds the pointer
+ var pinner runtime.Pinner
+ pinner.Pin(instance)
+ pinners[threadID] = pinner
+
+ return unsafe.Pointer(instance)
+}
+
+func GetInstance(instancePtr unsafe.Pointer) *RequestProcessorInstance {
+ if instancePtr == nil {
+ return nil
+ }
+ return (*RequestProcessorInstance)(instancePtr)
+}
+
+func DestroyInstance(threadID uint64) {
+ instancesMutex.Lock()
+ defer instancesMutex.Unlock()
+
+ if pinner, exists := pinners[threadID]; exists {
+ pinner.Unpin()
+ delete(pinners, threadID)
+ }
+
+ delete(instances, threadID)
+}
+
+// GetAllInstances is used for testing and debugging
+func GetAllInstances() []*RequestProcessorInstance {
+ instancesMutex.RLock()
+ defer instancesMutex.RUnlock()
+
+ result := make([]*RequestProcessorInstance, 0, len(instances))
+ for _, instance := range instances {
+ result = append(result, instance)
+ }
+ return result
+}
+
+func GetInstanceCount() int {
+ instancesMutex.RLock()
+ defer instancesMutex.RUnlock()
+ return len(instances)
+}
diff --git a/lib/request-processor/instance/wrapper.go b/lib/request-processor/instance/wrapper.go
new file mode 100644
index 000000000..1cc88e2cc
--- /dev/null
+++ b/lib/request-processor/instance/wrapper.go
@@ -0,0 +1,158 @@
+package instance
+
+import (
+ . "main/aikido_types"
+ "sync"
+ "unsafe"
+)
+
+// RequestProcessorInstance holds per-request state for each PHP thread.
+// In NTS mode (standard PHP), there's one global instance.
+// In ZTS mode (FrankenPHP), each thread gets its own instance with locking.
+type RequestProcessorInstance struct {
+ CurrentToken string
+ CurrentServer *ServerData
+ threadID uint64 // CACHED: OS thread ID cached at RINIT for fast context lookups
+ ContextInstance unsafe.Pointer // For context callbacks
+ ContextCallback unsafe.Pointer // C function pointer, must be per-instance in ZTS
+
+ RequestContext interface{}
+ EventContext interface{}
+
+ mu sync.Mutex // Only used when isZTS is true
+ isZTS bool
+}
+
+// NewRequestProcessorInstance creates an instance. Pass isZTS=true for FrankenPHP.
+func NewRequestProcessorInstance(threadID uint64, isZTS bool) *RequestProcessorInstance {
+ return &RequestProcessorInstance{
+ CurrentToken: "",
+ CurrentServer: nil,
+ threadID: threadID,
+ isZTS: isZTS,
+ }
+}
+
+func (i *RequestProcessorInstance) SetCurrentServer(server *ServerData) {
+ if i.isZTS {
+ i.mu.Lock()
+ defer i.mu.Unlock()
+ }
+ i.CurrentServer = server
+}
+
+func (i *RequestProcessorInstance) GetCurrentServer() *ServerData {
+ if i.isZTS {
+ i.mu.Lock()
+ defer i.mu.Unlock()
+ }
+ return i.CurrentServer
+}
+
+func (i *RequestProcessorInstance) SetCurrentToken(token string) {
+ if i.isZTS {
+ i.mu.Lock()
+ defer i.mu.Unlock()
+ }
+ i.CurrentToken = token
+}
+
+func (i *RequestProcessorInstance) GetCurrentToken() string {
+ if i.isZTS {
+ i.mu.Lock()
+ defer i.mu.Unlock()
+ }
+ return i.CurrentToken
+}
+
+func (i *RequestProcessorInstance) IsInitialized() bool {
+ if i.isZTS {
+ i.mu.Lock()
+ defer i.mu.Unlock()
+ }
+ return i.CurrentServer != nil
+}
+
+func (i *RequestProcessorInstance) IsZTS() bool {
+ return i.isZTS
+}
+
+func (i *RequestProcessorInstance) SetContextCallback(callback unsafe.Pointer) {
+ if i.isZTS {
+ i.mu.Lock()
+ defer i.mu.Unlock()
+ }
+ i.ContextCallback = callback
+}
+
+func (i *RequestProcessorInstance) GetContextCallback() unsafe.Pointer {
+ if i.isZTS {
+ i.mu.Lock()
+ defer i.mu.Unlock()
+ }
+ return i.ContextCallback
+}
+
+func (i *RequestProcessorInstance) SetThreadID(tid uint64) {
+ if i.isZTS {
+ i.mu.Lock()
+ defer i.mu.Unlock()
+ }
+ i.threadID = tid
+}
+
+func (i *RequestProcessorInstance) GetThreadID() uint64 {
+ if i.isZTS {
+ i.mu.Lock()
+ defer i.mu.Unlock()
+ }
+ return i.threadID
+}
+
+func (i *RequestProcessorInstance) SetRequestContext(ctx interface{}) {
+ if i.isZTS {
+ i.mu.Lock()
+ defer i.mu.Unlock()
+ }
+ i.RequestContext = ctx
+}
+
+func (i *RequestProcessorInstance) GetRequestContext() interface{} {
+ if i.isZTS {
+ i.mu.Lock()
+ defer i.mu.Unlock()
+ }
+ return i.RequestContext
+}
+
+func (i *RequestProcessorInstance) SetEventContext(ctx interface{}) {
+ if i.isZTS {
+ i.mu.Lock()
+ defer i.mu.Unlock()
+ }
+ i.EventContext = ctx
+}
+
+func (i *RequestProcessorInstance) GetEventContext() interface{} {
+ if i.isZTS {
+ i.mu.Lock()
+ defer i.mu.Unlock()
+ }
+ return i.EventContext
+}
+
+func (i *RequestProcessorInstance) SetContextInstance(ptr unsafe.Pointer) {
+ if i.isZTS {
+ i.mu.Lock()
+ defer i.mu.Unlock()
+ }
+ i.ContextInstance = ptr
+}
+
+func (i *RequestProcessorInstance) GetContextInstance() unsafe.Pointer {
+ if i.isZTS {
+ i.mu.Lock()
+ defer i.mu.Unlock()
+ }
+ return i.ContextInstance
+}
diff --git a/lib/request-processor/log/log.go b/lib/request-processor/log/log.go
index 1161ee991..b271991a3 100644
--- a/lib/request-processor/log/log.go
+++ b/lib/request-processor/log/log.go
@@ -3,41 +3,25 @@ package log
import (
"errors"
"fmt"
- "log"
+
"main/globals"
+ "main/instance"
"os"
"time"
)
-type LogLevel int
-
-const (
- DebugLevel LogLevel = iota
- InfoLevel
- WarnLevel
- ErrorLevel
-)
-
-var (
- currentLogLevel = ErrorLevel
- Logger = log.New(os.Stdout, "", 0)
- cliLogging = true
- logFilePath = ""
-)
-var LogFile *os.File
-
type AikidoFormatter struct{}
-func (f *AikidoFormatter) Format(level LogLevel, message string) string {
+func (f *AikidoFormatter) Format(level globals.LogLevel, threadID uint64, message string) string {
var levelStr string
switch level {
- case DebugLevel:
+ case globals.LogDebugLevel:
levelStr = "DEBUG"
- case InfoLevel:
+ case globals.LogInfoLevel:
levelStr = "INFO"
- case WarnLevel:
+ case globals.LogWarnLevel:
levelStr = "WARN"
- case ErrorLevel:
+ case globals.LogErrorLevel:
levelStr = "ERROR"
default:
return "invalid log level"
@@ -46,108 +30,146 @@ func (f *AikidoFormatter) Format(level LogLevel, message string) string {
if len(message) > 1024 {
message = message[:1024] + "... [truncated]"
}
- if cliLogging {
- return fmt.Sprintf("[AIKIDO][%s] %s\n", levelStr, message)
+
+ globals.LogMutex.RLock()
+ isCliLogging := globals.CliLogging
+ globals.LogMutex.RUnlock()
+
+ if isCliLogging {
+ return fmt.Sprintf("[AIKIDO][%s][tid:%d] %s\n", levelStr, threadID, message)
}
- return fmt.Sprintf("[AIKIDO][%s][%s] %s\n", levelStr, time.Now().Format("15:04:05"), message)
+ return fmt.Sprintf("[AIKIDO][%s][tid:%d][%s] %s\n", levelStr, threadID, time.Now().Format("15:04:05"), message)
}
func initLogFile() {
- if cliLogging {
+ globals.LogMutex.Lock()
+ defer globals.LogMutex.Unlock()
+
+ if globals.CliLogging {
return
}
- if LogFile != nil {
+ if globals.LogFile != nil {
return
}
var err error
- LogFile, err = os.OpenFile(logFilePath, os.O_CREATE|os.O_WRONLY, 0666)
+ globals.LogFile, err = os.OpenFile(globals.LogFilePath, os.O_CREATE|os.O_WRONLY, 0666)
if err != nil {
return
}
- Logger.SetOutput(LogFile)
+ globals.Logger.SetOutput(globals.LogFile)
}
-func logMessage(level LogLevel, args ...interface{}) {
- if level >= currentLogLevel {
+func logMessage(inst *instance.RequestProcessorInstance, level globals.LogLevel, args ...interface{}) {
+ globals.LogMutex.RLock()
+ lvl := globals.CurrentLogLevel
+ globals.LogMutex.RUnlock()
+
+ if level >= lvl {
initLogFile()
formatter := &AikidoFormatter{}
message := fmt.Sprint(args...)
- formattedMessage := formatter.Format(level, message)
- Logger.Print(formattedMessage)
+ threadID := uint64(0)
+ if inst != nil {
+ threadID = inst.GetThreadID()
+ }
+ formattedMessage := formatter.Format(level, threadID, message)
+ globals.Logger.Print(formattedMessage)
}
}
-func logMessagef(level LogLevel, format string, args ...interface{}) {
- if level >= currentLogLevel {
+func logMessagef(inst *instance.RequestProcessorInstance, level globals.LogLevel, format string, args ...interface{}) {
+ globals.LogMutex.RLock()
+ lvl := globals.CurrentLogLevel
+ globals.LogMutex.RUnlock()
+
+ if level >= lvl {
initLogFile()
formatter := &AikidoFormatter{}
message := fmt.Sprintf(format, args...)
- formattedMessage := formatter.Format(level, message)
- Logger.Print(formattedMessage)
+ threadID := uint64(0)
+ if inst != nil {
+ threadID = inst.GetThreadID()
+ }
+ formattedMessage := formatter.Format(level, threadID, message)
+ globals.Logger.Print(formattedMessage)
}
}
-func Debug(args ...interface{}) {
- logMessage(DebugLevel, args...)
+func Debug(inst *instance.RequestProcessorInstance, args ...interface{}) {
+ logMessage(inst, globals.LogDebugLevel, args...)
}
-func Info(args ...interface{}) {
- logMessage(InfoLevel, args...)
+func Info(inst *instance.RequestProcessorInstance, args ...interface{}) {
+ logMessage(inst, globals.LogInfoLevel, args...)
}
-func Warn(args ...interface{}) {
- logMessage(WarnLevel, args...)
+func Warn(inst *instance.RequestProcessorInstance, args ...interface{}) {
+ logMessage(inst, globals.LogWarnLevel, args...)
}
-func Error(args ...interface{}) {
- logMessage(ErrorLevel, args...)
+func Error(inst *instance.RequestProcessorInstance, args ...interface{}) {
+ logMessage(inst, globals.LogErrorLevel, args...)
}
-func Debugf(format string, args ...interface{}) {
- logMessagef(DebugLevel, format, args...)
+func Debugf(inst *instance.RequestProcessorInstance, format string, args ...interface{}) {
+ logMessagef(inst, globals.LogDebugLevel, format, args...)
}
-func Infof(format string, args ...interface{}) {
- logMessagef(InfoLevel, format, args...)
+func Infof(inst *instance.RequestProcessorInstance, format string, args ...interface{}) {
+ logMessagef(inst, globals.LogInfoLevel, format, args...)
}
-func Warnf(format string, args ...interface{}) {
- logMessagef(WarnLevel, format, args...)
+func Warnf(inst *instance.RequestProcessorInstance, format string, args ...interface{}) {
+ logMessagef(inst, globals.LogWarnLevel, format, args...)
}
-func Errorf(format string, args ...interface{}) {
- logMessagef(ErrorLevel, format, args...)
-
+func Errorf(inst *instance.RequestProcessorInstance, format string, args ...interface{}) {
+ logMessagef(inst, globals.LogErrorLevel, format, args...)
}
+// SetLogLevel changes the current log level (thread-safe)
func SetLogLevel(level string) error {
+ var newLevel globals.LogLevel
+
switch level {
case "DEBUG":
- currentLogLevel = DebugLevel
+ newLevel = globals.LogDebugLevel
case "INFO":
- currentLogLevel = InfoLevel
+ newLevel = globals.LogInfoLevel
case "WARN":
- currentLogLevel = WarnLevel
+ newLevel = globals.LogWarnLevel
case "ERROR":
- currentLogLevel = ErrorLevel
+ newLevel = globals.LogErrorLevel
default:
return errors.New("invalid log level")
}
+
+ globals.LogMutex.Lock()
+ defer globals.LogMutex.Unlock()
+ globals.CurrentLogLevel = newLevel
return nil
}
func Init(diskLogs bool) {
+ globals.LogMutex.Lock()
+ defer globals.LogMutex.Unlock()
+
if !diskLogs {
+ globals.CliLogging = true
return
}
- cliLogging = false
+ globals.CliLogging = false
currentTime := time.Now()
timeStr := currentTime.Format("20060102150405")
- logFilePath = fmt.Sprintf("/var/log/aikido-"+globals.Version+"/aikido-request-processor-%s-%d.log", timeStr, os.Getpid())
+ globals.LogFilePath = fmt.Sprintf("/var/log/aikido-"+globals.Version+"/aikido-request-processor-%s-%d.log", timeStr, os.Getpid())
}
func Uninit() {
- if LogFile != nil {
- LogFile.Close()
+ globals.LogMutex.Lock()
+ defer globals.LogMutex.Unlock()
+
+ if globals.LogFile != nil {
+ globals.LogFile.Close()
+ globals.LogFile = nil
}
}
diff --git a/lib/request-processor/main.go b/lib/request-processor/main.go
index 4b820ca5d..d6e674829 100644
--- a/lib/request-processor/main.go
+++ b/lib/request-processor/main.go
@@ -8,6 +8,7 @@ import (
"main/context"
"main/globals"
"main/grpc"
+ "main/instance"
"main/log"
"main/utils"
zen_internals "main/vulnerabilities/zen-internals"
@@ -16,6 +17,8 @@ import (
"unsafe"
)
+type HandlerFunction func(*instance.RequestProcessorInstance) string
+
var eventHandlers = map[int]HandlerFunction{
C.EVENT_PRE_REQUEST: OnPreRequest,
C.EVENT_POST_REQUEST: OnPostRequest,
@@ -38,49 +41,68 @@ func initializeServer(server *ServerData) {
grpc.GetCloudConfig(server, 5*time.Second)
}
+//export CreateInstance
+func CreateInstance(threadID uint64, isZTS bool) unsafe.Pointer {
+ return instance.CreateInstance(threadID, isZTS)
+}
+
+//export DestroyInstance
+func DestroyInstance(threadID uint64) {
+ instance.DestroyInstance(threadID)
+}
+
//export RequestProcessorInit
-func RequestProcessorInit(initJson string) (initOk bool) {
+func RequestProcessorInit(instancePtr unsafe.Pointer, initJson string) (initOk bool) {
+ inst := instance.GetInstance(instancePtr)
defer func() {
if r := recover(); r != nil {
- log.Warn("Recovered from panic:", r)
+ log.Warn(inst, "Recovered from panic:", r)
initOk = false
}
}()
- config.Init(initJson)
+ if inst == nil {
+ return false
+ }
+
+ config.Init(inst, initJson)
- log.Debugf("Aikido Request Processor v%s (server PID: %d, request processor PID: %d) started in \"%s\" mode!",
+ log.Debugf(inst, "Aikido Request Processor v%s (server PID: %d, request processor PID: %d) started in \"%s\" mode!",
globals.Version,
globals.EnvironmentConfig.ServerPID,
globals.EnvironmentConfig.RequestProcessorPID,
globals.EnvironmentConfig.PlatformName,
)
- log.Debugf("Init data: %s", initJson)
- log.Debugf("Started with token: \"AIK_RUNTIME_***%s\"", utils.AnonymizeToken(globals.CurrentToken))
+ log.Debugf(inst, "Init data: %s", initJson)
+ log.Debugf(inst, "Started with token: \"AIK_RUNTIME_***%s\"", utils.AnonymizeToken(inst.GetCurrentToken()))
if globals.EnvironmentConfig.PlatformName != "cli" {
grpc.Init()
- server := globals.GetCurrentServer()
+ server := inst.GetCurrentServer()
if server != nil {
initializeServer(server)
}
grpc.StartCloudConfigRoutine()
}
if !zen_internals.Init() {
- log.Error("Error initializing zen-internals library!")
+ log.Error(inst, "Error initializing zen-internals library!")
return false
}
return true
}
-var CContextCallback C.ContextCallback
+func GoContextCallback(inst *instance.RequestProcessorInstance, contextId int) string {
+ if inst == nil {
+ return ""
+ }
-func GoContextCallback(contextId int) string {
- if CContextCallback == nil {
+ contextCallbackPtr := inst.GetContextCallback()
+ if contextCallbackPtr == nil {
return ""
}
- contextData := C.call(CContextCallback, C.int(contextId))
+ contextCallback := (C.ContextCallback)(contextCallbackPtr)
+ contextData := C.call(contextCallback, C.int(contextId))
if contextData == nil {
return ""
}
@@ -98,36 +120,47 @@ func GoContextCallback(contextId int) string {
}
//export RequestProcessorContextInit
-func RequestProcessorContextInit(contextCallback C.ContextCallback) (initOk bool) {
+func RequestProcessorContextInit(instancePtr unsafe.Pointer, contextCallback C.ContextCallback) (initOk bool) {
+ inst := instance.GetInstance(instancePtr)
defer func() {
if r := recover(); r != nil {
- log.Warn("Recovered from panic:", r)
+ log.Warn(inst, "Recovered from panic:", r)
initOk = false
}
}()
- log.Debug("Initializing context...")
- CContextCallback = contextCallback
- return context.Init(GoContextCallback)
+ if inst == nil {
+ return false
+ }
+
+ inst.SetContextCallback(unsafe.Pointer(contextCallback))
+ return context.Init(instancePtr, GoContextCallback)
}
/*
RequestProcessorConfigUpdate is used to update the Aikido Config loaded from env variables and send this config via gRPC to the Aikido Agent.
*/
//export RequestProcessorConfigUpdate
-func RequestProcessorConfigUpdate(configJson string) (initOk bool) {
+func RequestProcessorConfigUpdate(instancePtr unsafe.Pointer, configJson string) (initOk bool) {
+ inst := instance.GetInstance(instancePtr)
defer func() {
if r := recover(); r != nil {
- log.Warn("Recovered from panic:", r)
+ log.Warn(inst, "Recovered from panic:", r)
initOk = false
}
}()
- log.Debugf("Reloading Aikido config...")
+ if inst == nil {
+ return false
+ }
+
+ log.Debugf(inst, "Reloading Aikido config...")
conf := AikidoConfigData{}
- reloadResult := config.ReloadAikidoConfig(&conf, configJson)
- server := globals.GetCurrentServer()
+ reloadResult := config.ReloadAikidoConfig(inst, &conf, configJson)
+
+ server := inst.GetCurrentServer()
+
if server == nil {
return false
}
@@ -147,15 +180,25 @@ func RequestProcessorConfigUpdate(configJson string) (initOk bool) {
}
//export RequestProcessorOnEvent
-func RequestProcessorOnEvent(eventId int) (outputJson *C.char) {
+func RequestProcessorOnEvent(instancePtr unsafe.Pointer, eventId int) (outputJson *C.char) {
+ inst := instance.GetInstance(instancePtr)
defer func() {
if r := recover(); r != nil {
- log.Warn("Recovered from panic:", r)
+ log.Warn(inst, "Recovered from panic:", r)
outputJson = nil
}
}()
- goString := eventHandlers[eventId]()
+ if inst == nil {
+ return nil
+ }
+
+ handler, exists := eventHandlers[eventId]
+ if !exists {
+ return nil
+ }
+
+ goString := handler(inst)
if goString == "" {
return nil
}
@@ -168,30 +211,41 @@ func RequestProcessorOnEvent(eventId int) (outputJson *C.char) {
Otherwise, it returns the environment value.
*/
//export RequestProcessorGetBlockingMode
-func RequestProcessorGetBlockingMode() int {
- return utils.GetBlockingMode(globals.GetCurrentServer())
+func RequestProcessorGetBlockingMode(instancePtr unsafe.Pointer) int {
+ inst := instance.GetInstance(instancePtr)
+ if inst == nil {
+ return -1
+ }
+ return utils.GetBlockingMode(inst.GetCurrentServer())
}
//export RequestProcessorReportStats
-func RequestProcessorReportStats(sink, kind string, attacksDetected, attacksBlocked, interceptorThrewError, withoutContext, total int32, timings []int64) {
+func RequestProcessorReportStats(instancePtr unsafe.Pointer, sink, kind string, attacksDetected, attacksBlocked, interceptorThrewError, withoutContext, total int32, timings []int64) {
if globals.EnvironmentConfig.PlatformName == "cli" {
return
}
+
+ inst := instance.GetInstance(instancePtr)
+ if inst == nil {
+ return
+ }
+
clonedTimings := make([]int64, len(timings))
copy(clonedTimings, timings)
- go grpc.OnMonitoredSinkStats(globals.GetCurrentServer(), strings.Clone(sink), strings.Clone(kind), attacksDetected, attacksBlocked, interceptorThrewError, withoutContext, total, clonedTimings)
+ go grpc.OnMonitoredSinkStats(inst, strings.Clone(sink), strings.Clone(kind), attacksDetected, attacksBlocked, interceptorThrewError, withoutContext, total, clonedTimings)
}
//export RequestProcessorUninit
-func RequestProcessorUninit() {
- log.Debug("Uninit: {}")
+func RequestProcessorUninit(instancePtr unsafe.Pointer) {
+ inst := instance.GetInstance(instancePtr)
+ log.Debug(inst, "Uninit: {}")
zen_internals.Uninit()
if globals.EnvironmentConfig.PlatformName != "cli" {
grpc.Uninit()
}
- log.Debugf("Aikido Request Processor v%s stopped!", globals.Version)
+ log.Debugf(inst, "Aikido Request Processor v%s stopped!", globals.Version)
config.Uninit()
}
diff --git a/lib/request-processor/utils/build_route_from_url.go b/lib/request-processor/utils/build_route_from_url.go
index bbdbefdb2..765a9b00d 100644
--- a/lib/request-processor/utils/build_route_from_url.go
+++ b/lib/request-processor/utils/build_route_from_url.go
@@ -2,7 +2,7 @@ package utils
import (
"errors"
- "main/globals"
+ "main/instance"
"net"
"net/url"
"regexp"
@@ -83,13 +83,13 @@ func CompileCustomPattern(pattern string) (*regexp.Regexp, error) {
return compiled, nil
}
-func BuildRouteFromURL(url string) string {
+func BuildRouteFromURL(inst *instance.RequestProcessorInstance, url string) string {
path := tryParseURLPath(url)
if path == "" {
return ""
}
- route := strings.Join(replaceURLSegments(path), "/")
+ route := strings.Join(replaceURLSegments(inst, path), "/")
if route == "/" || route == "" {
return "/"
@@ -106,25 +106,27 @@ func tryParseURLPath(rawURL string) string {
return parsedURL.Path
}
-func replaceURLSegments(path string) []string {
+func replaceURLSegments(inst *instance.RequestProcessorInstance, path string) []string {
segments := strings.Split(path, "/")
newSegments := make([]string, 0, len(segments))
for i, segment := range segments {
if segment == "" && i != 0 {
continue
}
- newSegments = append(newSegments, replaceURLSegmentWithParam(segment))
+ newSegments = append(newSegments, replaceURLSegmentWithParam(inst, segment))
}
return newSegments
}
-func replaceURLSegmentWithParam(segment string) string {
- server := globals.GetCurrentServer()
- if server != nil {
- paramMatchers := server.ParamMatchers
- for param, regex := range paramMatchers {
- if regex.MatchString(segment) {
- return ":" + param
+func replaceURLSegmentWithParam(inst *instance.RequestProcessorInstance, segment string) string {
+ if inst != nil {
+ server := inst.GetCurrentServer()
+ if server != nil {
+ paramMatchers := server.ParamMatchers
+ for param, regex := range paramMatchers {
+ if regex.MatchString(segment) {
+ return ":" + param
+ }
}
}
}
diff --git a/lib/request-processor/utils/utils.go b/lib/request-processor/utils/utils.go
index ea6887ad1..7af403b54 100644
--- a/lib/request-processor/utils/utils.go
+++ b/lib/request-processor/utils/utils.go
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"main/helpers"
+ "main/instance"
"main/log"
"net"
"net/netip"
@@ -196,7 +197,7 @@ func isLocalhost(ip string) bool {
return parsedIP.IsLoopback()
}
-func IsIpInSet(ipSet *netipx.IPSet, ip string) int {
+func IsIpInSet(inst *instance.RequestProcessorInstance, ipSet *netipx.IPSet, ip string) int {
if ipSet == nil || ipSet.Equal(&netipx.IPSet{}) {
// No IPs configured in the list -> return default value
return NoConfig
@@ -204,7 +205,7 @@ func IsIpInSet(ipSet *netipx.IPSet, ip string) int {
ipAddress, err := netip.ParseAddr(ip)
if err != nil {
- log.Infof("Invalid ip address: %s\n", ip)
+ log.Infof(inst, "Invalid ip address: %s\n", ip)
return NoConfig
}
@@ -221,7 +222,7 @@ func IsIpInSet(ipSet *netipx.IPSet, ip string) int {
return NotFound
}
-func IsIpAllowedOnEndpoint(server *ServerData, allowedIps *netipx.IPSet, ip string) int {
+func IsIpAllowedOnEndpoint(inst *instance.RequestProcessorInstance, server *ServerData, allowedIps *netipx.IPSet, ip string) int {
if server == nil {
return NoConfig
}
@@ -229,17 +230,17 @@ func IsIpAllowedOnEndpoint(server *ServerData, allowedIps *netipx.IPSet, ip stri
return Found
}
- return IsIpInSet(allowedIps, ip)
+ return IsIpInSet(inst, allowedIps, ip)
}
-func IsIpBypassed(server *ServerData, ip string) bool {
+func IsIpBypassed(inst *instance.RequestProcessorInstance, server *ServerData, ip string) bool {
if server == nil {
return false
}
server.CloudConfigMutex.Lock()
defer server.CloudConfigMutex.Unlock()
- return IsIpInSet(server.CloudConfig.BypassedIps, ip) == Found
+ return IsIpInSet(inst, server.CloudConfig.BypassedIps, ip) == Found
}
func getIpFromXForwardedFor(value string) string {
@@ -333,7 +334,7 @@ type IpListMatch struct {
Description string
}
-func IsIpInList(ipList map[string]IpList, ip string) (int, []IpListMatch) {
+func IsIpInList(inst *instance.RequestProcessorInstance, ipList map[string]IpList, ip string) (int, []IpListMatch) {
if len(ipList) == 0 {
return NoConfig, []IpListMatch{}
}
@@ -357,7 +358,7 @@ func IsIpInList(ipList map[string]IpList, ip string) (int, []IpListMatch) {
return Found, matches
}
-func IsIpAllowed(server *ServerData, ip string) bool {
+func IsIpAllowed(inst *instance.RequestProcessorInstance, server *ServerData, ip string) bool {
server.CloudConfigMutex.Lock()
defer server.CloudConfigMutex.Unlock()
@@ -365,22 +366,22 @@ func IsIpAllowed(server *ServerData, ip string) bool {
return true
}
- result, _ := IsIpInList(server.CloudConfig.AllowedIps, ip)
+ result, _ := IsIpInList(inst, server.CloudConfig.AllowedIps, ip)
// IP is allowed if it's found in the allowed lists or if the allowed lists are not configured
return result == Found || result == NoConfig
}
-func IsIpBlocked(server *ServerData, ip string) (bool, []IpListMatch) {
+func IsIpBlocked(inst *instance.RequestProcessorInstance, server *ServerData, ip string) (bool, []IpListMatch) {
server.CloudConfigMutex.Lock()
defer server.CloudConfigMutex.Unlock()
- result, matches := IsIpInList(server.CloudConfig.BlockedIps, ip)
+ result, matches := IsIpInList(inst, server.CloudConfig.BlockedIps, ip)
return result == Found, matches
}
-func IsIpMonitored(server *ServerData, ip string) (bool, []IpListMatch) {
+func IsIpMonitored(inst *instance.RequestProcessorInstance, server *ServerData, ip string) (bool, []IpListMatch) {
server.CloudConfigMutex.Lock()
defer server.CloudConfigMutex.Unlock()
- result, matches := IsIpInList(server.CloudConfig.MonitoredIps, ip)
+ result, matches := IsIpInList(inst, server.CloudConfig.MonitoredIps, ip)
return result == Found, matches
}
diff --git a/lib/request-processor/utils/utils_test.go b/lib/request-processor/utils/utils_test.go
index 7bca29249..e5b14143c 100644
--- a/lib/request-processor/utils/utils_test.go
+++ b/lib/request-processor/utils/utils_test.go
@@ -10,6 +10,7 @@ import (
"encoding/json"
. "main/aikido_types"
"main/globals"
+ "main/instance"
"math/big"
"regexp"
"strings"
@@ -230,7 +231,7 @@ func TestBuildRouteFromURL(t *testing.T) {
for _, test := range tests {
t.Run(test.url, func(t *testing.T) {
- result := BuildRouteFromURL(test.url)
+ result := BuildRouteFromURL(nil, test.url)
if result != test.expected {
t.Errorf("expected %s, got %s", test.expected, result)
}
@@ -353,7 +354,7 @@ func TestBuildRouteFromURL_EdgeCases(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- result := BuildRouteFromURL(tt.url)
+ result := BuildRouteFromURL(nil, tt.url)
if result != tt.expected {
t.Errorf("BuildRouteFromURL(%q) = %q, want %q", tt.url, result, tt.expected)
}
@@ -362,11 +363,6 @@ func TestBuildRouteFromURL_EdgeCases(t *testing.T) {
}
func TestBuildRouteFromURL_WithParamMatchers(t *testing.T) {
- originalServer := globals.GetCurrentServer()
- defer func() {
- globals.CurrentServer = originalServer
- }()
-
mustCompileCustomPattern := func(pattern string) *regexp.Regexp {
re, err := CompileCustomPattern(pattern)
if err != nil {
@@ -384,7 +380,9 @@ func TestBuildRouteFromURL_WithParamMatchers(t *testing.T) {
"tenant": mustCompileCustomPattern("aikido-{digits}"),
"slug": mustCompileCustomPattern("aikido-{alpha}-{digits}-{alpha}"),
}
- globals.CurrentServer = server
+
+ testInst := instance.NewRequestProcessorInstance(0, false)
+ testInst.SetCurrentServer(server)
tests := []struct {
name string
@@ -420,7 +418,7 @@ func TestBuildRouteFromURL_WithParamMatchers(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- result := BuildRouteFromURL(tt.url)
+ result := BuildRouteFromURL(testInst, tt.url)
if result != tt.expected {
t.Errorf("BuildRouteFromURL(%q) = %q, want %q", tt.url, result, tt.expected)
}
@@ -798,7 +796,7 @@ func TestIsIpBlockedByPrefix(t *testing.T) {
IpList, _ := BuildIpList("test", []string{"1.2.0.0/16"})
server.CloudConfig.BlockedIps["test"] = *IpList
ip := "1.2.3.4"
- result, _ := IsIpBlocked(server, ip)
+ result, _ := IsIpBlocked(nil, server, ip)
if result != true {
t.Errorf("expected true, got %v", result)
}
@@ -810,7 +808,7 @@ func TestIsIpBlockedByIp(t *testing.T) {
IpList, _ := BuildIpList("test", []string{"1.2.3.4"})
server.CloudConfig.BlockedIps["test"] = *IpList
ip := "1.2.3.4"
- result, _ := IsIpBlocked(server, ip)
+ result, _ := IsIpBlocked(nil, server, ip)
if result != true {
t.Errorf("expected true, got %v", result)
}
@@ -822,7 +820,7 @@ func TestIsIpNotBlockedByPrefix(t *testing.T) {
IpList, _ := BuildIpList("test", []string{"1.2.0.0/16"})
server.CloudConfig.BlockedIps["test"] = *IpList
ip := "2.3.4.5"
- result, _ := IsIpBlocked(server, ip)
+ result, _ := IsIpBlocked(nil, server, ip)
if result != false {
t.Errorf("expected false, got %v", result)
}
@@ -834,7 +832,7 @@ func TestIsIpNotBlockedByIp(t *testing.T) {
IpList, _ := BuildIpList("test", []string{"1.2.3.4"})
server.CloudConfig.BlockedIps["test"] = *IpList
ip := "2.3.4.5"
- result, _ := IsIpBlocked(server, ip)
+ result, _ := IsIpBlocked(nil, server, ip)
if result != false {
t.Errorf("expected false, got %v", result)
}
@@ -845,7 +843,7 @@ func TestIsIpv6BlockedByPrefix(t *testing.T) {
IpList, _ := BuildIpList("test", []string{"2001:db8::/32"})
server.CloudConfig.BlockedIps["test"] = *IpList
ip := "2001:db8:1234:5678:90ab:cdef:1234:5678"
- result, _ := IsIpBlocked(server, ip)
+ result, _ := IsIpBlocked(nil, server, ip)
if result != true {
t.Errorf("expected true, got %v", result)
}
@@ -857,7 +855,7 @@ func TestIsIpv6BlockedByIp(t *testing.T) {
IpList, _ := BuildIpList("test", []string{"2001:db8::1"})
server.CloudConfig.BlockedIps["test"] = *IpList
ip := "2001:db8::1"
- result, _ := IsIpBlocked(server, ip)
+ result, _ := IsIpBlocked(nil, server, ip)
if result != true {
t.Errorf("expected true, got %v", result)
}
@@ -869,7 +867,7 @@ func TestIsIpv6NotBlockedByPrefix(t *testing.T) {
IpList, _ := BuildIpList("test", []string{"2001:db8::/32"})
server.CloudConfig.BlockedIps["test"] = *IpList
ip := "2001:db9::1"
- result, _ := IsIpBlocked(server, ip)
+ result, _ := IsIpBlocked(nil, server, ip)
if result != false {
t.Errorf("expected false, got %v", result)
}
@@ -881,7 +879,7 @@ func TestIsIpv6NotBlockedByIp(t *testing.T) {
IpList, _ := BuildIpList("test", []string{"2001:db8::1"})
server.CloudConfig.BlockedIps["test"] = *IpList
ip := "2001:db8::2"
- result, _ := IsIpBlocked(server, ip)
+ result, _ := IsIpBlocked(nil, server, ip)
if result != false {
t.Errorf("expected false, got %v", result)
}
diff --git a/lib/request-processor/vulnerabilities/path-traversal/checkContextForPathTraversal.go b/lib/request-processor/vulnerabilities/path-traversal/checkContextForPathTraversal.go
index 22f02e310..ec830acad 100644
--- a/lib/request-processor/vulnerabilities/path-traversal/checkContextForPathTraversal.go
+++ b/lib/request-processor/vulnerabilities/path-traversal/checkContextForPathTraversal.go
@@ -3,16 +3,17 @@ package path_traversal
import (
"main/context"
"main/helpers"
+ "main/instance"
"main/utils"
"strings"
)
-func CheckContextForPathTraversal(filename string, operation string, checkPathStart bool) *utils.InterceptorResult {
+func CheckContextForPathTraversal(inst *instance.RequestProcessorInstance, filename string, operation string, checkPathStart bool) *utils.InterceptorResult {
trimmedFilename := helpers.TrimInvisible(filename)
sanitizedPath := SanitizePath(trimmedFilename)
for _, source := range context.SOURCES {
- mapss := source.CacheGet()
+ mapss := source.CacheGet(inst)
for str, path := range mapss {
trimmedInputString := helpers.TrimInvisible(str)
diff --git a/lib/request-processor/vulnerabilities/path-traversal/checkContextForPathTraversal_test.go b/lib/request-processor/vulnerabilities/path-traversal/checkContextForPathTraversal_test.go
index 401279f21..500d840c5 100644
--- a/lib/request-processor/vulnerabilities/path-traversal/checkContextForPathTraversal_test.go
+++ b/lib/request-processor/vulnerabilities/path-traversal/checkContextForPathTraversal_test.go
@@ -9,7 +9,7 @@ import (
func TestCheckContextForPathTraversal(t *testing.T) {
t.Run("it detects path traversal from body parameter", func(t *testing.T) {
- context.LoadForUnitTests(map[string]string{
+ inst := context.LoadForUnitTests(map[string]string{
"remoteAddress": "ip",
"method": "POST",
"url": "url",
@@ -18,7 +18,7 @@ func TestCheckContextForPathTraversal(t *testing.T) {
})
operation := "operation"
- result := CheckContextForPathTraversal("../file/test.txt", operation, true)
+ result := CheckContextForPathTraversal(inst, "../file/test.txt", operation, true)
if result == nil {
t.Errorf("expected result, got nil")
@@ -46,14 +46,8 @@ func TestCheckContextForPathTraversal(t *testing.T) {
})
t.Run("it does not flag safe operation", func(t *testing.T) {
- context.LoadForUnitTests(map[string]string{
- "remoteAddress": "ip",
- "method": "POST",
- "url": "url",
- })
-
operation := "path.normalize"
- context.LoadForUnitTests(map[string]string{
+ inst := context.LoadForUnitTests(map[string]string{
"url": "/_next/static/RjAvHy_jB1ciRT_xBrSyI/_ssgManifest.js",
"method": "GET",
"headers": context.GetJsonString(map[string]interface{}{
@@ -78,12 +72,12 @@ func TestCheckContextForPathTraversal(t *testing.T) {
"x-forwarded-proto": "http",
"x-forwarded-for": "127.0.0.1",
}),
- "source": "http.createServer",
- "cookies": context.GetJsonString(map[string]interface{}{"Phpstorm-8262f4a6": "6a1925f9-2f0e-45ea-8336-a6988d56b1aa"}),
- "remoteAddress": "127.0.0.1",
- })
+ "source": "http.createServer",
+ "cookies": context.GetJsonString(map[string]interface{}{"Phpstorm-8262f4a6": "6a1925f9-2f0e-45ea-8336-a6988d56b1aa"}),
+ "remoteAddress": "127.0.0.1",
+ })
- result := CheckContextForPathTraversal("../../web/spec-extension/cookies", operation, true)
+ result := CheckContextForPathTraversal(inst, "../../web/spec-extension/cookies", operation, true)
if result != nil {
t.Errorf("expected nil, got %v", result)
}
diff --git a/lib/request-processor/vulnerabilities/shell-injection/checkContextForShellInjection.go b/lib/request-processor/vulnerabilities/shell-injection/checkContextForShellInjection.go
index 3c971cec9..03cb314a4 100644
--- a/lib/request-processor/vulnerabilities/shell-injection/checkContextForShellInjection.go
+++ b/lib/request-processor/vulnerabilities/shell-injection/checkContextForShellInjection.go
@@ -3,13 +3,14 @@ package shell_injection
import (
"main/context"
"main/helpers"
+ "main/instance"
"main/utils"
)
-func CheckContextForShellInjection(command string, operation string) *utils.InterceptorResult {
+func CheckContextForShellInjection(inst *instance.RequestProcessorInstance, command string, operation string) *utils.InterceptorResult {
trimmedCommand := helpers.TrimInvisible(command)
for _, source := range context.SOURCES {
- mapss := source.CacheGet()
+ mapss := source.CacheGet(inst)
for str, path := range mapss {
trimmedInputString := helpers.TrimInvisible(str)
diff --git a/lib/request-processor/vulnerabilities/shell-injection/checkContextForShellInjection_test.go b/lib/request-processor/vulnerabilities/shell-injection/checkContextForShellInjection_test.go
index cdf48fc78..8d70e25ac 100644
--- a/lib/request-processor/vulnerabilities/shell-injection/checkContextForShellInjection_test.go
+++ b/lib/request-processor/vulnerabilities/shell-injection/checkContextForShellInjection_test.go
@@ -8,7 +8,7 @@ import (
func TestCheckContextForShellInjection(t *testing.T) {
t.Run("it detects shell injection", func(t *testing.T) {
- context.LoadForUnitTests(map[string]string{
+ inst := context.LoadForUnitTests(map[string]string{
"remoteAddress": "ip",
"method": "POST",
"url": "url",
@@ -19,7 +19,7 @@ func TestCheckContextForShellInjection(t *testing.T) {
"route": "/",
})
operation := "child_process.exec"
- result := CheckContextForShellInjection("binary --domain www.example`whoami`.com", operation)
+ result := CheckContextForShellInjection(inst, "binary --domain www.example`whoami`.com", operation)
if result == nil {
t.Errorf("expected result, got nil")
@@ -48,7 +48,7 @@ func TestCheckContextForShellInjection(t *testing.T) {
t.Run("it detects shell injection from route params", func(t *testing.T) {
operation := "child_process.exec"
- context.LoadForUnitTests(map[string]string{
+ inst := context.LoadForUnitTests(map[string]string{
"remoteAddress": "ip",
"method": "POST",
"url": "url",
@@ -59,7 +59,7 @@ func TestCheckContextForShellInjection(t *testing.T) {
"route": "/",
})
- result := CheckContextForShellInjection("binary --domain www.example`whoami`.com", operation)
+ result := CheckContextForShellInjection(inst, "binary --domain www.example`whoami`.com", operation)
if result == nil {
t.Errorf("expected result, got nil")
diff --git a/lib/request-processor/vulnerabilities/sql-injection/checkContextForSqlInjection.go b/lib/request-processor/vulnerabilities/sql-injection/checkContextForSqlInjection.go
index 281a3ee03..c5aaec796 100644
--- a/lib/request-processor/vulnerabilities/sql-injection/checkContextForSqlInjection.go
+++ b/lib/request-processor/vulnerabilities/sql-injection/checkContextForSqlInjection.go
@@ -3,6 +3,7 @@ package sql_injection
import (
"main/context"
"main/helpers"
+ "main/instance"
"main/utils"
)
@@ -10,12 +11,12 @@ import (
* This function goes over all the different input types in the context and checks
* if it's a possible SQL Injection, if so the function returns an InterceptorResult
*/
-func CheckContextForSqlInjection(sql string, operation string, dialect string) *utils.InterceptorResult {
+func CheckContextForSqlInjection(inst *instance.RequestProcessorInstance, sql string, operation string, dialect string) *utils.InterceptorResult {
trimmedSql := helpers.TrimInvisible(sql)
dialectId := utils.GetSqlDialectFromString(dialect)
for _, source := range context.SOURCES {
- mapss := source.CacheGet()
+ mapss := source.CacheGet(inst)
for str, path := range mapss {
trimmedInputString := helpers.TrimInvisible(str)
diff --git a/lib/request-processor/vulnerabilities/ssrf/checkBlockedDomain.go b/lib/request-processor/vulnerabilities/ssrf/checkBlockedDomain.go
index 23018884c..e1ccbc970 100644
--- a/lib/request-processor/vulnerabilities/ssrf/checkBlockedDomain.go
+++ b/lib/request-processor/vulnerabilities/ssrf/checkBlockedDomain.go
@@ -1,14 +1,24 @@
package ssrf
import (
- "main/globals"
+ . "main/aikido_types"
"main/helpers"
+ "main/instance"
)
// IsBlockedOutboundDomain checks if an outbound request to a hostname should be blocked
// based on the cloud configuration for blocked/allowed domains
func IsBlockedOutboundDomain(hostname string) bool {
- server := globals.GetCurrentServer()
+ return IsBlockedOutboundDomainWithInst(nil, hostname)
+}
+
+// IsBlockedOutboundDomainWithInst checks if an outbound request to a hostname should be blocked
+// based on the cloud configuration for blocked/allowed domains
+func IsBlockedOutboundDomainWithInst(inst *instance.RequestProcessorInstance, hostname string) bool {
+ var server *ServerData
+ if inst != nil {
+ server = inst.GetCurrentServer()
+ }
if server == nil {
return false
}
diff --git a/lib/request-processor/vulnerabilities/ssrf/checkBlockedDomain_test.go b/lib/request-processor/vulnerabilities/ssrf/checkBlockedDomain_test.go
index 189503598..bf6ebbb68 100644
--- a/lib/request-processor/vulnerabilities/ssrf/checkBlockedDomain_test.go
+++ b/lib/request-processor/vulnerabilities/ssrf/checkBlockedDomain_test.go
@@ -3,14 +3,14 @@ package ssrf
import (
"main/aikido_types"
"main/context"
- "main/globals"
"main/helpers"
+ "main/instance"
"testing"
"go4.org/netipx"
)
-func setupTestServerForBlockedDomains(blockNewOutgoingRequests bool, outboundDomains map[string]bool, bypassedIps *netipx.IPSet, requestIp string) func() {
+func setupTestServerForBlockedDomains(blockNewOutgoingRequests bool, outboundDomains map[string]bool, bypassedIps *netipx.IPSet, requestIp string) (*instance.RequestProcessorInstance, func()) {
// Normalize domain keys like config.go does at load time
normalizedDomains := map[string]bool{}
for domain, block := range outboundDomains {
@@ -25,9 +25,9 @@ func setupTestServerForBlockedDomains(blockNewOutgoingRequests bool, outboundDom
},
}
- // Store original server and restore it later
- originalServer := globals.GetCurrentServer()
- globals.CurrentServer = server
+ // Create a test instance with the server
+ inst := instance.NewRequestProcessorInstance(0, false)
+ inst.SetCurrentServer(server)
// Setup test context with request IP
contextData := map[string]string{}
@@ -36,10 +36,9 @@ func setupTestServerForBlockedDomains(blockNewOutgoingRequests bool, outboundDom
}
context.LoadForUnitTests(contextData)
- // Return cleanup function
- return func() {
+ // Return instance and cleanup function
+ return inst, func() {
context.UnloadForUnitTests()
- globals.CurrentServer = originalServer
}
}
@@ -48,10 +47,10 @@ func TestIsBlockedOutboundDomain_ExplicitlyBlockedDomain(t *testing.T) {
outboundDomains := map[string]bool{
"evil.com": true,
}
- cleanup := setupTestServerForBlockedDomains(false, outboundDomains, nil, "")
+ inst, cleanup := setupTestServerForBlockedDomains(false, outboundDomains, nil, "")
defer cleanup()
- isBlocked := IsBlockedOutboundDomain("evil.com")
+ isBlocked := IsBlockedOutboundDomainWithInst(inst, "evil.com")
if !isBlocked {
t.Error("Expected blocked domain to be blocked, but it was allowed")
@@ -65,10 +64,10 @@ func TestIsBlockedOutboundDomain_ExplicitlyBlockedDomainRegardlessOfFlag(t *test
"evil.com": true,
"safe.com": false,
}
- cleanup := setupTestServerForBlockedDomains(false, outboundDomains, nil, "")
+ inst, cleanup := setupTestServerForBlockedDomains(false, outboundDomains, nil, "")
defer cleanup()
- isBlocked := IsBlockedOutboundDomain("evil.com")
+ isBlocked := IsBlockedOutboundDomainWithInst(inst, "evil.com")
if !isBlocked {
t.Error("Expected blocked domain to be blocked regardless of blockNewOutgoingRequests flag")
@@ -80,10 +79,10 @@ func TestIsBlockedOutboundDomain_AllowedDomainWithBlockNewEnabled(t *testing.T)
outboundDomains := map[string]bool{
"safe.com": false,
}
- cleanup := setupTestServerForBlockedDomains(true, outboundDomains, nil, "")
+ inst, cleanup := setupTestServerForBlockedDomains(true, outboundDomains, nil, "")
defer cleanup()
- isBlocked := IsBlockedOutboundDomain("safe.com")
+ isBlocked := IsBlockedOutboundDomainWithInst(inst, "safe.com")
if isBlocked {
t.Error("Expected allowed domain to be allowed when blockNewOutgoingRequests is true")
@@ -95,10 +94,10 @@ func TestIsBlockedOutboundDomain_NewDomainBlockedWhenFlagEnabled(t *testing.T) {
outboundDomains := map[string]bool{
"safe.com": false,
}
- cleanup := setupTestServerForBlockedDomains(true, outboundDomains, nil, "")
+ inst, cleanup := setupTestServerForBlockedDomains(true, outboundDomains, nil, "")
defer cleanup()
- isBlocked := IsBlockedOutboundDomain("unknown.com")
+ isBlocked := IsBlockedOutboundDomainWithInst(inst, "unknown.com")
if !isBlocked {
t.Error("Expected unknown domain to be blocked when blockNewOutgoingRequests is true")
@@ -111,10 +110,10 @@ func TestIsBlockedOutboundDomain_NewDomainAllowedWhenFlagDisabled(t *testing.T)
outboundDomains := map[string]bool{
"safe.com": false,
}
- cleanup := setupTestServerForBlockedDomains(false, outboundDomains, nil, "")
+ inst, cleanup := setupTestServerForBlockedDomains(false, outboundDomains, nil, "")
defer cleanup()
- isBlocked := IsBlockedOutboundDomain("unknown.com")
+ isBlocked := IsBlockedOutboundDomainWithInst(inst, "unknown.com")
if isBlocked {
t.Error("Expected unknown domain to be allowed when blockNewOutgoingRequests is false")
@@ -126,18 +125,18 @@ func TestIsBlockedOutboundDomain_CaseInsensitiveHostname(t *testing.T) {
outboundDomains := map[string]bool{
"evil.com": true,
}
- cleanup := setupTestServerForBlockedDomains(false, outboundDomains, nil, "")
+ inst, cleanup := setupTestServerForBlockedDomains(false, outboundDomains, nil, "")
defer cleanup()
// Test with uppercase hostname
- isBlocked := IsBlockedOutboundDomain("EVIL.COM")
+ isBlocked := IsBlockedOutboundDomainWithInst(inst, "EVIL.COM")
if !isBlocked {
t.Error("Expected uppercase hostname to be blocked (case-insensitive matching)")
}
// Test with mixed case
- isBlocked = IsBlockedOutboundDomain("Evil.Com")
+ isBlocked = IsBlockedOutboundDomainWithInst(inst, "Evil.Com")
if !isBlocked {
t.Error("Expected mixed case hostname to be blocked (case-insensitive matching)")
@@ -145,14 +144,11 @@ func TestIsBlockedOutboundDomain_CaseInsensitiveHostname(t *testing.T) {
}
func TestIsBlockedOutboundDomain_NoServerReturnsNil(t *testing.T) {
- // Test that function returns nil when there's no server
- originalServer := globals.GetCurrentServer()
- globals.CurrentServer = nil
- defer func() {
- globals.CurrentServer = originalServer
- }()
+ // Test that function returns false when there's no server
+ inst := instance.NewRequestProcessorInstance(0, false)
+ // Don't set a server, so inst.GetCurrentServer() will return nil
- isBlocked := IsBlockedOutboundDomain("evil.com")
+ isBlocked := IsBlockedOutboundDomainWithInst(inst, "evil.com")
if isBlocked {
t.Error("Expected nil isBlocked when there's no server")
@@ -162,10 +158,10 @@ func TestIsBlockedOutboundDomain_NoServerReturnsNil(t *testing.T) {
func TestIsBlockedOutboundDomain_EmptyDomainsListWithBlockNewEnabled(t *testing.T) {
// Test that all domains are blocked when the list is empty and blockNewOutgoingRequests is true
outboundDomains := map[string]bool{}
- cleanup := setupTestServerForBlockedDomains(true, outboundDomains, nil, "")
+ inst, cleanup := setupTestServerForBlockedDomains(true, outboundDomains, nil, "")
defer cleanup()
- isBlocked := IsBlockedOutboundDomain("example.com")
+ isBlocked := IsBlockedOutboundDomainWithInst(inst, "example.com")
if !isBlocked {
t.Error("Expected domain to be blocked when domains list is empty and blockNewOutgoingRequests is true")
@@ -175,10 +171,10 @@ func TestIsBlockedOutboundDomain_EmptyDomainsListWithBlockNewEnabled(t *testing.
func TestIsBlockedOutboundDomain_EmptyDomainsListWithBlockNewDisabled(t *testing.T) {
// Test that all domains are allowed when the list is empty and blockNewOutgoingRequests is false
outboundDomains := map[string]bool{}
- cleanup := setupTestServerForBlockedDomains(false, outboundDomains, nil, "")
+ inst, cleanup := setupTestServerForBlockedDomains(false, outboundDomains, nil, "")
defer cleanup()
- isBlocked := IsBlockedOutboundDomain("example.com")
+ isBlocked := IsBlockedOutboundDomainWithInst(inst, "example.com")
if isBlocked {
t.Error("Expected domain to be allowed when domains list is empty and blockNewOutgoingRequests is false")
@@ -194,11 +190,11 @@ func TestIsBlockedOutboundDomain_PunycodeBypass_BlockedUnicodeRequestedAsPunycod
outboundDomains := map[string]bool{
"münchen.de": true,
}
- cleanup := setupTestServerForBlockedDomains(false, outboundDomains, nil, "")
+ inst, cleanup := setupTestServerForBlockedDomains(false, outboundDomains, nil, "")
defer cleanup()
// Attacker tries to bypass by using Punycode encoding
- isBlocked := IsBlockedOutboundDomain("xn--mnchen-3ya.de")
+ isBlocked := IsBlockedOutboundDomainWithInst(inst, "xn--mnchen-3ya.de")
if !isBlocked {
t.Error("Expected Punycode hostname to be blocked when Unicode equivalent is in blocked list")
@@ -211,11 +207,11 @@ func TestIsBlockedOutboundDomain_PunycodeBypass_BlockedPunycodeRequestedAsUnicod
outboundDomains := map[string]bool{
"xn--mnchen-3ya.de": true,
}
- cleanup := setupTestServerForBlockedDomains(false, outboundDomains, nil, "")
+ inst, cleanup := setupTestServerForBlockedDomains(false, outboundDomains, nil, "")
defer cleanup()
// Attacker tries to bypass by using Unicode
- isBlocked := IsBlockedOutboundDomain("münchen.de")
+ isBlocked := IsBlockedOutboundDomainWithInst(inst, "münchen.de")
if !isBlocked {
t.Error("Expected Unicode hostname to be blocked when Punycode equivalent is in blocked list")
@@ -227,11 +223,11 @@ func TestIsBlockedOutboundDomain_PunycodeBypass_AllowedUnicodeRequestedAsPunycod
outboundDomains := map[string]bool{
"münchen.de": false,
}
- cleanup := setupTestServerForBlockedDomains(true, outboundDomains, nil, "")
+ inst, cleanup := setupTestServerForBlockedDomains(true, outboundDomains, nil, "")
defer cleanup()
// Request using Punycode encoding
- isBlocked := IsBlockedOutboundDomain("xn--mnchen-3ya.de")
+ isBlocked := IsBlockedOutboundDomainWithInst(inst, "xn--mnchen-3ya.de")
if isBlocked {
t.Error("Expected Punycode hostname to be allowed when Unicode equivalent is in allow list")
@@ -243,11 +239,11 @@ func TestIsBlockedOutboundDomain_PunycodeBypass_AllowedPunycodeRequestedAsUnicod
outboundDomains := map[string]bool{
"xn--mnchen-3ya.de": false,
}
- cleanup := setupTestServerForBlockedDomains(true, outboundDomains, nil, "")
+ inst, cleanup := setupTestServerForBlockedDomains(true, outboundDomains, nil, "")
defer cleanup()
// Request using Unicode
- isBlocked := IsBlockedOutboundDomain("münchen.de")
+ isBlocked := IsBlockedOutboundDomainWithInst(inst, "münchen.de")
if isBlocked {
t.Error("Expected Unicode hostname to be allowed when Punycode equivalent is in allow list")
@@ -259,11 +255,11 @@ func TestIsBlockedOutboundDomain_PunycodeBypass_MixedSubdomains(t *testing.T) {
outboundDomains := map[string]bool{
"böse.evil.com": true,
}
- cleanup := setupTestServerForBlockedDomains(false, outboundDomains, nil, "")
+ inst, cleanup := setupTestServerForBlockedDomains(false, outboundDomains, nil, "")
defer cleanup()
// Attacker tries with Punycode subdomain (xn--bse-sna = böse)
- isBlocked := IsBlockedOutboundDomain("xn--bse-sna.evil.com")
+ isBlocked := IsBlockedOutboundDomainWithInst(inst, "xn--bse-sna.evil.com")
if !isBlocked {
t.Error("Expected Punycode subdomain to be blocked when Unicode equivalent is in blocked list")
@@ -275,11 +271,11 @@ func TestIsBlockedOutboundDomain_PunycodeBypass_RussianDomain(t *testing.T) {
outboundDomains := map[string]bool{
"москва.ru": true,
}
- cleanup := setupTestServerForBlockedDomains(false, outboundDomains, nil, "")
+ inst, cleanup := setupTestServerForBlockedDomains(false, outboundDomains, nil, "")
defer cleanup()
// Attacker tries with Punycode
- isBlocked := IsBlockedOutboundDomain("xn--80adxhks.ru")
+ isBlocked := IsBlockedOutboundDomainWithInst(inst, "xn--80adxhks.ru")
if !isBlocked {
t.Error("Expected Punycode Cyrillic hostname to be blocked when Unicode equivalent is in blocked list")
@@ -291,11 +287,11 @@ func TestIsBlockedOutboundDomain_PunycodeBypass_ChineseDomain(t *testing.T) {
outboundDomains := map[string]bool{
"中文.com": true,
}
- cleanup := setupTestServerForBlockedDomains(false, outboundDomains, nil, "")
+ inst, cleanup := setupTestServerForBlockedDomains(false, outboundDomains, nil, "")
defer cleanup()
// Attacker tries with Punycode
- isBlocked := IsBlockedOutboundDomain("xn--fiq228c.com")
+ isBlocked := IsBlockedOutboundDomainWithInst(inst, "xn--fiq228c.com")
if !isBlocked {
t.Error("Expected Punycode Chinese hostname to be blocked when Unicode equivalent is in blocked list")
@@ -307,11 +303,11 @@ func TestIsBlockedOutboundDomain_PunycodeBypass_WithPortStripped(t *testing.T) {
outboundDomains := map[string]bool{
"münchen.de": true,
}
- cleanup := setupTestServerForBlockedDomains(false, outboundDomains, nil, "")
+ inst, cleanup := setupTestServerForBlockedDomains(false, outboundDomains, nil, "")
defer cleanup()
// Just the hostname without port
- isBlocked := IsBlockedOutboundDomain("xn--mnchen-3ya.de")
+ isBlocked := IsBlockedOutboundDomainWithInst(inst, "xn--mnchen-3ya.de")
if !isBlocked {
t.Error("Expected Punycode hostname to be blocked")
diff --git a/lib/request-processor/vulnerabilities/ssrf/checkContextForSSRF.go b/lib/request-processor/vulnerabilities/ssrf/checkContextForSSRF.go
index a7fab591c..89eecfb78 100644
--- a/lib/request-processor/vulnerabilities/ssrf/checkContextForSSRF.go
+++ b/lib/request-processor/vulnerabilities/ssrf/checkContextForSSRF.go
@@ -3,21 +3,22 @@ package ssrf
import (
"main/context"
"main/helpers"
+ "main/instance"
"main/utils"
)
/* This is called before a request is made to check for SSRF and block the request (not execute it) if SSRF found */
-func CheckContextForSSRF(hostname string, port uint32, operation string) *utils.InterceptorResult {
+func CheckContextForSSRF(inst *instance.RequestProcessorInstance, hostname string, port uint32, operation string) *utils.InterceptorResult {
trimmedHostname := helpers.TrimInvisible(hostname)
// Check if this is a request to the server itself (including HTTP/HTTPS special case)
// If so, don't block it as it's not an SSRF attack
- if IsRequestToItself(trimmedHostname, port) {
+ if IsRequestToItself(inst, trimmedHostname, port) {
return nil
}
for _, source := range context.SOURCES {
- mapss := source.CacheGet()
+ mapss := source.CacheGet(inst)
for str, path := range mapss {
trimmedInputString := helpers.TrimInvisible(str)
@@ -37,7 +38,7 @@ func CheckContextForSSRF(hostname string, port uint32, operation string) *utils.
return &interceptorResult
}
- resolvedIpStatus := getResolvedIpStatusForHostname(trimmedHostname)
+ resolvedIpStatus := getResolvedIpStatusForHostname(inst, trimmedHostname)
if resolvedIpStatus != nil {
interceptorResult.Metadata["resolvedIp"] = resolvedIpStatus.ip
if resolvedIpStatus.isIMDS {
@@ -51,10 +52,10 @@ func CheckContextForSSRF(hostname string, port uint32, operation string) *utils.
return &interceptorResult
}
- // Hostname matched in the user input but we did not managed to determine if it's a SSRF attack at this point.
- // Storing the matching information (interceptor result) in order to use it once the request completes,
- // as at that point we might have more information to determine if SSRF or not.
- context.EventContextSetCurrentSsrfInterceptorResult(&interceptorResult)
+ // Hostname matched in the user input but we did not managed to determine if it's a SSRF attack at this point.
+ // Storing the matching information (interceptor result) in order to use it once the request completes,
+ // as at that point we might have more information to determine if SSRF or not.
+ context.EventContextSetCurrentSsrfInterceptorResult(inst, &interceptorResult)
}
}
}
@@ -62,15 +63,15 @@ func CheckContextForSSRF(hostname string, port uint32, operation string) *utils.
}
/* This is called after the request is made to check for SSRF in the effective hostname - hostname optained after redirects from the PHP library that made the request (curl) */
-func CheckEffectiveHostnameForSSRF(effectiveHostname string) *utils.InterceptorResult {
- interceptorResult := context.GetCurrentSsrfInterceptorResult()
+func CheckEffectiveHostnameForSSRF(inst *instance.RequestProcessorInstance, effectiveHostname string) *utils.InterceptorResult {
+ interceptorResult := context.GetCurrentSsrfInterceptorResult(inst)
if interceptorResult == nil {
// The initially requested hostname was not found in the user input -> no SSRF
return nil
}
interceptorResult.Metadata["effectiveHostname"] = effectiveHostname
- resolvedIpStatus := getResolvedIpStatusForHostname(effectiveHostname)
+ resolvedIpStatus := getResolvedIpStatusForHostname(inst, effectiveHostname)
if resolvedIpStatus != nil {
interceptorResult.Metadata["resolvedIp"] = resolvedIpStatus.ip
if resolvedIpStatus.isIMDS {
@@ -88,8 +89,8 @@ func CheckEffectiveHostnameForSSRF(effectiveHostname string) *utils.InterceptorR
}
/* This is called after the request is made to check for SSRF in the resolvedIP - IP optained from the PHP library that made the request (curl) */
-func CheckResolvedIpForSSRF(resolvedIp string) *utils.InterceptorResult {
- interceptorResult := context.GetCurrentSsrfInterceptorResult()
+func CheckResolvedIpForSSRF(inst *instance.RequestProcessorInstance, resolvedIp string) *utils.InterceptorResult {
+ interceptorResult := context.GetCurrentSsrfInterceptorResult(inst)
if interceptorResult == nil {
// The initially requested hostname was not found in the user input -> no SSRF
return nil
diff --git a/lib/request-processor/vulnerabilities/ssrf/checkPostRequestSSRF_test.go b/lib/request-processor/vulnerabilities/ssrf/checkPostRequestSSRF_test.go
index f785b6088..d3d2e3b1d 100644
--- a/lib/request-processor/vulnerabilities/ssrf/checkPostRequestSSRF_test.go
+++ b/lib/request-processor/vulnerabilities/ssrf/checkPostRequestSSRF_test.go
@@ -2,22 +2,25 @@ package ssrf
import (
"main/context"
+ "main/instance"
"main/utils"
"testing"
)
func TestCheckResolvedIpForSSRF_NoStoredInterceptorResult_ReturnsNil(t *testing.T) {
- context.ResetEventContext()
- t.Cleanup(func() { context.ResetEventContext() })
+ inst := instance.NewRequestProcessorInstance(0, false)
+ context.ResetEventContext(inst)
+ t.Cleanup(func() { context.ResetEventContext(inst) })
- if res := CheckResolvedIpForSSRF("127.0.0.1"); res != nil {
+ if res := CheckResolvedIpForSSRF(inst, "127.0.0.1"); res != nil {
t.Fatalf("expected nil, got %#v", res)
}
}
func TestCheckResolvedIpForSSRF_PublicIp_ReturnsNil(t *testing.T) {
- context.ResetEventContext()
- t.Cleanup(func() { context.ResetEventContext() })
+ inst := instance.NewRequestProcessorInstance(0, false)
+ context.ResetEventContext(inst)
+ t.Cleanup(func() { context.ResetEventContext(inst) })
ir := &utils.InterceptorResult{
Operation: "curl_exec",
@@ -26,9 +29,9 @@ func TestCheckResolvedIpForSSRF_PublicIp_ReturnsNil(t *testing.T) {
Metadata: map[string]string{},
Payload: "http://example.test",
}
- context.EventContextSetCurrentSsrfInterceptorResult(ir)
+ context.EventContextSetCurrentSsrfInterceptorResult(inst, ir)
- if res := CheckResolvedIpForSSRF("8.8.8.8"); res != nil {
+ if res := CheckResolvedIpForSSRF(inst, "8.8.8.8"); res != nil {
t.Fatalf("expected nil, got %#v", res)
}
if _, ok := ir.Metadata["isPrivateIp"]; ok {
@@ -40,8 +43,9 @@ func TestCheckResolvedIpForSSRF_PublicIp_ReturnsNil(t *testing.T) {
}
func TestCheckResolvedIpForSSRF_PrivateIp_ReturnsInterceptorResultWithMetadata(t *testing.T) {
- context.ResetEventContext()
- t.Cleanup(func() { context.ResetEventContext() })
+ inst := instance.NewRequestProcessorInstance(0, false)
+ context.ResetEventContext(inst)
+ t.Cleanup(func() { context.ResetEventContext(inst) })
ir := &utils.InterceptorResult{
Operation: "curl_exec",
@@ -50,9 +54,9 @@ func TestCheckResolvedIpForSSRF_PrivateIp_ReturnsInterceptorResultWithMetadata(t
Metadata: map[string]string{},
Payload: "http://example.test",
}
- context.EventContextSetCurrentSsrfInterceptorResult(ir)
+ context.EventContextSetCurrentSsrfInterceptorResult(inst, ir)
- res := CheckResolvedIpForSSRF("127.0.0.1")
+ res := CheckResolvedIpForSSRF(inst, "127.0.0.1")
if res == nil {
t.Fatalf("expected non-nil interceptor result")
}
@@ -68,8 +72,9 @@ func TestCheckResolvedIpForSSRF_PrivateIp_ReturnsInterceptorResultWithMetadata(t
}
func TestCheckEffectiveHostnameForSSRF_PrivateIpHostname_ReturnsInterceptorResultWithMetadata(t *testing.T) {
- context.ResetEventContext()
- t.Cleanup(func() { context.ResetEventContext() })
+ inst := instance.NewRequestProcessorInstance(0, false)
+ context.ResetEventContext(inst)
+ t.Cleanup(func() { context.ResetEventContext(inst) })
ir := &utils.InterceptorResult{
Operation: "curl_exec",
@@ -78,9 +83,9 @@ func TestCheckEffectiveHostnameForSSRF_PrivateIpHostname_ReturnsInterceptorResul
Metadata: map[string]string{},
Payload: "http://example.test",
}
- context.EventContextSetCurrentSsrfInterceptorResult(ir)
+ context.EventContextSetCurrentSsrfInterceptorResult(inst, ir)
- res := CheckEffectiveHostnameForSSRF("127.0.0.1")
+ res := CheckEffectiveHostnameForSSRF(inst, "127.0.0.1")
if res == nil {
t.Fatalf("expected non-nil interceptor result")
}
@@ -99,8 +104,9 @@ func TestCheckEffectiveHostnameForSSRF_PrivateIpHostname_ReturnsInterceptorResul
}
func TestCheckEffectiveHostnameForSSRF_IMDSHostname_ReturnsInterceptorResultWithIMDSMetadata(t *testing.T) {
- context.ResetEventContext()
- t.Cleanup(func() { context.ResetEventContext() })
+ inst := instance.NewRequestProcessorInstance(0, false)
+ context.ResetEventContext(inst)
+ t.Cleanup(func() { context.ResetEventContext(inst) })
ir := &utils.InterceptorResult{
Operation: "curl_exec",
@@ -109,9 +115,9 @@ func TestCheckEffectiveHostnameForSSRF_IMDSHostname_ReturnsInterceptorResultWith
Metadata: map[string]string{},
Payload: "http://example.test",
}
- context.EventContextSetCurrentSsrfInterceptorResult(ir)
+ context.EventContextSetCurrentSsrfInterceptorResult(inst, ir)
- res := CheckEffectiveHostnameForSSRF("169.254.169.254")
+ res := CheckEffectiveHostnameForSSRF(inst, "169.254.169.254")
if res == nil {
t.Fatalf("expected non-nil interceptor result")
}
diff --git a/lib/request-processor/vulnerabilities/ssrf/getResolvedIpStatus.go b/lib/request-processor/vulnerabilities/ssrf/getResolvedIpStatus.go
index fe98c0108..aadfa03da 100644
--- a/lib/request-processor/vulnerabilities/ssrf/getResolvedIpStatus.go
+++ b/lib/request-processor/vulnerabilities/ssrf/getResolvedIpStatus.go
@@ -1,6 +1,9 @@
package ssrf
-import "main/helpers"
+import (
+ "main/helpers"
+ "main/instance"
+)
type ResolvedIpStatus struct {
ip string
@@ -15,8 +18,8 @@ we expect that for most of the cases, the result will be already cached at the O
We do our own DNS resolution, because we want to actually block potential SSRF attacks and we did not find any way to hook PHP's DNS
resolution calls.
*/
-func getResolvedIpStatusForHostname(hostname string) *ResolvedIpStatus {
- resolvedIps := helpers.ResolveHostname(hostname)
+func getResolvedIpStatusForHostname(inst *instance.RequestProcessorInstance, hostname string) *ResolvedIpStatus {
+ resolvedIps := helpers.ResolveHostname(inst, hostname)
imdsIP := FindIMDSIp(hostname, resolvedIps)
if imdsIP != "" {
diff --git a/lib/request-processor/vulnerabilities/ssrf/getResolvedIpStatus_test.go b/lib/request-processor/vulnerabilities/ssrf/getResolvedIpStatus_test.go
index d372fd468..fe1bec06d 100644
--- a/lib/request-processor/vulnerabilities/ssrf/getResolvedIpStatus_test.go
+++ b/lib/request-processor/vulnerabilities/ssrf/getResolvedIpStatus_test.go
@@ -14,7 +14,7 @@ func TestResolvedIpStatus(t *testing.T) {
}
for _, test := range tests {
- result := getResolvedIpStatusForHostname(test.hostname)
+ result := getResolvedIpStatusForHostname(nil, test.hostname)
if result == nil {
t.Errorf("For hostname '%s' expected DNS resolution to not fail", test.hostname)
break
diff --git a/lib/request-processor/vulnerabilities/ssrf/isRequestToItself.go b/lib/request-processor/vulnerabilities/ssrf/isRequestToItself.go
index f0ba26dd5..898632d34 100644
--- a/lib/request-processor/vulnerabilities/ssrf/isRequestToItself.go
+++ b/lib/request-processor/vulnerabilities/ssrf/isRequestToItself.go
@@ -2,8 +2,8 @@ package ssrf
import (
"main/context"
- "main/globals"
"main/helpers"
+ "main/instance"
"net/url"
)
@@ -11,16 +11,21 @@ import (
// This includes a special case for HTTP/HTTPS: if the server is running on HTTP (port 80) and makes a request
// to HTTPS (port 443) of the same hostname, or vice versa, it's considered a request to itself.
// This prevents false positives when a server makes requests to itself via different protocols.
-func IsRequestToItself(outboundHostname string, outboundPort uint32) bool {
+func IsRequestToItself(inst *instance.RequestProcessorInstance, outboundHostname string, outboundPort uint32) bool {
+ if inst == nil {
+ return false
+ }
+
+ server := inst.GetCurrentServer()
+
// Check if trust proxy is enabled
// If not enabled, we don't consider requests to itself as safe
- server := globals.GetCurrentServer()
- if server == nil || !server.AikidoConfig.TrustProxy {
+ if server != nil && !server.AikidoConfig.TrustProxy {
return false
}
// Get the current server URL from the incoming request
- serverURL := context.GetUrl()
+ serverURL := context.GetUrl(inst)
if serverURL == "" {
return false
}
diff --git a/lib/request-processor/vulnerabilities/ssrf/isRequestToItself_test.go b/lib/request-processor/vulnerabilities/ssrf/isRequestToItself_test.go
index 91aa06b26..92ef614a6 100644
--- a/lib/request-processor/vulnerabilities/ssrf/isRequestToItself_test.go
+++ b/lib/request-processor/vulnerabilities/ssrf/isRequestToItself_test.go
@@ -1,61 +1,59 @@
package ssrf
import (
- "main/aikido_types"
+ . "main/aikido_types"
"main/context"
- "main/globals"
+ "main/instance"
"testing"
)
-func setupTestContext(serverURL string, trustProxy bool) func() {
+func setupTestContext(serverURL string, trustProxy bool) (*instance.RequestProcessorInstance, func()) {
// Setup a mock server with trust proxy setting
- server := &aikido_types.ServerData{
- AikidoConfig: aikido_types.AikidoConfigData{
+ testServer := &ServerData{
+ AikidoConfig: AikidoConfigData{
TrustProxy: trustProxy,
},
}
- // Store original server and restore it later
- originalServer := globals.GetCurrentServer()
- globals.CurrentServer = server
-
- // Use the proper test context loader
- context.LoadForUnitTests(map[string]string{
+ // Use the proper test context loader - it returns the mock instance with threadID set
+ testInst := context.LoadForUnitTests(map[string]string{
"url": serverURL,
})
- // Return cleanup function
- return func() {
+ // Set the server on the instance
+ testInst.SetCurrentServer(testServer)
+
+ // Return instance and cleanup function
+ return testInst, func() {
context.UnloadForUnitTests()
- globals.CurrentServer = originalServer
}
}
func TestIsRequestToItself_ReturnsFalseIfHostnamesDifferent(t *testing.T) {
- cleanup := setupTestContext("http://aikido.dev:4000", true)
+ inst, cleanup := setupTestContext("http://aikido.dev:4000", true)
defer cleanup()
- result := IsRequestToItself("google.com", 4000)
+ result := IsRequestToItself(inst, "google.com", 4000)
if result != false {
t.Errorf("Expected false when hostnames are different, got %v", result)
}
}
func TestIsRequestToItself_ReturnsFalseIfHostnamesDifferentHTTPS(t *testing.T) {
- cleanup := setupTestContext("https://aikido.dev", true)
+ inst, cleanup := setupTestContext("https://aikido.dev", true)
defer cleanup()
- result := IsRequestToItself("google.com", 443)
+ result := IsRequestToItself(inst, "google.com", 443)
if result != false {
t.Errorf("Expected false when hostnames are different (HTTPS), got %v", result)
}
}
func TestIsRequestToItself_ReturnsFalseIfHostnamesDifferentWithCustomPort(t *testing.T) {
- cleanup := setupTestContext("https://aikido.dev:4000", true)
+ inst, cleanup := setupTestContext("https://aikido.dev:4000", true)
defer cleanup()
- result := IsRequestToItself("google.com", 443)
+ result := IsRequestToItself(inst, "google.com", 443)
if result != false {
t.Errorf("Expected false when hostnames are different (custom port), got %v", result)
}
@@ -96,10 +94,10 @@ func TestIsRequestToItself_ReturnsTrueIfServerDoesRequestToItself(t *testing.T)
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
- cleanup := setupTestContext(tt.serverURL, true)
+ inst, cleanup := setupTestContext(tt.serverURL, true)
defer cleanup()
- result := IsRequestToItself(tt.outboundHostname, tt.outboundPort)
+ result := IsRequestToItself(inst, tt.outboundHostname, tt.outboundPort)
if result != true {
t.Errorf("Expected true for %s, got %v", tt.description, result)
}
@@ -130,10 +128,10 @@ func TestIsRequestToItself_ReturnsTrueForSpecialCaseHTTPHTTPS(t *testing.T) {
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
- cleanup := setupTestContext(tt.serverURL, true)
+ inst, cleanup := setupTestContext(tt.serverURL, true)
defer cleanup()
- result := IsRequestToItself(tt.outboundHostname, tt.outboundPort)
+ result := IsRequestToItself(inst, tt.outboundHostname, tt.outboundPort)
if result != true {
t.Errorf("Expected true for special case %s, got %v", tt.description, result)
}
@@ -164,10 +162,10 @@ func TestIsRequestToItself_ReturnsFalseIfTrustProxyIsFalse(t *testing.T) {
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
- cleanup := setupTestContext(tt.serverURL, false) // Trust proxy is false
+ inst, cleanup := setupTestContext(tt.serverURL, false) // Trust proxy is false
defer cleanup()
- result := IsRequestToItself(tt.outboundHostname, tt.outboundPort)
+ result := IsRequestToItself(inst, tt.outboundHostname, tt.outboundPort)
if result != false {
t.Errorf("Expected false when trust proxy is disabled for %s, got %v", tt.description, result)
}
diff --git a/lib/request-processor/vulnerabilities/zen-internals/zen_internals.go b/lib/request-processor/vulnerabilities/zen-internals/zen_internals.go
index e305b11eb..feef27526 100644
--- a/lib/request-processor/vulnerabilities/zen-internals/zen_internals.go
+++ b/lib/request-processor/vulnerabilities/zen-internals/zen_internals.go
@@ -25,21 +25,33 @@ import (
"main/globals"
"main/log"
"main/utils"
+ "sync"
"unsafe"
)
-var (
+type ZenInternalsLibrary struct {
handle unsafe.Pointer
detectSqlInjection C.detect_sql_injection_func
-)
+ mu sync.RWMutex
+ initialized bool
+}
+
+var zenLib = &ZenInternalsLibrary{}
func Init() bool {
+ zenLib.mu.Lock()
+ defer zenLib.mu.Unlock()
+
+ if zenLib.initialized {
+ return true
+ }
+
zenInternalsLibPath := C.CString(fmt.Sprintf("/opt/aikido-%s/libzen_internals_%s-unknown-linux-gnu.so", globals.Version, utils.GetArch()))
defer C.free(unsafe.Pointer(zenInternalsLibPath))
handle := C.dlopen(zenInternalsLibPath, C.RTLD_LAZY)
if handle == nil {
- log.Errorf("Failed to load zen-internals library from '%s' with error %s!", C.GoString(zenInternalsLibPath), C.GoString(C.dlerror()))
+ log.Errorf(nil, "Failed to load zen-internals library from '%s' with error %s!", C.GoString(zenInternalsLibPath), C.GoString(C.dlerror()))
return false
}
@@ -48,27 +60,43 @@ func Init() bool {
vDetectSqlInjection := C.dlsym(handle, detectSqlInjectionFnName)
if vDetectSqlInjection == nil {
- log.Error("Failed to load detect_sql_injection function from zen-internals library!")
+ log.Error(nil, "Failed to load detect_sql_injection function from zen-internals library!")
+ C.dlclose(handle)
return false
}
- detectSqlInjection = (C.detect_sql_injection_func)(vDetectSqlInjection)
- log.Debugf("Loaded zen-internals library!")
+ zenLib.handle = handle
+ zenLib.detectSqlInjection = (C.detect_sql_injection_func)(vDetectSqlInjection)
+ zenLib.initialized = true
+ log.Debugf(nil, "Loaded zen-internals library!")
return true
}
func Uninit() {
- detectSqlInjection = nil
+ zenLib.mu.Lock()
+ defer zenLib.mu.Unlock()
- if handle != nil {
- C.dlclose(handle)
- handle = nil
+ if !zenLib.initialized {
+ return
+ }
+
+ zenLib.detectSqlInjection = nil
+
+ if zenLib.handle != nil {
+ C.dlclose(zenLib.handle)
+ zenLib.handle = nil
}
+
+ zenLib.initialized = false
}
// DetectSQLInjection performs SQL injection detection using the loaded library
func DetectSQLInjection(query string, user_input string, dialect int) int {
- if detectSqlInjection == nil {
+ zenLib.mu.RLock()
+ detectFn := zenLib.detectSqlInjection
+ zenLib.mu.RUnlock()
+
+ if detectFn == nil {
return 0
}
@@ -81,11 +109,11 @@ func DetectSQLInjection(query string, user_input string, dialect int) int {
queryLen := C.size_t(len(query))
userInputLen := C.size_t(len(user_input))
- result := int(C.call_detect_sql_injection(detectSqlInjection,
+ result := int(C.call_detect_sql_injection(detectFn,
cQuery, queryLen,
cUserInput, userInputLen,
C.int(dialect)))
- log.Debugf("DetectSqlInjection(\"%s\", \"%s\", %d) -> %d", query, user_input, dialect, result)
+ log.Debugf(nil, "DetectSqlInjection(\"%s\", \"%s\", %d) -> %d", query, user_input, dialect, result)
return result
}
diff --git a/package/rpm/aikido.spec b/package/rpm/aikido.spec
index 5921ead29..f8e948f2c 100644
--- a/package/rpm/aikido.spec
+++ b/package/rpm/aikido.spec
@@ -50,12 +50,22 @@ for php_path in /usr/bin/php* /usr/local/bin/php*; do
fi
done
-if [ ${#PHP_VERSIONS[@]} -eq 0 ]; then
- echo "No PHP versions found! Exiting!"
- exit 1
+if [ ${#PHP_VERSIONS[@]} -gt 0 ]; then
+ echo "Found PHP versions: ${PHP_VERSIONS[*]}"
fi
-echo "Found PHP versions: ${PHP_VERSIONS[*]}"
+
+
+FRANKENPHP_PHP_VERSION=""
+if command -v frankenphp >/dev/null 2>&1; then
+ FRANKENPHP_PHP_VERSION=$(frankenphp -v 2>/dev/null | grep -oP 'PHP \K\d+\.\d+' | head -n 1)
+
+ if [ -n "$FRANKENPHP_PHP_VERSION" ]; then
+ echo "Found FrankenPHP with embedded PHP $FRANKENPHP_PHP_VERSION"
+ else
+ echo "Found FrankenPHP but could not determine PHP version"
+ fi
+fi
for PHP_VERSION in "${PHP_VERSIONS[@]}"; do
echo "Installing for PHP $PHP_VERSION..."
@@ -69,10 +79,26 @@ for PHP_VERSION in "${PHP_VERSIONS[@]}"; do
PHP_EXT_DIR=$($PHP_BIN -i | grep "^extension_dir" | awk '{print $3}')
PHP_MOD_DIR=$($PHP_BIN -i | grep "Scan this dir for additional .ini files" | awk -F"=> " '{print $2}')
+ # Detect if PHP is ZTS or NTS
+ PHP_THREAD_SAFETY=$($PHP_BIN -i | grep "Thread Safety" | awk -F"=> " '{print $2}' | tr -d ' ')
+ if [ "$PHP_THREAD_SAFETY" = "enabled" ]; then
+ EXT_SUFFIX="-zts"
+ echo "PHP $PHP_VERSION is ZTS (Thread Safe)"
+ else
+ EXT_SUFFIX="-nts"
+ echo "PHP $PHP_VERSION is NTS (Non-Thread Safe)"
+ fi
+
# Install Aikido PHP extension
if [ -d "$PHP_EXT_DIR" ]; then
- echo "Installing new Aikido extension in $PHP_EXT_DIR/aikido-%{version}.so..."
- ln -sf /opt/aikido-%{version}/aikido-extension-php-$PHP_VERSION.so $PHP_EXT_DIR/aikido-%{version}.so
+ EXT_FILE="aikido-extension-php-$PHP_VERSION$EXT_SUFFIX.so"
+ if [ -f "/opt/aikido-%{version}/$EXT_FILE" ]; then
+ echo "Installing new Aikido extension in $PHP_EXT_DIR/aikido-%{version}.so..."
+ ln -sf /opt/aikido-%{version}/$EXT_FILE $PHP_EXT_DIR/aikido-%{version}.so
+ else
+ echo "Warning: Extension file /opt/aikido-%{version}/$EXT_FILE not found! Skipping..."
+ continue
+ fi
else
echo "No extension dir for PHP $PHP_VERSION! Skipping..."
continue
@@ -117,6 +143,36 @@ for PHP_VERSION in "${PHP_VERSIONS[@]}"; do
fi
done
+if [ -n "$FRANKENPHP_PHP_VERSION" ]; then
+ echo "Installing for FrankenPHP with PHP $FRANKENPHP_PHP_VERSION... ZTS (Thread Safe)"
+
+ FRANKENPHP_EXT_DIR="/usr/lib/frankenphp/modules"
+ FRANKENPHP_INI_DIR="/etc/frankenphp/php.d"
+
+ if [ -d "$FRANKENPHP_EXT_DIR" ]; then
+ echo "Installing new Aikido extension in $FRANKENPHP_EXT_DIR/aikido-%{version}.so..."
+ ln -sf /opt/aikido-%{version}/aikido-extension-php-$FRANKENPHP_PHP_VERSION-zts.so $FRANKENPHP_EXT_DIR/aikido-%{version}.so
+ else
+ echo "FrankenPHP extension directory $FRANKENPHP_EXT_DIR not found! Creating it..."
+ mkdir -p $FRANKENPHP_EXT_DIR
+ ln -sf /opt/aikido-%{version}/aikido-extension-php-$FRANKENPHP_PHP_VERSION-zts.so $FRANKENPHP_EXT_DIR/aikido-%{version}.so
+ fi
+
+ if [ -d "$FRANKENPHP_INI_DIR" ]; then
+ echo "Installing new Aikido mod in $FRANKENPHP_INI_DIR/zz-aikido-%{version}.ini..."
+ ln -sf /opt/aikido-%{version}/aikido.ini $FRANKENPHP_INI_DIR/zz-aikido-%{version}.ini
+ else
+ echo "FrankenPHP ini directory $FRANKENPHP_INI_DIR not found! Creating it..."
+ mkdir -p $FRANKENPHP_INI_DIR
+ ln -sf /opt/aikido-%{version}/aikido.ini $FRANKENPHP_INI_DIR/zz-aikido-%{version}.ini
+ fi
+fi
+
+if [ ${#PHP_VERSIONS[@]} -eq 0 ] && [ -z "$FRANKENPHP_PHP_VERSION" ]; then
+ echo "No PHP or FrankenPHP found! Exiting!"
+ exit 1
+fi
+
mkdir -p /run/aikido-%{version}
chmod 777 /run/aikido-%{version}
@@ -153,6 +209,9 @@ done
echo "Found PHP versions: ${PHP_VERSIONS[*]}"
+FRANKENPHP_EXT_DIR="/usr/lib/frankenphp/modules"
+FRANKENPHP_INI_DIR="/etc/frankenphp/php.d"
+
for PHP_VERSION in "${PHP_VERSIONS[@]}"; do
echo "Uninstalling for PHP $PHP_VERSION..."
@@ -207,6 +266,20 @@ for PHP_VERSION in "${PHP_VERSIONS[@]}"; do
fi
done
+if [ -d "$FRANKENPHP_EXT_DIR" ] || [ -d "$FRANKENPHP_INI_DIR" ]; then
+ echo "Uninstalling for FrankenPHP..."
+
+ if [ -f "$FRANKENPHP_EXT_DIR/aikido-%{version}.so" ]; then
+ echo "Uninstalling Aikido extension from $FRANKENPHP_EXT_DIR/aikido-%{version}.so..."
+ rm -f $FRANKENPHP_EXT_DIR/aikido-%{version}.so
+ fi
+
+ if [ -f "$FRANKENPHP_INI_DIR/zz-aikido-%{version}.ini" ]; then
+ echo "Uninstalling Aikido mod from $FRANKENPHP_INI_DIR/zz-aikido-%{version}.ini..."
+ rm -f $FRANKENPHP_INI_DIR/zz-aikido-%{version}.ini
+ fi
+fi
+
# Remove the Aikido logs folder
rm -rf /var/log/aikido-%{version}
diff --git a/tests/cli/aikido_ops/test_register_param_matcher.phpt b/tests/cli/aikido_ops/test_register_param_matcher.phpt
index b726fbed9..c13b611be 100644
--- a/tests/cli/aikido_ops/test_register_param_matcher.phpt
+++ b/tests/cli/aikido_ops/test_register_param_matcher.phpt
@@ -12,6 +12,6 @@ $result = \aikido\register_param_matcher("param_name", "{digits}-{alpha}");
?>
---EXPECT--
-[AIKIDO][INFO] Token changed to "AIK_RUNTIME_***UMMY"
-[AIKIDO][INFO] Registered param matcher param_name -> {digits}-{alpha}
\ No newline at end of file
+--EXPECTREGEX--
+\[AIKIDO\]\[INFO\]\[tid:\d+\] Token changed to "AIK_RUNTIME_\*\*\*UMMY"
+\[AIKIDO\]\[INFO\]\[tid:\d+\] Registered param matcher param_name -> \{digits\}-\{alpha\}
\ No newline at end of file
diff --git a/tests/cli/aikido_ops/test_register_param_matcher_invalid.phpt b/tests/cli/aikido_ops/test_register_param_matcher_invalid.phpt
index e8d2e280b..05d1e55c6 100644
--- a/tests/cli/aikido_ops/test_register_param_matcher_invalid.phpt
+++ b/tests/cli/aikido_ops/test_register_param_matcher_invalid.phpt
@@ -21,11 +21,11 @@ foreach ($invalidPatterns as $name => $pattern) {
?>
---EXPECT--
-[AIKIDO][INFO] Token changed to "AIK_RUNTIME_***UMMY"
-Error compiling param matcher no_braces -> regex "digits-alpha": pattern should contain { or }
-bool(false)
-Error compiling param matcher unclosed_brace -> regex "{digits": pattern should contain { or }
-bool(false)
-Error compiling param matcher with_slash -> regex "aikido/{digits}": pattern should not contain slashes
-bool(false)
+--EXPECTREGEX--
+\[AIKIDO\]\[INFO\]\[tid:\d+\] Token changed to "AIK_RUNTIME_\*\*\*UMMY"
+Error compiling param matcher no_braces -> regex "digits-alpha": pattern should contain \{ or \}
+bool\(false\)
+Error compiling param matcher unclosed_brace -> regex "\{digits": pattern should contain \{ or \}
+bool\(false\)
+Error compiling param matcher with_slash -> regex "aikido\/\{digits}": pattern should not contain slashes
+bool\(false\)
diff --git a/tests/cli/aikido_ops/test_set_rate_limit_group.phpt b/tests/cli/aikido_ops/test_set_rate_limit_group.phpt
index 57137ba78..9158fb7ef 100644
--- a/tests/cli/aikido_ops/test_set_rate_limit_group.phpt
+++ b/tests/cli/aikido_ops/test_set_rate_limit_group.phpt
@@ -11,5 +11,5 @@ AIKIDO_LOG_LEVEL=INFO
?>
---EXPECT--
-[AIKIDO][INFO] Got rate limit group: my_user_group
+--EXPECTREGEX--
+\[AIKIDO\]\[INFO\]\[tid:\d+\] Got rate limit group: my_user_group
diff --git a/tests/cli/aikido_ops/test_set_token_works.phpt b/tests/cli/aikido_ops/test_set_token_works.phpt
index d0bca41d2..31e72c7ce 100644
--- a/tests/cli/aikido_ops/test_set_token_works.phpt
+++ b/tests/cli/aikido_ops/test_set_token_works.phpt
@@ -11,5 +11,5 @@ AIKIDO_LOG_LEVEL=INFO
?>
---EXPECT--
-[AIKIDO][INFO] Token changed to "AIK_RUNTIME_***here"
+--EXPECTREGEX--
+\[AIKIDO\]\[INFO\]\[tid:\d+\] Token changed to "AIK_RUNTIME_\*\*\*here"
diff --git a/tests/cli/outgoing_request/test_curl.phpt b/tests/cli/outgoing_request/test_curl.phpt
index fed21aa57..b9ef1390d 100644
--- a/tests/cli/outgoing_request/test_curl.phpt
+++ b/tests/cli/outgoing_request/test_curl.phpt
@@ -62,16 +62,16 @@ curl_close($ch6);
?>
---EXPECT--
-[AIKIDO][INFO] [BEFORE] Got domain: example.com
-[AIKIDO][INFO] [AFTER] Got domain: example.com port: 443
-[AIKIDO][INFO] [BEFORE] Got domain: httpbin.org
-[AIKIDO][INFO] [AFTER] Got domain: httpbin.org port: 443
-[AIKIDO][INFO] [BEFORE] Got domain: facebook.com
-[AIKIDO][INFO] [AFTER] Got domain: facebook.com port: 443
-[AIKIDO][INFO] [BEFORE] Got domain: facebook.com
-[AIKIDO][INFO] [AFTER] Got domain: facebook.com port: 443
-[AIKIDO][INFO] [BEFORE] Got domain: www.aikido.dev
-[AIKIDO][INFO] [AFTER] Got domain: www.aikido.dev port: 80
-[AIKIDO][INFO] [BEFORE] Got domain: some-invalid-domain.com
-[AIKIDO][INFO] [AFTER] Got domain: some-invalid-domain.com port: 4113
\ No newline at end of file
+--EXPECTREGEX--
+\[AIKIDO\]\[INFO\]\[tid:\d+\] \[BEFORE\] Got domain: example.com
+\[AIKIDO\]\[INFO\]\[tid:\d+\] \[AFTER\] Got domain: example.com port: 443
+\[AIKIDO\]\[INFO\]\[tid:\d+\] \[BEFORE\] Got domain: httpbin.org
+\[AIKIDO\]\[INFO\]\[tid:\d+\] \[AFTER\] Got domain: httpbin.org port: 443
+\[AIKIDO\]\[INFO\]\[tid:\d+\] \[BEFORE\] Got domain: facebook.com
+\[AIKIDO\]\[INFO\]\[tid:\d+\] \[AFTER\] Got domain: facebook.com port: 443
+\[AIKIDO\]\[INFO\]\[tid:\d+\] \[BEFORE\] Got domain: facebook.com
+\[AIKIDO\]\[INFO\]\[tid:\d+\] \[AFTER\] Got domain: facebook.com port: 443
+\[AIKIDO\]\[INFO\]\[tid:\d+\] \[BEFORE\] Got domain: www.aikido.dev
+\[AIKIDO\]\[INFO\]\[tid:\d+\] \[AFTER\] Got domain: www.aikido.dev port: 80
+\[AIKIDO\]\[INFO\]\[tid:\d+\] \[BEFORE\] Got domain: some-invalid-domain.com
+\[AIKIDO\]\[INFO\]\[tid:\d+\] \[AFTER\] Got domain: some-invalid-domain.com port: 4113
\ No newline at end of file
diff --git a/tests/cli/outgoing_request/test_curl_share.phpt b/tests/cli/outgoing_request/test_curl_share.phpt
index e5bc699e4..4ce629aa4 100644
--- a/tests/cli/outgoing_request/test_curl_share.phpt
+++ b/tests/cli/outgoing_request/test_curl_share.phpt
@@ -66,17 +66,17 @@ curl_exec($ch6);
?>
---EXPECT--
-[AIKIDO][INFO] [BEFORE] Got domain: example.com
-[AIKIDO][INFO] [AFTER] Got domain: example.com port: 443
-[AIKIDO][INFO] [BEFORE] Got domain: httpbin.org
-[AIKIDO][INFO] [AFTER] Got domain: httpbin.org port: 443
-[AIKIDO][INFO] [BEFORE] Got domain: facebook.com
-[AIKIDO][INFO] [AFTER] Got domain: facebook.com port: 443
-[AIKIDO][INFO] [BEFORE] Got domain: facebook.com
-[AIKIDO][INFO] [AFTER] Got domain: facebook.com port: 443
-[AIKIDO][INFO] [BEFORE] Got domain: www.aikido.dev
-[AIKIDO][INFO] [AFTER] Got domain: www.aikido.dev port: 80
-[AIKIDO][INFO] [BEFORE] Got domain: some-invalid-domain.com
-[AIKIDO][INFO] [AFTER] Got domain: some-invalid-domain.com port: 4113
+--EXPECTREGEX--
+\[AIKIDO\]\[INFO\]\[tid:\d+\] \[BEFORE\] Got domain: example.com
+\[AIKIDO\]\[INFO\]\[tid:\d+\] \[AFTER\] Got domain: example.com port: 443
+\[AIKIDO\]\[INFO\]\[tid:\d+\] \[BEFORE\] Got domain: httpbin.org
+\[AIKIDO\]\[INFO\]\[tid:\d+\] \[AFTER\] Got domain: httpbin.org port: 443
+\[AIKIDO\]\[INFO\]\[tid:\d+\] \[BEFORE\] Got domain: facebook.com
+\[AIKIDO\]\[INFO\]\[tid:\d+\] \[AFTER\] Got domain: facebook.com port: 443
+\[AIKIDO\]\[INFO\]\[tid:\d+\] \[BEFORE\] Got domain: facebook.com
+\[AIKIDO\]\[INFO\]\[tid:\d+\] \[AFTER\] Got domain: facebook.com port: 443
+\[AIKIDO\]\[INFO\]\[tid:\d+\] \[BEFORE\] Got domain: www.aikido.dev
+\[AIKIDO\]\[INFO\]\[tid:\d+\] \[AFTER\] Got domain: www.aikido.dev port: 80
+\[AIKIDO\]\[INFO\]\[tid:\d+\] \[BEFORE\] Got domain: some-invalid-domain.com
+\[AIKIDO\]\[INFO\]\[tid:\d+\] \[AFTER\] Got domain: some-invalid-domain.com port: 4113
diff --git a/tests/cli/outgoing_request/test_outgoing_request_file_get_contents.phpt b/tests/cli/outgoing_request/test_outgoing_request_file_get_contents.phpt
index 4025de203..e37d75bd1 100644
--- a/tests/cli/outgoing_request/test_outgoing_request_file_get_contents.phpt
+++ b/tests/cli/outgoing_request/test_outgoing_request_file_get_contents.phpt
@@ -12,6 +12,6 @@ file_get_contents("http://www.example.com");
?>
---EXPECT--
-[AIKIDO][INFO] [BEFORE] Got domain: www.example.com
-[AIKIDO][INFO] [AFTER] Got domain: www.example.com port: 80
\ No newline at end of file
+--EXPECTREGEX--
+.*\[AIKIDO\]\[INFO\]\[tid:\d+\] \[BEFORE\] Got domain: www.example.com
+\[AIKIDO\]\[INFO\]\[tid:\d+\] \[AFTER\] Got domain: www.example.com port: 80
\ No newline at end of file
diff --git a/tests/cli/shell_execution/test_shell_execution.phpt b/tests/cli/shell_execution/test_shell_execution.phpt
index 717d2159c..e45848eae 100644
--- a/tests/cli/shell_execution/test_shell_execution.phpt
+++ b/tests/cli/shell_execution/test_shell_execution.phpt
@@ -55,24 +55,24 @@ if (is_resource($process)) {
echo "\n";
?>
---EXPECTF--
-[AIKIDO][INFO] Got shell command: echo "Hello from exec!"
+--EXPECTREGEX--
+\[AIKIDO\]\[INFO\]\[tid:\d+\] Got shell command: echo "Hello from exec!"
Array
-(
- [0] => Hello from exec!
-)
+\(
+ \[0\] => Hello from exec!
+\)
-[AIKIDO][INFO] Got shell command: echo "Hello from shell_exec!"
+\[AIKIDO\]\[INFO\]\[tid:\d+\] Got shell command: echo "Hello from shell_exec!"
Hello from shell_exec!
-[AIKIDO][INFO] Got shell command: echo "Hello from system!"
+\[AIKIDO\]\[INFO\]\[tid:\d+\] Got shell command: echo "Hello from system!"
Hello from system!
-[AIKIDO][INFO] Got shell command: echo "Hello from passthru!"
+\[AIKIDO\]\[INFO\]\[tid:\d+\] Got shell command: echo "Hello from passthru!"
Hello from passthru!
-[AIKIDO][INFO] Got shell command: echo "Hello from popen!"
+\[AIKIDO\]\[INFO\]\[tid:\d+\] Got shell command: echo "Hello from popen!"
Hello from popen!
-[AIKIDO][INFO] Got shell command: echo "Hello from proc_open!"
+\[AIKIDO\]\[INFO\]\[tid:\d+\] Got shell command: echo "Hello from proc_open!"
Hello from proc_open!
diff --git a/tests/server/test_disable/test.py b/tests/server/test_disable/test.py
index ad695e158..bd523f20a 100755
--- a/tests/server/test_disable/test.py
+++ b/tests/server/test_disable/test.py
@@ -16,7 +16,8 @@ def run_test():
assert_response_code_is(response, 200)
assert_response_body_contains(response, "File opened!")
- if mock_server_get_platform_name() != "apache2handler":
+ platform = mock_server_get_platform_name()
+ if platform != "apache2handler" and platform != "frankenphp":
events = mock_server_get_events()
assert_events_length_is(events, 0)
diff --git a/tests/server/test_domains/test.py b/tests/server/test_domains/test.py
index 46e88e5d9..96319a531 100755
--- a/tests/server/test_domains/test.py
+++ b/tests/server/test_domains/test.py
@@ -18,7 +18,14 @@ def run_test():
events = mock_server_get_events()
assert_events_length_is(events, 2)
assert_started_event_is_valid(events[0])
- assert_event_contains_subset_file(events[1], "expect_domains.json")
+
+ with open("expect_domains.json", 'r') as file:
+ expected = json.load(file)
+
+ if mock_server_get_platform_name() == "frankenphp":
+ expected["hostnames"] = [h for h in expected["hostnames"] if h.get("hostname") != "127.0.0.1"]
+
+ assert_event_contains_subset("__root", events[1], expected)
if __name__ == "__main__":
load_test_args()
diff --git a/tests/server/test_tor_monitored_ip/index.php b/tests/server/test_tor_monitored_ip/index.php
index b05fef240..64697f6bb 100755
--- a/tests/server/test_tor_monitored_ip/index.php
+++ b/tests/server/test_tor_monitored_ip/index.php
@@ -1,5 +1,7 @@
diff --git a/tests/server/test_user_agent_monitored/index.php b/tests/server/test_user_agent_monitored/index.php
index b05fef240..64697f6bb 100755
--- a/tests/server/test_user_agent_monitored/index.php
+++ b/tests/server/test_user_agent_monitored/index.php
@@ -1,5 +1,7 @@
diff --git a/tools/build.sh b/tools/build.sh
index d6fea69ce..834b10100 100755
--- a/tools/build.sh
+++ b/tools/build.sh
@@ -1,8 +1,20 @@
+set -e
+
export PATH="$PATH:$HOME/go/bin:$HOME/.local/bin"
PHP_VERSION=$(php -v | grep -oP 'PHP \K\d+\.\d+' | head -n 1)
-AIKIDO_EXTENSION=aikido-extension-php-$PHP_VERSION.so
-AIKIDO_EXTENSION_DEBUG=aikido-extension-php-$PHP_VERSION.so.debug
+
+# Detect if PHP is ZTS or NTS
+if php -v | grep -q "ZTS"; then
+ EXT_SUFFIX="-zts"
+ echo "Building ZTS extension"
+else
+ EXT_SUFFIX="-nts"
+ echo "Building NTS extension"
+fi
+
+AIKIDO_EXTENSION=aikido-extension-php-$PHP_VERSION$EXT_SUFFIX.so
+AIKIDO_EXTENSION_DEBUG=aikido-extension-php-$PHP_VERSION$EXT_SUFFIX.so.debug
rm -rf build
mkdir build
@@ -23,6 +35,16 @@ go test ./...
go build -ldflags "-s -w" -buildmode=c-shared -o ../../build/aikido-request-processor.so
cd ../../build
CXX=g++ CXXFLAGS="-fPIC -g -O2 -I../lib/php-extension/include" LDFLAGS="-lstdc++" ../lib/php-extension/configure
+sed -i "s/available_tags=''/available_tags='CXX'/" libtool
+if ! grep -q "BEGIN LIBTOOL TAG CONFIG: CXX" libtool; then
+ sed -i '/^# ### BEGIN LIBTOOL TAG CONFIG: disable-shared$/i\
+# ### BEGIN LIBTOOL TAG CONFIG: CXX\
+LTCXX="g++"\
+CXXFLAGS="-fPIC -g -O2"\
+compiler_CXX="g++"\
+# ### END LIBTOOL TAG CONFIG: CXX\
+' libtool
+fi
make -j$(nproc)
cd ./modules/
mv aikido.so $AIKIDO_EXTENSION
diff --git a/tools/mock_aikido_core.py b/tools/mock_aikido_core.py
index 2f6abb41b..cd33fec04 100755
--- a/tools/mock_aikido_core.py
+++ b/tools/mock_aikido_core.py
@@ -143,7 +143,7 @@ def mock_get_token():
if __name__ == '__main__':
if len(sys.argv) < 2 or len(sys.argv) > 3:
- print("Usage: python mock_server.py [config_file]")
+ print("Usage: python mock_aikido_core.py [config_file]")
sys.exit(1)
port = int(sys.argv[1])
diff --git a/tools/rpm_build.sh b/tools/rpm_build.sh
index c77ecfcf8..1425d3565 100755
--- a/tools/rpm_build.sh
+++ b/tools/rpm_build.sh
@@ -8,6 +8,13 @@ VERSION=$(grep '#define PHP_AIKIDO_VERSION' lib/php-extension/include/php_aikido
AIKIDO_INTERNALS_REPO=https://api.github.com/repos/AikidoSec/zen-internals
AIKIDO_INTERNALS_LIB=libzen_internals_$arch-unknown-linux-gnu.so
+# Detect if PHP is ZTS or NTS
+if php -v | grep -q "ZTS"; then
+ EXT_SUFFIX="-zts"
+else
+ EXT_SUFFIX="-nts"
+fi
+
mkdir -p ~/rpmbuild/SOURCES/aikido-php-firewall-$VERSION
cp -rf package/rpm/opt ~/rpmbuild/SOURCES/aikido-php-firewall-$VERSION/
@@ -16,7 +23,7 @@ cp -f package/rpm/aikido.spec ~/rpmbuild/SPECS/
cp build/aikido-agent ~/rpmbuild/SOURCES/aikido-php-firewall-$VERSION/opt/aikido/aikido-agent
cp build/aikido-request-processor.so ~/rpmbuild/SOURCES/aikido-php-firewall-$VERSION/opt/aikido/aikido-request-processor.so
-cp build/modules/aikido-extension-php-$PHP_VERSION.so ~/rpmbuild/SOURCES/aikido-php-firewall-$VERSION/opt/aikido/aikido-extension-php-$PHP_VERSION.so
+cp build/modules/aikido-extension-php-$PHP_VERSION$EXT_SUFFIX.so ~/rpmbuild/SOURCES/aikido-php-firewall-$VERSION/opt/aikido/aikido-extension-php-$PHP_VERSION$EXT_SUFFIX.so
curl -L -o $AIKIDO_INTERNALS_LIB $(curl -s $AIKIDO_INTERNALS_REPO/releases/latest | jq -r ".assets[] | select(.name == \"$AIKIDO_INTERNALS_LIB\") | .browser_download_url")
mv $AIKIDO_INTERNALS_LIB ~/rpmbuild/SOURCES/aikido-php-firewall-$VERSION/opt/aikido/$AIKIDO_INTERNALS_LIB
diff --git a/tools/rpm_full_build.sh b/tools/rpm_full_build.sh
index 2b38d6bca..b7dbfab7f 100755
--- a/tools/rpm_full_build.sh
+++ b/tools/rpm_full_build.sh
@@ -1,2 +1,3 @@
-rpm -e aikido-php-firewall
+rpm -e aikido-php-firewall || true
+set -e
./tools/build.sh && ./tools/rpm_build.sh && ./tools/rpm_install.sh
diff --git a/tools/run_server_tests.py b/tools/run_server_tests.py
index 545a1abde..9af8ff839 100755
--- a/tools/run_server_tests.py
+++ b/tools/run_server_tests.py
@@ -7,9 +7,12 @@
import json
import argparse
import socket
+import urllib.request
from server_tests.php_built_in.main import php_built_in_start_server
from server_tests.apache.main import apache_mod_php_init, apache_mod_php_process_test, apache_mod_php_pre_tests, apache_mod_php_start_server, apache_mod_php_uninit
from server_tests.nginx.main import nginx_php_fpm_init, nginx_php_fpm_process_test, nginx_php_fpm_pre_tests, nginx_php_fpm_start_server, nginx_php_fpm_uninit
+from server_tests.frankenphp_classic.main import frankenphp_classic_init, frankenphp_classic_process_test, frankenphp_classic_pre_tests, frankenphp_classic_start_server, frankenphp_classic_uninit
+from server_tests.frankenphp_worker.main import frankenphp_worker_init, frankenphp_worker_process_test, frankenphp_worker_pre_tests, frankenphp_worker_start_server, frankenphp_worker_uninit
INIT = 0
PROCESS_TEST = 1
@@ -39,6 +42,20 @@
nginx_php_fpm_start_server,
nginx_php_fpm_uninit
),
+ "frankenphp-classic": (
+ frankenphp_classic_init,
+ frankenphp_classic_process_test,
+ frankenphp_classic_pre_tests,
+ frankenphp_classic_start_server,
+ frankenphp_classic_uninit
+ ),
+ "frankenphp-worker": (
+ frankenphp_worker_init,
+ frankenphp_worker_process_test,
+ frankenphp_worker_pre_tests,
+ frankenphp_worker_start_server,
+ frankenphp_worker_uninit
+ ),
}
used_ports = set()
@@ -52,10 +69,24 @@ def is_port_in_active_use(port):
result = sock.connect_ex(('127.0.0.1', port))
return result == 0
+def wait_for_port_ready(port, timeout=30, check_interval=0.1):
+ """Wait for a port to become ready to accept connections."""
+ start_time = time.time()
+
+ while time.time() - start_time < timeout:
+ if is_port_in_active_use(port):
+ return True
+ time.sleep(check_interval)
+
+ return False
+
def generate_unique_port():
with lock:
while True:
port = random.randint(1024, 9999)
+ # Exclude port 2019 (Caddy admin endpoint)
+ if port == 2019:
+ continue
if port not in used_ports and not is_port_in_active_use(port):
used_ports.add(port)
return port
@@ -77,19 +108,37 @@ def print_test_results(s, tests):
print(f"\t- {t}")
def handle_test_scenario(data, root_tests_dir, test_lib_dir, server, benchmark, valgrind, debug):
+ _handle_test_scenario_impl(data, root_tests_dir, test_lib_dir, server, benchmark, valgrind, debug)
+
+def _handle_test_scenario_impl(data, root_tests_dir, test_lib_dir, server, benchmark, valgrind, debug):
test_name = data["test_name"]
mock_port = data["mock_port"]
server_port = data["server_port"]
+ mock_aikido_core = None
+ server_process = None
+ test_process = None
try:
print(f"Running {test_name}...")
- print(f"Starting mock server on port {mock_port} with start_config.json for {test_name}...")
- mock_aikido_core = subprocess.Popen(["python3", "mock_aikido_core.py", str(mock_port), data["config_path"]])
- time.sleep(5)
+
+ # For frankenphp modes, mock servers are already started and FrankenPHP is already running
+ if server in ["frankenphp-worker", "frankenphp-classic"]:
+ mock_aikido_core = data.get("mock_process")
+ if not mock_aikido_core:
+ raise RuntimeError(f"Mock process not found for {test_name} in {server} mode")
+ else:
+ # For other server modes, start mock server normally
+ print(f"Starting mock server on port {mock_port} with start_config.json for {test_name}...")
+ mock_aikido_core = subprocess.Popen(["python3", "-u", "mock_aikido_core.py", str(mock_port), data["config_path"]], cwd=os.path.dirname(os.path.abspath(__file__)))
+
+ # Wait for mock server to be ready (instead of fixed sleep)
+ if not wait_for_port_ready(mock_port, timeout=10):
+ raise RuntimeError(f"Mock server on port {mock_port} failed to start within 10 seconds")
+ print(f"Mock server on port {mock_port} is ready")
- print(f"Starting {server} server on port {server_port} for {test_name}...")
+ print(f"Starting {server} server on port {server_port} for {test_name}...")
- server_start = servers[server][START_SERVER]
- server_process = server_start(data, test_lib_dir, valgrind)
+ server_start = servers[server][START_SERVER]
+ server_process = server_start(data, test_lib_dir, valgrind)
time.sleep(20)
@@ -105,16 +154,13 @@ def handle_test_scenario(data, root_tests_dir, test_lib_dir, server, benchmark,
subprocess.run(["python3", test_script_name, str(server_port), str(mock_port), test_name],
env=dict(os.environ, PYTHONPATH=f"{test_lib_dir}:$PYTHONPATH"),
cwd=test_script_cwd,
- check=True, timeout=600, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ check=True, timeout=600)
passed_tests.append(test_name)
except subprocess.CalledProcessError as e:
print(f"Error in testing scenario {test_name}:")
- print(f"Exception output: {e.output}")
print(f"Test exit code: {e.returncode}")
- print(f"Test stdout: {e.stdout.decode()}")
- print(f"Test stderr: {e.stderr.decode()}")
failed_tests.append(test_name)
except subprocess.TimeoutExpired:
@@ -128,7 +174,8 @@ def handle_test_scenario(data, root_tests_dir, test_lib_dir, server, benchmark,
failed_tests.append(test_name)
finally:
- if server_process:
+ # For frankenphp modes, don't stop the server (it's shared across all tests)
+ if server_process and server not in ["frankenphp-worker", "frankenphp-classic"]:
server_process.terminate()
server_process.wait()
print(f"PHP server on port {server_port} stopped.")
@@ -172,8 +219,41 @@ def main(root_tests_dir, test_lib_dir, test_dirs, server="php-built-in", benchma
tests_data.append(test_data)
if servers[server][PRE_TESTS] is not None:
- test_data = servers[server][PRE_TESTS]()
-
+ pre_tests = servers[server][PRE_TESTS]
+ if server in ["frankenphp-classic", "frankenphp-worker"]:
+ pre_tests(tests_data)
+ else:
+ pre_tests()
+
+ # For frankenphp modes, start ALL mock servers BEFORE starting FrankenPHP
+ # since one FrankenPHP process handles all tests and initializes immediately
+ if server in ["frankenphp-worker", "frankenphp-classic"]:
+ print(f"Starting all mock servers for {server} mode...")
+ for test_data in tests_data:
+ test_name = test_data["test_name"]
+ mock_port = test_data["mock_port"]
+ print(f"Starting mock server on port {mock_port} for {test_name}...")
+ mock_process = subprocess.Popen(
+ ["python3", "-u", "mock_aikido_core.py", str(mock_port), test_data["config_path"]],
+ cwd=os.path.dirname(os.path.abspath(__file__))
+ )
+ test_data["mock_process"] = mock_process
+
+ # Wait for ALL mock servers to be ready
+ print("Waiting for all mock servers to be ready...")
+ for test_data in tests_data:
+ mock_port = test_data["mock_port"]
+ test_name = test_data["test_name"]
+ if not wait_for_port_ready(mock_port, timeout=10):
+ raise RuntimeError(f"Mock server on port {mock_port} for {test_name} failed to start")
+ print(f"All {len(tests_data)} mock servers are ready!")
+
+ # Now start FrankenPHP ONCE with all mock servers ready
+ server_start = servers[server][START_SERVER]
+ server_start(tests_data[0], test_lib_dir, valgrind)
+ print(f"FrankenPHP started with all mock servers ready")
+ time.sleep(5) # Give FrankenPHP time to initialize
+
threads = []
for test_data in tests_data:
args = (test_data, root_tests_dir, test_lib_dir, server, benchmark, valgrind, debug)
@@ -181,7 +261,6 @@ def main(root_tests_dir, test_lib_dir, test_dirs, server="php-built-in", benchma
threads.append(thread)
thread.start()
time.sleep(10)
-
for thread in threads:
thread.join()
@@ -197,7 +276,7 @@ def main(root_tests_dir, test_lib_dir, test_dirs, server="php-built-in", benchma
parser.add_argument("--benchmark", action="store_true", help="Enable benchmarking.")
parser.add_argument("--valgrind", action="store_true", help="Enable valgrind.")
parser.add_argument("--debug", action="store_true", help="Enable debugging logs.")
- parser.add_argument("--server", type=str, choices=["php-built-in", "apache-mod-php", "nginx-php-fpm"], default="php-built-in", help="Select the type of server testing.")
+ parser.add_argument("--server", type=str, choices=["php-built-in", "apache-mod-php", "nginx-php-fpm", "frankenphp-classic", "frankenphp-worker"], default="php-built-in", help="Select the type of server testing.")
parser.add_argument("--max-tests", type=int, default=0, help="Maximum number of tests to execute.")
parser.add_argument("--max-runs", type=int, default=1, help="Maximum number of test runs.")
diff --git a/tools/sample_apps_build.sh b/tools/sample_apps_build.sh
index e65c0ebc0..a65382cb6 100644
--- a/tools/sample_apps_build.sh
+++ b/tools/sample_apps_build.sh
@@ -6,8 +6,18 @@ export PATH="$PATH:$HOME/go/bin:$HOME/.local/bin"
PHP_VERSION=$(php -v | grep -oP 'PHP \K\d+\.\d+' | head -n 1)
AIKIDO_VERSION=$(grep '#define PHP_AIKIDO_VERSION' lib/php-extension/include/php_aikido.h | awk -F'"' '{print $2}')
-AIKIDO_EXTENSION=aikido-extension-php-$AIKIDO_VERSION.so
-AIKIDO_EXTENSION_DEBUG=aikido-extension-php-$AIKIDO_VERSION.so.debug
+
+# Detect if PHP is ZTS or NTS
+if php -v | grep -q "ZTS"; then
+ EXT_SUFFIX="-zts"
+ echo "Building ZTS extension"
+else
+ EXT_SUFFIX="-nts"
+ echo "Building NTS extension"
+fi
+
+AIKIDO_EXTENSION=aikido-extension-php-$AIKIDO_VERSION$EXT_SUFFIX.so
+AIKIDO_EXTENSION_DEBUG=aikido-extension-php-$AIKIDO_VERSION$EXT_SUFFIX.so.debug
AIKIDO_INTERNALS_REPO=https://api.github.com/repos/AikidoSec/zen-internals
AIKIDO_INTERNALS_LIB=libzen_internals_$arch-unknown-linux-gnu.so
@@ -31,6 +41,16 @@ go mod tidy
go build -ldflags "-s -w" -buildmode=c-shared -o ../../build/aikido-request-processor.so
cd ../../build
CXX=g++ CXXFLAGS="-fPIC -g -O2 -I../lib/php-extension/include" LDFLAGS="-lstdc++" ../lib/php-extension/configure
+sed -i "s/available_tags=''/available_tags='CXX'/" libtool
+if ! grep -q "BEGIN LIBTOOL TAG CONFIG: CXX" libtool; then
+ sed -i '/^# ### BEGIN LIBTOOL TAG CONFIG: disable-shared$/i\
+# ### BEGIN LIBTOOL TAG CONFIG: CXX\
+LTCXX="g++"\
+CXXFLAGS="-fPIC -g -O2"\
+compiler_CXX="g++"\
+# ### END LIBTOOL TAG CONFIG: CXX\
+' libtool
+fi
make
cd ./modules/
mv aikido.so $AIKIDO_EXTENSION
diff --git a/tools/server_tests/apache/main.py b/tools/server_tests/apache/main.py
index 22161fa37..272dbc6ff 100755
--- a/tools/server_tests/apache/main.py
+++ b/tools/server_tests/apache/main.py
@@ -148,7 +148,7 @@ def toggle_config_line(file_path, line_to_check, comment_ch, enable=False):
commented_line_pattern = r"\s*" + re.escape(line_to_check.strip()) + r".*"
if enable:
- commented_line_pattern = "\s*" + comment_ch + commented_line_pattern
+ commented_line_pattern = r"\s*" + comment_ch + commented_line_pattern
# Initialize a flag to track changes
changes_made = False
@@ -295,7 +295,22 @@ def apache_mod_php_process_test(test_data):
def apache_mod_php_pre_tests():
- subprocess.run([f'/usr/sbin/{apache_binary}', '-k', 'start'])
+ # Stop any existing Apache processes first
+ subprocess.run(['pkill', apache_binary], stderr=subprocess.DEVNULL)
+ subprocess.run(['pkill', '-9', apache_binary], stderr=subprocess.DEVNULL)
+ time.sleep(1)
+
+ # Clean up log files
+ subprocess.run(['rm', '-rf', f'/var/log/aikido-*/*'], stderr=subprocess.DEVNULL)
+
+ if not os.path.exists('/etc/httpd'):
+ # Debian/Ubuntu Apache - use apache2ctl which sources /etc/apache2/envvars
+ # This ensures APACHE_RUN_DIR and other variables are properly set
+ # apache2ctl will source envvars and then start Apache with the correct environment
+ subprocess.run(['/usr/sbin/apache2ctl', 'start'], check=True)
+ else:
+ # CentOS/RHEL Apache
+ subprocess.run([f'/usr/sbin/{apache_binary}', '-k', 'start'], check=True)
def apache_mod_php_start_server(test_data, test_lib_dir, valgrind):
diff --git a/tools/server_tests/frankenphp_classic/main.py b/tools/server_tests/frankenphp_classic/main.py
new file mode 100644
index 000000000..8aaac05ba
--- /dev/null
+++ b/tools/server_tests/frankenphp_classic/main.py
@@ -0,0 +1,91 @@
+import os
+import subprocess
+import time
+import urllib.request
+
+frankenphp_bin = "frankenphp"
+caddyfile_path = "/tmp/frankenphp_test.caddyfile"
+log_dir = "/var/log/frankenphp"
+
+caddyfile_base_template = """{{
+ frankenphp {{
+ num_threads {num_threads}
+ max_threads {max_threads}
+ }}
+}}
+"""
+
+site_block_template = """http://:{port} {{
+ root * {test_dir}
+ php_server {{
+{env_vars}
+ }}
+}}
+"""
+
+def create_folder(folder_path):
+ if not os.path.exists(folder_path):
+ os.makedirs(folder_path)
+
+def frankenphp_create_site_block(test_data):
+ env_vars = ""
+ for key, value in test_data["env"].items():
+ env_vars += f" env {key} \"{value}\"\n"
+
+ return site_block_template.format(
+ port=test_data["server_port"],
+ test_dir=test_data["test_dir"],
+ env_vars=env_vars
+ )
+
+def frankenphp_classic_init(tests_dir):
+ if os.path.exists(caddyfile_path):
+ os.remove(caddyfile_path)
+ create_folder(log_dir)
+ create_folder('/etc/frankenphp/php.d')
+
+def frankenphp_classic_process_test(test_data):
+ test_data["site_block"] = frankenphp_create_site_block(test_data)
+ return test_data
+
+def frankenphp_classic_pre_tests(tests_data):
+ subprocess.run(['pkill', '-9', '-x', 'frankenphp'], stderr=subprocess.DEVNULL)
+ subprocess.run(['pkill', '-9', '-f', 'mock_aikido_core'], stderr=subprocess.DEVNULL)
+ subprocess.run(['rm', '-rf', f'{log_dir}/*'])
+ subprocess.run(['rm', '-rf', f'/var/log/aikido-*/*'])
+
+ total_workers = len(tests_data)
+ threads = total_workers * 2
+
+ with open(caddyfile_path, 'w') as f:
+ f.write(caddyfile_base_template.format(num_threads=threads, max_threads=threads*2))
+ for test_data in tests_data:
+ f.write("\n" + test_data["site_block"])
+
+ print(f"Caddyfile prepared for {len(tests_data)} tests with {threads} threads")
+
+def frankenphp_classic_start_server(test_data, test_lib_dir, valgrind):
+ result = subprocess.run(['pgrep', '-x', 'frankenphp'], capture_output=True, text=True)
+
+ if not result.stdout.strip():
+ print("Starting FrankenPHP classic server...")
+ process = subprocess.Popen(
+ [frankenphp_bin, 'run', '--config', caddyfile_path]
+ )
+ time.sleep(2)
+
+ result = subprocess.run(['pgrep', '-x', 'frankenphp'], capture_output=True, text=True)
+ if not result.stdout.strip():
+ raise RuntimeError("FrankenPHP classic failed to spawn!")
+
+ print("FrankenPHP classic process started")
+ else:
+ print("FrankenPHP classic is already running")
+
+ return None
+
+def frankenphp_classic_uninit():
+ subprocess.run(['pkill', '-9', '-x', 'frankenphp'], stderr=subprocess.DEVNULL)
+ if os.path.exists(caddyfile_path):
+ os.remove(caddyfile_path)
+
diff --git a/tools/server_tests/frankenphp_worker/main.py b/tools/server_tests/frankenphp_worker/main.py
new file mode 100644
index 000000000..e310e9648
--- /dev/null
+++ b/tools/server_tests/frankenphp_worker/main.py
@@ -0,0 +1,167 @@
+import os
+import subprocess
+import time
+
+frankenphp_bin = "frankenphp"
+caddyfile_path = "/tmp/frankenphp_worker_test.caddyfile"
+log_dir = "/var/log/frankenphp"
+worker_scripts_dir = "/tmp/frankenphp_workers"
+
+num_workers = 2
+
+def get_php_version():
+ """Get PHP version as a tuple (major, minor)"""
+ try:
+ result = subprocess.run(['php', '-r', 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;'],
+ capture_output=True, text=True, check=True)
+ version_str = result.stdout.strip()
+ major, minor = version_str.split('.')
+ return (int(major), int(minor))
+ except:
+ return (8, 3) # Default to newer version
+
+def get_caddyfile_base_template():
+ """Get the appropriate caddyfile template based on PHP version"""
+ php_version = get_php_version()
+
+ # FrankenPHP 1.1.0 (PHP 8.2) doesn't support the {{ global options block
+ if php_version == (8, 2):
+ # Use a simpler format without global options block
+ return ""
+ else:
+ # Newer versions support the global options block
+ return """{{
+ frankenphp {{
+ num_threads {num_threads}
+ max_threads {max_threads}
+ }}
+}}
+"""
+
+site_block_template = """http://:{port} {{
+ root * {test_dir}
+ php_server {{
+{env_vars}
+ worker {{
+ file {worker_script}
+ num {num_workers}
+ }}
+ }}
+}}
+"""
+
+worker_script_template = """ 0; $nbWorkers = frankenphp_handle_request($handler)) {{
+ gc_collect_cycles();
+}}
+"""
+
+def create_folder(folder_path):
+ if not os.path.exists(folder_path):
+ os.makedirs(folder_path)
+
+def frankenphp_worker_create_script(test_dir, test_name):
+ worker_script_path = os.path.join(worker_scripts_dir, f"{test_name}.php")
+ worker_script_content = worker_script_template.format(test_dir=test_dir)
+
+ with open(worker_script_path, 'w') as f:
+ f.write(worker_script_content)
+
+ return worker_script_path
+
+def frankenphp_worker_create_site_block(test_data, worker_script_path):
+ env_vars = f" env DOCUMENT_ROOT \"{test_data['test_dir']}\"\n"
+ for key, value in test_data["env"].items():
+ env_vars += f" env {key} \"{value}\"\n"
+
+ return site_block_template.format(
+ port=test_data["server_port"],
+ test_dir=test_data["test_dir"],
+ worker_script=worker_script_path,
+ env_vars=env_vars,
+ num_workers=num_workers
+ )
+
+def frankenphp_worker_init(tests_dir):
+ if os.path.exists(caddyfile_path):
+ os.remove(caddyfile_path)
+ subprocess.run(['rm', '-rf', f'{worker_scripts_dir}/*'])
+ create_folder(log_dir)
+ create_folder('/etc/frankenphp/php.d')
+ create_folder(worker_scripts_dir)
+
+def frankenphp_worker_process_test(test_data):
+ test_name = test_data["test_name"]
+ worker_script_path = frankenphp_worker_create_script(test_data["test_dir"], test_name)
+ test_data["site_block"] = frankenphp_worker_create_site_block(test_data, worker_script_path)
+ return test_data
+
+def frankenphp_worker_pre_tests(tests_data):
+ subprocess.run(['pkill', '-9', '-x', 'frankenphp'], stderr=subprocess.DEVNULL)
+ subprocess.run(['pkill', '-9', '-f', 'mock_aikido_core'], stderr=subprocess.DEVNULL)
+ subprocess.run(['rm', '-rf', f'{log_dir}/*'])
+ subprocess.run(['rm', '-rf', f'/var/log/aikido-*/*'])
+ subprocess.run(['rm', '-rf', f'{worker_scripts_dir}/*'])
+
+ total_workers = len(tests_data)
+ threads = total_workers * 3
+
+ with open(caddyfile_path, 'w') as f:
+ base_template = get_caddyfile_base_template()
+ if base_template:
+ f.write(base_template.format(num_threads=threads, max_threads=threads*2))
+ for test_data in tests_data:
+ f.write("\n" + test_data["site_block"])
+
+ print(f"Caddyfile prepared for {len(tests_data)} tests with {threads} threads")
+ return threads
+
+def frankenphp_worker_start_server(test_data, test_lib_dir, valgrind):
+ result = subprocess.run(['pgrep', '-x', 'frankenphp'], capture_output=True, text=True)
+
+ if not result.stdout.strip():
+ print("Starting FrankenPHP worker server...")
+ process = subprocess.Popen(
+ [frankenphp_bin, 'run', '--config', caddyfile_path]
+ )
+ time.sleep(2)
+
+ result = subprocess.run(['pgrep', '-x', 'frankenphp'], capture_output=True, text=True)
+ if not result.stdout.strip():
+ raise RuntimeError("FrankenPHP worker failed to spawn!")
+
+ print("FrankenPHP worker process started")
+ else:
+ print("FrankenPHP worker is already running")
+
+ return None
+
+def frankenphp_worker_uninit():
+ subprocess.run(['pkill', '-9', '-x', 'frankenphp'], stderr=subprocess.DEVNULL)
+ if os.path.exists(caddyfile_path):
+ os.remove(caddyfile_path)
+ subprocess.run(['rm', '-rf', f'{worker_scripts_dir}/*'])
+
diff --git a/tools/server_tests/nginx/main.py b/tools/server_tests/nginx/main.py
index 68e322175..8dc4cbb36 100644
--- a/tools/server_tests/nginx/main.py
+++ b/tools/server_tests/nginx/main.py
@@ -33,7 +33,7 @@ def get_user_of_process(process_name):
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
pass
-nginx_conf_template = """
+nginx_conf_template = r"""
server {{
listen {port};
server_name {name};
@@ -188,6 +188,9 @@ def nginx_php_fpm_process_test(test_data):
def nginx_php_fpm_pre_tests():
subprocess.run(['pkill', 'nginx'])
subprocess.run(['pkill', 'php-fpm'])
+ time.sleep(2)
+ subprocess.run(['pkill', '-9', 'php-fpm'], stderr=subprocess.DEVNULL)
+ time.sleep(2)
subprocess.run(['rm', '-rf', f'{log_dir}/nginx/*'])
subprocess.run(['rm', '-rf', f'{log_dir}/php-fpm/*'])
subprocess.run(['rm', '-rf', f'{log_dir}/aikido-*/*'])
diff --git a/tools/server_tests/php_built_in/main.py b/tools/server_tests/php_built_in/main.py
index af76a929a..abc079463 100644
--- a/tools/server_tests/php_built_in/main.py
+++ b/tools/server_tests/php_built_in/main.py
@@ -10,5 +10,5 @@ def php_built_in_start_server(test_data, test_lib_dir, valgrind):
return subprocess.Popen(
php_server_process_cmd,
- env=test_data["env"]
+ env=dict(os.environ, **test_data["env"])
)