PHP Patch Check

Build a local tool to verify PHP security patches in Docker containers.

Introduction

30 Second Summary

A website can look perfectly healthy while the software behind it is missing a critical security fix. Without a repeatable check, that invisible gap is easy to overlook.

In this project, you will build a PHP lab bound to localhost where an old service looks identical to its patched replacement. A Python checker turns that hidden difference into before-and-after evidence through a safe inspection workflow.

What You'll Build

Picture two identical Hello World pages beside a terminal report that tracks the old service from NOT PATCHED through NOT PATCHED, PARTIAL MITIGATIONS PRESENT to PATCHED.

By the end of this project, you'll have:

  • A localhost-only Hello World comparison that you can run through Docker. The two Apache with PHP services look identical until your checker reveals the patch gap.
  • A branch-aware terminal report with the evidence behind each plain-English patch finding.
  • A reproducible security record showing the old service progressing through three patch states. Your README.md explains the safety boundaries. It links to the official sources.
  • Secret Mission: Add a machine-readable JSON view of the same defensive report.

Are there any prerequisites?

You need a supported macOS release with at least 4 GB of RAM. You also need internet access for the initial downloads plus permission to install applications.

Before We Start

This first checkpoint locks in what you are proving before any hands-on work begins. You are committing to an inspection-only workflow that uses version evidence plus configuration evidence without sending exploit traffic.

Install and Verify the Local Tools

This inspection-only lab cannot run until its local foundation is ready. A missing installation would stop the workflow before it can inspect PHP.

In this step, you will install Docker Desktop, Python 3.15.1, and Visual Studio Code. You will also verify Docker Compose so every later check starts from a known-good setup.

In this step, get ready to:
  • Prepare Docker Desktop for local container work.
  • Install the required Python release.
  • Complete the toolchain check in Terminal and Visual Studio Code.
Install Docker Desktop

Docker Desktop provides the local engine that runs isolated services. Its version check also reveals whether Terminal can communicate with that engine.

  • Press Cmd+Space to open your Mac's search bar.
  • Type Terminal into the search bar.
  • Press Enter to open Terminal.
  • Check the current Docker state by running this command:
docker version

What does this check prove?

  • The Client section confirms that the Docker command-line client is installed.
  • The Server section confirms that Docker Desktop's local engine is running.

✔️ I see Client and Server sections

Good start. Docker Desktop is installed and its local engine is ready to accept commands.

ⓧ I only see Client details

The command-line client is installed. Docker Desktop still needs to finish starting its local engine.

  • Press Cmd+Space to open your Mac's search bar.
  • Type Docker into the search bar.
  • Press Enter to open Docker Desktop.
  • Accept the applicable terms if a terms screen appears.
  • Wait for Docker Desktop to finish starting.
  • Return to the Terminal window from earlier.
  • Repeat the Docker check shown above.

You should now see both the Client and Server sections.

ⓧ Command not found

Docker Desktop is not installed yet. The official installer provides both the desktop application and the command-line tools.

  • Visit the official Docker Desktop installation guide.
  • Use the page's chip guidance to choose the installer for Apple silicon or Intel.
  • Download Docker.dmg.
  • Open the downloaded Docker.dmg file.
  • Drag Docker into Applications.

Where is Docker installed?

Dragging Docker into Applications places the app at /Applications/Docker.app. This gives macOS a stable location for future launches.

  • Press Cmd+Space to open your Mac's search bar.
  • Type Docker into the search bar.
  • Press Enter to open Docker Desktop.
  • Accept the applicable terms if a terms screen appears.
  • Wait for Docker Desktop to finish starting.
  • Return to the Terminal window from earlier.
  • Repeat the Docker check shown above.

You should now see both the Client and Server sections.

Still missing the Server section?

A Client-only result means Terminal can find Docker's command-line tool. The local engine is still unavailable.

  • Confirm that Docker Desktop has finished starting before repeating the check.

Need another pair of eyes? Help me troubleshoot why Docker Desktop is not showing its Server section.

Install Python 3.15.1

The defensive checker runs with Python. Pinning the lab to release 3.15.1 gives every learner the same runtime for the later inspection script.

  • Switch back to the Terminal window from earlier.
  • Check the installed Python release by running this command:
python3 --version

What does this check prove?

This command prints the Python release that your Mac uses for python3. The project requires the output Python 3.15.1.

✔️ I see the required Python release

Python is ready. Your Mac is using release 3.15.1 for the checker.

ⓧ I see an older Python release

Your Mac currently resolves python3 to an older installation. Install release 3.15.1 with Python.org's macOS installer.

  • Visit the official Python macOS downloads page.
  • Select the macOS installer for Python 3.15.1.
  • Open the downloaded installer package.
  • Follow the installer prompts to complete the installation.

A fresh Terminal session reloads the command paths created by the installer.

  • Quit the current Terminal session.
  • Press Cmd+Space to open your Mac's search bar.
  • Type Terminal into the search bar.
  • Press Enter to open a fresh Terminal session.
  • Repeat the Python version check shown above.

You should now see Python 3.15.1.

ⓧ Command not found

Python is not available through python3 yet. The official macOS installer adds the runtime needed by the checker.

  • Visit the official Python macOS downloads page.
  • Select the macOS installer for Python 3.15.1.
  • Open the downloaded installer package.
  • Follow the installer prompts to complete the installation.

A fresh Terminal session loads the command paths created by the installer.

  • Quit the current Terminal session.
  • Press Cmd+Space to open your Mac's search bar.
  • Type Terminal into the search bar.
  • Press Enter to open a fresh Terminal session.
  • Repeat the Python version check shown above.

You should now see Python 3.15.1.

Still seeing an older Python release?

A fresh Terminal can still resolve an earlier Python installation when its command path takes priority.

Need help with your command path? Help me make python3 use Python 3.15.1 on my Mac.

Install Visual Studio Code and verify every tool

Visual Studio Code gives you one place to create the lab files and preview its documentation. Opening the editor now confirms the final desktop tool is available before any files are written.

  • Press Cmd+Space to open your Mac's search bar.
  • Type Visual Studio Code into the search bar.
  • Press Enter to open the editor if it is installed.

✔️ Visual Studio Code opens

Your editor is ready. You now have the workspace that will hold the lab files in the next step.

ⓧ The app is not found

Visual Studio Code still needs to be installed. Its macOS disk image contains the application you will copy into Applications.

  • Visit the official Visual Studio Code macOS setup guide.
  • Download the macOS .dmg file.
  • Open the downloaded .dmg file.
  • Drag Visual Studio Code.app into Applications.
  • Press Cmd+Space to open your Mac's search bar.
  • Type Visual Studio Code into the search bar.
  • Press Enter to open the editor.

You should now see the Visual Studio Code editor window.

Visual Studio Code did not open?

Confirm that Visual Studio Code.app is inside Applications before searching again.

Still stuck? Help me troubleshoot my Visual Studio Code installation on macOS.

The final checkpoint tests the complete toolchain from one Terminal session. Each successful response proves that the next step can create the local lab without an environment detour.

Before you run these checks, do you expect all three commands to return a working result?

  • Switch back to the Terminal window from earlier.
  • Verify Docker, Docker Compose, and Python by running these commands:
docker version
docker compose version
python3 --version

What proves the setup is ready?

  • Docker prints a Client section.
  • Docker prints a Server section.
  • Docker Compose prints its version.
  • Python prints Python 3.15.1.
  • Switch back to the Visual Studio Code window you opened above.

You should see a responsive editor window. No project files or containers exist yet.

That is the local foundation complete. Docker can run services, Python can run the checker, and Visual Studio Code is ready for the project files.

One of the final checks failed?

Return to the matching outcome path above. Complete its installation or startup instructions before repeating the final checkpoint.

Need help isolating the failed tool? Help me troubleshoot my final PHP Patch Check tool verification.

Your local toolchain is ready. Next, you will create the project files and launch the two localhost-only PHP services.

Launch the Local Comparison Lab

Your local tools are ready, but the lab still needs a repeatable structure. Docker Compose will define two isolated Apache services that run PHP on your Mac.

Both services will serve the same harmless page. Their identical appearance exposes the core problem: a browser cannot prove whether the runtime contains a security fix.

In this step, get ready to:
  • Build the local file structure.
  • Define the old and new services.
  • Launch the comparison lab.
Create the project files

A Visual Studio Code workspace keeps every lab file under one named folder. You will place that folder on your Desktop so it stays easy to find.

A Workspace Trust prompt may appear

Visual Studio Code may display a Workspace Trust dialog after you open the new folder. You are creating these files yourself, so you can trust this workspace.

  • Switch back to Visual Studio Code from earlier.
  • Select File from the macOS menu bar.
  • Select Open Folder....

The macOS folder picker opens. This is where you choose the location for the workspace.

  • Select Desktop as the location.
  • Select New Folder.

A name field appears inside the folder picker.

  • Enter php-patch-check as the folder name.
  • Select Open.

Visual Studio Code may now ask whether you trust the folder's authors.

  • Select Yes, I trust the authors if the Workspace Trust dialog appears.

Your php-patch-check workspace now appears in the Explorer sidebar. That gives every lab file one clear home.

The application page belongs in app. The two PHP configuration files belong in config.

  • Right-click php-patch-check in the Explorer sidebar.
  • Select New Folder.

Visual Studio Code displays a field for the first folder name.

  • Enter app as the folder name.

You should see app under php-patch-check in the Explorer.

  • Right-click php-patch-check in the Explorer sidebar.
  • Select New Folder.

Visual Studio Code displays a field for the second folder name.

  • Enter config as the folder name.

The Explorer should now show separate app and config folders.

The page itself stays deliberately simple. Its only job is to prove that each web service can answer a local request.

  • Right-click the app folder in the Explorer sidebar.
  • Select New File....

Visual Studio Code displays a field for the file name.

  • Enter index.php as the file name.
  • Add the Hello World response to app/index.php by copying this code:
<?php
echo "Hello World from PHP Patch Check\n";

What does this page do?

  • The opening PHP tag tells the server to interpret the file as PHP code.
  • The echo statement sends the harmless comparison message to the browser.
  • The final newline keeps the response tidy when another tool reads it.
  • Save app/index.php.
  • Confirm index.php appears inside app in the Explorer.

Don't see index.php?

  • Expand the app folder in the Explorer if it is collapsed.
  • Check that the editor tab shows index.php before saving.

Still missing the file? Help me create app/index.php in the correct Visual Studio Code folder.

Both services begin with matching configuration values. This shared baseline lets the next checker isolate the effect of the PHP release.

  • Right-click the config folder in the Explorer sidebar.
  • Select New File....

Visual Studio Code displays a field for the first configuration file.

  • Enter old.ini as the file name.
  • Add the initial old-service settings to config/old.ini by copying this configuration:
session.upload_progress.enabled=1
memory_limit=128M
post_max_size=8M

What do these settings establish?

  • The session.upload_progress.enabled directive starts enabled.
  • The memory_limit directive sets a finite 128M limit.
  • The post_max_size directive limits POST data to 8M.
  • Save config/old.ini.
  • Confirm old.ini appears inside config in the Explorer.

Is old.ini in the wrong folder?

  • Drag old.ini into the config folder if it appears at the workspace level.
  • Check that the file name ends with .ini.

Need a hand? Help me place config/old.ini correctly.

  • Right-click the config folder in the Explorer sidebar.
  • Select New File....

Visual Studio Code displays a field for the second configuration file.

  • Enter new.ini as the file name.
  • Add the matching new-service settings to config/new.ini by copying this configuration:
session.upload_progress.enabled=1
memory_limit=128M
post_max_size=8M

Why do the settings match?

The two services need the same initial INI values so configuration does not explain any later difference. Their PHP releases become the key comparison point.

  • Save config/new.ini.
  • Confirm new.ini appears beside old.ini in the config folder.

Do the INI files differ?

  • Compare the three lines in old.ini with the three lines in new.ini.
  • Remove any extra spaces around the equals signs.

Still seeing a mismatch? Help me compare the initial old.ini and new.ini files.

Define the comparison services

The Compose file describes which image each service runs. It also controls which host addresses can reach each container.

Both published ports begin with 127.0.0.1. This keeps the pages reachable from your Mac without publishing them to every network interface.

  • Right-click php-patch-check in the Explorer sidebar.
  • Select New File....

Visual Studio Code displays a field for the Compose file name.

  • Enter compose.yaml as the file name.
  • Define the two services in compose.yaml by copying this configuration:
services:
  old:
    image: php:8.3.35-apache
    ports:
      - "127.0.0.1:8081:80"
    volumes:
      - ./app:/var/www/html:ro
      - ./config/old.ini:/usr/local/etc/php/conf.d/zz-patch-check.ini:ro

  new:
    image: php:8.3.36-apache
    ports:
      - "127.0.0.1:8082:80"
    volumes:
      - ./app:/var/www/html:ro
      - ./config/new.ini:/usr/local/etc/php/conf.d/zz-patch-check.ini:ro

What does this configuration do?

  • The old service runs php:8.3.35-apache at 127.0.0.1:8081.
  • The new service runs php:8.3.36-apache at 127.0.0.1:8082.
  • The application mounts at Apache's web document location.
  • The :ro suffix makes each mounted path read-only inside its container.
  • Save compose.yaml.
  • Confirm compose.yaml appears beside the app and config folders.

Seeing YAML highlighting errors?

  • Use spaces for indentation throughout compose.yaml.
  • Keep both quoted port mappings indented beneath ports.
  • Keep each volume mapping indented beneath its service's volumes key.

Need help with the structure? Help me find the YAML indentation problem in compose.yaml.

Your four project files should now match the lab's starting state. Use the second tab if you want to compare every file before starting the containers.

✔️ Awesome, I've got everything!

Great. Double-check that every file is saved before you launch the lab.

ⓧ I'd like to double check the full code

<?php
echo "Hello World from PHP Patch Check\n";

Role of app/index.php

This complete file provides the identical browser response used by both services.

session.upload_progress.enabled=1
memory_limit=128M
post_max_size=8M

Role of config/old.ini

This complete file provides the old service's initial PHP configuration.

session.upload_progress.enabled=1
memory_limit=128M
post_max_size=8M

Role of config/new.ini

This complete file provides the matching configuration for the new service.

services:
  old:
    image: php:8.3.35-apache
    ports:
      - "127.0.0.1:8081:80"
    volumes:
      - ./app:/var/www/html:ro
      - ./config/old.ini:/usr/local/etc/php/conf.d/zz-patch-check.ini:ro

  new:
    image: php:8.3.36-apache
    ports:
      - "127.0.0.1:8082:80"
    volumes:
      - ./app:/var/www/html:ro
      - ./config/new.ini:/usr/local/etc/php/conf.d/zz-patch-check.ini:ro

Role of compose.yaml

This complete file defines both localhost-only services with read-only application and configuration mounts.

Start and compare the lab

The files now describe a runnable comparison. Starting the services turns that configuration into two live local pages.

  • Right-click php-patch-check in the Explorer sidebar.
  • Select Open in Integrated Terminal.

The terminal panel opens inside php-patch-check. Commands run here can use compose.yaml directly.

  • Start both services in the background by running this command:
docker compose up --detach

What does this command do?

Docker Compose creates or recreates the old and new services from compose.yaml. The --detach option leaves both services running in the background.

Allow extra time on this first start while Docker downloads the two PHP images. The command returns control to the terminal when the services are ready in the background.

  • Wait for the terminal prompt to return.

Docker has now applied the Compose configuration without reporting a startup failure.

Did the services fail to start?

  • Confirm Docker Desktop is still running.
  • Check that compose.yaml is saved in the php-patch-check folder.
  • Check that ports 8081 and 8082 are available on your Mac.

Still blocked? Help me troubleshoot why docker compose up --detach did not start both services.

Before you load the pages, do you think their visible text will reveal which PHP release sits underneath?

  • Open your preferred web browser from the macOS Dock.
  • Enter http://127.0.0.1:8081 in the address bar.

You should see Hello World from PHP Patch Check from the old service.

  • Open a new browser tab.
  • Enter http://127.0.0.1:8082 in the address bar.

You should see the same Hello World from PHP Patch Check message from the new service.

That's your first live result: both isolated services are answering on localhost. The pages are intentionally indistinguishable.

Why is the identical result important?

A healthy-looking page only proves that the web service can answer a request. It does not prove that the PHP runtime includes a security patch.

You have now seen that shortfall directly. The next step adds terminal evidence that can distinguish the two releases.

  • Switch back to the compose.yaml editor tab.
  • Confirm the old port mapping is 127.0.0.1:8081:80.
  • Confirm the new port mapping is 127.0.0.1:8082:80.

Both mappings start with 127.0.0.1. This confirms the lab pages are published only on your Mac.

Can't load one of the pages?

  • Check that the browser address uses http.
  • Check that the old page uses port 8081.
  • Check that the new page uses port 8082.

Need help finding the failed service? Help me troubleshoot a localhost PHP page that does not load.

Your localhost-only comparison lab is live. Next, you will build the checker that exposes the hidden patch difference.

Build the Patch Checker

Your Docker Compose lab now serves the same page from two PHP services. The matching pages hide the security difference between their installed releases.

You will build a Python checker that inspects each container from Terminal. It also asks Apache which PHP module serves web requests.

In this step, get ready to:
  • Inspect the PHP version and selected INI settings inside each service.
  • Compare each PHP branch with its fixed release.
  • Print the patch report and capture the before screenshot.
Collect PHP and Apache details

A browser page can expose sensitive configuration details. This checker keeps those details out of the web page by inspecting the running services through Compose.

  • Create checker.py inside the PHP Patch Check project folder by using the Visual Studio Code file tree.
  • Add the service names and inspection probe by pasting this code into checker.py:
import subprocess

SERVICE_NAMES = ("old", "new")
FIXED_RELEASES = {
    (8, 2): (8, 2, 35),
    (8, 3): (8, 3, 36),
    (8, 4): (8, 4, 27),
    (8, 5): (8, 5, 12),
}

PHP_PROBE = r'''
echo "version=" . PHP_VERSION . PHP_EOL;
$names = [
    "session.upload_progress.enabled",
    "memory_limit",
    "post_max_size",
    "fatal_error_backtraces",
];
foreach ($names as $name) {
    $value = ini_get($name);
    if ($value === false) {
        $value = "NOT_AVAILABLE";
    }
    echo $name . "=" . $value . PHP_EOL;
}
'''

What does this setup define?

  • The SERVICE_NAMES tuple identifies the two Compose services that need inspection.
  • The FIXED_RELEASES map stores the fixed release for each supported PHP branch.
  • The PHP_PROBE string reads the version and selected INI values inside each container.
  • Save checker.py.
  • Confirm that the editor shows both service names and four fixed-release entries.

Does the PHP probe look incomplete?

Check that PHP_PROBE starts and ends with three single quotes. The PHP lines must remain between those markers.

Still stuck? Help me check the constants and PHP probe in checker.py.

  • Add the container inspection functions directly below the closing quotes of PHP_PROBE by pasting this code:
def run_in_service(service, *command):
    completed = subprocess.run(
        ["docker", "compose", "exec", "-T", service, *command],
        capture_output=True,
        text=True,
        check=True,
    )
    return completed.stdout, completed.stderr


def read_php_details(service):
    stdout, _ = run_in_service(service, "php", "-r", PHP_PROBE)
    details = {}
    for line in stdout.splitlines():
        if "=" in line:
            key, value = line.split("=", 1)
            details[key] = value
    return details


def detect_runtime(service):
    stdout, stderr = run_in_service(service, "apache2ctl", "-M")
    modules = stdout + stderr
    if "php_module" in modules:
        return "mod_php"
    return "unknown"

How does inspection work?

  • The run_in_service() function runs a command inside a named service. The -T flag disables pseudo-terminal allocation.
  • The read_php_details() function converts each probe line into a dictionary entry.
  • The detect_runtime() function checks Apache's loaded modules. It reports mod_php when it finds php_module.
  • Save checker.py.
  • Confirm that detect_runtime() ends by returning unknown when the PHP module is absent.

Do the inspection functions look misaligned?

Keep each function at the left edge of the file. Keep every function body indented by four spaces.

Need another pair of eyes? Help me check the indentation of my service inspection functions.

Assess branch-aware patch status

Patch checks must compare a release with the fixed version from the same branch. The checker also needs to interpret INI values before it can recognize the temporary stopgap set.

  • Add the version and INI parsing helpers below detect_runtime() by pasting this code:
def parse_version(version_text):
    core_version = version_text.split("-", 1)[0]
    return tuple(int(part) for part in core_version.split(".")[:3])


def format_version(version_tuple):
    return ".".join(str(part) for part in version_tuple)


def is_disabled(value):
    return value.strip().lower() in {"", "0", "off", "false", "no"}


def parse_size(value):
    normalized = value.strip().upper()
    if normalized == "-1":
        return -1
    multipliers = {
        "K": 1024,
        "M": 1024 * 1024,
        "G": 1024 * 1024 * 1024,
    }
    if normalized[-1] in multipliers:
        return int(normalized[:-1]) * multipliers[normalized[-1]]
    return int(normalized)

Why normalize these values?

  • The version helpers convert release text into comparable number tuples. They also turn tuples back into readable release text.
  • The is_disabled() helper recognizes the different values PHP may use for a disabled setting.
  • The parse_size() helper converts shorthand sizes into bytes. It preserves -1 as the unlimited-memory value.
  • Save checker.py.
  • Confirm that parse_size() handles kilobytes, megabytes, and gigabytes.

Seeing uneven helper indentation?

Check that every helper begins with def at the left edge. Keep the multiplier entries inside the dictionary.

Still unsure? Help me check my version and INI parsing helpers.

  • Add the stopgap assessment helper below parse_size() by pasting this code:
def has_partial_mitigations(details, branch):
    session_disabled = is_disabled(
        details["session.upload_progress.enabled"]
    )
    memory_unlimited = details["memory_limit"].strip() == "-1"
    post_size = parse_size(details["post_max_size"])
    post_size_conservative = 0 < post_size <= 8 * 1024 * 1024

    fatal_backtraces_disabled = True
    if branch == (8, 5):
        fatal_backtraces_disabled = is_disabled(
            details["fatal_error_backtraces"]
        )

    return all(
        (
            session_disabled,
            memory_unlimited,
            post_size_conservative,
            fatal_backtraces_disabled,
        )
    )

What counts as the full stopgap set?

  • The helper checks whether session upload progress is disabled.
  • It checks whether the PHP memory limit is unlimited.
  • It checks whether the POST limit is no more than 8M.
  • For PHP 8.5 only, it also checks whether fatal error backtraces are disabled.
  • Save checker.py.
  • Confirm that has_partial_mitigations() returns all(...) across the four checks.

Is the mitigation tuple malformed?

Check that all four condition names sit inside the tuple passed to all(). Keep the closing parentheses aligned with their matching opening lines.

Need help tracing the brackets? Help me check the has_partial_mitigations function.

  • Start the branch assessment function below has_partial_mitigations() by pasting this code:
def assess(details):
    version_text = details["version"]
    version_tuple = parse_version(version_text)
    branch = version_tuple[:2]

    if branch <= (8, 1):
        return (
            "NOT PATCHED",
            f"PHP {version_text} is on a branch that no longer receives "
            "updates from the PHP project.",
        )

    fixed_release = FIXED_RELEASES.get(branch)
    if fixed_release is None:
        return (
            "NOT PATCHED",
            f"PHP {version_text} is outside this advisory's verified "
            "8.2 through 8.5 release matrix.",
        )

    if version_tuple >= fixed_release:
        return (
            "PATCHED",
            f"PHP {version_text} meets the fixed release "
            f"{format_version(fixed_release)} for its branch.",
        )

How are branches classified?

  • PHP 8.1 or older receives NOT PATCHED because those branches no longer receive PHP project updates.
  • A branch outside the verified 8.2 through 8.5 matrix also receives NOT PATCHED.
  • A release at or above its branch's fixed release receives PATCHED.
  • Save checker.py.
  • Confirm that the first three assessment paths each return a status and a reason.

Does the assessment end too early?

The remaining assessment paths belong inside the same assess() function. Keep the next code indented by four spaces.

Need help locating the insertion point? Help me continue the assess function after the PATCHED return block.

  • Complete assess() directly below its patched return block by pasting this indented code:
    if has_partial_mitigations(details, branch):
        return (
            "NOT PATCHED, PARTIAL MITIGATIONS PRESENT",
            f"PHP {version_text} is below fixed release "
            f"{format_version(fixed_release)}; the lab stopgaps are present, "
            "but PHP says they do not make the installation safe.",
        )

    return (
        "NOT PATCHED",
        f"PHP {version_text} is below fixed release "
        f"{format_version(fixed_release)} and the full lab stopgap set "
        "is not present.",
    )

Why are there two unpatched outcomes?

The longer status recognizes that every configured stopgap is present. Its reason still states that the installed PHP release remains below the fixed release.

The final outcome identifies an unpatched release without the full stopgap set. Updating remains the complete fix in both cases.

  • Save checker.py.
  • Confirm that assess() now contains PATCHED and both NOT PATCHED outcomes.

Is the final assessment code outside the function?

Every line in this final fragment starts with at least four spaces. Align its first if with the earlier branch checks inside assess().

Still seeing a structural problem? Help me check the completed assess function.

Print and capture the report

The assessment logic now needs a report shape that combines evidence with its verdict. A consistent dictionary also makes the checker reusable later.

  • Add the per-service report builder below assess() by pasting this code:
def inspect_service(service):
    details = read_php_details(service)
    status, reason = assess(details)
    return {
        "service": service,
        "php_version": details["version"],
        "web_runtime": detect_runtime(service),
        "session.upload_progress.enabled": details[
            "session.upload_progress.enabled"
        ],
        "memory_limit": details["memory_limit"],
        "post_max_size": details["post_max_size"],
        "fatal_error_backtraces": details["fatal_error_backtraces"],
        "status": status,
        "reason": reason,
    }

What goes into each report?

The inspect_service() function collects the version and selected INI values. It adds the detected web runtime.

The same report also carries the status and plain-English reason. That keeps the evidence beside its conclusion.

  • Save checker.py.
  • Confirm that the returned dictionary ends with status and reason entries.

Does the report dictionary look unbalanced?

Check the multi-line session setting entry carefully. Its opening square bracket closes after the setting name.

Need help matching the brackets? Help me check the inspect_service report dictionary.

  • Add report collection and terminal formatting below inspect_service() by pasting this code:
def collect_reports():
    return [inspect_service(service) for service in SERVICE_NAMES]


def print_reports(reports):
    for report in reports:
        print(f"\n=== {report['service'].upper()} ===")
        print(f"PHP version: {report['php_version']}")
        print(f"Web runtime: {report['web_runtime']}")
        print(
            "session.upload_progress.enabled: "
            f"{report['session.upload_progress.enabled']}"
        )
        print(f"memory_limit: {report['memory_limit']}")
        print(f"post_max_size: {report['post_max_size']}")
        print(
            "fatal_error_backtraces: "
            f"{report['fatal_error_backtraces']}"
        )
        print(f"Status: {report['status']}")
        print(f"Reason: {report['reason']}")

How is the terminal report organized?

The collect_reports() function inspects every name in SERVICE_NAMES. The result is a list with one report per service.

The print_reports() function prints each evidence field on its own line. The status and reason close each service section.

  • Save checker.py.
  • Confirm that the print function includes lines for the PHP version and web runtime.

Are the report keys inconsistent?

Compare every key inside square brackets with the dictionary returned by inspect_service(). Python treats spelling differences as different keys.

Need help comparing them? Help me match the print_reports keys with inspect_service.

  • Finish checker.py with the execution and error-handling block by pasting this code:
if __name__ == "__main__":
    try:
        print_reports(collect_reports())
    except subprocess.CalledProcessError as error:
        print("The checker could not inspect the running lab.")
        print("Confirm Docker Desktop is running and both services are up.")
        if error.stderr:
            print(error.stderr.strip())

What happens when the file runs?

The main block collects the reports before printing them. It catches a failed container command so the checker can provide a useful next step.

  • Save checker.py.
  • Confirm that the final line prints captured error details only when they are available.

Is the main block nested incorrectly?

Keep the opening if at the left edge. Indent the try and error-handling bodies by four spaces per level.

Still seeing indentation trouble? Help me check the final main block in checker.py.

✔️ Awesome, I've got everything!

Your checker is complete. Make sure checker.py is saved before you run it.

ⓧ I'd like to double check the full code

import subprocess

SERVICE_NAMES = ("old", "new")
FIXED_RELEASES = {
    (8, 2): (8, 2, 35),
    (8, 3): (8, 3, 36),
    (8, 4): (8, 4, 27),
    (8, 5): (8, 5, 12),
}

PHP_PROBE = r'''
echo "version=" . PHP_VERSION . PHP_EOL;
$names = [
    "session.upload_progress.enabled",
    "memory_limit",
    "post_max_size",
    "fatal_error_backtraces",
];
foreach ($names as $name) {
    $value = ini_get($name);
    if ($value === false) {
        $value = "NOT_AVAILABLE";
    }
    echo $name . "=" . $value . PHP_EOL;
}
'''


def run_in_service(service, *command):
    completed = subprocess.run(
        ["docker", "compose", "exec", "-T", service, *command],
        capture_output=True,
        text=True,
        check=True,
    )
    return completed.stdout, completed.stderr


def read_php_details(service):
    stdout, _ = run_in_service(service, "php", "-r", PHP_PROBE)
    details = {}
    for line in stdout.splitlines():
        if "=" in line:
            key, value = line.split("=", 1)
            details[key] = value
    return details


def detect_runtime(service):
    stdout, stderr = run_in_service(service, "apache2ctl", "-M")
    modules = stdout + stderr
    if "php_module" in modules:
        return "mod_php"
    return "unknown"


def parse_version(version_text):
    core_version = version_text.split("-", 1)[0]
    return tuple(int(part) for part in core_version.split(".")[:3])


def format_version(version_tuple):
    return ".".join(str(part) for part in version_tuple)


def is_disabled(value):
    return value.strip().lower() in {"", "0", "off", "false", "no"}


def parse_size(value):
    normalized = value.strip().upper()
    if normalized == "-1":
        return -1
    multipliers = {
        "K": 1024,
        "M": 1024 * 1024,
        "G": 1024 * 1024 * 1024,
    }
    if normalized[-1] in multipliers:
        return int(normalized[:-1]) * multipliers[normalized[-1]]
    return int(normalized)


def has_partial_mitigations(details, branch):
    session_disabled = is_disabled(
        details["session.upload_progress.enabled"]
    )
    memory_unlimited = details["memory_limit"].strip() == "-1"
    post_size = parse_size(details["post_max_size"])
    post_size_conservative = 0 < post_size <= 8 * 1024 * 1024

    fatal_backtraces_disabled = True
    if branch == (8, 5):
        fatal_backtraces_disabled = is_disabled(
            details["fatal_error_backtraces"]
        )

    return all(
        (
            session_disabled,
            memory_unlimited,
            post_size_conservative,
            fatal_backtraces_disabled,
        )
    )


def assess(details):
    version_text = details["version"]
    version_tuple = parse_version(version_text)
    branch = version_tuple[:2]

    if branch <= (8, 1):
        return (
            "NOT PATCHED",
            f"PHP {version_text} is on a branch that no longer receives "
            "updates from the PHP project.",
        )

    fixed_release = FIXED_RELEASES.get(branch)
    if fixed_release is None:
        return (
            "NOT PATCHED",
            f"PHP {version_text} is outside this advisory's verified "
            "8.2 through 8.5 release matrix.",
        )

    if version_tuple >= fixed_release:
        return (
            "PATCHED",
            f"PHP {version_text} meets the fixed release "
            f"{format_version(fixed_release)} for its branch.",
        )

    if has_partial_mitigations(details, branch):
        return (
            "NOT PATCHED, PARTIAL MITIGATIONS PRESENT",
            f"PHP {version_text} is below fixed release "
            f"{format_version(fixed_release)}; the lab stopgaps are present, "
            "but PHP says they do not make the installation safe.",
        )

    return (
        "NOT PATCHED",
        f"PHP {version_text} is below fixed release "
        f"{format_version(fixed_release)} and the full lab stopgap set "
        "is not present.",
    )


def inspect_service(service):
    details = read_php_details(service)
    status, reason = assess(details)
    return {
        "service": service,
        "php_version": details["version"],
        "web_runtime": detect_runtime(service),
        "session.upload_progress.enabled": details[
            "session.upload_progress.enabled"
        ],
        "memory_limit": details["memory_limit"],
        "post_max_size": details["post_max_size"],
        "fatal_error_backtraces": details["fatal_error_backtraces"],
        "status": status,
        "reason": reason,
    }


def collect_reports():
    return [inspect_service(service) for service in SERVICE_NAMES]


def print_reports(reports):
    for report in reports:
        print(f"\n=== {report['service'].upper()} ===")
        print(f"PHP version: {report['php_version']}")
        print(f"Web runtime: {report['web_runtime']}")
        print(
            "session.upload_progress.enabled: "
            f"{report['session.upload_progress.enabled']}"
        )
        print(f"memory_limit: {report['memory_limit']}")
        print(f"post_max_size: {report['post_max_size']}")
        print(
            "fatal_error_backtraces: "
            f"{report['fatal_error_backtraces']}"
        )
        print(f"Status: {report['status']}")
        print(f"Reason: {report['reason']}")


if __name__ == "__main__":
    try:
        print_reports(collect_reports())
    except subprocess.CalledProcessError as error:
        print("The checker could not inspect the running lab.")
        print("Confirm Docker Desktop is running and both services are up.")
        if error.stderr:
            print(error.stderr.strip())

What should the complete file contain?

The complete file moves from container inspection to branch assessment. It finishes by printing one report for each service.

Before you run the checker, do you expect the matching browser pages to produce matching patch results?

  • Run the completed checker from the PHP Patch Check project folder by using this command:
python3 checker.py

What does the report reveal?

The OLD section reports PHP 8.3.35. It identifies mod_php as the web runtime.

Its status is NOT PATCHED because the release is below fixed release 8.3.36.

The NEW section reports PHP 8.3.36 with mod_php. Its status is PATCHED.

You have exposed the hidden patch gap that the two identical pages could not show. The same evidence now separates a vulnerable release from its fixed replacement.

Did the checker fail to inspect the lab?

  • Confirm Docker Desktop is still running.
  • Confirm the old and new services remain available from the previous step.
  • Compare checker.py with the full-code tab to find missing indentation or misspelled report keys.

Need help with the output? Help me troubleshoot my PHP Patch Check report.

  • Create the screenshots folder inside the PHP Patch Check project by using the Visual Studio Code file tree.
  • Capture the terminal report by using your preferred Mac screenshot tool.
  • Save the captured image as screenshots/before.png.
  • Confirm that the saved image clearly shows the contrasting OLD and NEW statuses.

Your checker now proves which service contains the fixed release. Next, you will test the temporary stopgaps before upgrading the old service properly.

Apply Stopgaps and Patch the Service

Your checker has exposed the old service's missing PHP security fix. You can now compare temporary configuration guidance with complete remediation.

The first test changes configuration while keeping the vulnerable runtime in place. The resulting report reveals how much protection those settings actually provide.

In this step, get ready to:
  • Apply the temporary configuration stopgaps to the old service.
  • Measure the stopgaps with the existing checker.
  • Complete the remediation and verify both services.
Apply temporary stopgaps

PHP's temporary guidance reduces specific confirmed paths while an installation waits for an update. This lab uses the settings for an application that does not accept file uploads.

  • In Visual Studio Code, switch back to config/old.ini from earlier.
  • Replace its contents with the settings below:
session.upload_progress.enabled=0
memory_limit=-1
post_max_size=8M

What do these settings change?

  • The session.upload_progress.enabled=0 setting disables the confirmed session upload progress path.
  • The memory_limit=-1 setting removes PHP's finite memory limit in this lab.
  • The post_max_size=8M setting limits accepted POST data to 8 MB.
  • Save config/old.ini.
  • Restart the old service so its Apache worker loads the changed INI settings by running:
docker compose restart old

What does this command do?

This command restarts the running old service. The restarted PHP worker reads the mounted config/old.ini file.

Before you rerun the checker, what status do you think configuration changes earn when the PHP version stays the same?

  • Test the restarted service with the existing checker by running:
python3 checker.py

What should I see?

The OLD report now shows session.upload_progress.enabled as 0. It also shows memory_limit as -1.

Its status reads NOT PATCHED, PARTIAL MITIGATIONS PRESENT because PHP 8.3.35 remains below fixed release 8.3.36.

The NEW report remains PATCHED on PHP 8.3.36.

Still seeing the original status?

  • Confirm that config/old.ini contains all three updated values.
  • Confirm that you saved config/old.ini before restarting the service.
  • Run the restart command again if the service still reports the earlier values.

Need another pair of eyes? Help me troubleshoot why the old service is not loading its updated INI settings.

Why is the status still not patched?

These settings block only confirmed paths. They do not remove the vulnerable code or cover every allocation failure and stale-state path.

The report makes that boundary explicit. Configuration can reduce exposure while only an updated release completes the remediation.

✔️ Awesome, I've got everything!

Your old service now uses the complete temporary stopgap set.

ⓧ I'd like to double check the full code

session.upload_progress.enabled=0
memory_limit=-1
post_max_size=8M

What should match?

Your complete config/old.ini file should contain these three settings in this order.

Patch the old service image

The partial result proves that configuration alone leaves the old runtime below its fixed release. The complete remediation updates the old service to the patched image already used by the new service.

Your Docker Compose file controls which PHP image each service runs. A targeted image change preserves the localhost-only port binding and the read-only mounts.

  • Switch back to compose.yaml from earlier.
  • Find this image line inside the old service:
    image: php:8.3.35-apache

What does the current line prove?

This line pins the old service to PHP 8.3.35. That release produced the earlier NOT PATCHED report.

  • Replace that image line with the two lines below:
    # Final patched state. Step 2 starts with php:8.3.35-apache.
    image: php:8.3.36-apache

What does this change do?

The image line moves the old service to patched release php:8.3.36-apache. The comment records the initial image so another person can reproduce the comparison.

  • Save compose.yaml.
Recreate and verify the patched service

Saving the image change updates the desired service configuration. Compose must recreate the old container before that service can run the patched image.

  • Recreate the old service in the background by running:
docker compose up --detach old

What does this command do?

Compose detects the changed image for the old service. It recreates that container while leaving the service running in the background.

The localhost-only port mapping and mounted project files remain in place. The old page stays available at http://127.0.0.1:8081.

Could not recreate the service?

  • Confirm that Docker Desktop remains running.
  • Confirm that the updated image line sits inside the old service in compose.yaml.
  • Check the YAML indentation against the full-file reference below.

Still stuck? Help me troubleshoot why Docker Compose cannot recreate the old PHP service.

  • Switch back to the browser tab for http://127.0.0.1:8081.
  • Refresh the old service page.
  • Switch back to the browser tab for http://127.0.0.1:8082.
  • Refresh the new service page.

Both pages still show Hello World from PHP Patch Check. The harmless page looks unchanged after the security update.

Before you run the final check, do you expect any difference between the OLD and NEW reports now?

  • Verify both service versions and patch statuses by running:
python3 checker.py

What should I see?

Both OLD and NEW now show PHP 8.3.36. Both reports identify the web runtime as mod_php.

Each service reports PATCHED because its version meets fixed release 8.3.36 for the PHP 8.3 branch.

Does OLD still show PHP 8.3.35?

  • Confirm that the old image in compose.yaml is php:8.3.36-apache.
  • Confirm that you saved compose.yaml before recreating the service.
  • Run the recreation command again after correcting the file.

Need help tracing the old image? Help me find why the checker still reports PHP 8.3.35 after recreating the service.

That completes the remediation. The same checker that exposed the missing fix now proves both services run the patched release.

  • Capture the final terminal report with the same screenshot method you used for the before-check evidence.
  • Save the captured image as screenshots/after.png.

✔️ Awesome, I've got everything!

Your old service now matches the new service's patched PHP image.

ⓧ I'd like to double check the full code

services:
  old:
    # Final patched state. Step 2 starts with php:8.3.35-apache.
    image: php:8.3.36-apache
    ports:
      - "127.0.0.1:8081:80"
    volumes:
      - ./app:/var/www/html:ro
      - ./config/old.ini:/usr/local/etc/php/conf.d/zz-patch-check.ini:ro

  new:
    image: php:8.3.36-apache
    ports:
      - "127.0.0.1:8082:80"
    volumes:
      - ./app:/var/www/html:ro
      - ./config/new.ini:/usr/local/etc/php/conf.d/zz-patch-check.ini:ro

What should match?

Both services should use php:8.3.36-apache. Their ports should remain explicitly bound to 127.0.0.1.

Your lab now preserves the complete before-and-after story. Next, you will turn the evidence into a README that another person can understand and reproduce.

Document the Evidence

Your lab now proves that both services run patched PHP releases. A terminal result is useful in the moment, but a reviewer needs the reasoning and evidence in one place.

A README gives the experiment a durable narrative. You will write it in Markdown with Visual Studio Code.

The finished document connects the inspection-only safety boundary to the before-and-after proof.

In this step, get ready to:
  • Document the inspection-only patch-checking workflow.
  • Add the before-and-after evidence.
  • Preview the completed README with its official sources.
Create the README foundation

A security README needs enough context for someone to understand the risk without triggering either vulnerability. This opening records the safety boundary before explaining the checker output.

  • Create README.md beside compose.yaml in the project folder shown in the Explorer sidebar.
  • Add the project purpose and patch-checking explanation by copying this content into README.md:
# PHP Patch Check

PHP Patch Check is a defensive local lab for verifying the fixes released for CVE-2026-109618 and CVE-2026-109619. It uses version and configuration inspection only. It does not contain exploit code, attack payloads, malformed requests, or deliberate memory exhaustion.

## What the two bugs mean

CVE-2026-109618 is an out-of-memory handling flaw. In plain English, PHP could jump out of request work while internal data was only partly initialized. Cleanup could later trust that incomplete data as if initialization had finished.

CVE-2026-109619 is a stale-state flaw across requests. Request-owned state could remain after its backing memory had been freed. A later request handled by the same worker could use that stale state before normal initialization reset it.

Both issues could allow unauthenticated remote code execution. Updating is the only complete mitigation.

## What the checker does

For the `old` and `new` Compose services, the checker reports:

- PHP version
- Web runtime
- `session.upload_progress.enabled`
- `memory_limit`
- `post_max_size`
- `fatal_error_backtraces`, when available
- `PATCHED`, `NOT PATCHED`, or `NOT PATCHED, PARTIAL MITIGATIONS PRESENT`
- One plain-English reason

The fixed releases used by the checker are PHP 8.5.12, 8.4.27, 8.3.36, and 8.2.35. PHP 8.1 and older are reported as unsupported by the PHP project.

What does this section document?

  • The opening defines an inspection-only lab with no exploit traffic.
  • The vulnerability summaries explain how incomplete request data or stale request state can become dangerous.
  • The status list makes the branch-aware checker output understandable to someone who has not run the project.
  • Save README.md.
  • Press Cmd+K. Release the keys before pressing V to open the Markdown preview beside the editor.

You will see a rendered title followed by the vulnerability explanations and a bulleted checker summary.

README preview missing content?

  • Confirm the file is named README.md with the uppercase letters shown.
  • Check that each heading begins with the same number of # characters shown in the content block.

Still stuck? Help me troubleshoot why my README.md content is missing from the Visual Studio Code Markdown preview.

Add the workflow and evidence

Reproducible evidence needs clear safety limits and a sequence another person can follow. The next section records the localhost boundary before describing the original patch gap.

  • Append the safety boundaries and reproduction path to README.md by copying this content after the final sentence:


## Safety boundaries

- Both Apache ports bind explicitly to `127.0.0.1`.
- The checker inspects running containers through Docker.
- No diagnostic PHP page is exposed.
- No vulnerability is triggered or reproduced.
- Do not change the port mappings to all interfaces.

## Run the final patched state

```bash
docker compose up --detach
python3 checker.py
```

Open the harmless pages at:

- http://127.0.0.1:8081
- http://127.0.0.1:8082

## Reproduce the before-and-after check

1. Change the `old` image in `compose.yaml` to `php:8.3.35-apache`.
2. Run `docker compose up --detach old`.
3. Run `python3 checker.py` and capture the NOT PATCHED result.
4. Apply the stopgap settings in `config/old.ini`, restart `old`, and observe the partial-mitigation result.
5. Restore `php:8.3.36-apache`, run `docker compose up --detach old`, and confirm PATCHED.

Why record the full progression?

The safety list keeps both web services on the local machine. The reproduction sequence shows how the same checker distinguishes an old image from its patched replacement.

The intermediate status records that configuration stopgaps can reduce specific paths while vulnerable code remains installed.

  • Save README.md.

The side preview updates with the safety list, final run commands, local addresses, and five-step reproduction sequence.

Code blocks rendering as plain text?

  • Confirm each command group has an opening and closing line containing three backticks.
  • Check that the numbered reproduction items each begin on a separate line.

Need another pair of eyes? Help me fix the command blocks in my README.md preview.

The README references two image files so the result can be checked at a glance. The earlier terminal capture supplies the missing before state.

  • Locate the screenshot from the first checker run in Finder where OLD showed NOT PATCHED.
  • Drag that image into the screenshots folder shown in the Visual Studio Code Explorer.
  • Rename the image to before.png in the Explorer.
  • Confirm after.png remains beside before.png.

The screenshots folder now contains the evidence from both ends of the patch-validation workflow.

  • Append the mitigation limits and screenshot references to README.md by copying this content after the reproduction sequence:


## Why the stopgaps are not a fix

For this no-upload Hello World lab, the temporary settings are:

```ini
session.upload_progress.enabled=0
memory_limit=-1
post_max_size=8M
```

They reduce specific confirmed paths. They do not cover underlying allocation failure or every way request-owned state could survive. PHP states that they do not make an installation safe.

## Evidence

### Before

![Old service reported as not patched](screenshots/before.png)

### After

![Both services reported as patched](screenshots/after.png)

## What I learned

- A page can look healthy while its runtime is missing a critical security fix.
- Patch checks must compare releases within the correct PHP branch.
- Configuration mitigations can reduce exposure without removing vulnerable code.
- Remediation is stronger when the same checker proves the result before and after an update.

What makes this evidence useful?

The stopgap section states the central security lesson. Temporary settings reduce selected paths without removing vulnerable code.

The two screenshots make the remediation visible. The learning bullets explain what the comparison proves.

  • Save README.md.

The preview now shows the temporary settings as an INI code block. You will also see the unpatched and patched screenshots under separate headings.

Screenshot not rendering?

  • Confirm both files are inside the screenshots folder beside the project files.
  • Check that the filenames use the exact lowercase spellings before.png and after.png.

Still seeing a broken image? Help me troubleshoot the relative screenshot paths in my README.md.

Finish and preview the README

The final sections make the lab reusable after the demonstration. They show how to pause or remove the containers before linking every security claim to an official source.

  • Complete README.md by appending the cleanup guidance and official links:


## Cleanup

Pause the lab:

```bash
docker compose stop
```

Remove its containers and network:

```bash
docker compose down
```

## Official sources

- [PHP security release, 21 October 2026](https://www.php.net/archive/2026.php#2026-10-21-5)
- [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)

Why end with cleanup and sources?

The cleanup commands distinguish pausing the lab from removing its containers and network. The official links let a reviewer verify the release information and vulnerability explanations.

  • Save README.md.

The preview now ends with two cleanup options and three official advisory links.

Final sections missing?

  • Check that the cleanup section appears after the four learning bullets.
  • Confirm each official source uses matching opening and closing parentheses around its URL.

Need help comparing the file? Help me find the Markdown difference between my README.md and the completed version.

✔️ Awesome, I've got everything!

Your README sections are in place. The final preview check confirms that the evidence renders correctly.

ⓧ I'd like to double check the full code

# PHP Patch Check

PHP Patch Check is a defensive local lab for verifying the fixes released for CVE-2026-109618 and CVE-2026-109619. It uses version and configuration inspection only. It does not contain exploit code, attack payloads, malformed requests, or deliberate memory exhaustion.

## What the two bugs mean

CVE-2026-109618 is an out-of-memory handling flaw. In plain English, PHP could jump out of request work while internal data was only partly initialized. Cleanup could later trust that incomplete data as if initialization had finished.

CVE-2026-109619 is a stale-state flaw across requests. Request-owned state could remain after its backing memory had been freed. A later request handled by the same worker could use that stale state before normal initialization reset it.

Both issues could allow unauthenticated remote code execution. Updating is the only complete mitigation.

## What the checker does

For the `old` and `new` Compose services, the checker reports:

- PHP version
- Web runtime
- `session.upload_progress.enabled`
- `memory_limit`
- `post_max_size`
- `fatal_error_backtraces`, when available
- `PATCHED`, `NOT PATCHED`, or `NOT PATCHED, PARTIAL MITIGATIONS PRESENT`
- One plain-English reason

The fixed releases used by the checker are PHP 8.5.12, 8.4.27, 8.3.36, and 8.2.35. PHP 8.1 and older are reported as unsupported by the PHP project.

## Safety boundaries

- Both Apache ports bind explicitly to `127.0.0.1`.
- The checker inspects running containers through Docker.
- No diagnostic PHP page is exposed.
- No vulnerability is triggered or reproduced.
- Do not change the port mappings to all interfaces.

## Run the final patched state

```bash
docker compose up --detach
python3 checker.py
```

Open the harmless pages at:

- http://127.0.0.1:8081
- http://127.0.0.1:8082

## Reproduce the before-and-after check

1. Change the `old` image in `compose.yaml` to `php:8.3.35-apache`.
2. Run `docker compose up --detach old`.
3. Run `python3 checker.py` and capture the NOT PATCHED result.
4. Apply the stopgap settings in `config/old.ini`, restart `old`, and observe the partial-mitigation result.
5. Restore `php:8.3.36-apache`, run `docker compose up --detach old`, and confirm PATCHED.

## Why the stopgaps are not a fix

For this no-upload Hello World lab, the temporary settings are:

```ini
session.upload_progress.enabled=0
memory_limit=-1
post_max_size=8M
```

They reduce specific confirmed paths. They do not cover underlying allocation failure or every way request-owned state could survive. PHP states that they do not make an installation safe.

## Evidence

### Before

![Old service reported as not patched](screenshots/before.png)

### After

![Both services reported as patched](screenshots/after.png)

## What I learned

- A page can look healthy while its runtime is missing a critical security fix.
- Patch checks must compare releases within the correct PHP branch.
- Configuration mitigations can reduce exposure without removing vulnerable code.
- Remediation is stronger when the same checker proves the result before and after an update.

## Cleanup

Pause the lab:

```bash
docker compose stop
```

Remove its containers and network:

```bash
docker compose down
```

## Official sources

- [PHP security release, 21 October 2026](https://www.php.net/archive/2026.php#2026-10-21-5)
- [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)

Before you inspect the final preview, which sections do you expect to render as images, code blocks, and clickable sources?

  • Press Cmd+K. Release the keys before pressing V to open the completed Markdown preview beside README.md.
  • Open each link under Official sources from the preview.

You will see both screenshots, separate explanations for the two CVEs, run commands, cleanup commands, four learning bullets, and three source links. The links lead to one PHP release page and two GitHub advisories.

Strong finish. Your patch result now has the safety context, reproducible evidence, and official references needed for someone else to trust it.

Secret mission

Export the Report as JSON

Your human-readable report proves both services are patched. This mission adds a machine-readable JSON view that scripts can process while preserving the checker's existing inspection and assessment logic.

Clean Up Your Resources

Clean Up Your Resources

This local lab costs $0 for personal or educational use because it creates no cloud resources. Decide whether to keep the lab running, pause its containers, or remove its local resources.

Resources you used:

  • The old local container.
  • The new local container.
  • The default network created by Docker Compose.
  • The local project folder containing compose.yaml.

Keep everything running

No action is needed. Choose this option if you want the patched lab ready for demonstrations or further checks.

  • Keep Docker Desktop running.
  • Leave both Compose services running.
  • Visit http://127.0.0.1:8081 to show the patched old service.
  • Visit http://127.0.0.1:8082 to show the patched new service.

Pause - I'll come back to this later

This option frees the resources used by the running containers. Your containers remain available for a later start.

  • Switch back to the terminal in the folder containing compose.yaml.
  • Stop both services by running this command:
docker compose stop

What does this command do?

This command stops the running containers without removing them. Your project files remain on your Mac.

  • Refresh http://127.0.0.1:8081 in your browser.
  • Confirm that your browser can no longer reach the Hello World page.

Your lab is paused. The containers remain ready for the next time you continue the project.

Containers still responding?

Confirm that you ran the command from the folder containing compose.yaml. Refresh the browser after the command finishes.

Still stuck? Help me check why my PHP Patch Check containers did not stop.

Delete - I don't want to use this again

This option removes the lab containers, the default network, and your local project files. Deleting the project folder is final, so keep a copy first if you want to preserve your evidence.

  • Switch back to the terminal in the folder containing compose.yaml.
  • Remove both containers and the default network by running this command:
docker compose down

What does this command remove?

This command removes the Compose containers and the default network. The pulled PHP images remain cached locally.

  • Refresh http://127.0.0.1:8081 in your browser.
  • Confirm that your browser can no longer reach the first Hello World page.
  • Refresh http://127.0.0.1:8082 in your browser.
  • Confirm that your browser can no longer reach the second Hello World page.

Lab resources still available?

Confirm that the command finished in the folder containing compose.yaml. Make sure Docker Desktop was running when you issued it.

Need help? Help me remove the remaining PHP Patch Check containers and network.

  • Use Finder to locate the folder that contains compose.yaml.
  • Delete that project folder in Finder.
  • Confirm that Finder no longer shows the project folder.

This removes checker.py, checker_json.py, README.md, and your screenshot evidence from your Mac.

Nice Work!

Nice Work!

You did it! Your localhost-only PHP Patch Check lab now verifies both Apache services as patched.

You've learned how to:

  • Built a localhost-only comparison lab with Docker Compose. It serves the same Hello World page from two isolated Apache with PHP services.
  • Created a branch-aware patch checker that evaluates each PHP release against the fixed version for its branch. Its report shows the web runtime plus selected settings. It also gives a patch status with a plain-English reason.
  • Proved that partial mitigations leave vulnerable PHP code in place. Your evidence captures the old service at NOT PATCHED, at NOT PATCHED, PARTIAL MITIGATIONS PRESENT, then at PATCHED.
  • Completed the Secret Mission by exporting the same defensive reports as indented JSON. checker_json.py reuses collect_reports for both service objects.

Ready to quiz yourself?