Skip to content

Commit f71ed6b

Browse files
committed
Add Rust rewrite of the AppSec helper alongside the C++ implementation
This introduces a complete Rust reimplementation of the AppSec helper, a library loaded into the PHP sidecar that executes the Datadog WAF on request data, handles remote configuration, collects telemetry, and provides RASP capabilities. The Rust helper is built as a cdylib (libddappsec-helper-rust.so) and ships alongside the existing C++ helper (libddappsec-helper.so), selectable at runtime. The Rust crate lives under appsec/helper-rust/ and is structured around a Tokio async runtime exposed via a C FFI entry point (appsec_helper_main/appsec_helper_shutdown). The server module accepts PHP extension connections over a Unix socket. Each connection is handled by the client module, which implements the msgpack-based protocol codec (client_init, config_sync, request_init, request_exec, request_shutdown) matching the C++ wire format. The service module manages WAF instances per service configuration with atomic updates via arc-swap, and includes sub-modules for rate limiting, trace sampling, WAF diagnostics, and ruleset management. The rc module reads remote configuration from shared memory published by the sidecar, while rc_notify registers for push-style RC update callbacks via FFI. The telemetry module submits metrics and logs to the sidecar by resolving symbols at runtime, and integrates with the logger so error-level messages are automatically forwarded as telemetry logs. The wire protocol between the PHP extension and the helper has been revised. The request_exec command now sends data as an array followed by a map of options (rasp_rule, subctx_id, subctx_last_call) instead of sending rasp_rule as a positional field before the data. The client_init response gains a sixth field, helper_runtime, which the Rust helper sets to "rust" and the C++ helper sets to "cpp". Both helpers have been updated to speak this new protocol. In the PHP extension, helper_process.c gains a DD_APPSEC_HELPER_RUST_REDIRECTION configuration option (INI setting datadog.appsec.helper_rust_redirection). When enabled, the extension looks for libddappsec-helper-rust.so next to the configured helper path and loads it instead of the C++ binary. The extension tracks which runtime is connected via a helper_runtime enum and reports it in phpinfo() output as "Yes (Rust)" or "Yes (C++)". A new span tag _dd.appsec.helper_runtime is set when using the Rust helper. A testing function send_invalid_msg is added for protocol error testing. The binary is built against musl using nightly Rust with -Z build-std to rebuild the standard library with LLVM's libunwind, then patchelf removes the musl libc NEEDED entry so the resulting .so runs on both glibc and musl systems without modification. A glibc_compat.c shim provides ceil/ceilf/fcntl64/dlopen/dlsym/dlclose implementations for the musl build so it links without pulling in glibc symbols. CI changes in .gitlab/generate-appsec.php add four new pipeline jobs: "helper-rust build and test" (cargo test + format check), "helper-rust code coverage" (unit test coverage uploaded to codecov), "helper-rust integration coverage" (integration test coverage), and "appsec integration tests (helper-rust)" (integration tests on PHP 7.4, 8.1, 8.3, 8.4-zts, 8.5-musl). The existing integration test job template is refactored into a shared .appsec_integration_tests base. The package pipeline in generate-package.php adds a "compile appsec helper rust" job for amd64 and arm64, and the artifact scripts now include libddappsec-helper-rust.so in both glibc and musl packages. The Gradle build system (build.gradle) is extended with tasks for building, testing, and coverage-instrumenting the Rust helper, and gains musl test support via a new nginx-fpm-musl Docker image. A libddwaf-rust git submodule is added under appsec/third_party/ for the WAF Rust bindings. Codecov flags helper-rust-unit and helper-rust-integration are configured in codecov.yml.
1 parent 147f3c2 commit f71ed6b

122 files changed

Lines changed: 14355 additions & 328 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitlab-ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ appsec-trigger:
5757
strategy: depend
5858
variables:
5959
PARENT_PIPELINE_ID: $CI_PIPELINE_ID
60-
GIT_SUBMODULE_PATHS: libdatadog appsec/third_party/cpp-base64 appsec/third_party/libddwaf appsec/third_party/msgpack-c
60+
GIT_SUBMODULE_PATHS: libdatadog appsec/third_party/cpp-base64 appsec/third_party/libddwaf appsec/third_party/libddwaf-rust appsec/third_party/msgpack-c
6161

6262
profiler-trigger:
6363
stage: tests
@@ -92,6 +92,6 @@ package-trigger:
9292
strategy: depend
9393
variables:
9494
PARENT_PIPELINE_ID: $CI_PIPELINE_ID
95-
GIT_SUBMODULE_PATHS: libdatadog appsec/third_party/cpp-base64 appsec/third_party/libddwaf appsec/third_party/msgpack-c
95+
GIT_SUBMODULE_PATHS: libdatadog appsec/third_party/cpp-base64 appsec/third_party/libddwaf appsec/third_party/libddwaf-rust appsec/third_party/msgpack-c
9696
NIGHTLY_BUILD: $NIGHTLY_BUILD
9797
RELIABILITY_ENV_BRANCH: $RELIABILITY_ENV_BRANCH
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
#!/usr/bin/env bash
2+
set -e -o pipefail
3+
4+
MAKE_JOBS=${MAKE_JOBS:-$(nproc)}
5+
6+
mkdir -p appsec_$(uname -m)
7+
8+
git config --global --add safe.directory '*'
9+
10+
cd appsec/helper-rust
11+
12+
export CARGO_TARGET_DIR=/tmp/cargo-target
13+
RUST_TARGET=$(uname -m)-unknown-linux-musl
14+
15+
# Build using nightly toolchain with unstable features
16+
# -Z build-std: Rebuild std library for musl
17+
# -Z build-std-features=llvm-libunwind: Use LLVM libunwind instead of libgcc_s
18+
cargo +nightly-"$RUST_TARGET" build \
19+
--release \
20+
-Zhost-config \
21+
-Ztarget-applies-to-host \
22+
--target "$RUST_TARGET"
23+
24+
# Remove musl libc dependency using patchelf (makes binary work on both musl and glibc)
25+
BINARY_PATH="/tmp/cargo-target/$RUST_TARGET/release/libddappsec_helper_rust.so"
26+
ARCH=$(uname -m)
27+
if [ "$ARCH" = "x86_64" ]; then
28+
patchelf --remove-needed libc.musl-x86_64.so.1 "$BINARY_PATH" 2>/dev/null || true
29+
elif [ "$ARCH" = "aarch64" ]; then
30+
patchelf --remove-needed libc.musl-aarch64.so.1 "$BINARY_PATH" 2>/dev/null || true
31+
fi
32+
33+
# Copy to output
34+
cp -v "$BINARY_PATH" "../../appsec_$(uname -m)/libddappsec-helper-rust.so"
35+
36+
# Run tests
37+
cargo +nightly-"$RUST_TARGET" test \
38+
--release \
39+
-Zhost-config \
40+
-Ztarget-applies-to-host \
41+
--target "$RUST_TARGET"

.gitlab/generate-appsec.php

Lines changed: 202 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,16 +94,52 @@
9494
-DENABLE_ASAN=$ASAN_FLAG"
9595
- ASAN_OPTIONS=malloc_context_size=0 make -j 4 xtest
9696

97-
"appsec integration tests":
97+
.appsec_integration_tests:
9898
stage: test
9999
image: 486234852809.dkr.ecr.us-east-1.amazonaws.com/docker:24.0.4-gbi-focal # TODO: use a proper docker image with java pre-installed?
100100
tags: [ "docker-in-docker:amd64" ]
101101
variables:
102102
KUBERNETES_CPU_REQUEST: 8
103103
KUBERNETES_MEMORY_REQUEST: 24Gi
104104
KUBERNETES_MEMORY_LIMIT: 30Gi
105+
DOCKER_LOOPBACK_SIZE: 30G
105106
ARCH: amd64
107+
HELPER_RUST_FLAG: ""
106108
GRADLE_USER_HOME: "$CI_PROJECT_DIR/.gradle-home"
109+
before_script:
110+
<?php echo $ecrLoginSnippet, "\n"; ?>
111+
<?php dockerhub_login() ?>
112+
script:
113+
- apt update && apt install -y openjdk-17-jre
114+
- find "$CI_PROJECT_DIR"/appsec/tests/integration/build || true
115+
- |
116+
cd appsec/tests/integration
117+
CACHE_PATH=build/php-appsec-volume-caches-${ARCH}.tar.gz
118+
if [ -f "$CACHE_PATH" ]; then
119+
echo "Loading cache from $CACHE_PATH"
120+
TERM=dumb ./gradlew loadCaches --info
121+
fi
122+
123+
TERM=dumb ./gradlew $targets --info -Pbuildscan --scan $HELPER_RUST_FLAG
124+
TERM=dumb ./gradlew saveCaches --info
125+
after_script:
126+
- mkdir -p "${CI_PROJECT_DIR}/artifacts"
127+
- find appsec/tests/integration/build/test-results -name "*.xml" -exec cp --parents '{}' "${CI_PROJECT_DIR}/artifacts/" \;
128+
- .gitlab/upload-junit-to-datadog.sh "test.source.file:appsec"
129+
artifacts:
130+
reports:
131+
junit: "artifacts/**/test-results/**/TEST-*.xml"
132+
paths:
133+
- "artifacts/"
134+
when: "always"
135+
cache:
136+
- key: "appsec int test cache"
137+
paths:
138+
- appsec/tests/integration/build/*.tar.gz
139+
- .gradle-home/wrapper/dists/
140+
141+
"appsec integration tests":
142+
extends: .appsec_integration_tests
107143
parallel:
108144
matrix:
109145
- targets:
@@ -129,33 +165,194 @@
129165
- test8.4-release-zts
130166
- test8.5-release
131167
- test8.5-release-zts
168+
- test8.5-release-musl
169+
170+
"appsec integration tests (helper-rust)":
171+
extends: .appsec_integration_tests
172+
variables:
173+
HELPER_RUST_FLAG: "-PuseHelperRust"
174+
parallel:
175+
matrix:
176+
- targets:
177+
- test7.4-release
178+
- test8.1-release
179+
- test8.3-debug
180+
- test8.4-release-zts
181+
- test8.5-release-musl
182+
183+
"helper-rust build and test":
184+
stage: test
185+
image: 486234852809.dkr.ecr.us-east-1.amazonaws.com/docker:24.0.4-gbi-focal
186+
tags: [ "docker-in-docker:amd64" ]
187+
interruptible: true
188+
rules:
189+
- if: $CI_COMMIT_BRANCH == "master"
190+
interruptible: false
191+
- when: on_success
192+
variables:
193+
KUBERNETES_CPU_REQUEST: 4
194+
KUBERNETES_MEMORY_REQUEST: 8Gi
195+
KUBERNETES_MEMORY_LIMIT: 10Gi
196+
ARCH: amd64
132197
before_script:
133198
<?php echo $ecrLoginSnippet, "\n"; ?>
134199
<?php dockerhub_login() ?>
135200
script:
136201
- apt update && apt install -y openjdk-17-jre
137-
- find "$CI_PROJECT_DIR"/appsec/tests/integration/build || true
138202
- |
139203
cd appsec/tests/integration
140204
CACHE_PATH=build/php-appsec-volume-caches-${ARCH}.tar.gz
141205
if [ -f "$CACHE_PATH" ]; then
142206
echo "Loading cache from $CACHE_PATH"
143207
TERM=dumb ./gradlew loadCaches --info
144208
fi
209+
# Build and test helper-rust (includes formatting check and cargo test)
210+
TERM=dumb ./gradlew testHelperRust --info -Pbuildscan --scan
211+
TERM=dumb ./gradlew saveCaches --info
212+
cache:
213+
- key: "appsec int test cache"
214+
paths:
215+
- appsec/tests/integration/build/*.tar.gz
216+
- .gradle-home/wrapper/dists/
217+
218+
"helper-rust code coverage":
219+
stage: test
220+
image: 486234852809.dkr.ecr.us-east-1.amazonaws.com/docker:24.0.4-gbi-focal
221+
tags: [ "docker-in-docker:amd64" ]
222+
interruptible: true
223+
rules:
224+
- if: $CI_COMMIT_BRANCH == "master"
225+
interruptible: false
226+
- when: on_success
227+
variables:
228+
KUBERNETES_CPU_REQUEST: 4
229+
KUBERNETES_MEMORY_REQUEST: 8Gi
230+
KUBERNETES_MEMORY_LIMIT: 10Gi
231+
ARCH: amd64
232+
before_script:
233+
<?php echo $ecrLoginSnippet, "\n"; ?>
234+
<?php dockerhub_login() ?>
235+
script:
236+
- apt update && apt install -y openjdk-17-jre
237+
- |
238+
echo "Installing codecov CLI"
239+
curl https://keybase.io/codecovsecurity/pgp_keys.asc | gpg --no-default-keyring --keyring trustedkeys.gpg --import
240+
CODECOV_VERSION=0.6.1
241+
curl -Os https://uploader.codecov.io/v${CODECOV_VERSION}/linux/codecov
242+
curl -Os https://uploader.codecov.io/v${CODECOV_VERSION}/linux/codecov.SHA256SUM
243+
curl -Os https://uploader.codecov.io/v${CODECOV_VERSION}/linux/codecov.SHA256SUM.sig
244+
gpgv codecov.SHA256SUM.sig codecov.SHA256SUM
245+
shasum -a 256 -c codecov.SHA256SUM
246+
rm codecov.SHA256SUM.sig codecov.SHA256SUM
247+
chmod +x codecov
248+
mv codecov /usr/local/bin/codecov
249+
- |
250+
echo "Installing vault for codecov token"
251+
curl -o vault.zip https://releases.hashicorp.com/vault/1.20.0/vault_1.20.0_linux_amd64.zip
252+
unzip vault.zip
253+
mv vault /usr/local/bin/vault
254+
rm vault.zip
255+
- |
256+
cd appsec/tests/integration
257+
CACHE_PATH=build/php-appsec-volume-caches-${ARCH}.tar.gz
258+
if [ -f "$CACHE_PATH" ]; then
259+
echo "Loading cache from $CACHE_PATH"
260+
TERM=dumb ./gradlew loadCaches --info
261+
fi
262+
# Run unit tests with coverage instrumentation
263+
TERM=dumb ./gradlew coverageHelperRust --info -Pbuildscan --scan
264+
TERM=dumb ./gradlew saveCaches --info
265+
- |
266+
echo "Extracting coverage data from Docker volume"
267+
mkdir -p "$CI_PROJECT_DIR"/appsec/helper-rust
268+
docker run --rm -v php-helper-rust-coverage:/vol alpine cat /vol/coverage-unit.lcov > "$CI_PROJECT_DIR"/appsec/helper-rust/coverage-unit.lcov
269+
- |
270+
echo "Uploading helper-rust unit test coverage to codecov"
271+
cd "$CI_PROJECT_DIR"
272+
CODECOV_TOKEN=$(vault kv get --format=json kv/k8s/gitlab-runner/dd-trace-php/codecov | jq -r .data.data.token)
273+
codecov -t "$CODECOV_TOKEN" -n helper-rust-unit -F helper-rust-unit -v -f appsec/helper-rust/coverage-unit.lcov
274+
artifacts:
275+
paths:
276+
- appsec/helper-rust/coverage-unit.lcov
277+
when: always
278+
cache:
279+
- key: "appsec int test cache"
280+
paths:
281+
- appsec/tests/integration/build/*.tar.gz
282+
- .gradle-home/wrapper/dists/
145283

146-
TERM=dumb ./gradlew $targets --info -Pbuildscan --scan
284+
"helper-rust integration coverage":
285+
stage: test
286+
image: 486234852809.dkr.ecr.us-east-1.amazonaws.com/docker:24.0.4-gbi-focal
287+
tags: [ "docker-in-docker:amd64" ]
288+
interruptible: true
289+
rules:
290+
- if: $CI_COMMIT_BRANCH == "master"
291+
interruptible: false
292+
- when: on_success
293+
variables:
294+
KUBERNETES_CPU_REQUEST: 8
295+
KUBERNETES_MEMORY_REQUEST: 24Gi
296+
KUBERNETES_MEMORY_LIMIT: 30Gi
297+
ARCH: amd64
298+
before_script:
299+
<?php echo $ecrLoginSnippet, "\n"; ?>
300+
<?php dockerhub_login() ?>
301+
script:
302+
- apt update && apt install -y openjdk-17-jre
303+
- |
304+
echo "Installing codecov CLI"
305+
curl https://keybase.io/codecovsecurity/pgp_keys.asc | gpg --no-default-keyring --keyring trustedkeys.gpg --import
306+
CODECOV_VERSION=0.6.1
307+
curl -Os https://uploader.codecov.io/v${CODECOV_VERSION}/linux/codecov
308+
curl -Os https://uploader.codecov.io/v${CODECOV_VERSION}/linux/codecov.SHA256SUM
309+
curl -Os https://uploader.codecov.io/v${CODECOV_VERSION}/linux/codecov.SHA256SUM.sig
310+
gpgv codecov.SHA256SUM.sig codecov.SHA256SUM
311+
shasum -a 256 -c codecov.SHA256SUM
312+
rm codecov.SHA256SUM.sig codecov.SHA256SUM
313+
chmod +x codecov
314+
mv codecov /usr/local/bin/codecov
315+
- |
316+
echo "Installing vault for codecov token"
317+
curl -o vault.zip https://releases.hashicorp.com/vault/1.20.0/vault_1.20.0_linux_amd64.zip
318+
unzip vault.zip
319+
mv vault /usr/local/bin/vault
320+
rm vault.zip
321+
- |
322+
cd appsec/tests/integration
323+
CACHE_PATH=build/php-appsec-volume-caches-${ARCH}.tar.gz
324+
if [ -f "$CACHE_PATH" ]; then
325+
echo "Loading cache from $CACHE_PATH"
326+
TERM=dumb ./gradlew loadCaches --info
327+
fi
328+
# Build helper-rust with coverage instrumentation
329+
TERM=dumb ./gradlew buildHelperRustWithCoverage --info -Pbuildscan --scan
330+
# Run integration tests with coverage-instrumented binary
331+
TERM=dumb ./gradlew test8.3-debug --info -Pbuildscan --scan -PuseHelperRustCoverage
332+
# Generate coverage report from profraw files
333+
TERM=dumb ./gradlew generateHelperRustIntegrationCoverage --info -Pbuildscan --scan
147334
TERM=dumb ./gradlew saveCaches --info
335+
- |
336+
echo "Extracting coverage data from Docker volume"
337+
mkdir -p "$CI_PROJECT_DIR"/appsec/helper-rust
338+
docker run --rm -v php-helper-rust-coverage:/vol alpine cat /vol/coverage-integration.lcov > "$CI_PROJECT_DIR"/appsec/helper-rust/coverage-integration.lcov
339+
- |
340+
echo "Uploading helper-rust integration test coverage to codecov"
341+
cd "$CI_PROJECT_DIR"
342+
CODECOV_TOKEN=$(vault kv get --format=json kv/k8s/gitlab-runner/dd-trace-php/codecov | jq -r .data.data.token)
343+
codecov -t "$CODECOV_TOKEN" -n helper-rust-integration -F helper-rust-integration -v -f appsec/helper-rust/coverage-integration.lcov
148344
after_script:
149345
- mkdir -p "${CI_PROJECT_DIR}/artifacts"
150-
- find appsec/tests/integration/build/test-results -name "*.xml" -exec cp --parents '{}' "${CI_PROJECT_DIR}/artifacts/" \;
346+
- find appsec/tests/integration/build/test-results -name "*.xml" -exec cp --parents '{}' "${CI_PROJECT_DIR}/artifacts/" \; || true
151347
- cp -r appsec/tests/integration/build/test-logs "${CI_PROJECT_DIR}/artifacts/" 2>/dev/null || true
152348
- .gitlab/silent-upload-junit-to-datadog.sh "test.source.file:appsec"
153349
artifacts:
154350
reports:
155351
junit: "artifacts/**/test-results/**/TEST-*.xml"
156352
paths:
157353
- "artifacts/"
158-
when: "always"
354+
- appsec/helper-rust/coverage-integration.lcov
355+
when: always
159356
cache:
160357
- key: "appsec int test cache"
161358
paths:

.gitlab/generate-package.php

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,24 @@
295295
paths:
296296
- "appsec_*"
297297

298+
"compile appsec helper rust":
299+
stage: appsec
300+
image: "registry.ddbuild.io/images/mirror/datadog/dd-appsec-php-ci:nginx-fpm-php-8.5-release-musl"
301+
tags: [ "arch:$ARCH" ]
302+
needs: [ "prepare code" ]
303+
parallel:
304+
matrix:
305+
- ARCH: ["amd64", "arm64" ]
306+
variables:
307+
MAKE_JOBS: 12
308+
KUBERNETES_CPU_REQUEST: 12
309+
KUBERNETES_MEMORY_REQUEST: 8Gi
310+
KUBERNETES_MEMORY_LIMIT: 12Gi
311+
script: .gitlab/build-appsec-helper-rust.sh
312+
artifacts:
313+
paths:
314+
- "appsec_*"
315+
298316
"pecl build":
299317
stage: tracing
300318
image: "registry.ddbuild.io/images/mirror/datadog/dd-trace-ci:php-7.4_bookworm-6"
@@ -628,13 +646,20 @@
628646
}
629647
?>
630648

631-
# Compile appsec helper
649+
# Compile appsec helper (C++)
632650
- job: "compile appsec helper"
633651
parallel:
634652
matrix:
635653
- ARCH: "<?= $platform['arch'] ?>"
636654
artifacts: true
637655

656+
# Compile appsec helper (Rust)
657+
- job: "compile appsec helper rust"
658+
parallel:
659+
matrix:
660+
- ARCH: "<?= $platform['arch'] ?>"
661+
artifacts: true
662+
638663
<?php
639664
foreach ($profiler_minor_major_targets as $major_minor) {
640665
?>
@@ -705,6 +730,11 @@
705730
matrix:
706731
- ARCH: "<?= $arch ?>"
707732
artifacts: true
733+
- job: "compile appsec helper rust"
734+
parallel:
735+
matrix:
736+
- ARCH: "<?= $arch ?>"
737+
artifacts: true
708738
- job: "compile loader: [linux-gnu, <?= $arch ?>]"
709739
artifacts: true
710740
- job: "compile loader: [linux-musl, <?= $arch ?>]"

.gitmodules

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,7 @@
1414
[submodule "tea/benchmarks/google-benchmark"]
1515
path = tea/benchmarks/google-benchmark
1616
url = https://github.com/google/benchmark.git
17+
[submodule "appsec/third_party/libddwaf-rust"]
18+
path = appsec/third_party/libddwaf-rust
19+
url = https://github.com/DataDog/libddwaf-rust.git
20+
branch = glopes/v2

appsec/.dockerignore

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Ignore everything by default
2+
*
3+
4+
# Include only what's needed for helper-rust portable build
5+
!helper-rust/
6+
!third_party/libddwaf-rust/
7+
8+
# Exclude build artifacts and caches from helper-rust
9+
helper-rust/target/
10+
helper-rust/.cargo/
11+
helper-rust/build/
12+
helper-rust/.idea/
13+
helper-rust/*.log
14+
15+
# Exclude build artifacts from libddwaf-rust
16+
third_party/libddwaf-rust/target/
17+
third_party/libddwaf-rust/.cargo/

0 commit comments

Comments
 (0)