PHP Patch Check

Build a local checker to verify PHP patches and document remediation.

Introduction

30 Second Summary

A website can keep serving the same familiar page long after its runtime needs a critical security fix. Without a clear check, an outdated runtime can hide behind a working screen.

In this project, you will build a localhost-only PHP patch lab with Docker Compose. You will use a Python checker to turn each server's runtime details into a plain-English patch verdict.

What You'll Build

One checker run shows the old server's journey through NOT PATCHED, NOT PATCHED BUT MITIGATED, and PATCHED beside a consistently patched server.

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

  • Two localhost-only Hello World pages that you can open side by side. Docker Compose shows each published port bound to 127.0.0.1.
  • A reusable Python patch checker that gives each server a plain-English verdict. Its report includes the runtime version, the raw web SAPI, the friendly mod_php classification, and the requested settings.
  • A clear set of remediation evidence showing the old server before mitigation, after temporary stopgaps, and after the complete upgrade.
  • Secret Mission: Add automation-friendly exit codes so the checker returns success only when every reachable server is patched.

Are there any prerequisites?

You need basic familiarity with files and macOS Terminal commands. The project assumes Docker Desktop, Python, and Visual Studio Code are already installed on an Apple silicon Mac.

Before We Start

An intentionally outdated PHP runtime needs a firm safety boundary before any hands-on work begins. The outdated PHP service stays bound to localhost for version and configuration reads without exploit traffic.

Set Up the Local Workspace

Your defensive boundary keeps the intentionally outdated PHP runtime away from public traffic. A useful security lab also starts with a known toolchain.

In this step, you'll verify Docker Desktop plus Docker Compose. You'll also confirm Python 3.

You will then create the empty php-patch-check workspace in Visual Studio Code.

In this step, get ready to:
  • Confirm the container toolchain prints version information.
  • Confirm Python 3 prints its version information.
  • Create the empty workspace in Visual Studio Code.
Verify your local tools

macOS Terminal lets each tool report its own state. These checks confirm that the container engine responds before the outdated runtime starts.

  • Press Cmd+Space to open your Mac's search bar.
  • Type Terminal and press Enter.
  • Verify Docker Desktop, Docker Compose, and Python 3 by running these commands:
docker version
docker compose version
python3 --version

What Do These Checks Prove?

  • The first command asks Docker for client and server version details.
  • The second command confirms the Compose integration can report its version.
  • The third command confirms Python 3 can run scripts on your Mac.

You'll see version information from all three checks. The Docker output includes both client and server details.

Missing Version Information?

A Docker check can fail while the desktop engine is still starting.

  • Press Cmd+Space to open your Mac's search bar.
  • Type Docker Desktop and press Enter.
  • Wait until Docker Desktop reports that its engine is running.
  • Run the three version checks again.

Help me troubleshoot a Docker or Python version check on my Mac.

Create the project folders

A predictable folder layout keeps application files separate from configuration files. It also gives your screenshots a dedicated home.

  • Create the workspace plus its three empty subfolders by running this command:
mkdir -p ~/php-patch-check/app ~/php-patch-check/config ~/php-patch-check/docs

What Does This Command Do?

  • The mkdir command creates directories.
  • The -p flag creates any missing directory components.
  • The app directory will hold the local PHP pages.
  • The config directory will hold temporary mitigation settings.
  • The docs directory will hold your evidence screenshots.
  • Check that Terminal returns to a fresh prompt without printing an error.

Could Not Create the Folders?

The command writes the workspace inside your Mac home folder. A typing mistake in the path can place the folders somewhere else.

  • Check that the path begins with ~/php-patch-check.
  • Run the folder command again after correcting the path.

Help me find or create my PHP Patch Check folders.

Finding your home folder in a macOS file picker can be fiddly. Your username in the left sidebar takes you there.

  • Press Cmd+Space to open your Mac's search bar.
  • Type Visual Studio Code and press Enter.

You'll see Visual Studio Code open.

  • Click File in the top menu bar.
  • Click Open Folder....

You'll see the macOS folder picker.

  • Click your username in the folder picker's left sidebar.
  • Double-click the php-patch-check folder.
Confirm the workspace layout

Before you inspect the workspace, which three empty folders do you expect to find?

  • Select the Explorer icon in the left sidebar.
  • Expand the php-patch-check workspace.
  • Expand the app folder.
  • Confirm that the app folder contains no files.
  • Expand the config folder.
  • Confirm that the config folder contains no files.
  • Expand the docs folder.
  • Confirm that the docs folder contains no files.

You'll see app, config, and docs under the php-patch-check workspace. Each folder is empty.

Good foundation. Your local tools respond correctly and your workspace now has a clear place for every lab artifact.

Your local workspace is ready. Next, you'll launch the old and patched PHP services behind localhost-only bindings.

Launch the Local PHP Lab

Your toolchain checks are complete. Your empty workspace is ready to become a local security lab.

This step uses Docker Compose to run PHP 8.3.35 beside PHP 8.3.36. Seeing both pages makes the patch boundary tangible before you build the checker.

Each service uses Apache mod_php from an official PHP image. Both ports bind to 127.0.0.1 to keep the lab on your Mac.

In this step, get ready to:
  • Create a Hello World page.
  • Expose a read-only PHP status endpoint.
  • Launch both services through loopback-only ports.
Create the PHP endpoints

The visible page gives you an immediate signal that each web server works. The diagnostic endpoint reads only the runtime details needed for the patch assessment.

  • Select the app folder in the left sidebar of VS Code.
  • Use the new-file control at the top of the sidebar.
  • Enter index.php as the file name.
  • Fill app/index.php by pasting the code below:
<?php

echo '<h1>Hello World from PHP Patch Check</h1>';

What does this page do?

The PHP script prints one HTML heading. That heading gives both services the same visible test page.

  • Save app/index.php.
  • Look under app in the left sidebar.

You should see index.php listed inside app.

Page file missing?

Check that index.php sits inside app. Move the file there if it appears beside the folder.

Confirm the file name ends in .php.

Help me fix the page file.

  • Select the app folder in the left sidebar.
  • Use the new-file control at the top of the sidebar.
  • Enter status.php as the file name.
  • Fill app/status.php by pasting the code below:
<?php

$setting_names = [
    'session.upload_progress.enabled',
    'memory_limit',
    'post_max_size',
    'fatal_error_backtraces',
];

$settings = [];

foreach ($setting_names as $setting_name) {
    $value = ini_get($setting_name);
    $settings[$setting_name] = $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 report?

  • The $setting_names list limits the endpoint to four security-relevant settings.
  • The loop reads each setting through ini_get().
  • A missing setting becomes null so the checker can display it clearly.
  • The JSON response includes PHP_VERSION. It also includes PHP_SAPI.
  • Save app/status.php.
  • Look for both PHP files under app in the left sidebar.

You should see index.php plus status.php inside app.

Status file showing a syntax problem?

Check every opening bracket against its closing bracket. Confirm the final json_encode() call ends with a closing parenthesis.

Compare the quote marks around each setting name. PHP requires matching quotes.

Help me find the PHP syntax issue.

Define the two services

The Compose file gives each service a fixed PHP image. It also maps each web server to a different loopback port.

  • Select the php-patch-check workspace at the top of the left sidebar.
  • Use the new-file control at the top of the sidebar.
  • Enter compose.yaml as the file name.
  • Fill compose.yaml by pasting the configuration below:
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

How does this configuration protect the lab?

  • The old service uses php:8.3.35-apache.
  • The new service uses php:8.3.36-apache.
  • The 127.0.0.1 host address keeps each published port on the loopback interface.
  • The :ro suffix mounts ./app as read-only inside each container.
  • Save compose.yaml.
  • Look for compose.yaml beside the app folder in the left sidebar.

You should see compose.yaml at the top level of php-patch-check.

Compose file in the wrong place?

Make sure compose.yaml sits beside app. A file inside app cannot resolve the expected relative mount.

Keep the port mappings inside quotes. YAML can interpret unquoted colon-separated values unexpectedly.

Help me check the Compose file.

✔️ Awesome, I've got everything!

Your three saved files now define the complete baseline lab.

ⓧ I'd like to double check the full code

Full app/index.php reference:

<?php

echo '<h1>Hello World from PHP Patch Check</h1>';

How this file fits

This file provides the visible page shared by both services.

Full app/status.php reference:

<?php

$setting_names = [
    'session.upload_progress.enabled',
    'memory_limit',
    'post_max_size',
    'fatal_error_backtraces',
];

$settings = [];

foreach ($setting_names as $setting_name) {
    $value = ini_get($setting_name);
    $settings[$setting_name] = $value === false ? null : $value;
}

echo json_encode(
    [
        'version' => PHP_VERSION,
        'sapi' => PHP_SAPI,
        'settings' => $settings,
    ],
    JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
);

How this file fits

This file provides the local JSON data used for runtime assessment.

Full compose.yaml reference:

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

How this file fits

This file pins both images. It also defines the local ports plus the read-only application mounts.

Start and verify the lab

Detached mode keeps both web servers running after the command finishes. You can use the returned terminal prompt for the verification command.

The first start can take a few minutes while Docker downloads both PHP images. A quiet pause during the download is normal.

  • Use VS Code's terminal panel to create an integrated terminal for the open php-patch-check workspace.
  • Start both services by running:
docker compose up -d

What does this command do?

Docker Compose reads compose.yaml from the current workspace. Detached mode starts both containers in the background.

  • Wait for Terminal to return to the command prompt.

You should see Docker create the services. The terminal then becomes available again.

Containers failed to start?

Confirm Docker Desktop is still running. A stopped Docker engine prevents Compose from creating containers.

Check that the terminal belongs to the open php-patch-check workspace. Compose needs to find compose.yaml there.

Help me diagnose the container startup.

Before you inspect the services, predict whether the ports column uses a loopback binding.

  • Inspect both running services by running:
docker compose ps

What does this command show?

The service list shows each container's current state. Its ports column reveals which host interface accepts traffic.

You should see both services running. The ports column should begin with 127.0.0.1 instead of 0.0.0.0.

Services missing from the list?

Rerun the start command if either service is absent. Check the indentation beneath old plus new if Compose lists only one service.

Help me interpret the service list.

Before you open the pages, predict whether both services show the same heading.

  • Navigate a new browser tab to http://127.0.0.1:8081.
  • Navigate another browser tab to http://127.0.0.1:8082.

You should see Hello World from PHP Patch Check on both pages.

  • Navigate a new browser tab to http://127.0.0.1:8081/status.php.
  • Navigate another browser tab to http://127.0.0.1:8082/status.php.

Each status page should show JSON with version. It should also show sapi plus settings.

That is the lab live. The old service reports PHP 8.3.35. The new service reports PHP 8.3.36.

Your two-version PHP lab is running through localhost-only ports. Next up, you will turn its runtime data into a clear patch verdict.

Build the Patch Checker

Your localhost services now expose the runtime evidence needed for a patch decision. You will turn that evidence into a reusable Python checker called checker.py.

A version string alone cannot explain the complete runtime state. The checker turns JSON data from the web-facing SAPI into a plain-English verdict based on a version-based patch policy.

In this step, get ready to:
  • Define the branch-specific patch policy.
  • Assess each runtime using its version plus configuration.
  • Run the checker to capture the baseline report.
Define the patch policy and helpers

The patch policy maps each supported PHP branch to its first fixed release. The checker also records the two local endpoints plus the four settings returned by status.php.

  • In the VS Code Explorer sidebar, select the php-patch-check folder.
  • Click the New File button.

You will see a filename field inside the selected workspace.

  • Enter checker.py as the filename.
  • Add the imports plus policy data by pasting this code into checker.py:
import json
from urllib.error import URLError
from urllib.request import urlopen

PATCHED_RELEASES = {
    (8, 2): (8, 2, 35),
    (8, 3): (8, 3, 36),
    (8, 4): (8, 4, 27),
    (8, 5): (8, 5, 12),
}

SERVERS = [
    ("old", "http://127.0.0.1:8081/status.php"),
    ("new", "http://127.0.0.1:8082/status.php"),
]

SETTING_NAMES = (
    "session.upload_progress.enabled",
    "memory_limit",
    "post_max_size",
    "fatal_error_backtraces",
)

What does this code define?

  • The imports provide JSON decoding plus local HTTP access using Python's standard library.
  • The PATCHED_RELEASES dictionary stores fixed minimums of 8.2.35, 8.3.36, 8.4.27, and 8.5.12.
  • The SERVERS list points to the two localhost status endpoints.
  • The SETTING_NAMES tuple controls which configuration values appear in every report.
  • Save checker.py by pressing Cmd+S on macOS or Ctrl+S on Windows.
  • Confirm checker.py appears beside compose.yaml in the VS Code Explorer sidebar.

Cannot find checker.py?

  • Check that the file sits directly inside php-patch-check.
  • Move the file out of app if it appears beside index.php.

Ask for help with placing checker.py in the workspace.

Version values arrive as text. These helpers convert that text into comparable number tuples before formatting it for report messages.

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


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


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

How do these helpers prepare the data?

  • The parse_version() function converts a release string into three integers that Python can compare.
  • The format_version() function turns a version tuple back into readable text.
  • The is_disabled() function recognizes common disabled values returned by PHP settings.
  • Save checker.py.
  • Confirm the editor now shows parse_version(), format_version(), and is_disabled() below the policy data.

Do the helpers appear inside a tuple?

  • Check that the closing parenthesis for SETTING_NAMES remains above parse_version().
  • Restore the blank line before def parse_version if the function was pasted into the tuple.

Ask for help with checking the helper placement.

A mitigation verdict needs evidence that every required stopgap is present. The next helper returns a list of gaps so an empty list means the lab policy found no missing stopgap.

  • Append the mitigation checks below is_disabled() by pasting this function:
def mitigation_gaps(version, settings):
    gaps = []

    if not is_disabled(settings.get("session.upload_progress.enabled")):
        gaps.append("session upload progress is enabled")

    if settings.get("memory_limit") != "-1":
        gaps.append("memory_limit is not -1")

    if str(settings.get("post_max_size", "")).strip().upper() != "8M":
        gaps.append("post_max_size is not 8M for this no-upload lab")

    if version[:2] == (8, 5) and not is_disabled(
        settings.get("fatal_error_backtraces")
    ):
        gaps.append("fatal_error_backtraces is enabled")

    return gaps

What counts as a mitigation gap?

  • Enabled session upload progress adds a gap to the result.
  • A memory limit other than -1 adds another gap.
  • A post_max_size value other than 8M adds a gap for this no-upload lab.
  • PHP 8.5 also requires disabled fatal error backtraces under this temporary policy.
  • Save checker.py.
  • Confirm mitigation_gaps() ends with return gaps.

Is mitigation_gaps nested incorrectly?

  • Align def mitigation_gaps with the other function definitions at the left edge.
  • Keep each gaps.append line indented beneath its matching condition.

Ask for help with checking the Python indentation.

Assess each runtime

The main decision function applies the patch threshold before looking at stopgaps. This keeps a fixed runtime in the PATCHED category without depending on temporary settings.

The following two snippets are consecutive sections of one assess() function. The function becomes runnable after both sections are pasted.

  • Start assess() below return gaps by pasting its branch checks:
def assess(version, settings):
    branch = version[:2]

    if branch <= (8, 1):
        return (
            "NOT PATCHED",
            "PHP {0} is outside the supported fixed branches; this version-only "
            "check cannot detect a vendor backport.".format(format_version(version)),
        )

    minimum = PATCHED_RELEASES.get(branch)

    if minimum is None:
        return (
            "NOT PATCHED",
            "No verified patch threshold is configured for PHP branch {0}.{1}.".format(
                branch[0], branch[1]
            ),
        )

How does the branch check work?

  • PHP 8.1 or older receives a conservative unpatched verdict under this version-only policy.
  • The vendor backport warning keeps the checker from claiming knowledge that the version string cannot provide.
  • An unrecognized newer branch also receives an unpatched verdict because no verified threshold exists in the policy.
  • Continue the same assess() function immediately after the previous code by pasting its verdict logic:
    if version >= minimum:
        return (
            "PATCHED",
            "PHP {0} meets the patched minimum {1} for its branch.".format(
                format_version(version), format_version(minimum)
            ),
        )

    gaps = mitigation_gaps(version, settings)

    if not gaps:
        return (
            "NOT PATCHED BUT MITIGATED",
            "PHP {0} is below {1}, but this lab's documented stopgaps are present; "
            "upgrading is still required.".format(
                format_version(version), format_version(minimum)
            ),
        )

    return (
        "NOT PATCHED",
        "PHP {0} is below {1}, and {2}.".format(
            format_version(version), format_version(minimum), "; ".join(gaps)
        ),
    )

How are the verdicts chosen?

  • A runtime at or above its branch minimum receives PATCHED.
  • An older runtime with no mitigation gaps receives NOT PATCHED BUT MITIGATED.
  • An older runtime with one or more gaps receives NOT PATCHED plus a plain-English reason.
  • Save checker.py.
  • Confirm the final return statement remains indented inside assess().

Does assess look split apart?

  • Remove any text inserted between the two assess() sections.
  • Align the second section with the indentation shown in the code.
  • Check that every opening parenthesis has its matching closing parenthesis.

Ask for help with repairing the assess function.

The checker must label the web-facing runtime before reading the endpoint. A case-insensitive substring check classifies Apache text as mod_php plus FPM text as PHP-FPM.

  • Append the SAPI classifier plus endpoint reader below assess() by pasting this code:
def sapi_label(sapi):
    normalized = sapi.lower()
    if "apache" in normalized:
        return "mod_php"
    if "fpm" in normalized:
        return "PHP-FPM"
    return "other or unknown SAPI"


def fetch_status(url):
    with urlopen(url, timeout=3) as response:
        return json.loads(response.read().decode("utf-8"))

Why inspect the web endpoint?

  • The sapi_label() function preserves the raw SAPI while adding a readable classification.
  • The fetch_status() function sends an ordinary local request to status.php.
  • Reading through the web endpoint measures the runtime that handles web requests.
  • Save checker.py.
  • Confirm sapi_label() appears immediately above fetch_status().

Is the endpoint reader nested?

  • Align def fetch_status with def sapi_label at the left edge.
  • Keep the return json.loads line indented beneath the with statement.

Ask for help with checking fetch_status.

Run the baseline and capture evidence

The final functions turn each endpoint response into a compact report. The launch loop keeps one unreachable service from hiding the result for the other service.

  • Append the report formatter below fetch_status() by pasting this function:
def print_report(name, url, data):
    version = parse_version(data["version"])
    settings = data["settings"]
    result, reason = assess(version, settings)

    print("\n{0} ({1})".format(name, url))
    print("  Version: {0}".format(data["version"]))
    print("  SAPI: {0} ({1})".format(data["sapi"], sapi_label(data["sapi"])))

    for setting_name in SETTING_NAMES:
        value = settings.get(setting_name)
        display_value = "not available on this PHP version" if value is None else value
        print("  {0}: {1}".format(setting_name, display_value))

    print("  Result: {0}".format(result))
    print("  Why: {0}".format(reason))

What appears in each report?

  • The report starts with the server name plus its local status URL.
  • The version line shows the runtime value reported by PHP.
  • The SAPI line shows the raw value plus its friendly classification.
  • The settings loop displays unavailable options with a readable message.
  • The final lines show the verdict plus the reason behind it.
  • Append the launch loop below print_report() by pasting this code:
print("PHP Patch Check")
print("===============")

for server_name, server_url in SERVERS:
    try:
        server_data = fetch_status(server_url)
        print_report(server_name, server_url, server_data)
    except (URLError, ValueError, KeyError, TypeError) as error:
        print("\n{0} ({1})".format(server_name, server_url))
        print("  Could not check this server: {0}".format(error))

How does the checker start?

  • The opening lines print a clear report title.
  • The loop fetches every server listed in SERVERS.
  • The exception handler prints a reachable error description without ending the entire report early.
  • Save checker.py.

Before you run the checker, do you expect the two services to receive the same verdict?

  • Run the completed checker from the php-patch-check terminal by using this command:
python3 checker.py

What should you see?

The old report shows PHP 8.3.35 with a result of NOT PATCHED.

The new report shows PHP 8.3.36 with a result of PATCHED.

Both reports show their raw web SAPI followed by the mod_php classification. Each report also lists all four requested settings.

That is the key baseline captured. Your checker now proves that the old service falls below its branch threshold while the new service meets it.

Did the checker fail to produce both reports?

  • Check that both Compose services remain running.
  • Check that SERVERS contains the two status URLs exactly as shown.
  • Compare the reported Python line number with the matching section in checker.py.

Ask for help with debugging the baseline checker.

Use these tabs to confirm the completed file before recording your evidence.

✔️ Awesome, I've got everything!

Your checker reports both services with versions, web SAPI details, configuration values, verdicts, and reasons.

ⓧ I'd like to double check the full code

import json
from urllib.error import URLError
from urllib.request import urlopen

PATCHED_RELEASES = {
    (8, 2): (8, 2, 35),
    (8, 3): (8, 3, 36),
    (8, 4): (8, 4, 27),
    (8, 5): (8, 5, 12),
}

SERVERS = [
    ("old", "http://127.0.0.1:8081/status.php"),
    ("new", "http://127.0.0.1:8082/status.php"),
]

SETTING_NAMES = (
    "session.upload_progress.enabled",
    "memory_limit",
    "post_max_size",
    "fatal_error_backtraces",
)


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


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


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


def mitigation_gaps(version, settings):
    gaps = []

    if not is_disabled(settings.get("session.upload_progress.enabled")):
        gaps.append("session upload progress is enabled")

    if settings.get("memory_limit") != "-1":
        gaps.append("memory_limit is not -1")

    if str(settings.get("post_max_size", "")).strip().upper() != "8M":
        gaps.append("post_max_size is not 8M for this no-upload lab")

    if version[:2] == (8, 5) and not is_disabled(
        settings.get("fatal_error_backtraces")
    ):
        gaps.append("fatal_error_backtraces is enabled")

    return gaps


def assess(version, settings):
    branch = version[:2]

    if branch <= (8, 1):
        return (
            "NOT PATCHED",
            "PHP {0} is outside the supported fixed branches; this version-only "
            "check cannot detect a vendor backport.".format(format_version(version)),
        )

    minimum = PATCHED_RELEASES.get(branch)

    if minimum is None:
        return (
            "NOT PATCHED",
            "No verified patch threshold is configured for PHP branch {0}.{1}.".format(
                branch[0], branch[1]
            ),
        )

    if version >= minimum:
        return (
            "PATCHED",
            "PHP {0} meets the patched minimum {1} for its branch.".format(
                format_version(version), format_version(minimum)
            ),
        )

    gaps = mitigation_gaps(version, settings)

    if not gaps:
        return (
            "NOT PATCHED BUT MITIGATED",
            "PHP {0} is below {1}, but this lab's documented stopgaps are present; "
            "upgrading is still required.".format(
                format_version(version), format_version(minimum)
            ),
        )

    return (
        "NOT PATCHED",
        "PHP {0} is below {1}, and {2}.".format(
            format_version(version), format_version(minimum), "; ".join(gaps)
        ),
    )


def sapi_label(sapi):
    normalized = sapi.lower()
    if "apache" in normalized:
        return "mod_php"
    if "fpm" in normalized:
        return "PHP-FPM"
    return "other or unknown SAPI"


def fetch_status(url):
    with urlopen(url, timeout=3) as response:
        return json.loads(response.read().decode("utf-8"))


def print_report(name, url, data):
    version = parse_version(data["version"])
    settings = data["settings"]
    result, reason = assess(version, settings)

    print("\n{0} ({1})".format(name, url))
    print("  Version: {0}".format(data["version"]))
    print("  SAPI: {0} ({1})".format(data["sapi"], sapi_label(data["sapi"])))

    for setting_name in SETTING_NAMES:
        value = settings.get(setting_name)
        display_value = "not available on this PHP version" if value is None else value
        print("  {0}: {1}".format(setting_name, display_value))

    print("  Result: {0}".format(result))
    print("  Why: {0}".format(reason))


print("PHP Patch Check")
print("===============")

for server_name, server_url in SERVERS:
    try:
        server_data = fetch_status(server_url)
        print_report(server_name, server_url, server_data)
    except (URLError, ValueError, KeyError, TypeError) as error:
        print("\n{0} ({1})".format(server_name, server_url))
        print("  Could not check this server: {0}".format(error))

How should you use this reference?

Compare this reference with your saved checker.py from top to bottom. Keep every identifier plus indentation level exactly aligned.

The report is now ready to become your before-remediation evidence.

  • Press Shift-Command-4 on your Mac.
  • Drag the crosshair around the complete terminal report.

The screenshot is saved to your Desktop by default.

  • Drag the new screenshot from your Desktop into the docs folder in the VS Code Explorer sidebar.
  • Rename the screenshot to before.png.
  • Confirm the VS Code Explorer sidebar shows docs/before.png.

Your baseline now records the patch gap in a repeatable report. Next, you will compare temporary stopgaps with a complete PHP upgrade.

Mitigate, Upgrade, and Verify

Your baseline report proved that PHP 8.3.35 falls below the fixed threshold for its branch. The old service now gives you a controlled way to compare temporary exposure reduction with complete remediation.

Temporary PHP configuration can reduce specific attack paths. It cannot repair unsafe state handling inside the runtime.

You will apply the documented stopgaps first. Once the checker exposes their limit, you will replace the old runtime with the patched release.

In this step, get ready to:
  • Apply temporary stopgaps to the old service.
  • Prove that mitigation alone leaves the old runtime unpatched.
  • Upgrade the old service to produce final evidence from two patched services.
Apply the temporary stopgaps

An INI file lets PHP load configuration when the service starts. This file records three temporary settings for the no-upload lab.

  • Switch back to the Visual Studio Code workspace from earlier.
  • Use the Explorer sidebar to create mitigations.ini inside the config folder.
  • Add the temporary guidance by pasting this code into config/mitigations.ini:
; Temporary exposure reduction for a no-upload application.
; This is not a complete fix. Upgrade PHP.
memory_limit=-1
post_max_size=8M
session.upload_progress.enabled=0

; PHP 8.5 only:
; fatal_error_backtraces=0

What do these settings change?

  • The memory_limit=-1 setting removes PHP's configured memory ceiling for this temporary stopgap.
  • The post_max_size=8M setting follows the temporary guidance for an application that does not require uploads.
  • The session.upload_progress.enabled=0 setting disables the session upload progress path.
  • The commented fatal_error_backtraces=0 line preserves the additional guidance for PHP 8.5.
  • Save config/mitigations.ini.
  • Confirm that mitigations.ini now appears inside the config folder in the Explorer sidebar.

Cannot find the mitigation file?

Check that mitigations.ini sits directly inside ~/php-patch-check/config. Confirm that the filename does not have an extra text extension.

Ask for help checking the file location and contents.

✔️ Awesome, I've got everything!

Your temporary guidance file is saved in the correct folder.

ⓧ I'd like to double check the full code

; Temporary exposure reduction for a no-upload application.
; This is not a complete fix. Upgrade PHP.
memory_limit=-1
post_max_size=8M
session.upload_progress.enabled=0

; PHP 8.5 only:
; fatal_error_backtraces=0

What should I compare?

Check each active setting against your file. Keep the PHP 8.5 setting commented because this lab uses PHP 8.3.

The old container needs access to this file before PHP can load the settings. A read-only mount keeps the container from changing your local guidance file.

  • Select compose.yaml in the Visual Studio Code Explorer sidebar.
  • Add ./config/mitigations.ini:/usr/local/etc/php/conf.d/php-patch-check.ini:ro as the second item under the old service's volumes list.
  • Save compose.yaml.

Before you recreate the service, predict which container changes when the command targets only old.

  • Recreate only the old service by running:
docker compose up -d --force-recreate old

What does this command do?

Docker Compose rebuilds the old container from its updated service definition. The new service remains available on its existing localhost port.

Before you run the checker, predict whether an unchanged PHP version can earn a fully patched verdict.

  • Test the temporary state by running:
python3 checker.py

What should the report show?

The old report still shows PHP 8.3.35. Its result now reads NOT PATCHED BUT MITIGATED.

The checker recognizes the stopgaps. The version remains below the fixed minimum of 8.3.36.

Still seeing NOT PATCHED?

Check that the mitigation mount is nested under the old service's volumes list. YAML indentation controls which service receives the mount.

Confirm that you saved compose.yaml before recreating the container. Help me diagnose why the old service cannot see the mitigation settings.

  • Keep the complete checker report visible in Terminal.
  • Press Shift+Command+4 to enter screenshot selection mode.
  • Drag across the complete Terminal report.
  • Move the resulting screenshot from your Desktop into ~/php-patch-check/docs using Finder.
  • Rename the screenshot to mitigated.png.

You have captured the key distinction: configuration reduced known exposure paths while the runtime stayed below the patched release.

Replace the stopgap with the patched image

Why is the stopgap incomplete?

Every temporary setting covers a known path. PHP 8.5 guidance also includes fatal_error_backtraces=0 followed by restarting every worker.

The setting remains a comment because this lab runs PHP 8.3. Updating to a patched release is the complete remediation.

The next service definition upgrades old to PHP 8.3.36. It also removes the temporary mitigation mount.

  • Select the complete old service block in compose.yaml.
  • Replace the selected block with this final service definition:
  old:
    image: php:8.3.36-apache
    ports:
      - "127.0.0.1:8081:80"
    volumes:
      - ./app:/var/www/html:ro

What does this service definition change?

The image value now uses the fixed PHP 8.3.36 release. The localhost-only port mapping remains unchanged.

Only the read-only application mount remains. The saved config/mitigations.ini file stays in your workspace as documentation.

  • Save compose.yaml.

Before you recreate the container, predict whether its next status response uses the old version or the updated image version.

  • Recreate the upgraded old service by running:
docker compose up -d --force-recreate old

What happens during recreation?

Docker Compose replaces the existing old container with one created from php:8.3.36-apache. The service keeps its 127.0.0.1:8081 binding.

Could not recreate the old service?

Check that the old service has one image entry. Confirm that the temporary mitigation mount is gone.

Ask for help checking the final service definition.

✔️ Awesome, I've got everything!

Your final Compose file uses the patched image for both services.

ⓧ 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

What should I compare?

Confirm that both services use php:8.3.36-apache. Each service should keep its original localhost-only port plus the read-only ./app mount.

Verify the patched state and capture proof

Before the final check, predict how the old verdict changes when its version reaches the branch threshold without loading the temporary settings.

  • Verify both running services by running:
python3 checker.py

What should the final report show?

Both services now report PATCHED. The old report shows PHP 8.3.36.

This result proves that the old service crossed the verified patch threshold. Its final state no longer relies on the temporary mitigation mount.

Does the old report still show 8.3.35?

Confirm that you saved compose.yaml with php:8.3.36-apache under the old service. Recreate that service again after saving.

Get help tracing a stale container or unsaved Compose file.

  • Keep both complete PATCHED reports visible in Terminal.
  • Press Shift+Command+4 to enter screenshot selection mode.
  • Drag across the complete Terminal report.
  • Move the resulting screenshot from your Desktop into ~/php-patch-check/docs using Finder.
  • Rename the screenshot to after.png.

That closes the remediation loop. Your evidence now shows the old service moving from unpatched to mitigated-only to fully patched.

Your lab now proves the difference between a temporary stopgap and a complete PHP upgrade. Next, you will turn the three reports into a safety-focused portfolio write-up.

Document the Portfolio Project

Both local services now run the patched PHP release. Your screenshots preserve every stage of the remediation journey.

A credible defensive write-up explains the risk plus the decision rule. It shows the observed change within clear limits.

In this step, get ready to:
  • Explain the defensive boundary around the local lab.
  • Document the checker policy plus its limits.
  • Present the remediation journey as reproducible evidence.
Explain the safety boundary and decision rule

A Markdown README gives reviewers the context that terminal screenshots cannot provide. Your opening sections define the local-only boundary before explaining how the checker reaches a verdict.

  • In the existing Visual Studio Code workspace, select New File... from the Explorer view.
  • Enter README.md in the file name field.
  • Press Enter to create the file.
  • Add the safety boundary plus the patch policy to README.md by pasting this content:
# PHP Patch Check

PHP Patch Check is a defensive, local-only project for verifying the PHP2Shell fixes for CVE-2026-109618 and CVE-2026-109619. It does not contain exploit code, attack payloads, or malformed requests.

## Safety boundary

- Both Apache services bind to `127.0.0.1` only.
- The checker sends ordinary GET requests only to local diagnostic endpoints.
- The diagnostic endpoint returns only PHP version, SAPI, and four selected settings.
- Do not deploy the intentionally old image or the diagnostic endpoint to a public server.

## The bugs in plain English

During request setup, PHP can run out of memory and jump out before some internal request data is fully initialized. Cleanup may then treat that half-finished data as valid. A related issue allows request-owned global state to outlive the memory behind it, so a later request handled by the same worker can trust stale data. The fixed releases change this unsafe failure and reset behavior.

## What the checker does

The checker reads each local server's PHP version, actual web SAPI, `session.upload_progress.enabled`, `memory_limit`, `post_max_size`, and `fatal_error_backtraces` when available. It compares the version with these fixed branch minimums:

| Branch | Fixed minimum |
| --- | --- |
| PHP 8.2 | 8.2.35 |
| PHP 8.3 | 8.3.36 |
| PHP 8.4 | 8.4.27 |
| PHP 8.5 | 8.5.12 |

PHP 8.1 and older are reported as unpatched by this version-only policy because those branches no longer receive normal upstream releases. A distribution or LTS vendor may carry a backport that this checker cannot detect from `PHP_VERSION` alone.

What does this section prove?

  • The safety boundary makes the defensive scope explicit.
  • The plain-English explanation connects each verdict to a real runtime risk.
  • The SAPI detail shows that the checker observes the web request path.
  • The threshold table turns the official fixed releases into a repeatable policy.
  • The backport caveat states what a version-only checker cannot prove.
  • Save README.md.
  • Select Open Preview to the Side from the README.md editor toolbar.

You should see the project title plus three rendered sections. The fixed minimums should appear in a four-row table.

README Headings Not Rendering?

  • Check that each heading begins with the same number of # characters shown above.
  • Check that every table row keeps its vertical separators.
  • Confirm that the file is named README.md.

Ask for help with the formatting if the preview still looks like plain text: Help me troubleshoot my README Markdown formatting.

Document the three runtime states

Reproducible security evidence shows how someone can reach the same result. This section records the baseline plus the temporary stopgap plus the complete fix.

  • Return to the README.md editor.
  • Place your cursor after the paragraph about vendor backports.
  • Add the verdict definitions plus the reproduction instructions by pasting this content:
The verdicts are:

- `PATCHED`: the runtime meets the verified minimum for its branch.
- `NOT PATCHED`: the runtime is below the minimum or outside the supported policy.
- `NOT PATCHED BUT MITIGATED`: the runtime is still vulnerable by version, but this no-upload lab has the documented temporary settings. This is not a complete fix.

## Run the lab

Start the services and run the checker:

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

Inspect the localhost bindings:

```bash
docker compose ps
```

Open the Hello World pages at `http://127.0.0.1:8081` and `http://127.0.0.1:8082`.

## Reproduce the three states

1. Baseline: set the `old` image to `php:8.3.35-apache` with no mitigation mount, recreate it with `docker compose up -d --force-recreate old`, and run the checker.
2. Temporary stopgap: mount `./config/mitigations.ini:/usr/local/etc/php/conf.d/php-patch-check.ini:ro` under `old`, recreate it, and run the checker again.
3. Fixed: set the `old` image to `php:8.3.36-apache`, remove the mitigation mount, recreate it, and run the checker a third time.

PHP states that only updating is a complete solution. For a no-upload application, its temporary guidance includes `memory_limit=-1`, `post_max_size=8M`, and `session.upload_progress.enabled=0`. PHP 8.5 guidance also includes `fatal_error_backtraces=0`. These settings reduce specific paths but do not make an old runtime safe.

How does the remediation story work?

  • The verdict list defines the exact meaning of every checker outcome.
  • The Docker Compose commands make the local run procedure repeatable.
  • The reproduction list preserves all three runtime states.
  • The closing warning separates exposure reduction from complete remediation.
  • Save README.md.
  • Return to the Markdown preview.

You should see three verdict bullets plus two command blocks. The remediation journey should appear as a numbered three-stage list.

Code Blocks or Numbering Look Wrong?

  • Confirm that each command block has an opening fence plus a closing fence.
  • Confirm that the three reproduction lines begin with consecutive numbers.
  • Remove any leading spaces before the section headings.

Use this prompt if the rendered structure still differs: Help me fix my README command blocks and numbered list.

Add evidence, lessons, and sources

Screenshots show that each state happened in the lab. Official sources make the policy traceable beyond your own checker.

  • Return to the bottom of README.md.
  • Add the evidence plus the learning summary plus the sources by pasting this content:
## Evidence

### Before

![Old server unpatched and new server patched](docs/before.png)

### Stopgap only

![Old server not patched but mitigated](docs/mitigated.png)

### After

![Both servers patched](docs/after.png)

## What I learned

- A version inventory needs branch-specific fixed thresholds, not just a major version check.
- The web SAPI must be observed through the web request path because a container shell reports the CLI SAPI instead.
- Temporary configuration changes can reduce exposure without repairing the vulnerable engine behavior.
- Remediation is not complete until the service is recreated on the fixed image and checked again.

## 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)

## Cleanup

Pause the lab with `docker compose stop`, resume it with `docker compose start`, or remove its containers and network with `docker compose down`.

Why finish with evidence and sources?

  • The evidence section maps one screenshot to each runtime state.
  • The learning bullets explain the technical judgment behind the work.
  • The official links let a reviewer trace the thresholds back to PHP plus GitHub.
  • The cleanup section shows how to stop or remove the local services.

Before you inspect the finished README, do you expect every stage of the remediation journey to have its own image?

  • Save README.md.
  • Return to the Markdown preview.
  • Confirm that the preview displays three evidence images.
  • Count the four bullets under What I learned.
  • Confirm that the preview contains the local run commands.
  • Confirm that the preview contains the cleanup commands.
  • Open the three links under Official sources in separate browser tabs.

Each link should load the PHP release notice or its corresponding advisory. This makes the patch policy reviewable from the original sources.

That closes the evidence loop. Your README now explains the risk from baseline through verified remediation.

Missing an Image or Broken Link?

  • Check that before.png is inside the docs folder.
  • Check that mitigated.png is inside the docs folder.
  • Check that after.png is inside the docs folder.

Ask for a focused comparison if an image or source still fails: Help me troubleshoot my README images or official source links.

The complete README.md is available below for a final comparison.

✔️ Awesome, I've got everything!

  • Confirm that README.md is saved in the php-patch-check workspace.

ⓧ I'd like to double check the full code

# PHP Patch Check

PHP Patch Check is a defensive, local-only project for verifying the PHP2Shell fixes for CVE-2026-109618 and CVE-2026-109619. It does not contain exploit code, attack payloads, or malformed requests.

## Safety boundary

- Both Apache services bind to `127.0.0.1` only.
- The checker sends ordinary GET requests only to local diagnostic endpoints.
- The diagnostic endpoint returns only PHP version, SAPI, and four selected settings.
- Do not deploy the intentionally old image or the diagnostic endpoint to a public server.

## The bugs in plain English

During request setup, PHP can run out of memory and jump out before some internal request data is fully initialized. Cleanup may then treat that half-finished data as valid. A related issue allows request-owned global state to outlive the memory behind it, so a later request handled by the same worker can trust stale data. The fixed releases change this unsafe failure and reset behavior.

## What the checker does

The checker reads each local server's PHP version, actual web SAPI, `session.upload_progress.enabled`, `memory_limit`, `post_max_size`, and `fatal_error_backtraces` when available. It compares the version with these fixed branch minimums:

| Branch | Fixed minimum |
| --- | --- |
| PHP 8.2 | 8.2.35 |
| PHP 8.3 | 8.3.36 |
| PHP 8.4 | 8.4.27 |
| PHP 8.5 | 8.5.12 |

PHP 8.1 and older are reported as unpatched by this version-only policy because those branches no longer receive normal upstream releases. A distribution or LTS vendor may carry a backport that this checker cannot detect from `PHP_VERSION` alone.

The verdicts are:

- `PATCHED`: the runtime meets the verified minimum for its branch.
- `NOT PATCHED`: the runtime is below the minimum or outside the supported policy.
- `NOT PATCHED BUT MITIGATED`: the runtime is still vulnerable by version, but this no-upload lab has the documented temporary settings. This is not a complete fix.

## Run the lab

Start the services and run the checker:

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

Inspect the localhost bindings:

```bash
docker compose ps
```

Open the Hello World pages at `http://127.0.0.1:8081` and `http://127.0.0.1:8082`.

## Reproduce the three states

1. Baseline: set the `old` image to `php:8.3.35-apache` with no mitigation mount, recreate it with `docker compose up -d --force-recreate old`, and run the checker.
2. Temporary stopgap: mount `./config/mitigations.ini:/usr/local/etc/php/conf.d/php-patch-check.ini:ro` under `old`, recreate it, and run the checker again.
3. Fixed: set the `old` image to `php:8.3.36-apache`, remove the mitigation mount, recreate it, and run the checker a third time.

PHP states that only updating is a complete solution. For a no-upload application, its temporary guidance includes `memory_limit=-1`, `post_max_size=8M`, and `session.upload_progress.enabled=0`. PHP 8.5 guidance also includes `fatal_error_backtraces=0`. These settings reduce specific paths but do not make an old runtime safe.

## Evidence

### Before

![Old server unpatched and new server patched](docs/before.png)

### Stopgap only

![Old server not patched but mitigated](docs/mitigated.png)

### After

![Both servers patched](docs/after.png)

## What I learned

- A version inventory needs branch-specific fixed thresholds, not just a major version check.
- The web SAPI must be observed through the web request path because a container shell reports the CLI SAPI instead.
- Temporary configuration changes can reduce exposure without repairing the vulnerable engine behavior.
- Remediation is not complete until the service is recreated on the fixed image and checked again.

## 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)

## Cleanup

Pause the lab with `docker compose stop`, resume it with `docker compose start`, or remove its containers and network with `docker compose down`.

How to compare the full file

This reference combines the safety boundary plus the risk explanation plus the patch policy. It also includes the reproduction steps plus the evidence plus the official sources plus the cleanup commands.

Secret mission

Add Automation-Friendly Exit Codes

Your checker already gives people a clear patch report. Add one combined exit status so local scripts can accept a fully patched lab and reject every other outcome.

Clean Up Your Resources

Clean Up Your Resources

This project has no cloud charges for personal local use, while Docker Desktop licensing differs for larger enterprises or government use. Choose the cleanup option that fits what you want to do next.

Resources you used:

  • The patched old Apache mod_php container on 127.0.0.1:8081.
  • The patched new Apache mod_php container on 127.0.0.1:8082.
  • The local Docker Compose network connecting both services.

Your local ~/php-patch-check workspace is evidence to keep. The cleanup choices below leave its code and screenshots on your Mac.

Keep everything running

No action is needed. Choose this if you want both patched services ready for another demonstration.

  • Leave Docker Desktop running while you use the lab.
  • Keep both Hello World pages available through 127.0.0.1 only.
  • Retain the ~/php-patch-check workspace with its three report screenshots.

Pause - I'll come back to this later

Shut down both services to free local resources. Their containers remain available for a later restart.

  • Return to Terminal from earlier.
  • Stop both services from the ~/php-patch-check folder by running:
docker compose stop

This command stops both containers. Docker keeps them available for a later restart.

  • Visit http://127.0.0.1:8081 in your browser.
  • Visit http://127.0.0.1:8082 in your browser.

You should see that neither page responds. This confirms both local services are stopped.

  • Restart both services later from the ~/php-patch-check folder by running:
docker compose start

This command starts the existing service containers again. Both localhost pages become available without recreating the lab.

Delete - I don't want to use this again

Removing the runtime looks final. Your project files and screenshots remain untouched on your Mac.

  • Return to Terminal from earlier.
  • Remove both containers and their Compose network from the ~/php-patch-check folder by running:
docker compose down

This command stops both containers. It also removes the Compose network.

  • Check that the runtime cleanup finished by running:
docker compose ps

You should see no project services listed. Your ~/php-patch-check workspace remains available as portfolio evidence.

Nice Work!

Nice Work!

Strong finish. You built a defensive PHP patch-verification lab that keeps both Apache services on localhost. Your checker now proves that both runtimes meet the patched release threshold.

You've learned how to:

  • Built a localhost-only patch lab with Docker Compose. Both Apache mod_php services stayed bound to 127.0.0.1.
  • Created a branch-aware patch checker that inventories each web-facing runtime. It distinguishes temporary mitigation from complete remediation.
  • Captured the transition from PHP 8.3.35 to PHP 8.3.36 with three evidence screenshots. Your README.md explains the risk. It also records the decision policy and official sources.
  • Secret Mission: Added automation-friendly exit codes to checker.py. The checker now returns 0 only when every reachable service is patched.

Ready to quiz yourself?