PHP Patch Check
Build a local Docker lab that verifies PHP patch status and mitigations.
Introduction
30 Second Summary
A web page can look perfectly healthy while outdated software sits underneath it. The screen alone cannot prove that a critical security fix is present.
In this project, you will use Docker Compose to build a defensive lab with localhost-only container networking. A Python checker compares PHP 8.3.35 with 8.3.36 under Apache mod_php.
What You'll Build
You will run one local check to watch the old service move from NOT PATCHED through NOT PATCHED BUT MITIGATED to PATCHED after its container image is replaced.
By the end of this project, you'll have:
- A localhost-only comparison you can open in your browser. The two Hello World pages look identical while showing PHP 8.3.35 and 8.3.36.
- A defensive checker report that pairs each service's version with its execution mode. It also shows the relevant settings plus a plain-English patch reason.
- Before-and-after evidence that captures NOT PATCHED, NOT PATCHED BUT MITIGATED, and PATCHED. The final report proves both services reach PATCHED on PHP 8.3.36.
- Secret Mission: Add automated tests for every supported PHP fixed-version boundary. Include the unsupported PHP 8.1 branch.
Are there any prerequisites?
You need a 64-bit Windows PC with at least 8 GB of RAM plus hardware virtualization. Step 1 checks your WSL 2 access before providing full installation guidance for Docker Desktop plus Visual Studio Code.
Before We Start
Before any hands-on work, lock in the purpose of this project: compare upstream PHP patch status, web execution mode, and relevant PHP settings. The intentionally vulnerable service stays on localhost so other devices and the internet cannot reach it.
Prepare the Windows Workspace
PHP Patch Check runs its services inside Linux containers on your Windows PC. Those containers depend on WSL 2.
You will prepare Docker Desktop before building the lab. Visual Studio Code will hold the empty workspace where Docker Compose verifies the engine.
In this step, get ready to:
- Verify that WSL 2 supports Docker Desktop.
- Install Docker Desktop with Linux-container support.
- Prepare the empty Visual Studio Code workspace.
Check the Windows foundation
Docker Desktop needs WSL 2.1.5 or later. Checking this first prevents Windows setup problems from looking like lab problems.
- Press the Windows key to open Windows search.
- Type PowerShell into the search field.
- Press Enter to open PowerShell.
- Check your installed WSL version by running this command:
wsl --version
What does this command check?
The command reports the WSL components installed on your PC. Docker Desktop requires WSL 2.1.5 or later for this setup.
Your output fits one of these three states.
✔️ I see the required version
Your output shows WSL 2.1.5 or higher. Your WSL layer is ready for Docker Desktop.
ⓧ I see an older version
- Return to Windows search.
- Type PowerShell into the search field.
- Choose the administrator option for PowerShell.
- Update WSL by running this command:
wsl --update
What does this command do?
This command updates the installed WSL components. Windows may ask you to restart before the new version becomes active.
- Restart Windows if PowerShell prompts you.
- Return to PowerShell after the restart.
- Check the updated version by running this command:
wsl --version
What should you confirm?
The output should now show WSL 2.1.5 or higher. That result clears the version requirement.
ⓧ WSL is not installed
- Return to Windows search.
- Type PowerShell into the search field.
- Choose the administrator option for PowerShell.
- Install WSL by running this command:
wsl --install
What does this command do?
This command sets up WSL for Windows. The installation may require a restart before WSL becomes available.
- Restart Windows if PowerShell prompts you.
- Return to PowerShell after the restart.
- Verify the installation by running this command:
wsl --version
What should you confirm?
The output should show WSL 2.1.5 or higher. WSL is now ready to support Docker Desktop.
WSL still not ready?
Confirm that you used an administrator PowerShell window for the install or update. Restart Windows before checking the version again.
Ask for help with diagnosing your WSL setup.
- Open Docker Desktop's official Windows setup guide in your browser.
- Find the current system requirements in the guide.
- Confirm that your PC runs supported 64-bit Windows.
- Confirm that hardware virtualization is enabled.
- Confirm that your PC has at least 8 GB of RAM.
Install the desktop tools
Docker Desktop supplies the Linux engine that runs the PHP services. Visual Studio Code provides the workspace where you run every lab command.
- Return to Docker's Windows setup guide from earlier.
- Download Docker Desktop for Windows from the guide.
- Run the downloaded installer from your Downloads folder.
- Choose the recommended per-user installation.
- Keep the WSL 2 backend enabled.
- Complete the installer.
- Restart Windows if the installer prompts you.
That is the main installation done. Docker Desktop is now ready to start its Linux engine.
Signing in uses your Docker account credentials. Keep those details inside Docker's sign-in flow.
- Press the Windows key to open Windows search.
- Type Docker Desktop into the search field.
- Press Enter to start Docker Desktop.
- Accept the applicable personal-use terms.
- Sign in with your Docker account.
The first launch can take a few minutes while Docker Desktop starts the Linux engine.
- Open Docker Desktop's settings with the gear icon.
- Confirm that the backend uses WSL 2.
- Confirm that Linux containers are active.
- Return to the main Docker Desktop window.
- Wait until Docker Desktop reports that the engine is running.
You have the right Docker engine running for the lab. The final terminal checks will prove that Visual Studio Code can reach it.
- Open Visual Studio Code's official Windows setup guide in your browser.
- Download the Visual Studio Code User setup.
- Run the downloaded installer from your Downloads folder.
- Complete the installer.
Having trouble with either installer?
Confirm that each installer finished before opening the application. Restart Windows if an installer reports that system changes are pending.
Ask for help with diagnosing the Windows installation.
Open and verify the workspace
The workspace starts empty because the project files belong in the next step. The integrated terminal provides direct evidence that the editor can reach Docker's Linux engine.
- Press the Windows key to open Windows search.
- Type Visual Studio Code into the search field.
- Press Enter to open Visual Studio Code.
- Select File in the top menu.
- Select Open Folder.
Why start with an empty folder?
The empty workspace confirms that setup is complete before any project files exist. This keeps Windows problems separate from the PHP lab you build next.
- Select your Desktop in the folder picker.
- Choose the option to create a new folder.
- Enter php-patch-check as the folder name.
- Select the new php-patch-check folder.
- Confirm the folder selection to open it.
- Use the Visual Studio Code top menu to open the integrated terminal.
The editor now shows an empty php-patch-check workspace. The integrated terminal opens in that folder.
Before you run these checks, consider whether Docker can answer from both the client and the Linux server.
- Verify Docker and Docker Compose by running these commands in the integrated terminal:
docker version
docker compose version
What do these checks prove?
- The docker version command checks the local Docker client plus its connection to the running server.
- The docker compose version command confirms that the Compose plugin is available.
You should see both Client and Server sections from the first command. The second command should print a Docker Compose version.
You have cleared the setup gate. Your editor can now control the Linux-container engine that runs PHP Patch Check.
Missing the server or Compose output?
- Return to Docker Desktop if the Server section is missing.
- Wait until Docker Desktop reports that its engine is running.
- Restart Docker Desktop if the Compose check still fails.
Ask for help with diagnosing the Docker verification output.
Your Windows workspace can now communicate with the Linux Docker engine. Next, you will launch the two localhost-only PHP services.
Launch the Local Comparison Lab
Your Windows tools are ready. Docker Desktop can now turn your empty Visual Studio Code workspace into a running PHP comparison.
The baseline must keep both Apache services on your PC while giving them different patch versions. Docker Compose provides that controlled comparison before the checker measures the security gap.
In this step, get ready to:
- Create the shared Hello World application.
- Define two localhost-only services with different PHP versions.
- Start both services and verify their pages.
Create the workspace structure
The shared application is intentionally harmless. It prints a heading plus the PHP runtime version without sending a vulnerability trigger.
- Create the app folder with the New Folder icon in the Explorer toolbar.
- Create the checker folder with the New Folder icon.
You will see app and checker beneath the php-patch-check workspace.
- Create the config folder with the New Folder icon.
- Create the docs folder with the New Folder icon.
The Explorer now shows all four folders required by the finished project.
- Select the app folder in the Explorer.
- Create index.php inside app with the New File icon.
- Fill app/index.php with the harmless page by replacing its empty contents with this code:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PHP Patch Check</title>
</head>
<body>
<h1>Hello World</h1>
<p>PHP version: <?= PHP_VERSION ?></p>
</body>
</html>
What does this page do?
- The heading gives both services the same visible Hello World baseline.
- The PHP_VERSION constant prints the runtime version serving the request.
- The page contains no forms or requests that trigger either vulnerability.
- Save app/index.php.
- Confirm index.php appears beneath app in the Explorer.
File in the wrong folder?
Confirm the Explorer shows index.php nested beneath app. Move the file into app if it appears beside that folder.
Ask for help checking the file location.
✔️ Awesome, I've got everything!
Your harmless application file now matches the comparison baseline.
ⓧ I'd like to double check the full code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PHP Patch Check</title>
</head>
<body>
<h1>Hello World</h1>
<p>PHP version: <?= PHP_VERSION ?></p>
</body>
</html>
This complete file provides the shared page served by both PHP containers.
Define the two local services
Each service uses the same application files through a read-only mount. Separate image pins create the version boundary that this lab compares.
- Select the php-patch-check workspace folder in the Explorer.
- Create compose.yaml in that folder with the New File icon.
- Define the two services by placing this configuration in compose.yaml:
services:
old:
image: php:8.3.35-apache
ports:
- "127.0.0.1:8081:80"
volumes:
- ./app:/var/www/html:ro
new:
image: php:8.3.36-apache
ports:
- "127.0.0.1:8082:80"
volumes:
- ./app:/var/www/html:ro
What does this configuration do?
- The old service runs php:8.3.35-apache on host port 8081.
- The new service runs php:8.3.36-apache on host port 8082.
- The 127.0.0.1 host address keeps both published ports reachable only from your PC.
- The ro access mode gives each container a read-only view of ./app at /var/www/html.
- Save compose.yaml.
- Confirm compose.yaml appears beside the four folders in the Explorer.
Seeing YAML problems?
Check that old and new have the same indentation. Keep both port mappings inside double quotes.
Ask for help comparing the YAML indentation.
✔️ Awesome, I've got everything!
Your Compose file now defines both localhost-only PHP services.
ⓧ I'd like to double check the full code
services:
old:
image: php:8.3.35-apache
ports:
- "127.0.0.1:8081:80"
volumes:
- ./app:/var/www/html:ro
new:
image: php:8.3.36-apache
ports:
- "127.0.0.1:8082:80"
volumes:
- ./app:/var/www/html:ro
This complete configuration creates the exact two-service baseline used in this step.
Start and inspect the baseline
Allow several minutes for the first start while Docker downloads both images.
- Start the old and new services in the background by running this command in the integrated terminal:
docker compose up -d old new
What does this command do?
- Docker Compose creates the project network plus both service containers.
- The -d option leaves the services running in the background.
- The old and new service names limit the launch to the comparison servers.
You should see both services reach a running state after their images are available.
Services did not start?
Confirm Docker Desktop is still running with Linux-container support. Check that ports 8081 and 8082 are not already used by another local application.
Ask for help diagnosing the service startup.
Before you inspect the result, predict which host address should protect both published ports.
- Inspect the running services and their port bindings by running this command:
docker compose ps
What does this command show?
The status table lists the project containers plus their current state. Its port column proves which host addresses can reach each service.
You should see both services running. Their bindings should use 127.0.0.1:8081 and 127.0.0.1:8082 instead of 0.0.0.0.
Missing a service or localhost binding?
Compare the service names plus quoted port mappings in compose.yaml with the full-code reference. Save the file before starting the services again.
Ask for help reading the service table.
Before you open both pages, predict whether shared source code forces both services to report the same PHP version.
- Open the old service at http://127.0.0.1:8081 in your browser.
- Open the new service at http://127.0.0.1:8082 in your browser.
What should I see?
- Both pages show Hello World.
- The page on 8081 shows PHP version: 8.3.35.
- The page on 8082 shows PHP version: 8.3.36.
Your comparison lab is live. The same harmless page now runs on both sides of a real PHP patch boundary.
The two pages look equally healthy from the browser. Next, you will add a narrow inspection endpoint plus a checker that exposes their different patch states.
Make the Patch Gap Visible
Your two localhost pages look equally healthy. Their matching output gives you no evidence about whether either PHP runtime meets its upstream patch threshold.
You will add a narrow JSON endpoint for runtime facts. A temporary Python checker will request those facts through the internal Docker Compose network and turn them into a readable security assessment.
In this step, get ready to:
- Expose the PHP version, web SAPI, and five selected INI values through a local inspection endpoint.
- Build a checker that compares each PHP version with its upstream fixed release.
- Run the checker and capture the patch gap as your before evidence.
Create the local inspection endpoint
A command executed inside the container would report the CLI SAPI. The checker needs a web request so PHP_SAPI reports apache2handler, which confirms that Apache is running PHP as mod_php.
Why keep the endpoint narrow?
The endpoint returns only the facts needed for classification. It contains no malformed requests, attack payloads, or vulnerability triggers.
Both services already mount the same app folder as read-only. Creating one file makes the endpoint available through both localhost services without rebuilding their images.
- Use the Visual Studio Code Explorer to create inspect.php inside the existing app folder.
- Add the inspection endpoint by pasting this code into app/inspect.php:
<?php
header('Content-Type: application/json');
$directives = [
'session.upload_progress.enabled',
'memory_limit',
'post_max_size',
'upload_max_filesize',
'fatal_error_backtraces',
];
$settings = [];
foreach ($directives as $directive) {
$value = ini_get($directive);
$settings[$directive] = $value === false ? null : $value;
}
echo json_encode(
[
'version' => PHP_VERSION,
'sapi' => PHP_SAPI,
'settings' => $settings,
],
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
);
What does this endpoint return?
- The $directives array limits inspection to the five settings used by the checker.
- The ini_get() call reads each setting from the active web runtime.
- The response groups PHP_VERSION, PHP_SAPI, and the selected settings into readable JSON.
- Save app/inspect.php.
- Switch back to the browser tab for the old service.
- Enter http://127.0.0.1:8081/inspect.php in the address bar.
- Confirm the response contains version, sapi, and settings.
- Repeat the check at http://127.0.0.1:8082/inspect.php.
You should see 8.3.35 from the old service and 8.3.36 from the new service. Both responses should report apache2handler as the SAPI.
Inspection page missing?
Confirm that inspect.php sits inside the existing app folder. A file created beside that folder is outside the mounted web root.
Check the PHP syntax for missing brackets or commas. Help me compare my inspection endpoint with the expected PHP structure.
✔️ Awesome, I've got everything!
Your saved endpoint now returns the required runtime facts from both services.
ⓧ I'd like to double check the full code
<?php
header('Content-Type: application/json');
$directives = [
'session.upload_progress.enabled',
'memory_limit',
'post_max_size',
'upload_max_filesize',
'fatal_error_backtraces',
];
$settings = [];
foreach ($directives as $directive) {
$value = ini_get($directive);
$settings[$directive] = $value === false ? null : $value;
}
echo json_encode(
[
'version' => PHP_VERSION,
'sapi' => PHP_SAPI,
'settings' => $settings,
],
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
);
Build the defensive checker
The checker needs the same internal network as the PHP services. Compose service discovery lets it contact old and new by service name without publishing another host port.
Why use a temporary Python container?
The temporary container pins the checker to Python 3.15.1 without installing Python on Windows. Its standard library provides the HTTP and JSON features required by the checker.
The --rm option removes each one-off checker container after it exits. Your source file stays in the read-only mounted folder.
- Return to compose.yaml in Visual Studio Code.
- Append the temporary checker service below the existing new service:
checker:
image: python:3.15.1-alpine3.24
working_dir: /workspace
volumes:
- ./checker:/workspace:ro
command: ["python", "check.py"]
What does this service define?
- The pinned image provides the checker runtime.
- The read-only mount exposes the local checker folder at /workspace.
- The service starts in that mounted directory and runs check.py by default.
- Save compose.yaml.
- Confirm the checker service aligns with old and new beneath the top-level services key.
Compose service misaligned?
YAML indentation determines which properties belong to a service. Keep checker level with old and new.
Keep the volume line nested below volumes. Help me find an indentation problem in my Compose checker service.
✔️ Awesome, I've got everything!
Your Compose file now defines both PHP services and the temporary checker.
ⓧ I'd like to double check the full code
services:
old:
image: php:8.3.35-apache
ports:
- "127.0.0.1:8081:80"
volumes:
- ./app:/var/www/html:ro
new:
image: php:8.3.36-apache
ports:
- "127.0.0.1:8082:80"
volumes:
- ./app:/var/www/html:ro
checker:
image: python:3.15.1-alpine3.24
working_dir: /workspace
volumes:
- ./checker:/workspace:ro
command: ["python", "check.py"]
The checker separates its data tables, normalization helpers, mitigation rules, classification logic, and report output. Building those sections in sequence keeps each rule visible.
- Use the Visual Studio Code Explorer to create check.py inside the existing checker folder.
- Add the imports and reference tables by pasting this first section into checker/check.py:
import json
import urllib.request
FIXED_VERSIONS = {
(8, 2): (8, 2, 35),
(8, 3): (8, 3, 36),
(8, 4): (8, 4, 27),
(8, 5): (8, 5, 12),
}
TARGETS = (
("old", "http://old/inspect.php"),
("new", "http://new/inspect.php"),
)
SETTING_NAMES = (
"session.upload_progress.enabled",
"memory_limit",
"post_max_size",
"upload_max_filesize",
"fatal_error_backtraces",
)
SIZE_UNITS = {
"K": 1024,
"M": 1024 ** 2,
"G": 1024 ** 3,
}
What do these tables control?
- The FIXED_VERSIONS table stores the first fixed upstream release for each supported PHP branch.
- The TARGETS tuple uses Compose service names as internal hostnames.
- The setting and size tables keep report order consistent and normalize PHP shorthand sizes.
- Save checker/check.py.
- Confirm that Visual Studio Code shows no red syntax underline in the tables you added.
Seeing a Python syntax marker?
Check that every tuple and dictionary has its closing bracket. Confirm that every string retains its matching quotation marks.
Help me find the syntax mistake in my checker tables.
- Append the version and size helpers to checker/check.py:
def parse_version(value):
return tuple(int(part) for part in value.split(".")[:3])
def format_version(value):
return ".".join(str(part) for part in value)
def size_to_bytes(value):
normalized = value.strip().upper()
suffix = normalized[-1:]
if suffix in SIZE_UNITS:
return int(normalized[:-1]) * SIZE_UNITS[suffix]
return int(normalized)
def is_disabled(value):
if value is None:
return False
return str(value).strip().lower() in {"", "0", "off", "false", "no"}
def patch_assessment(version):
current = parse_version(version)
required = FIXED_VERSIONS.get(current[:2])
if required is None:
return False, None
return current >= required, required
How are values normalized?
- The version helpers convert dotted release strings into numeric tuples for reliable comparison.
- The size helper converts PHP shorthand units into bytes before comparing limits.
- The disabled-value helper recognizes the different forms that PHP settings use for an off state.
- The patch assessment returns both the classification result and the required release.
- Save checker/check.py.
- Confirm the new helper definitions align at the left edge of the file.
Helpers nested unexpectedly?
Each def line starts at the left edge. Only the statements inside a function use four spaces of indentation.
Help me correct the indentation in my checker helper functions.
- Append the temporary mitigation checks to checker/check.py:
def mitigation_checks(report):
version = parse_version(report["version"])
settings = report["settings"]
post_size = size_to_bytes(settings["post_max_size"])
upload_size = size_to_bytes(settings["upload_max_filesize"])
checks = {
"session upload progress is disabled": is_disabled(
settings["session.upload_progress.enabled"]
),
"memory_limit is -1": settings["memory_limit"].strip() == "-1",
"post_max_size is positive and no more than 8M": (
post_size > 0 and post_size <= 8 * 1024 ** 2
),
"upload_max_filesize is below post_max_size": upload_size < post_size,
}
if version[:2] == (8, 5):
checks["fatal error backtraces are disabled"] = is_disabled(
settings["fatal_error_backtraces"]
)
return checks
What counts as temporary mitigation?
- Session upload progress must be disabled.
- The memory limit must be -1.
- The POST limit must be positive and no greater than 8M.
- The upload limit must remain below the POST limit.
- PHP 8.5 also requires fatal error backtraces to be disabled.
- Save checker/check.py.
- Confirm the return checks line remains inside mitigation_checks().
Mitigation function highlighted?
Check the nested parentheses around the session and POST size checks. Each opening parenthesis needs a matching closing parenthesis.
Help me find a bracket or indentation error in mitigation_checks().
- Append the SAPI label and patched classification path to checker/check.py:
def sapi_label(value):
if value in {"apache", "apache2handler"}:
return f"mod_php ({value})"
if value == "fpm-fcgi":
return "PHP-FPM (fpm-fcgi)"
return value
def classify(report):
version = report["version"]
current = parse_version(version)
patched, required = patch_assessment(version)
if patched:
fixed = format_version(required)
return "PATCHED", f"PHP {version} meets the {fixed} fixed release for its branch."
How does the patched path work?
- The SAPI helper turns apache2handler into a readable mod_php label while preserving the raw value.
- The classification starts with the version threshold because an update is the only complete remediation.
- A version at or above its branch threshold returns PATCHED with the exact fixed release in the reason.
- Save checker/check.py.
- Confirm the patched return line remains nested inside the if patched block.
Patched branch outside the function?
Keep the classification body indented four spaces below def classify(report). Keep the patched result indented another four spaces below its condition.
Help me correct the nested indentation in classify().
- Append the unpatched classification paths inside classify():
checks = mitigation_checks(report)
missing = [name for name, passed in checks.items() if not passed]
if required is None and current[:2] <= (8, 1):
version_reason = "PHP 8.1 or older has no fixed upstream release in this table"
elif required is None:
version_reason = "this PHP branch is not covered by the checker table"
else:
version_reason = (
f"PHP {version} is below the {format_version(required)} fixed release"
)
if not missing:
return (
"NOT PATCHED BUT MITIGATED",
f"{version_reason}; the temporary controls are present, but only an update fully fixes it.",
)
return (
"NOT PATCHED",
f"{version_reason}; missing temporary controls: {', '.join(missing)}.",
)
How are unpatched versions separated?
- An unsupported branch receives a reason that avoids inventing a fixed threshold.
- A version below a known threshold receives the required release in its reason.
- A report with every temporary control passes into NOT PATCHED BUT MITIGATED.
- A report missing any checked control remains NOT PATCHED.
- Save checker/check.py.
- Confirm each return value contains a status and a reason.
Classification block misaligned?
These lines continue the existing classify() function. Keep four spaces before checks, each condition, and each return statement.
Help me align the remaining classify() logic correctly.
- Append the request, reporting, and entry-point functions to checker/check.py:
def fetch_report(url):
with urllib.request.urlopen(url, timeout=5) as response:
return json.load(response)
def print_report(name, report):
status, reason = classify(report)
print("=" * 68)
print(f"{name.upper()} SERVER")
print(f"PHP version: {report['version']}")
print(f"Execution mode: {sapi_label(report['sapi'])}")
print("Settings:")
for setting in SETTING_NAMES:
value = report["settings"].get(setting)
shown = "not available" if value is None else value
print(f" {setting}: {shown}")
print(f"Result: {status}")
print(f"Why: {reason}")
def main():
for name, url in TARGETS:
print_report(name, fetch_report(url))
if __name__ == "__main__":
main()
How does the report reach your terminal?
- The request helper opens each local endpoint with a five-second timeout and deserializes its JSON response.
- The report function prints the version, readable execution mode, selected settings, status, and reason.
- The main() function processes both targets in their defined order.
- Save checker/check.py.
- Confirm the file ends with the call to main().
Report code showing an error?
Check the quotation marks around dictionary keys inside each formatted string. The outer string uses double quotes while keys such as 'version' use single quotes.
Help me find the syntax mistake in the report functions.
✔️ Awesome, I've got everything!
Your checker now contains the complete version, mitigation, network, and reporting logic.
ⓧ I'd like to double check the full code
import json
import urllib.request
FIXED_VERSIONS = {
(8, 2): (8, 2, 35),
(8, 3): (8, 3, 36),
(8, 4): (8, 4, 27),
(8, 5): (8, 5, 12),
}
TARGETS = (
("old", "http://old/inspect.php"),
("new", "http://new/inspect.php"),
)
SETTING_NAMES = (
"session.upload_progress.enabled",
"memory_limit",
"post_max_size",
"upload_max_filesize",
"fatal_error_backtraces",
)
SIZE_UNITS = {
"K": 1024,
"M": 1024 ** 2,
"G": 1024 ** 3,
}
def parse_version(value):
return tuple(int(part) for part in value.split(".")[:3])
def format_version(value):
return ".".join(str(part) for part in value)
def size_to_bytes(value):
normalized = value.strip().upper()
suffix = normalized[-1:]
if suffix in SIZE_UNITS:
return int(normalized[:-1]) * SIZE_UNITS[suffix]
return int(normalized)
def is_disabled(value):
if value is None:
return False
return str(value).strip().lower() in {"", "0", "off", "false", "no"}
def patch_assessment(version):
current = parse_version(version)
required = FIXED_VERSIONS.get(current[:2])
if required is None:
return False, None
return current >= required, required
def mitigation_checks(report):
version = parse_version(report["version"])
settings = report["settings"]
post_size = size_to_bytes(settings["post_max_size"])
upload_size = size_to_bytes(settings["upload_max_filesize"])
checks = {
"session upload progress is disabled": is_disabled(
settings["session.upload_progress.enabled"]
),
"memory_limit is -1": settings["memory_limit"].strip() == "-1",
"post_max_size is positive and no more than 8M": (
post_size > 0 and post_size <= 8 * 1024 ** 2
),
"upload_max_filesize is below post_max_size": upload_size < post_size,
}
if version[:2] == (8, 5):
checks["fatal error backtraces are disabled"] = is_disabled(
settings["fatal_error_backtraces"]
)
return checks
def sapi_label(value):
if value in {"apache", "apache2handler"}:
return f"mod_php ({value})"
if value == "fpm-fcgi":
return "PHP-FPM (fpm-fcgi)"
return value
def classify(report):
version = report["version"]
current = parse_version(version)
patched, required = patch_assessment(version)
if patched:
fixed = format_version(required)
return "PATCHED", f"PHP {version} meets the {fixed} fixed release for its branch."
checks = mitigation_checks(report)
missing = [name for name, passed in checks.items() if not passed]
if required is None and current[:2] <= (8, 1):
version_reason = "PHP 8.1 or older has no fixed upstream release in this table"
elif required is None:
version_reason = "this PHP branch is not covered by the checker table"
else:
version_reason = (
f"PHP {version} is below the {format_version(required)} fixed release"
)
if not missing:
return (
"NOT PATCHED BUT MITIGATED",
f"{version_reason}; the temporary controls are present, but only an update fully fixes it.",
)
return (
"NOT PATCHED",
f"{version_reason}; missing temporary controls: {', '.join(missing)}.",
)
def fetch_report(url):
with urllib.request.urlopen(url, timeout=5) as response:
return json.load(response)
def print_report(name, report):
status, reason = classify(report)
print("=" * 68)
print(f"{name.upper()} SERVER")
print(f"PHP version: {report['version']}")
print(f"Execution mode: {sapi_label(report['sapi'])}")
print("Settings:")
for setting in SETTING_NAMES:
value = report["settings"].get(setting)
shown = "not available" if value is None else value
print(f" {setting}: {shown}")
print(f"Result: {status}")
print(f"Why: {reason}")
def main():
for name, url in TARGETS:
print_report(name, fetch_report(url))
if __name__ == "__main__":
main()
Run and capture the before evidence
The checker now has the same application evidence from both services. Before you run it, do you think matching Hello World pages guarantee matching patch results?
- Return to the integrated terminal from earlier.
- Run the one-off checker container with this command:
docker compose run --rm checker
What does this command do?
Compose starts the checker on the existing project network. The checker requests both inspection endpoints and removes its one-off container when the report finishes.
The first run can take a few minutes while Docker downloads the pinned Python image. Continued download activity means the command is still working.
The old server reports PHP 8.3.35, mod_php (apache2handler), and NOT PATCHED. The new server reports PHP 8.3.36, the same execution mode, and PATCHED.
That contrast is the intended shortfall. The pages look identical, but the old runtime remains below the first fixed PHP 8.3 release.
- Read each reported field and its one-line reason aloud before interpreting the status.
- Confirm that the old reason names 8.3.36 as the fixed release for its branch.
- Confirm that the new reason says its version meets the fixed release.
Checker cannot reach a service?
Confirm that the old and new services are still running. The checker depends on their shared Compose network and service names.
Confirm that both services can return inspect.php in your browser. Help me diagnose why the checker cannot fetch one of my PHP services.
Your terminal now contains the security evidence for the before state. Capture only the report so the image stays focused on the patch comparison.
- Press Windows key+Shift+S to open the Snipping Tool overlay.
- Drag a rectangle around both server reports.
- Select the capture notification to open it in Snipping Tool.
- Check the image for personal paths, notifications, or unrelated windows.
- Select Save as in Snipping Tool.
- Save the image as docs/before.png inside the php-patch-check workspace.
You have your first defensible result. The screenshot proves that visual application health and upstream patch status can tell different stories.
The patch gap is now measurable and documented. Next, you will apply the temporary controls to the old service before replacing its image with the complete fix.
Demonstrate the Stopgap and Patch
Your checker has exposed the patch gap. The old PHP service still reports NOT PATCHED on version 8.3.35.
The next challenge is to reduce exposure without confusing a stopgap with a complete fix. The vulnerable version must remain visible while you test the temporary controls.
You will then use Docker Compose to recreate the old service from the fixed image. This replacement makes the complete remediation measurable.
In this step, get ready to:
- Apply the temporary PHP controls to the old service.
- Recreate the old service from php:8.3.36-apache.
- Capture evidence that both services report PATCHED.
Apply the temporary controls
Temporary INI settings can reduce exposure while an update is being prepared. The PHP version remains the deciding factor for complete remediation.
- Return to the Explorer sidebar in Visual Studio Code.
- Select the config folder.
- Create config/mitigation.ini using the file creation control above the file tree.
- Add the temporary controls by pasting this configuration:
; Temporary exposure-reduction controls for the PHP 8.3 lab.
; Updating remains the only complete fix.
session.upload_progress.enabled=0
memory_limit=-1
post_max_size=8M
upload_max_filesize=2M
; PHP 8.5 also requires this temporary control:
; fatal_error_backtraces=0
What Do These Controls Do?
- The session.upload_progress.enabled=0 setting disables session upload progress tracking.
- The memory_limit=-1 setting applies the temporary memory control. An underlying allocation can still fail.
- The post_max_size=8M setting keeps the accepted POST size positive within the temporary limit.
- The upload_max_filesize=2M setting stays below the POST limit.
- Save config/mitigation.ini.
- Confirm that mitigation.ini appears inside config in the Explorer sidebar.
Cannot See the Settings File?
Check that the filename is exactly mitigation.ini. Confirm that the file sits inside config.
For focused help, help me verify the mitigation file.
The settings need to become part of a temporary image. A Dockerfile copies them into PHP's configuration directory during the build.
- Select the config folder in the Explorer sidebar.
- Create config/Dockerfile.mitigated using the file creation control above the file tree.
- Define the temporary image by pasting this configuration:
FROM php:8.3.35-apache
COPY mitigation.ini /tmp/mitigation.ini
RUN cp /tmp/mitigation.ini "$PHP_INI_DIR/conf.d/zz-mitigation.ini" \
&& rm /tmp/mitigation.ini
What Does This Dockerfile Do?
The build starts from php:8.3.35-apache. This preserves the vulnerable version for the stopgap demonstration.
The build installs the settings as zz-mitigation.ini in PHP's configuration directory. It removes the intermediate copy afterward.
- Save config/Dockerfile.mitigated.
- Confirm that Dockerfile.mitigated appears beside mitigation.ini.
Are the Files in Different Folders?
Keep Dockerfile.mitigated beside mitigation.ini inside config. The build context needs both files in that location.
For help checking the paths, help me arrange the mitigation files.
✔️ Awesome, I've got everything!
Your two mitigation files are ready. Both files should be saved inside config.
ⓧ I'd like to double check the full code
; Temporary exposure-reduction controls for the PHP 8.3 lab.
; Updating remains the only complete fix.
session.upload_progress.enabled=0
memory_limit=-1
post_max_size=8M
upload_max_filesize=2M
; PHP 8.5 also requires this temporary control:
; fatal_error_backtraces=0
What Should Match in the Settings File?
Compare every active setting with your saved config/mitigation.ini file. Keep the PHP 8.5 control commented out for this PHP 8.3 lab.
FROM php:8.3.35-apache
COPY mitigation.ini /tmp/mitigation.ini
RUN cp /tmp/mitigation.ini "$PHP_INI_DIR/conf.d/zz-mitigation.ini" \
&& rm /tmp/mitigation.ini
What Should Match in the Dockerfile?
Confirm that the base image is php:8.3.35-apache. Confirm that the installed configuration file is named zz-mitigation.ini.
The old service currently pulls the unmodified image. A temporary build block tells Compose to build the mitigated image from config.
- Switch back to compose.yaml.
- Find this line beneath the old service:
image: php:8.3.35-apache
What Does the Current Line Do?
This line starts the old service from the unmodified php:8.3.35-apache image. That image does not contain your temporary configuration.
- Replace the image line with this build block:
build:
context: ./config
dockerfile: Dockerfile.mitigated
What Does the Build Block Do?
The context value gives the build access to the files inside config.
The dockerfile value selects Dockerfile.mitigated as the image definition.
- Save compose.yaml.
- Build the temporary image by running this command:
docker compose up -d --build --force-recreate old
What Does This Command Do?
Compose builds the temporary image from config/Dockerfile.mitigated. It recreates only the old service.
The localhost port binding remains unchanged. The application mount remains read-only.
You should see the build finish. You should also see the old service start.
Did the Build Fail?
Confirm that mitigation.ini sits beside Dockerfile.mitigated. Check the indentation beneath build in compose.yaml.
For a focused diagnosis, help me debug the mitigated image build.
Before you run the checker, what status do you expect when the temporary controls pass but the PHP version remains 8.3.35?
- Test the temporary stopgap by running this command:
docker compose run --rm checker
What Does the Checker Prove?
- The old service still reports PHP 8.3.35.
- Its execution mode remains mod_php (apache2handler).
- Its result changes to NOT PATCHED BUT MITIGATED.
- Its reason warns that only an update fully fixes the patch gap.
You have now proved the limits of the stopgap. The controls reduce exposure while the vulnerable version remains measurable.
Replace the vulnerable image
An immutable container image packages the runtime used to create a container. Upgrading replaces the container from a new image instead of modifying PHP inside the running container.
- Return to compose.yaml.
- Find the temporary build block beneath the old service:
build:
context: ./config
dockerfile: Dockerfile.mitigated
What Does This Block Represent?
This block represents the temporary stopgap image. Its base remains php:8.3.35-apache.
- Replace the entire build block with this fixed image line:
image: php:8.3.36-apache
Why Does This Complete the Fix?
The php:8.3.36-apache image meets the first fixed upstream release for the PHP 8.3 branch. The recreated service receives the patched runtime from that image.
- Save compose.yaml.
- Recreate the old service from the fixed image by running this command:
docker compose up -d --force-recreate old
What Changes During Recreation?
Compose replaces the old container using php:8.3.36-apache. Its localhost binding remains 127.0.0.1:8081.
The application still comes from the read-only ./app mount. Only the underlying PHP image changes.
You should see the old service recreated. Its status should return to running.
Was the Old Service Not Recreated?
Confirm that the saved compose.yaml file uses php:8.3.36-apache for the old service. Run the recreation command again after saving the file.
For help tracing the image, help me verify the immutable image upgrade.
Prove the completed fix
The final checker run uses the same inspection endpoint as your earlier evidence. Any changed result now comes from the upgraded runtime.
Before you run the check, do you expect either service to remain below the PHP 8.3.36 threshold?
- Verify both services by running this command:
docker compose run --rm checker
What Should the Final Report Show?
- The old service reports PHP 8.3.36.
- The new service reports PHP 8.3.36.
- Both execution modes appear as mod_php (apache2handler).
- Both services report PATCHED.
- Each reason states that PHP 8.3.36 meets the fixed release for its branch.
That is the complete remediation proven. Both local services now run the fixed image.
Still Seeing the Old PHP Version?
Check that the old service uses php:8.3.36-apache in the saved compose.yaml file. Recreate the service before running the checker again.
For help tracing the stale result, help me debug the final PHP version check.
Screenshots can capture more than you expect. Keep personal paths or unrelated notifications outside the captured area.
- Resize the integrated terminal panel until both server reports fit inside the captured area.
- Capture only the final terminal report using your usual Windows screenshot workflow.
- Save the captured image as docs/after.png.
- Open docs/before.png from the Explorer sidebar.
- Open docs/after.png from the Explorer sidebar.
- Compare the two evidence images.
The before image should show the old service on 8.3.35 with NOT PATCHED. The after image should show both services on 8.3.36 with PATCHED.
✔️ Awesome, I've got everything!
Your final Compose configuration points both PHP services at the fixed image. Make sure compose.yaml is saved.
ⓧ I'd like to double check the full code
services:
old:
image: php:8.3.36-apache
ports:
- "127.0.0.1:8081:80"
volumes:
- ./app:/var/www/html:ro
new:
image: php:8.3.36-apache
ports:
- "127.0.0.1:8082:80"
volumes:
- ./app:/var/www/html:ro
checker:
image: python:3.15.1-alpine3.24
working_dir: /workspace
volumes:
- ./checker:/workspace:ro
command: ["python", "check.py"]
What Should Match?
Both PHP services use php:8.3.36-apache. Their ports remain bound to 127.0.0.1.
The checker service remains unchanged. Its read-only mount still points to ./checker:/workspace:ro.
Your evidence now separates a temporary stopgap from a complete image upgrade. Next, you will turn that evidence into a clear defensive project story.
Document the Security Evidence
You have replaced the old service with a patched PHP image. Both local services now report version 8.3.36 in Visual Studio Code.
A defensive security project becomes credible when every claim points to evidence. Your report will trace the remediation path while stating the checker's limits.
In this step, get ready to:
- Review the before-and-after evidence for personal information.
- Document the patch boundaries and remediation evidence in a README.
- Prepare a defensive post draft and preview the completed report.
Review the evidence files
The two screenshots form an evidence chain. The first records the original patch gap while the second records the result after the immutable image upgrade.
Screenshots can expose personal information around a terminal window. A careful review keeps the report focused on the security result.
- Expand the docs folder in the workspace file sidebar.
- Open docs/before.png from the file sidebar.
- Confirm that the old service shows PHP 8.3.35 with NOT PATCHED.
- Confirm that the new service shows PHP 8.3.36 with PATCHED.
- Open docs/after.png from the file sidebar.
- Confirm that both services show PHP 8.3.36 with PATCHED.
- Confirm that both screenshots exclude personal paths or unrelated windows.
That gives you a clean before-and-after record. The images show a measurable status change without using exploit code or attack payloads.
Build the README evidence trail
A Markdown report connects the checker output to the claims it supports. Its opening establishes the defensive scope before documenting either vulnerability.
- Create README.md beside compose.yaml by using the new-file control in the workspace file sidebar.
- Add the defensive scope and fixed-version boundaries by pasting this section:
# PHP Patch Check
PHP Patch Check is a defensive, local-only lab for verifying the upstream PHP fixes released on 21 October 2026 for CVE-2026-109618 and CVE-2026-109619. It contains no exploit code, attack payloads, or vulnerability-triggering requests.
## What happened
CVE-2026-109618 concerns out-of-memory handling. PHP can leave internal request data only partly initialized when an allocation failure interrupts processing. Request cleanup may later treat that invalid data as valid.
CVE-2026-109619 concerns stale global state. Request-owned state can survive after its backing memory has been freed. A later request handled by the same worker may use that stale state before normal initialization resets it.
The complete fix is to update. The upstream fixed releases are:
| Branch | First fixed release |
| --- | --- |
| PHP 8.5 | 8.5.12 |
| PHP 8.4 | 8.4.27 |
| PHP 8.3 | 8.3.36 |
| PHP 8.2 | 8.2.35 |
| PHP 8.1 or older | No maintained upstream release in this table; upgrade to a supported branch |
What does this section establish?
- The opening defines the lab as defensive and local-only.
- The separate CVE paragraphs preserve the distinction between the two security issues.
- The table records the first fixed upstream release for each supported branch.
- Save README.md.
- Open the Markdown preview by using the preview control in Visual Studio Code.
You should see the PHP Patch Check title followed by two separate CVE descriptions. A five-row fixed-release table should appear below them.
Does the table render as plain text?
Check that every table row starts with a vertical bar. Keep the separator row directly below the header row.
If the table still looks wrong, help me check the Markdown table formatting.
- Return to the README.md editor in Visual Studio Code.
- Append the checker explanation and evidence section by pasting this block:
## What the checker does
The checker requests a narrow JSON inspection page from each local service. It reads PHP version, PHP SAPI, session upload progress, memory limit, post size, upload size, and fatal error backtraces when available.
It prints `PATCHED`, `NOT PATCHED`, or `NOT PATCHED BUT MITIGATED`. This is an upstream-version checker, not proof that an arbitrary Linux distribution package is vulnerable: vendors can backport fixes without changing the visible upstream version.
## Run the patched lab
```powershell
docker compose up -d old new
docker compose run --rm checker
```
Open http://127.0.0.1:8081 and http://127.0.0.1:8082. Both ports are explicitly bound to `127.0.0.1`. Do not change them to `0.0.0.0`.
## Evidence
### Before

### After

How does this preserve the safe setup?
- The checker section defines the three possible status labels.
- The backport limitation prevents the upstream thresholds from being applied blindly to vendor packages.
- The Docker Compose commands reuse the existing local services.
- The host addresses preserve the localhost-only boundary.
- Save README.md.
- Open the updated Markdown preview.
You should see all three checker labels followed by the vendor-backport limitation. The Before and After screenshots should render beneath their matching headings.
Do the screenshots show as broken links?
Confirm that README.md sits beside the docs folder. Check that both image names remain lowercase.
If either image remains missing, help me diagnose the relative image paths.
- Return to the README.md editor in Visual Studio Code.
- Append the learning notes and supporting sources by pasting this final section:
## What I learned
- Two applications can look identical while running on different security patch levels.
- A web request is needed to observe the web SAPI; a shell command would report the CLI SAPI instead.
- Temporary configuration controls can reduce exposure without converting an old version into a patched version.
- Container upgrades replace a service from a new image, which makes the before-and-after change reproducible.
## Official sources
- [PHP security release, 21 October 2026](https://www.php.net/archive/2026.php)
- [GHSA-w8hw-w9fx-j79w: RCE via OOM corruption](https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w)
- [GHSA-6wmf-4rv5-p3rp: RCE via stale global state](https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp)
## Cleanup
```powershell
docker compose down
```
Why finish with lessons and sources?
- The learning bullets connect each technical choice to an observed result.
- The PHP release page supports the fixed-version boundaries.
- The two advisory links keep the separate CVE claims traceable.
- The cleanup section gives the reproduction process a defined end.
- Save README.md.
- Open the completed Markdown preview.
You should see four learning bullets followed by three source links. The cleanup command should appear in a formatted PowerShell block.
Does the final section spill into the page?
Check that the cleanup command has an opening code fence. Confirm that a closing code fence appears on its own line.
If the formatting still spills into the page, help me check the final Markdown code fence.
✔️ Awesome, I've got everything!
Your README now connects the security claims to the evidence. It also records the checker limitation and cleanup path.
ⓧ I'd like to double check the full code
# PHP Patch Check
PHP Patch Check is a defensive, local-only lab for verifying the upstream PHP fixes released on 21 October 2026 for CVE-2026-109618 and CVE-2026-109619. It contains no exploit code, attack payloads, or vulnerability-triggering requests.
## What happened
CVE-2026-109618 concerns out-of-memory handling. PHP can leave internal request data only partly initialized when an allocation failure interrupts processing. Request cleanup may later treat that invalid data as valid.
CVE-2026-109619 concerns stale global state. Request-owned state can survive after its backing memory has been freed. A later request handled by the same worker may use that stale state before normal initialization resets it.
The complete fix is to update. The upstream fixed releases are:
| Branch | First fixed release |
| --- | --- |
| PHP 8.5 | 8.5.12 |
| PHP 8.4 | 8.4.27 |
| PHP 8.3 | 8.3.36 |
| PHP 8.2 | 8.2.35 |
| PHP 8.1 or older | No maintained upstream release in this table; upgrade to a supported branch |
## What the checker does
The checker requests a narrow JSON inspection page from each local service. It reads PHP version, PHP SAPI, session upload progress, memory limit, post size, upload size, and fatal error backtraces when available.
It prints `PATCHED`, `NOT PATCHED`, or `NOT PATCHED BUT MITIGATED`. This is an upstream-version checker, not proof that an arbitrary Linux distribution package is vulnerable: vendors can backport fixes without changing the visible upstream version.
## Run the patched lab
```powershell
docker compose up -d old new
docker compose run --rm checker
```
Open http://127.0.0.1:8081 and http://127.0.0.1:8082. Both ports are explicitly bound to `127.0.0.1`. Do not change them to `0.0.0.0`.
## Evidence
### Before

### After

## What I learned
- Two applications can look identical while running on different security patch levels.
- A web request is needed to observe the web SAPI; a shell command would report the CLI SAPI instead.
- Temporary configuration controls can reduce exposure without converting an old version into a patched version.
- Container upgrades replace a service from a new image, which makes the before-and-after change reproducible.
## Official sources
- [PHP security release, 21 October 2026](https://www.php.net/archive/2026.php)
- [GHSA-w8hw-w9fx-j79w: RCE via OOM corruption](https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w)
- [GHSA-6wmf-4rv5-p3rp: RCE via stale global state](https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp)
## Cleanup
```powershell
docker compose down
```
How to use this reference
Compare the headings and paragraphs in order. Check the image paths and source URLs character by character.
Draft the responsible post
A public security post should describe the defensive method and its evidence. It should avoid any claim that the lab exploited either vulnerability.
- Create LINKEDIN.md beside README.md by using the new-file control in the workspace file sidebar.
- Add the defensive post draft by pasting this content:
I built PHP Patch Check, a defensive local lab for the critical PHP security fixes released on 21 October 2026.
Using Docker Compose, I compared PHP 8.3.35 with PHP 8.3.36 under Apache mod_php. I wrote a small Python checker that reads the upstream version, web SAPI, and relevant PHP settings, then reports PATCHED, NOT PATCHED, or NOT PATCHED BUT MITIGATED.
The most useful lesson was that a mitigation is not the same as a fix. I demonstrated the temporary controls, upgraded the container image, and captured evidence that the report changed to PATCHED.
The lab stays on localhost and contains no exploit code or attack payloads.
#PHP #Docker #CyberSecurity #Python #DevSecOps
What makes this post responsible?
- The opening identifies the work as a defensive local lab.
- The technical paragraph names the comparison and checker output.
- The lesson distinguishes temporary controls from a complete update.
- The closing sentence rules out exploit code and attack payloads.
- Save LINKEDIN.md.
- Open its Markdown preview by using the preview control in Visual Studio Code.
You should see four short paragraphs followed by five hashtags. The post should make no claim that you exploited either vulnerability.
- Return to the LINKEDIN.md editor.
- Read the sentence beginning with The most useful lesson.
- Confirm that the first-person lesson accurately reflects what you observed.
Your post now presents the lab as a defensive verification exercise. Its wording keeps the claims aligned with the evidence you collected.
Does the post appear as one paragraph?
Check that one blank line separates each paragraph. Keep the hashtag line separate from the defensive scope sentence.
If the spacing still looks wrong, help me format the post draft.
✔️ Awesome, I've got everything!
Your post explains the defensive comparison without overstating what the lab proves.
ⓧ I'd like to double check the full code
I built PHP Patch Check, a defensive local lab for the critical PHP security fixes released on 21 October 2026.
Using Docker Compose, I compared PHP 8.3.35 with PHP 8.3.36 under Apache mod_php. I wrote a small Python checker that reads the upstream version, web SAPI, and relevant PHP settings, then reports PATCHED, NOT PATCHED, or NOT PATCHED BUT MITIGATED.
The most useful lesson was that a mitigation is not the same as a fix. I demonstrated the temporary controls, upgraded the container image, and captured evidence that the report changed to PATCHED.
The lab stays on localhost and contains no exploit code or attack payloads.
#PHP #Docker #CyberSecurity #Python #DevSecOps
How to use this reference
Compare the paragraph order with your file. Confirm that the five hashtags remain on the final line.
Before you open the final preview, consider which sections connect the original patch gap to the completed remediation.
- Select README.md in the workspace file sidebar.
- Open the Markdown preview by using the preview control in Visual Studio Code.
You should see separate descriptions for both CVEs. The fixed-release table should cover PHP branches 8.2 through 8.5.
You should see all three checker labels. The vendor-backport limitation should appear directly below their introduction.
Both evidence images should render beneath their matching headings. The Before image should show the original contrast while the After image should show both services patched.
You should see four learning bullets followed by three official source links. The run and cleanup commands should each appear in a formatted PowerShell block.
Strong finish. Your report now connects the original patch gap to the image upgrade that resolved it.
Secret mission
Test Every Version Boundary
Your checker makes a security decision at an exact release boundary. Add automated tests that prove every final vulnerable release stays unpatched, every first fixed release becomes patched, and the unsupported branch has no fixed threshold.
Clean Up Your Resources
Clean Up Your Resources
This project uses no metered cloud resources. Docker Desktop is free for qualifying personal use under Docker's license terms.
Decide whether to keep the local lab running, stop it until later, or remove it from your PC.
Resources you used:
- Two localhost-only Apache mod_php service containers running PHP.
- The local Docker Compose network connecting the services to the temporary checker.
- The local php-patch-check workspace containing the application, checker, boundary tests, documentation, and screenshots.
Keep everything running
No action is needed. Choose this if you plan to rerun the checker or continue improving the documentation.
- Keep the old and new services running on their localhost-only ports.
- Rerun the defensive report from the integrated terminal by running:
docker compose run --rm checker
What Does This Command Do?
The command starts a temporary checker container against both local services. The --rm option removes that temporary container after the report finishes.
- Confirm the report lists both services as PATCHED.
Checker Cannot Reach the Services?
Confirm Docker Desktop is running. Confirm both PHP services are still active before retrying.
Help me troubleshoot why the checker cannot reach my local PHP services.
Pause - I'll come back to this later
Stop the running services to free their memory. Your containers, network, project files, and evidence stay available for your return.
- Stop the local services from the php-patch-check terminal by running this command:
docker compose stop
What Happens When You Stop?
Compose stops the service containers without removing them. This preserves the lab so it can start again from the same configuration.
- Refresh http://127.0.0.1:8081 to confirm the first page no longer loads.
- Refresh http://127.0.0.1:8082 to confirm the second page no longer loads.
Services Still Responding?
Confirm you ran the stop command from the terminal associated with the php-patch-check workspace.
Help me check why my Compose services are still running.
- Resume the preserved services later by running this command:
docker compose start
What Happens When You Resume?
Compose starts the existing service containers again. It does not rebuild the images or replace your project files.
- Refresh http://127.0.0.1:8081 to confirm the first Hello World page loads again.
- Refresh http://127.0.0.1:8082 to confirm the second Hello World page loads again.
Pages Still Unavailable?
Confirm Docker Desktop is running before starting the preserved containers. Retry from the php-patch-check terminal.
Help me troubleshoot why my stopped Compose services do not start.
Delete - I don't want to use this again
Deleting is permanent. The cleanup targets this Compose project and its local php-patch-check workspace.
- Copy any code, screenshots, or documentation you want to keep.
- Remove the Compose containers and network from the integrated terminal by running:
docker compose down
What Does Compose Remove?
Compose stops both PHP services. It removes their containers and the network created for this project.
- Refresh both local pages to confirm neither service responds.
Lab Resources Still Present?
Confirm the terminal is using the compose.yaml inside php-patch-check. Confirm Docker Desktop is running before retrying.
Help me remove the remaining Compose resources.
- Switch the integrated terminal in Visual Studio Code to Command Prompt.
- Remove the local workspace by running these commands:
cd ..
rmdir /s /q php-patch-check
What Do These Commands Remove?
The first command moves the terminal to the workspace's parent folder. The second command permanently removes the php-patch-check folder and everything inside it.
- Confirm the php-patch-check folder no longer appears in its parent folder.
Folder Still Present?
- Close any editor tabs or image previews that still use files from php-patch-check.
- Run the removal commands again from the folder directly above php-patch-check.
- Ask for help with a Windows file lock.
Nice Work!
Nice Work!
You did it! You built a defensive PHP patch-verification lab that turns an upstream security boundary into clear local evidence.
You also proved that temporary controls can reduce exposure while an immutable image upgrade delivers the complete fix.
What you learned:
- Built a localhost-only comparison lab with Docker Compose. You demonstrated how identical pages can run on different PHP patch levels.
- Created a narrow JSON inspection endpoint plus a Python checker. The report distinguishes NOT PATCHED, NOT PATCHED BUT MITIGATED, and PATCHED using version thresholds plus runtime settings.
- Applied temporary controls before replacing the vulnerable container image. Your before-and-after screenshots document why mitigation remains a stopgap until the software is updated.
- Secret Mission: Added isolated automated tests for every supported PHP branch boundary. You also verified that PHP 8.1 has no fixed threshold in the checker table.
Ready to quiz yourself?