Verify a PHP Security Patch
Use Docker and Python to verify and enforce a critical PHP patch.
Introduction
30 Second Summary
Two web pages can look identical even when one runs software with a critical security flaw. A visual check cannot prove that a security fix is present.
In this project, you will build a localhost-only PHP lab with two Apache containers managed by Docker Compose. A Python checker will reveal each runtime's security state as the lab moves from an unsafe release to a fixed one.
What You'll Build
Your finished demo exposes the security difference hidden behind two identical local PHP pages.
By the end of this project, you'll have:
- A localhost-only comparison lab where you can open two local URLs to see the same page. Each page reports its own PHP version through the Apache handler.
- A repeatable checker report with three visible milestones: NOT PATCHED, MITIGATED BUT UPGRADE REQUIRED, and PATCHED.
- A public remediation record on GitHub that documents the path from advisory to patched result. Someone else can reproduce the lab from the same project files.
- Secret Mission: Turn the checker into an enforcement gate that returns a failing process status whenever either service drifts from PATCHED.
Are there any prerequisites?
No prior setup is required. This guide walks you through every tool and account needed for the lab.
Before We Start
This project is a safe, non-exploit patch-verification lab that compares two localhost-only PHP services named legacy and patched. This opening checkpoint locks in why temporary configuration safeguards cannot replace upgrading to a fixed PHP release.
Install and Verify Docker Desktop
The patch-verification lab needs two isolated PHP runtimes on the same Intel Mac. Keeping them isolated protects your local system from changes inside the lab.
Docker Desktop provides the Docker Engine that runs those runtimes. Docker Compose will manage both services as one reproducible setup.
In this step, get ready to:
- Confirm that your Intel Mac meets Docker Desktop's requirements.
- Install Docker Desktop 4.96.3 using the Mac with Intel chip download.
- Verify Docker Engine plus Docker Compose from Terminal.
Check your Intel Mac requirements
Docker Desktop needs an Intel processor with at least 4 GB of memory. It also needs the current macOS release or one of the two previous major releases.
- Click the Apple menu in the upper-left corner of your screen.
- Select About This Mac.
- Confirm that Processor identifies an Intel processor.
- Confirm that Memory shows at least 4 GB.
- Confirm that your macOS release is current or one of the two previous major releases using Apple's current release list.
Mac Below a Requirement?
- Use a supported Intel Mac if Memory shows less than 4 GB.
- Follow Apple's Software Update guide if your macOS release falls outside Docker Desktop's supported range.
Help me check whether my Intel Mac meets Docker Desktop's requirements.
Install or update Docker Desktop
The installer must match your Mac's Intel processor. Your path also depends on whether Docker Desktop is already installed.
✔️ I have the required version
Docker Desktop is already installed at the required version. Start it so the container engine is available to Terminal.
- Press Cmd+Space to open Spotlight.
- Type Docker.
- Press Enter to start /Applications/Docker.app.
- Open the Docker menu in the macOS menu bar.
- Select About Docker Desktop.
- Confirm that the displayed version is 4.96.3.
- Wait until Docker Desktop reports that the engine is running.
ⓧ I have an older version
An older installation needs an update to match the project version. The Intel installer replaces the existing application with the pinned release.
- Open the Docker Desktop release notes.
- Locate release 4.96.3.
- Select Mac (Intel chip) to download Docker.dmg.
- Double-click Docker.dmg in your Downloads folder.
- Drag the Docker icon to the Applications folder.
- Confirm the replacement if macOS asks about the existing application.
Personal Use Stays Free
Docker Desktop is free for personal or educational use under its subscription terms.
The first launch can take several minutes while macOS completes its security checks.
- Press Cmd+Space to open Spotlight.
- Type Docker.
- Press Enter to start /Applications/Docker.app.
- Accept the subscription terms for personal or educational use.
- Wait until Docker Desktop reports that the engine is running.
- Open the Docker menu in the macOS menu bar.
- Select About Docker Desktop.
- Confirm that the displayed version is 4.96.3.
ⓧ Docker Desktop is not installed
Your Mac needs a fresh installation of the Intel build. The installer places the application at the standard macOS location.
- Open the Docker Desktop release notes.
- Locate release 4.96.3.
- Select Mac (Intel chip) to download Docker.dmg.
- Double-click Docker.dmg in your Downloads folder.
- Drag the Docker icon to the Applications folder.
Personal Use Stays Free
Docker Desktop is free for personal or educational use under its subscription terms.
The first launch can take several minutes while macOS completes its security checks.
- Press Cmd+Space to open Spotlight.
- Type Docker.
- Press Enter to start /Applications/Docker.app.
- Accept the subscription terms for personal or educational use.
- Wait until Docker Desktop reports that the engine is running.
- Open the Docker menu in the macOS menu bar.
- Select About Docker Desktop.
- Confirm that the displayed version is 4.96.3.
You have cleared the setup hurdle with Docker Desktop 4.96.3 running as the local container engine for your PHP lab.
Verify Docker and Compose in Terminal
A running application window only confirms that Docker Desktop opened. Terminal must also reach the engine plus the Compose workflow that manages the lab.
- Press Cmd+Space to open Spotlight.
- Type Terminal.
- Press Enter to open Terminal.
Before you run these checks, do you expect Docker to return both of its output sections?
- Verify the Docker components by running these commands:
docker version
docker compose version
What Do These Commands Prove?
- The docker version command checks whether the Docker client can communicate with the running engine.
- The docker compose version command confirms that the Compose workflow is available.
You'll see a Client section followed by a Server section from the first command. You'll then see Docker Compose version information from the second command.
Missing the Server Section?
- Return to Docker Desktop from earlier.
- Wait until it reports that the engine is running.
- Run the verification again.
- Close Terminal if the docker command is unavailable after installation.
- Reopen Terminal so it loads the updated command path.
Help me troubleshoot my Docker Desktop verification results.
That is the engine check complete. Terminal can now reach Docker Engine plus Docker Compose.
Your container engine is ready for the two-service PHP lab. Next up, you'll prepare the coding tools plus the portfolio workspace that holds its files.
Prepare the Project Workspace
Your Docker engine is running. The lab now has isolated Linux runtimes ready for the PHP services.
The security checker needs Python 3.15.1. The project files need Visual Studio Code 1.143.1.
A verified GitHub identity gives your final evidence a public home. This step prepares all three foundations before any lab code or containers exist.
In this step, get ready to:
- Install and verify Python 3.15.1.
- Prepare Visual Studio Code 1.143.1 for terminal launches.
- Create the empty local folder layout for your verified GitHub identity.
Install and verify Python
The checker uses Python's standard library to read each local service. Pinning the runtime to 3.15.1 keeps your results aligned with the project instructions.
Start with a diagnostic check. The command can return a version or report that it is unavailable.
- Check your current Python runtime by running this command in the Terminal from earlier:
python3.15 -VV
What does this command check?
The output identifies the Python runtime installed for the python3.15 command. Use that result to choose the matching tab below.
✔️ I see version 3.15.1 or higher
Your Python runtime meets the project requirement. Confirm that the output starts with Python 3.15.1 before continuing.
ⓧ I see an older version
An older Python build can behave differently from the project runtime. Install the pinned macOS package so the checker uses 3.15.1.
- Open the official Python 3.15.1 release page.
- Download the macOS installer for Python 3.15.1.
- Open the downloaded installer package from your Downloads folder.
- Complete the guided installation with the default destination.
The certificate helper is tucked inside the Python application folder. Running it prepares the installed runtime for secure web connections.
- In Finder, open /Applications/Python 3.15/Install Certificates.command.
- Switch back to the Terminal from earlier.
- Verify the updated runtime by running this command:
python3.15 -VV
What should I see?
The output should start with Python 3.15.1. That confirms the pinned interpreter is available to your shell.
ⓧ Command not found
The Python installer has not added the required runtime yet. The official macOS package provides the python3.15 command used throughout this project.
- Open the official Python 3.15.1 release page.
- Download the macOS installer for Python 3.15.1.
- Open the installer package from your Downloads folder.
- Finish the guided installation with its default destination.
Finding the certificate helper can be fiddly because it sits in a versioned application folder. Its full path is /Applications/Python 3.15/Install Certificates.command.
- Open /Applications/Python 3.15/Install Certificates.command in Finder.
- Return to the Terminal from earlier.
- Verify the installation by running this command:
python3.15 -VV
What should I see?
The output should begin with Python 3.15.1. Your checker now has the required runtime.
Still seeing the wrong Python result?
Close Terminal after the installer finishes. Reopen it before running the version check again.
Confirm that you opened the installer for Python 3.15.1 on macOS.
Help me fix my Python 3.15.1 installation on an Intel Mac.
Good progress. Your security checker now has the exact Python runtime it needs.
Install and verify Visual Studio Code
Visual Studio Code keeps the project files and Terminal in one workspace. The code command lets Terminal open the current folder directly in the editor.
Check whether the required editor command is already available. This diagnostic can show a version or report that the command is unavailable.
- Check your Visual Studio Code version by running this command in Terminal:
code --version
What does this command check?
The first output line identifies the installed Visual Studio Code version. The remaining lines identify its build and Mac architecture.
✔️ I see version 1.143.1 or higher
Visual Studio Code is current enough for this project. The successful command also proves that code is available in your PATH.
ⓧ I see an older version
Your editor command works, but the installed build is older than the project version. Replace it with Visual Studio Code 1.143.1.
- Open the official Visual Studio Code macOS setup page.
- Download the Intel or Universal macOS build of Visual Studio Code 1.143.1.
- Open the downloaded .dmg file.
- Drag Visual Studio Code.app into the Applications folder.
- Press Cmd+Space to open Spotlight.
- Type Visual Studio Code into Spotlight.
- Press Return to start the editor.
The terminal command setting is easy to miss because it lives in the Command Palette. You only need the editor command for this project.
- Press Cmd+Shift+P to open the Command Palette.
- Enter Shell Command: Install 'code' and optionally 'copilot' commands in PATH.
- Select No to add only code to PATH.
- Quit Terminal so it can refresh its environment.
- Press Cmd+Space to open Spotlight.
- Type Terminal into Spotlight.
- Press Return to reopen Terminal.
- Verify the updated editor by running this command:
code --version
What should I see?
The first output line should show 1.143.1. This confirms the editor version and terminal command are ready.
ⓧ Command not found
Visual Studio Code still needs to be installed. Its Command Palette also needs to add code to your PATH.
- Open the official Visual Studio Code macOS setup page.
- Download the Intel or Universal macOS build of Visual Studio Code 1.143.1.
- Open the downloaded .dmg file.
- Drag Visual Studio Code.app into the Applications folder.
- Press Cmd+Space to open Spotlight.
- Type Visual Studio Code into Spotlight.
- Press Return to start the editor.
Adding the terminal command takes one extra setting after installation. The Command Palette provides that setting.
- Press Cmd+Shift+P to open the Command Palette.
- Enter Shell Command: Install 'code' and optionally 'copilot' commands in PATH.
- Select No to add only code to PATH.
- Quit Terminal so the new command becomes available.
- Press Cmd+Space to open Spotlight.
- Type Terminal into Spotlight.
- Press Return to reopen Terminal.
- Verify the editor command by running:
code --version
What should I see?
The first output line should show 1.143.1. Terminal can now launch Visual Studio Code.
Still seeing a code command error?
Restart Terminal after running the Command Palette action. An existing Terminal session may not pick up the updated PATH.
Run the Command Palette action again if you dismissed its prompt. Select No when asked about the optional GitHub Copilot command.
Help me fix the code command on macOS.
Your editor can now open folders straight from Terminal. That connection keeps the build and verification loop in one place.
Create your GitHub and folder workspace
Your GitHub account establishes ownership of the evidence you publish later. The local workspace keeps the lab files separated from everything else on your Mac.
Choose the account path that matches your current GitHub state.
✔️ I have a verified account
- Sign in to GitHub with your existing personal account.
- Open your GitHub dashboard.
- Confirm that your signed-in username is visible.
Your dashboard confirms that the verified account is ready for the repository you publish later.
ⓧ I need a verified account
Account signup asks for an email address and password. Keep those credentials private throughout the project.
- Open the GitHub signup page.
- Create your personal GitHub account with an email address you control.
- Complete the email verification message from GitHub.
- Sign in to GitHub after verification.
- Open your GitHub dashboard.
- Confirm that your signed-in username is visible.
Your visible dashboard confirms that the verified identity is ready for the final evidence repository.
The project starts with an empty folder structure. This gives the PHP pages and security configuration their own clear locations.
- Click the Mac desktop once.
- Click File in the Finder menu bar.
- Select New Folder.
- Enter php-security-patch-verification in the selected name field.
- Press Return to save the folder name.
You should see php-security-patch-verification on your desktop. This is the named workspace used throughout the lab.
- Double-click php-security-patch-verification to open it in Finder.
- Click File in the Finder menu bar.
- Select New Folder.
- Enter app in the selected name field.
- Press Return to save the folder name.
The app folder should now appear inside the project workspace.
- Click File in the Finder menu bar.
- Select New Folder.
- Enter config in the selected name field.
- Press Return to save the folder name.
You should now see the empty app and config folders together. No application files or containers exist yet.
Visual Studio Code may ask whether you trust this folder. You created it yourself, so trusting it enables the integrated Terminal used by the project.
- Switch back to Visual Studio Code from earlier.
- Click File in the top menu bar.
- Select Open Folder....
- Select php-security-patch-verification from your desktop.
- Click Open.
- Select Yes, I trust the authors if the Workspace Trust dialog appears.
- Click View in the top menu bar.
- Select Terminal.
The integrated Terminal starts inside php-security-patch-verification. The Explorer sidebar should list app and config.
Before you run the final Python check, which version do you expect the integrated Terminal to report?
- Verify Python from the project workspace by running:
python3.15 -VV
Python workspace check
The output should start with Python 3.15.1. This proves the required runtime is available from inside the project folder.
Before you run the editor check, which folder do you expect Visual Studio Code to focus?
- Verify the terminal launch path by running:
code .
Editor workspace check
Visual Studio Code should open or focus php-security-patch-verification. Its Explorer sidebar should still show the empty app and config folders.
- Return to the GitHub dashboard from earlier.
- Confirm that your signed-in username remains visible.
That is the workspace ready. Python runs from the project folder, Visual Studio Code opens it from Terminal, and your verified GitHub identity is signed in.
Your tools and empty folder structure are ready. Next, you will launch the two PHP runtimes that make the hidden patch difference visible.
Launch the PHP Runtimes
Your project workspace is ready. You can now turn those empty folders into a running security lab.
This step uses Docker Compose to run two PHP versions through Apache mod_php. Their pages use the same structure. Their version evidence reveals a security difference that the browser layout cannot judge for you.
In this step, get ready to:
- Build the visible PHP page plus the read-only runtime endpoint.
- Define the baseline configuration for both services.
- Start both runtimes on localhost for comparison.
Create the PHP evidence endpoints
The main page proves that each web service responds through Apache. A separate read-only endpoint exposes the version plus the SAPI plus selected settings for the checker you build later.
- Use the VS Code file sidebar to create index.php inside the existing app directory.
- Fill app/index.php by copying this code:
<?php
echo '<h1>PHP Security Patch Verification Lab</h1>';
echo '<p>Hello from PHP ', PHP_VERSION, ' via ', PHP_SAPI, '.</p>';
echo '<p>Use the Python checker for the security verdict.</p>';
What does this page do?
- The heading gives both services the same visible lab identity.
- The PHP_VERSION constant inserts the runtime version handling the request.
- The PHP_SAPI constant identifies the Apache request handler.
- The final line directs security decisions to the checker instead of the page design.
- Save app/index.php.
- Confirm index.php appears under app in the VS Code file sidebar.
Cannot find the visible page?
Check that index.php sits inside app. A file created beside that directory will not reach the Apache document path.
Check that the filename uses lowercase letters. Help me check the location and contents of my PHP index page.
- Use the VS Code file sidebar to create status.php inside the existing app directory.
- Add the configuration-reading helper to app/status.php by copying this code:
<?php
function setting(string $name): string
{
$value = ini_get($name);
return $value === false ? 'unavailable' : $value;
}
What does this helper do?
- The setting() function asks PHP for one runtime configuration value.
- The ini_get() result stays as the exact string exposed by the runtime.
- The fallback returns unavailable when a directive does not exist in that PHP version.
- Save app/status.php.
- Confirm status.php appears beside index.php in the VS Code file sidebar.
Is the helper incomplete?
Check that the opening <?php line appears once at the top of status.php. Check that both braces surround the body of setting().
Help me compare my PHP helper with the required structure.
- In app/status.php locate the closing brace of setting().
- Add the runtime evidence output below that brace by copying this code:
echo 'php_version=', PHP_VERSION, PHP_EOL;
echo 'sapi=', PHP_SAPI, PHP_EOL;
echo 'memory_limit=', setting('memory_limit'), PHP_EOL;
echo 'post_max_size=', setting('post_max_size'), PHP_EOL;
echo 'upload_max_filesize=', setting('upload_max_filesize'), PHP_EOL;
echo 'session_upload_progress=', setting('session.upload_progress.enabled'), PHP_EOL;
echo 'fatal_error_backtraces=', setting('fatal_error_backtraces'), PHP_EOL;
What evidence does this expose?
- The first two lines expose the exact PHP version plus the web request SAPI.
- The remaining lines expose the configuration values that distinguish the baseline from later safeguards.
- Each fact uses a stable key so the Python checker can parse the response.
- The endpoint reads runtime state without sending an exploit payload.
- Save app/status.php.
- Confirm the file now ends with seven echo statements.
Missing an evidence field?
Check that every echo statement ends with PHP_EOL. Check that each configuration name matches the value passed to setting().
Help me find a missing runtime evidence line in status.php.
Configure the two Compose services
Both runtimes need the same starting INI values. Sharing one baseline keeps the version as the meaningful difference between the services.
- Use the VS Code file sidebar to create baseline.ini inside the existing config directory.
- Define the shared baseline in config/baseline.ini by copying this configuration:
memory_limit=128M
post_max_size=64M
upload_max_filesize=32M
session.upload_progress.enabled=1
What does this baseline represent?
- The memory setting gives each PHP worker a finite memory limit.
- The request setting permits POST bodies up to 64M.
- The upload setting keeps one uploaded file below the POST limit.
- The session setting leaves upload-progress tracking enabled.
- Save config/baseline.ini.
- Confirm baseline.ini appears under config in the VS Code file sidebar.
Baseline file in the wrong place?
Check that the path is exactly config/baseline.ini. The Compose volume expects that relative location.
Help me verify the baseline INI path and values.
The Compose file pins one service below the fixed threshold. It pins the other service at the patched release. Loopback-only port bindings keep both web servers on your Mac.
- Use the VS Code file sidebar to create compose.yaml inside php-security-patch-verification beside the app directory.
- Define both services in compose.yaml by copying this configuration:
services:
legacy:
image: php:8.3.35-apache
ports:
- "127.0.0.1:8081:80"
volumes:
- ./app:/var/www/html:ro
- ./config/baseline.ini:/usr/local/etc/php/conf.d/zz-security-lab.ini:ro
patched:
image: php:8.3.36-apache
ports:
- "127.0.0.1:8082:80"
volumes:
- ./app:/var/www/html:ro
- ./config/baseline.ini:/usr/local/etc/php/conf.d/zz-security-lab.ini:ro
How does the lab stay controlled?
- The legacy service pins the comparison image at php:8.3.35-apache.
- The patched service pins the fixed image at php:8.3.36-apache.
- The two port mappings publish Apache only on 127.0.0.1.
- The :ro suffix makes the application plus configuration mounts read-only inside each container.
- Save compose.yaml.
- Confirm the VS Code file sidebar shows compose.yaml beside the app plus config directories.
Compose file not at the top level?
Check that compose.yaml sits directly inside php-security-patch-verification. Its relative volume paths depend on that location.
Help me inspect the structure and indentation of compose.yaml.
✔️ Awesome, I've got everything!
Your four files are ready for the launch check. Make sure each file is saved before you continue.
ⓧ I'd like to double check the full code
The completed app/index.php file should match this reference.
<?php
echo '<h1>PHP Security Patch Verification Lab</h1>';
echo '<p>Hello from PHP ', PHP_VERSION, ' via ', PHP_SAPI, '.</p>';
echo '<p>Use the Python checker for the security verdict.</p>';
The completed app/status.php file should match this reference.
<?php
function setting(string $name): string
{
$value = ini_get($name);
return $value === false ? 'unavailable' : $value;
}
echo 'php_version=', PHP_VERSION, PHP_EOL;
echo 'sapi=', PHP_SAPI, PHP_EOL;
echo 'memory_limit=', setting('memory_limit'), PHP_EOL;
echo 'post_max_size=', setting('post_max_size'), PHP_EOL;
echo 'upload_max_filesize=', setting('upload_max_filesize'), PHP_EOL;
echo 'session_upload_progress=', setting('session.upload_progress.enabled'), PHP_EOL;
echo 'fatal_error_backtraces=', setting('fatal_error_backtraces'), PHP_EOL;
The completed compose.yaml file should match this reference.
services:
legacy:
image: php:8.3.35-apache
ports:
- "127.0.0.1:8081:80"
volumes:
- ./app:/var/www/html:ro
- ./config/baseline.ini:/usr/local/etc/php/conf.d/zz-security-lab.ini:ro
patched:
image: php:8.3.36-apache
ports:
- "127.0.0.1:8082:80"
volumes:
- ./app:/var/www/html:ro
- ./config/baseline.ini:/usr/local/etc/php/conf.d/zz-security-lab.ini:ro
The completed config/baseline.ini file should match this reference.
memory_limit=128M
post_max_size=64M
upload_max_filesize=32M
session.upload_progress.enabled=1
Launch and compare the runtimes
The first launch can take a few minutes while Docker downloads the two pinned image layers. Quiet periods during the download are expected.
- Start both services in the background from the project Terminal by running this command:
docker compose up --detach
What does this command do?
Docker Compose reads compose.yaml. It creates the project network plus both containers.
The --detach option leaves the services running in the background. Your Terminal remains available for later checks.
You will see both service names reach a running state. The two Apache servers are now listening through their localhost-only bindings.
Containers not starting?
Confirm Docker Desktop still reports that its engine is running. Confirm the Terminal remains inside php-security-patch-verification where compose.yaml is saved.
Check the YAML indentation if Compose rejects the configuration. Help me diagnose why my two PHP Compose services are not starting.
Before you compare the pages, consider whether their shared layout can prove which runtime has crossed the fixed-version threshold.
- Open http://127.0.0.1:8081 in your browser.
- Open http://127.0.0.1:8082 in a second browser tab.
- Compare the heading plus runtime line across both tabs.
At 8081 you will see PHP Security Patch Verification Lab with PHP 8.3.35 through apache2handler. At 8082 you will see the same heading with PHP 8.3.36 through apache2handler.
You now have a live comparison between the legacy runtime plus its patched replacement. The shared page structure still cannot convert those versions into a security verdict.
Your side-by-side PHP lab is live. Next up, you will build the checker that turns its runtime evidence into repeatable patch decisions.
Build the Patch Checker
Your two Apache pages now render the same lab while exposing different PHP versions. The matching browser output cannot prove which runtime crosses the official fixed-version threshold.
A Python checker will fetch read-only runtime evidence over HTTP. It will turn that evidence into a repeatable security decision.
In this step, get ready to:
- Define the verified PHP thresholds and branch-support windows.
- Fetch each service's version, SAPI, and INI settings.
- Classify each runtime and print its remediation status.
Define the verified policy data
A patch verdict needs a trusted threshold for each supported release branch. The checker also needs the current support window so an apparently patched version is not accepted from an unknown branch.
- In the VS Code Explorer sidebar from earlier, select the php-security-patch-verification folder.
- Use the file creation icon at the top of the Explorer sidebar to create check_php.py.
- Add the service endpoints and verified policy mappings by pasting this code into check_php.py:
from urllib.error import URLError
from urllib.request import urlopen
SERVICES = {
"legacy": "http://127.0.0.1:8081/status.php",
"patched": "http://127.0.0.1:8082/status.php",
}
FIXED_VERSIONS = {
"8.2": ((8, 2, 35), "8.2.35"),
"8.3": ((8, 3, 36), "8.3.36"),
"8.4": ((8, 4, 27), "8.4.27"),
"8.5": ((8, 5, 12), "8.5.12"),
}
BRANCH_SUPPORT = {
"8.2": "security fixes through 2026-12-31",
"8.3": "security fixes through 2027-12-31",
"8.4": "active support through 2026-12-31",
"8.5": "active support through 2027-12-31",
}
What does this code do?
- The SERVICES mapping gives each container a report label and its read-only status endpoint.
- The FIXED_VERSIONS mapping stores a comparable tuple beside the official fixed release text.
- The BRANCH_SUPPORT mapping records the support state current on 2026-10-23.
- Save check_php.py.
- Switch back to the Terminal panel from earlier.
- Confirm the current definitions parse by running this command:
python3.15 check_php.py
What does this check prove?
Python loads the imports and creates the three mappings. Reaching the end of the file confirms that the current definitions contain valid syntax.
The Terminal prompt returns without a traceback. That clean run confirms your policy data is ready for the checker logic.
Seeing a syntax traceback?
Check that every mapping starts with an opening brace and ends with a closing brace. Confirm that every endpoint and support message keeps its surrounding quotes.
Ask for help with the policy mappings in your checker.
Fetch and classify runtime evidence
Each status endpoint returns one fact per line. The next functions convert those lines into a dictionary before comparing the observed version and safeguards with the verified policy.
- In check_php.py, place your cursor below the closing brace of BRANCH_SUPPORT.
- Add the runtime evidence fetcher by pasting this function:
def fetch_facts(url):
try:
with urlopen(url, timeout=3) as response:
payload = response.read().decode("utf-8")
except URLError as error:
return None, str(error.reason)
facts = {}
for line in payload.splitlines():
if "=" in line:
key, value = line.split("=", 1)
facts[key] = value
return facts, None
How does evidence collection work?
- The urlopen() call waits up to three seconds for the local endpoint.
- The URLError branch turns an unreachable service into data the report can display.
- The line parser converts each key=value response into an entry in facts.
- Save check_php.py.
- Switch back to the Terminal panel from earlier.
- Press the Up Arrow key once.
- Press Enter.
Your prompt returns without a traceback. This confirms that the request handling and response parsing are syntactically valid.
Does the fetcher fail to parse?
Check the indentation beneath try, except, and the two loops. Confirm that the split operation uses "=" as its separator.
Ask for help with the evidence fetcher.
- In check_php.py, place your cursor below fetch_facts().
- Add the version parser by pasting this function:
def parse_version(version_text):
try:
return tuple(int(part) for part in version_text.split(".")[:3])
except (TypeError, ValueError):
return None
Why convert the version?
The parse_version() function converts a release such as 8.3.35 into an integer tuple. Tuple comparison lets the checker test the observed release against the fixed threshold.
An invalid value becomes None. That keeps unexpected evidence out of the patched path.
- Save check_php.py.
- Switch back to the Terminal panel from earlier.
- Press the Up Arrow key once.
- Press Enter.
The command returns to the prompt without a traceback. Your checker can now represent PHP versions as comparable values.
Seeing an error in the version parser?
Check the parentheses around TypeError, ValueError. Confirm that the slice ends at the first three version parts.
Ask for help with version tuple parsing.
- In check_php.py, place your cursor below parse_version().
- Add the safeguard checks by pasting these functions:
def is_disabled(value):
return value.strip().lower() in {"", "0", "off", "false", "no"}
def safeguards_active(facts, branch):
active = (
facts.get("memory_limit") == "-1"
and facts.get("post_max_size") == "8M"
and facts.get("upload_max_filesize") == "2M"
and is_disabled(facts.get("session_upload_progress", ""))
)
if branch == "8.5":
active = active and is_disabled(facts.get("fatal_error_backtraces", ""))
return active
How are safeguards evaluated?
The is_disabled() helper normalizes the different text forms PHP can use for a disabled setting. This keeps the verdict consistent across equivalent values.
The safeguards_active() function requires every relevant temporary control. It also checks fatal_error_backtraces only for the 8.5 branch.
- Save check_php.py.
- Switch back to the Terminal panel from earlier.
- Press the Up Arrow key once.
- Press Enter.
The Terminal returns to the prompt without a traceback. Your checker can now recognize the complete temporary safeguard set.
Seeing an indentation error?
Keep every setting comparison inside the parentheses assigned to active. Keep the PHP 8.5 condition inside safeguards_active().
Ask for help with the safeguard logic.
- In check_php.py, place your cursor below safeguards_active().
- Add the remediation decision logic by pasting this function:
def classify(facts):
version_text = facts.get("php_version", "unknown")
version = parse_version(version_text)
if version is None:
return "UNSUPPORTED OR UNKNOWN", "unknown", "unparseable version"
branch = f"{version[0]}.{version[1]}"
if branch not in FIXED_VERSIONS:
return "UNSUPPORTED OR UNKNOWN", "unknown", "no verified threshold"
fixed_tuple, fixed_text = FIXED_VERSIONS[branch]
if version >= fixed_tuple:
return "PATCHED", fixed_text, BRANCH_SUPPORT[branch]
if safeguards_active(facts, branch):
return "MITIGATED BUT UPGRADE REQUIRED", fixed_text, BRANCH_SUPPORT[branch]
return "NOT PATCHED", fixed_text, BRANCH_SUPPORT[branch]
How is the verdict chosen?
- An unparseable version or unverified branch becomes UNSUPPORTED OR UNKNOWN.
- A version at or above its fixed threshold becomes PATCHED.
- An older release with every temporary safeguard becomes MITIGATED BUT UPGRADE REQUIRED.
- An older release without that safeguard set becomes NOT PATCHED.
- Save check_php.py.
- Switch back to the Terminal panel from earlier.
- Press the Up Arrow key once.
- Press Enter.
The script returns to the prompt without a traceback. The four remediation paths now form a valid decision function.
Does the classification function fail?
Check that branch uses the first two tuple positions. Confirm that the patched comparison uses >=.
Ask for help with the verdict order.
- In check_php.py, place your cursor below classify().
- Add the per-service inspection function by pasting this code:
def inspect_service(name, url):
facts, error = fetch_facts(url)
if error:
return {
"name": name,
"version": "unreachable",
"sapi": "unknown",
"fixed": "unknown",
"support": error,
"status": "UNSUPPORTED OR UNKNOWN",
"facts": {},
}
status, fixed, support = classify(facts)
return {
"name": name,
"version": facts.get("php_version", "unknown"),
"sapi": facts.get("sapi", "unknown"),
"fixed": fixed,
"support": support,
"status": status,
"facts": facts,
}
What does a service record contain?
The inspect_service() function combines fetched evidence with its remediation verdict. The returned dictionary gives the report one consistent shape.
An unreachable endpoint receives an explicit status instead of stopping the whole checker. One failed service cannot hide the result for the other service.
- Save check_php.py.
- Switch back to the Terminal panel from earlier.
- Press the Up Arrow key once.
- Press Enter.
The prompt returns without a traceback. Each service can now produce a complete report record or an explicit unreachable result.
Seeing a dictionary error?
Check that both returned dictionaries contain matching keys. Confirm that each key-value pair ends with a comma.
Ask for help with the service record.
Print the evidence report
The checker now has evidence and a verdict for each service. The final function turns those records into a table that keeps the version, SAPI, threshold, support state, configuration, and remediation status together.
- In check_php.py, place your cursor below inspect_service().
- Add the report and script entry point by pasting this code:
def main():
records = [inspect_service(name, url) for name, url in SERVICES.items()]
print(f"{'SERVICE':<10} {'PHP':<12} {'SAPI':<18} {'FIXED IN':<10} STATUS")
print("-" * 88)
for record in records:
print(
f"{record['name']:<10} {record['version']:<12} "
f"{record['sapi']:<18} {record['fixed']:<10} {record['status']}"
)
print(f" support: {record['support']}")
if record["facts"]:
facts = record["facts"]
print(
" settings: "
f"memory_limit={facts.get('memory_limit', 'unknown')}, "
f"post_max_size={facts.get('post_max_size', 'unknown')}, "
f"upload_max_filesize={facts.get('upload_max_filesize', 'unknown')}, "
"session.upload_progress.enabled="
f"{facts.get('session_upload_progress', 'unknown')}, "
"fatal_error_backtraces="
f"{facts.get('fatal_error_backtraces', 'unknown')}"
)
if __name__ == "__main__":
main()
How is the report assembled?
The main() function inspects every entry in SERVICES. It prints one summary row before showing the branch-support text and selected settings.
The script entry point calls main() only when you run check_php.py directly. The complete checker now connects collection, classification, and reporting.
Before you run the finished checker, which service do you expect to cross the fixed threshold?
- Save check_php.py.
- Run the completed checker by running this command:
python3.15 check_php.py
What does this command do?
The command runs your standard-library checker against both local status endpoints. The services keep running while Python reads their evidence and prints the verdicts.
You'll see legacy on PHP 8.3.35 with apache2handler and a NOT PATCHED status. Its fixed threshold is 8.3.36.
You'll see patched on PHP 8.3.36 with apache2handler and a PATCHED status. Both records show security fixes through 2027-12-31.
You have turned two matching browser pages into distinct security evidence. The checker now proves which runtime needs remediation.
Not seeing both service verdicts?
Confirm that both local browser pages from the previous step still load. Check that SERVICES uses ports 8081 and 8082.
Compare your final function names with fetch_facts(), classify(), and inspect_service().
Ask for help with the completed checker.
✔️ Awesome, I've got everything!
Your saved checker matches the completed version and reports both runtime states.
ⓧ I'd like to double check the full code
from urllib.error import URLError
from urllib.request import urlopen
SERVICES = {
"legacy": "http://127.0.0.1:8081/status.php",
"patched": "http://127.0.0.1:8082/status.php",
}
FIXED_VERSIONS = {
"8.2": ((8, 2, 35), "8.2.35"),
"8.3": ((8, 3, 36), "8.3.36"),
"8.4": ((8, 4, 27), "8.4.27"),
"8.5": ((8, 5, 12), "8.5.12"),
}
BRANCH_SUPPORT = {
"8.2": "security fixes through 2026-12-31",
"8.3": "security fixes through 2027-12-31",
"8.4": "active support through 2026-12-31",
"8.5": "active support through 2027-12-31",
}
def fetch_facts(url):
try:
with urlopen(url, timeout=3) as response:
payload = response.read().decode("utf-8")
except URLError as error:
return None, str(error.reason)
facts = {}
for line in payload.splitlines():
if "=" in line:
key, value = line.split("=", 1)
facts[key] = value
return facts, None
def parse_version(version_text):
try:
return tuple(int(part) for part in version_text.split(".")[:3])
except (TypeError, ValueError):
return None
def is_disabled(value):
return value.strip().lower() in {"", "0", "off", "false", "no"}
def safeguards_active(facts, branch):
active = (
facts.get("memory_limit") == "-1"
and facts.get("post_max_size") == "8M"
and facts.get("upload_max_filesize") == "2M"
and is_disabled(facts.get("session_upload_progress", ""))
)
if branch == "8.5":
active = active and is_disabled(facts.get("fatal_error_backtraces", ""))
return active
def classify(facts):
version_text = facts.get("php_version", "unknown")
version = parse_version(version_text)
if version is None:
return "UNSUPPORTED OR UNKNOWN", "unknown", "unparseable version"
branch = f"{version[0]}.{version[1]}"
if branch not in FIXED_VERSIONS:
return "UNSUPPORTED OR UNKNOWN", "unknown", "no verified threshold"
fixed_tuple, fixed_text = FIXED_VERSIONS[branch]
if version >= fixed_tuple:
return "PATCHED", fixed_text, BRANCH_SUPPORT[branch]
if safeguards_active(facts, branch):
return "MITIGATED BUT UPGRADE REQUIRED", fixed_text, BRANCH_SUPPORT[branch]
return "NOT PATCHED", fixed_text, BRANCH_SUPPORT[branch]
def inspect_service(name, url):
facts, error = fetch_facts(url)
if error:
return {
"name": name,
"version": "unreachable",
"sapi": "unknown",
"fixed": "unknown",
"support": error,
"status": "UNSUPPORTED OR UNKNOWN",
"facts": {},
}
status, fixed, support = classify(facts)
return {
"name": name,
"version": facts.get("php_version", "unknown"),
"sapi": facts.get("sapi", "unknown"),
"fixed": fixed,
"support": support,
"status": status,
"facts": facts,
}
def main():
records = [inspect_service(name, url) for name, url in SERVICES.items()]
print(f"{'SERVICE':<10} {'PHP':<12} {'SAPI':<18} {'FIXED IN':<10} STATUS")
print("-" * 88)
for record in records:
print(
f"{record['name']:<10} {record['version']:<12} "
f"{record['sapi']:<18} {record['fixed']:<10} {record['status']}"
)
print(f" support: {record['support']}")
if record["facts"]:
facts = record["facts"]
print(
" settings: "
f"memory_limit={facts.get('memory_limit', 'unknown')}, "
f"post_max_size={facts.get('post_max_size', 'unknown')}, "
f"upload_max_filesize={facts.get('upload_max_filesize', 'unknown')}, "
"session.upload_progress.enabled="
f"{facts.get('session_upload_progress', 'unknown')}, "
"fatal_error_backtraces="
f"{facts.get('fatal_error_backtraces', 'unknown')}"
)
if __name__ == "__main__":
main()
Your checker now exposes the security difference hidden behind the matching pages. Next up, you will measure temporary safeguards before completing the patched upgrade.
Mitigate, Upgrade, and Publish
Your Python checker has exposed the hidden difference in your Docker Compose lab. The legacy PHP runtime remains below the verified patch threshold.
Temporary safeguards can reduce exposure while an upgrade is prepared. The fixed release remains the only complete remediation.
A public GitHub repository preserves this response sequence as evidence. It shows how you identified the risk before applying controls and verifying the upgrade.
In this step, get ready to:
- Apply temporary safeguards to the unpatched legacy service.
- Upgrade the legacy service to the fixed PHP release.
- Document the remediation trail in a public GitHub repository.
Apply temporary safeguards
The official advisory provides configuration safeguards for teams that cannot upgrade immediately. Your checker already recognizes these values as temporary controls.
- In the VS Code Explorer sidebar from earlier, select the config folder.
- Create config/mitigations.ini by copying this configuration:
memory_limit=-1
post_max_size=8M
upload_max_filesize=2M
session.upload_progress.enabled=0
What do these safeguards do?
- The memory_limit value removes the per-script memory limit represented in this lab.
- The post_max_size value limits the total POST request size to 8M.
- The upload_max_filesize value keeps an individual upload below the POST limit.
- The session.upload_progress.enabled value disables the confirmed session upload-progress path.
- Save config/mitigations.ini.
- Confirm the editor shows all four safeguards with their exact values.
Configuration values look different?
Check that each setting uses an equals sign with no surrounding spaces. Confirm that the filename ends in .ini.
Ask for help checking the temporary safeguards in your file.
✔️ Awesome, I've got everything!
Your mitigation file records the four temporary safeguards used by the legacy remediation path.
ⓧ I'd like to double check the full code
memory_limit=-1
post_max_size=8M
upload_max_filesize=2M
session.upload_progress.enabled=0
What should match?
Your complete file should contain these four settings in this order. Every value contributes to the checker’s mitigated classification.
- In compose.yaml, locate the legacy configuration mount.
- Find this line:
- ./config/baseline.ini:/usr/local/etc/php/conf.d/zz-security-lab.ini:ro
What does this line show?
The legacy service currently loads the baseline PHP settings. Those settings produced the original NOT PATCHED result.
- Replace the legacy configuration mount with this line:
- ./config/mitigations.ini:/usr/local/etc/php/conf.d/zz-security-lab.ini:ro
What changes now?
The legacy container now loads config/mitigations.ini at the official image configuration path. The patched service keeps its baseline mount.
- Save compose.yaml.
- Apply the updated Compose configuration by running:
docker compose up --detach
What does this command do?
Docker Compose detects the changed configuration mount. It recreates the affected legacy container in the background.
You will see the legacy service return to a running state. The patched service remains available on its existing localhost port.
Legacy service did not restart?
Check that config/mitigations.ini sits inside the project’s config folder. Confirm that the mount path in compose.yaml matches the filename exactly.
Ask for help diagnosing the failed Compose recreation.
Before you run the checker, consider whether temporary safeguards can produce the same verdict as a fixed release.
- Inspect both running services by running:
python3.15 check_php.py
What should I see?
The legacy service still reports PHP 8.3.35 through apache2handler. Its status now reads MITIGATED BUT UPGRADE REQUIRED.
The patched service remains on PHP 8.3.36. Its status remains PATCHED.
Legacy still reports NOT PATCHED?
Compare the printed legacy settings with -1, 8M, 2M, and 0. A different value means the container did not load the mitigation file.
Ask for help tracing the legacy runtime settings.
Upgrade the legacy runtime
The mitigated verdict proves that the checker can recognize exposure reduction. The final remediation replaces the vulnerable runtime with the verified fixed release.
- In compose.yaml, locate the legacy image line.
- Find this line:
image: php:8.3.35-apache
What does this image represent?
This tag pins the legacy service to PHP 8.3.35. That release sits below the verified 8.3.36 fixed-version threshold.
- Replace the legacy image line with this fixed tag:
image: php:8.3.36-apache
What does the upgrade change?
Both services now use the fixed PHP 8.3.36 Apache image. The legacy path keeps its mitigation file as evidence of the temporary control.
- Save compose.yaml.
- Recreate the legacy service with its fixed image by running:
docker compose up --detach
What happens during the upgrade?
Docker Compose detects the image change. It recreates the legacy container with PHP 8.3.36 while preserving the localhost port and mitigation mount.
The legacy service returns to a running state with the fixed runtime.
Upgrade did not complete?
Confirm that the legacy image tag contains 8.3.36 with no extra spaces. Check that Docker Desktop still reports a running engine.
Ask for help with the legacy image upgrade.
Before you inspect the result, consider whether the retained safeguards can prevent the upgraded runtime from receiving a patched verdict.
- Verify the final runtime state by running:
python3.15 check_php.py
What should I see?
Both services report PHP 8.3.36 through apache2handler. Both statuses read PATCHED.
The legacy settings still show the temporary safeguards. This result records the full path from exposure reduction to complete remediation.
Services show different PHP versions?
Confirm that both image lines in compose.yaml use php:8.3.36-apache. Reapply the Compose configuration after saving the file.
Ask for help comparing the observed containers with the final Compose file.
✔️ Awesome, I've got everything!
Your final Compose state keeps both services patched while retaining the hardened legacy configuration.
ⓧ I'd like to double check the full code
services:
legacy:
image: php:8.3.36-apache
ports:
- "127.0.0.1:8081:80"
volumes:
- ./app:/var/www/html:ro
- ./config/mitigations.ini:/usr/local/etc/php/conf.d/zz-security-lab.ini:ro
patched:
image: php:8.3.36-apache
ports:
- "127.0.0.1:8082:80"
volumes:
- ./app:/var/www/html:ro
- ./config/baseline.ini:/usr/local/etc/php/conf.d/zz-security-lab.ini:ro
What should match?
Both image tags should use PHP 8.3.36. Only the legacy service should mount config/mitigations.ini.
Document and publish the evidence
A remediation report connects the advisory to evidence that another person can reproduce. Your README records the lab’s safety boundary before documenting each verdict in the transition.
- In the VS Code Explorer sidebar, select the php-security-patch-verification folder.
- Create README.md by copying the opening documentation:
# PHP Security Patch Verification Lab
This project turns the official October 2026 PHP critical-RCE advisory into a safe, reproducible verification workflow. It compares PHP 8.3.35 with the fixed PHP 8.3.36 release, inspects the web SAPI and relevant configuration, demonstrates temporary safeguards, and verifies the final upgrade.
## Safety boundary
- No exploit code is included.
- No malicious request or proof of concept is executed.
- Both services bind only to `127.0.0.1`.
- The project verifies version and configuration evidence only.
- Temporary safeguards reduce exposure. The official advisory states that updating is the only complete mitigation.
## Advisory scope
- CVE-2026-109618: RCE via OOM corruption
- GHSA-w8hw-w9fx-j79w
- CVE-2026-109619: RCE via stale global state
- GHSA-6wmf-4rv5-p3rp
- Fixed PHP 8.3 release: 8.3.36
## Architecture
- `legacy`: the remediation path on `http://127.0.0.1:8081`
- `patched`: the comparison path on `http://127.0.0.1:8082`
- `status.php`: a read-only endpoint exposing PHP version, web SAPI, and selected INI values
- `check_php.py`: a standard-library Python checker that applies verified fixed-version thresholds
What does this section establish?
- The safety boundary keeps the repository focused on defensive verification.
- The advisory scope connects the lab to the two documented vulnerabilities.
- The architecture explains how each local service contributes evidence.
- Save README.md.
- Confirm the editor shows the safety boundary and architecture sections.
README headings look incorrect?
Check that the project title starts with one hash. Confirm that each section heading starts with two hashes.
Ask for help checking the Markdown structure.
- Place the cursor after the final architecture bullet in README.md.
- Press Enter twice.
- Add the run instructions and remediation sequence by copying:
## Run the final state
```console
docker compose up --detach
python3.15 check_php.py
```
Both services should report `PATCHED` on PHP 8.3.36. The legacy path retains the temporary safeguards to document defense in depth.
## Reproduce the transition
1. Change the `legacy` image in `compose.yaml` to `php:8.3.35-apache`.
2. Mount `config/baseline.ini` into `legacy`.
3. Run `docker compose up --detach`, then run `python3.15 check_php.py`. The legacy result should be `NOT PATCHED`.
4. Mount `config/mitigations.ini` into `legacy` and rerun both commands. The legacy result should be `MITIGATED BUT UPGRADE REQUIRED`.
5. Change the legacy image to `php:8.3.36-apache`, rerun both commands, and confirm `PATCHED`.
What does this section preserve?
The run instructions reproduce the final state. The numbered sequence preserves the progression from NOT PATCHED to mitigation before the verified upgrade.
- Save README.md.
- Confirm the editor shows all five transition steps beneath the final-state command block.
Command blocks do not render cleanly?
Confirm that each command block starts and ends with three backticks. Keep console directly after the opening backticks.
Ask for help checking the run section formatting.
- Place the cursor after the fifth remediation step in README.md.
- Press Enter twice.
- Add the temporary safeguard evidence by copying:
## Temporary safeguards represented
```ini
memory_limit=-1
post_max_size=8M
upload_max_filesize=2M
session.upload_progress.enabled=0
```
PHP 8.5 also has the `fatal_error_backtraces` setting. The checker treats it as an additional safeguard only for an unpatched PHP 8.5 branch and reports it as unavailable on PHP 8.3.
Why record the safeguards?
This section shows exactly which controls produced the mitigated verdict. It also explains why the PHP 8.5-only setting is inspected without becoming a PHP 8.3 requirement.
- Save README.md.
- Confirm the editor shows the four safeguards inside one INI code block.
Safeguards appear as plain text?
Check that the safeguard lines sit between the opening and closing backticks. Keep ini directly after the opening backticks.
Ask for help checking the safeguard block.
- Place the cursor after the PHP 8.5 safeguard paragraph in README.md.
- Press Enter twice.
- Complete the cleanup and reference sections by copying:
## Cleanup
Pause without deleting containers:
```console
docker compose stop
```
Remove the project containers and network:
```console
docker compose down
```
## Official references
- PHP security announcement: https://www.php.net/archive/2026.php#2026-10-21-5
- OOM corruption advisory: https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w
- Stale global state advisory: https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp
- PHP supported versions: https://www.php.net/supported-versions.php
- Official PHP Docker image: https://hub.docker.com/_/php
- Docker Compose port syntax: https://docs.docker.com/reference/compose-file/services/#ports
What does the final section add?
The cleanup section gives readers a safe way to stop or remove the local services. The official references make every threshold and safeguard traceable.
- Save README.md.
- Confirm the editor ends with six official reference links.
Reference links look incomplete?
Confirm that each reference occupies its own bullet. Check that no URL wraps onto a separate source line.
Ask for help comparing the final documentation sections.
✔️ Awesome, I've got everything!
Your README now documents the advisory, safety boundary, architecture, remediation sequence, safeguards, cleanup path, and official sources.
ⓧ I'd like to double check the full code
# PHP Security Patch Verification Lab
This project turns the official October 2026 PHP critical-RCE advisory into a safe, reproducible verification workflow. It compares PHP 8.3.35 with the fixed PHP 8.3.36 release, inspects the web SAPI and relevant configuration, demonstrates temporary safeguards, and verifies the final upgrade.
## Safety boundary
- No exploit code is included.
- No malicious request or proof of concept is executed.
- Both services bind only to `127.0.0.1`.
- The project verifies version and configuration evidence only.
- Temporary safeguards reduce exposure. The official advisory states that updating is the only complete mitigation.
## Advisory scope
- CVE-2026-109618: RCE via OOM corruption
- GHSA-w8hw-w9fx-j79w
- CVE-2026-109619: RCE via stale global state
- GHSA-6wmf-4rv5-p3rp
- Fixed PHP 8.3 release: 8.3.36
## Architecture
- `legacy`: the remediation path on `http://127.0.0.1:8081`
- `patched`: the comparison path on `http://127.0.0.1:8082`
- `status.php`: a read-only endpoint exposing PHP version, web SAPI, and selected INI values
- `check_php.py`: a standard-library Python checker that applies verified fixed-version thresholds
## Run the final state
```console
docker compose up --detach
python3.15 check_php.py
```
Both services should report `PATCHED` on PHP 8.3.36. The legacy path retains the temporary safeguards to document defense in depth.
## Reproduce the transition
1. Change the `legacy` image in `compose.yaml` to `php:8.3.35-apache`.
2. Mount `config/baseline.ini` into `legacy`.
3. Run `docker compose up --detach`, then run `python3.15 check_php.py`. The legacy result should be `NOT PATCHED`.
4. Mount `config/mitigations.ini` into `legacy` and rerun both commands. The legacy result should be `MITIGATED BUT UPGRADE REQUIRED`.
5. Change the legacy image to `php:8.3.36-apache`, rerun both commands, and confirm `PATCHED`.
## Temporary safeguards represented
```ini
memory_limit=-1
post_max_size=8M
upload_max_filesize=2M
session.upload.progress.enabled=0
```
PHP 8.5 also has the `fatal_error_backtraces` setting. The checker treats it as an additional safeguard only for an unpatched PHP 8.5 branch and reports it as unavailable on PHP 8.3.
## Cleanup
Pause without deleting containers:
```console
docker compose stop
```
Remove the project containers and network:
```console
docker compose down
```
## Official references
- PHP security announcement: https://www.php.net/archive/2026.php#2026-10-21-5
- OOM corruption advisory: https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w
- Stale global state advisory: https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp
- PHP supported versions: https://www.php.net/supported-versions.php
- Official PHP Docker image: https://hub.docker.com/_/php
- Docker Compose port syntax: https://docs.docker.com/reference/compose-file/services/#ports
What should match?
The full README should preserve the three remediation states. It should also contain the safety boundary and every official reference shown here.
Publish only verification evidence
Your repository should contain source files and documentation only. Secrets, Docker image layers, and exploit material stay out of the upload.
- Switch back to the signed-in GitHub dashboard from earlier.
- Select New repository.
- Enter php-security-patch-verification as the repository name.
- Choose Public as the visibility.
- Leave the optional starter files unselected.
- Click Create repository.
Your empty public repository is now ready to receive the verified project files.
- Select Add file above the repository file list.
- Click Upload files.
- Drag the contents of the local php-security-patch-verification folder into the upload area.
- Review the upload list for app, config, compose.yaml, check_php.py, and README.md.
- Confirm the upload list contains no secrets, image layers, or exploit material.
- Enter Document PHP patch verification in the Commit message field.
- Choose the option that adds the commit to the current branch.
- Click Propose changes.
Upload did not complete?
Confirm that no single uploaded file exceeds the browser limit. Check that the upload contains no more than 100 files.
Ask for help with the repository upload.
Before you return to the repository page, consider which files a reviewer needs to reproduce every remediation state.
- Return to the main page of php-security-patch-verification.
- Open README.md from the repository file list.
You will see the completed documentation with the safety boundary, remediation sequence, cleanup commands, and official references. The public repository now contains the reproducible lab files without sensitive or offensive material.
That completes the evidence trail. You have shown temporary exposure reduction before proving the patched upgrade in both running services.
Secret mission
Turn the Checker into an Enforcement Gate
Your checker already explains whether each PHP runtime is patched. In this mission, you will turn that report into a policy gate that returns a failing process status when either service drifts from the expected patched state.
Clean Up Your Resources
Clean Up Your Resources
This project has no usage-based cloud cost. Choose whether to keep the lab available, pause it to free local memory, or delete its local resources.
Resources you used:
- Two final PHP 8.3.36 Apache containers managed by Docker Compose.
- The Docker Compose network created for the lab.
- Downloaded PHP image layers stored by Docker Desktop.
- The local php-security-patch-verification source folder.
- The public GitHub repository named php-security-patch-verification.
Keep everything running
No action is needed. Choose this option if you want both localhost services to remain available for demonstrations or further experiments.
- Leave Docker Desktop running.
- Keep both Compose services running on http://127.0.0.1:8081 and http://127.0.0.1:8082.
- Preserve the local php-security-patch-verification folder.
- Keep the public php-security-patch-verification repository available on GitHub.
Pause - I'll come back to this later
Stopping the services frees the local memory used by their running processes. Their containers remain available for a later restart.
- Switch back to the Terminal session in the php-security-patch-verification folder.
- Stop both services without removing their containers by running this command:
docker compose stop
What Does This Command Do?
The docker compose stop command stops both services while keeping their containers. The project files, downloaded images, and Compose configuration remain available.
- Confirm http://127.0.0.1:8081 and http://127.0.0.1:8082 stop responding in your browser.
Services Still Responding?
- Confirm Docker Desktop is still running.
- Confirm Terminal is in the folder that contains compose.yaml.
Ask for help diagnosing why the Compose services did not stop.
When you return to the lab, the preserved containers can resume without rebuilding the project.
- Restart both services by running this command:
docker compose start
What Does This Command Do?
The docker compose start command starts the preserved containers. It uses the existing images and configuration.
- Refresh both localhost URLs to confirm the PHP lab pages load again.
Trouble Restarting the Lab?
- Confirm Docker Desktop reports that its engine is running.
- Confirm Terminal is still in the folder containing compose.yaml.
Ask for help restarting the preserved Compose containers.
Delete - I don't want to use this again
Deleting the lab is permanent on this Mac. The GitHub repository remains online until you delete it separately.
- Switch back to the Terminal session in the php-security-patch-verification folder.
- Remove the Compose containers and network by running this command:
docker compose down
What Does This Command Do?
The docker compose down command stops both services. It also removes the project containers and Compose network.
- Confirm Terminal reports that both project containers and the Compose network were removed.
Compose Resources Still Present?
- Confirm Docker Desktop is still running.
- Confirm Terminal is in the folder that contains the lab's compose.yaml file.
Ask for help removing the remaining Compose resources.
Compose cleanup leaves downloaded images on your Mac. Removing both PHP image tags reclaims their local disk space.
- Use Docker Desktop's local image list to locate php:8.3.35-apache.
- Delete the downloaded layers for php:8.3.35-apache.
- Use the local image list to locate php:8.3.36-apache.
- Delete the downloaded layers for php:8.3.36-apache.
- Confirm neither PHP image tag remains in Docker Desktop's local image list.
The public repository is independent from your local files. Delete it separately when you no longer want the published project to remain online.
- Open the public php-security-patch-verification repository on GitHub.
- Go to the repository settings.
- Use the repository deletion controls to delete it.
- Confirm the former repository URL no longer opens.
The final command permanently removes the source folder from your Mac. Make sure you have kept any evidence you still need before continuing.
- Return to the Terminal session inside php-security-patch-verification.
- Remove the local project folder by running these commands:
cd ..
rm -rf php-security-patch-verification
What Do These Commands Do?
- The cd .. command moves Terminal to the folder that contains your project folder.
- The rm -rf php-security-patch-verification command permanently removes the local project folder and its contents.
- Confirm php-security-patch-verification no longer appears in the folder that previously contained it.
Still See the Project Folder?
- Confirm the terminal started inside php-security-patch-verification before the first command ran.
- Check that the folder name matches php-security-patch-verification exactly.
Ask for help removing the local project folder safely.
Nice Work!
Nice Work!
You did it! Your localhost-only Docker Compose lab now proves that both PHP services run the patched release. The checker enforces that state with a passing policy gate.
You've learned how to:
- Built a localhost-only security lab that compares pinned Apache mod_php runtimes through identical browser pages.
- Created a Python security checker that converts each service's runtime evidence into a repeatable remediation status.
- Demonstrated the full remediation trail from NOT PATCHED through MITIGATED BUT UPGRADE REQUIRED to PATCHED. Published the evidence in a public GitHub repository. Documented the safety boundaries with official references.
- Secret Mission: Extended check_php.py with an enforcement gate that compares each observed status with EXPECTED_STATUS. Runtime drift now prints Policy gate: FAIL before SystemExit(1). A compliant result prints Policy gate: PASS before SystemExit(0).
Ready to quiz yourself?