# Web Stack Patch Checker (PHP2Shell edition) (`ataism/web-stack-patch-checker`) Actor

Check your client sites for the PHP2Shell advisory: PHP version from public headers, patched yes/no/unknown, risk tier, fix line. One GET per URL.

- **URL**: https://apify.com/ataism/web-stack-patch-checker.md
- **Developed by:** [Kaizoku Data](https://apify.com/ataism) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.00 / 1,000 url checkeds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Web Stack Patch Checker (PHP2Shell edition)

Paste a list of your sites and get one row per URL: the PHP version its public headers show, whether that version indicates the PHP2Shell fix (CVE-2026-109618, CVE-2026-109619), a risk tier and the exact fix line. **$2.00 per 1,000 URLs checked** (pay per event; blocks, robots.txt refusals and errors are free).

**Only check sites you own or manage, or have written permission to check.**

- **Fleet triage in minutes.** Built for agencies, MSPs and hosting resellers with dozens or thousands of client sites: sort by `riskTier` and start with `high`.
- **One normal page request per URL.** One GET (redirects followed, at most 5) plus the host's robots.txt. No exploit traffic, no other paths, no probing.
- **Exact fix lines.** `upgrade to 8.3.36+`, or for end-of-life branches `upgrade to a supported branch: 8.3.36+ ...`.
- **Honest about what headers can't tell.** Distribution builds (`-1ubuntu2.14`, `+deb12u1`), hidden versions and proxies are marked `unknown` or `review`, with a `php -v` verify note.
- **Ready for the next incident.** All advisory knowledge sits in one data file; future editions are a data update.

This is a triage of public signals, not a vulnerability scan. A hidden version is not proof of a fix: run `php -v` on the server. We never say vulnerable or safe; rows say what a version string indicates.

### Quick start

1. Open the Actor, keep the sample input (a header echo test URL that reports PHP/8.3.30, and a scraping sandbox site) and press Start. It takes about 10 seconds.
2. Replace the URLs with the home pages of the sites you manage, one per line.
3. Open the *Overview* table, sort by risk tier, and download it as CSV or Excel, or call the Actor from code (see below).

### What is sent, and what is not

| Sent | Never sent |
|---|---|
| One plain GET of each URL you list, exactly as given (the fragment after `#` is dropped, as browsers do) | Exploit payloads, multipart bodies, POST requests |
| The same GET for each redirect hop (at most 5; every hop re-checked: public addresses only, no platform hosts, robots.txt) | Any other path: no `/phpinfo.php`, `/info.php`, admin pages or guesses |
| `/robots.txt` once per host (read before anything else, honoured with our exact token) | HEAD requests, retries after a refusal, query changes, fuzzing |
| User-Agent `web-stack-patch-checker-actor/0.1 (https://apify.com/ataism/web-stack-patch-checker)` | Cookies, credentials, proxies, browser disguises |

At most one request per second per host (robots.txt included); a robots.txt `Crawl-delay` is honoured in full. A 401, 403, 406, 429, 451, 999 or challenge page is recorded and not charged, and after 3 of them the host is not contacted again in that run.

What is read from the response: the `X-Powered-By` and `Server` headers, the **names** of cookies the site sets (never their values) and the `<meta name="generator">` tag in the first 256 KB of HTML. No page text is saved.

### Risk tiers

| Tier | Meaning | What to do |
|---|---|---|
| `high` | A plain version (no suffix) that indicates an unpatched release (for example 8.3.30 < 8.3.36) or an end-of-life branch (for example 7.4.33 or 8.1.34: no upstream fix) | Update now; see `fixLine` |
| `review` | PHP is likely (session cookie, WordPress or Drupal generator tag, `X-Powered-By: PHP`) but the version is hidden; or the version is a distribution build whose fix status the version string can't show, including end-of-life distribution builds such as `8.1.2-1ubuntu2.14` (distribution LTS releases can backport fixes; these rows keep `eol: true` and the end-of-life fix line); or a pre-release build (`8.4.27RC1`, `8.3.36-dev`) | Run `php -v` on the server, or check your distribution's advisory |
| `low` | The version indicates the fixed release or later | Confirm on the server if the header may be stale |
| `none` | No PHP signal in headers, cookie names or the generator tag | Not proof that PHP is absent; many sites hide it |

When the `Server` header shows Apache, the row notes that the advisory rates Apache mod_php as the confirmed attack path (over HTTP we cannot tell mod_php from PHP-FPM). Rows on the 8.2 branch note that its security support ends on 2026-12-31. Every row notes that headers can be stale, set by a proxy or CDN, or reflect only the front server.

### The advisory (PHP2Shell)

Checked on 2026-10-22 against the PHP advisories [GHSA-w8hw-w9fx-j79w](https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w) and [GHSA-6wmf-4rv5-p3rp](https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp), the [php.net releases list](https://www.php.net/releases/index.php?json) and [supported versions](https://www.php.net/supported-versions.php):

- **CVE-2026-109618** "RCE via OOM corruption" and **CVE-2026-109619** "RCE via stale global state", both CVSS 4.0 **9.2** (`AV:N/AC:H/AT:P/PR:N/UI:N/...`), published 2026-10-21.
- **Affected:** PHP `< 8.2.35`, `< 8.3.36`, `< 8.4.27`, `< 8.5.12`. **Fixed:** 8.2.35, 8.3.36, 8.4.27 and 8.5.12, all released on 2026-10-21.
- **End-of-life branches** (8.1 and older) get no upstream fix. We write "end-of-life branch: no upstream fix".
- **Server API:** the advisory names Apache mod_php as the confirmed application-independent path; FrankenPHP likely; PHP-FPM susceptible by source analysis, with no complete FPM exploit shown; LSAPI likely but unvalidated.
- **Mitigation:** the only complete one is to update. Interim and partial: `memory_limit=-1` enforced administratively, a conservative `post_max_size`, and blocking multipart requests at the frontend if your site doesn't use them.
- **Security support ends:** 8.2 on 2026-12-31, 8.3 on 2027-12-31, 8.4 on 2028-12-31, 8.5 on 2029-12-31. 8.1 ended on 2025-12-31 (last release 8.1.34).

**Future editions:** all incident knowledge (CVE ids, fixed versions per branch, support dates, signal patterns, wording) lives in one data file, `src/advisories.json`. The next incident (an nginx, WordPress core or OpenSSL-style release) is a data update, and every row names the edition it was checked against (`incident`).

### Input

| Field | What it does |
|---|---|
| `urls` | Pages of sites you own, manage or have written permission to check, one per line. A URL without `http://` or `https://` is read as `https://`. At most 5,000 per run; a URL listed twice is checked once (the repeat is a free `duplicate` row). |
| `maxConcurrency` | URLs checked at the same time across different hosts, 1 to 20, default 10. One host still gets at most 1 request per second. |

#### Example 1: a client fleet

```json
{
  "urls": [
    "https://www.client-one.example/",
    "https://shop.client-two.example/",
    "client-three.example"
  ]
}
```

#### Example 2: a large list, more hosts in parallel

```json
{
  "urls": ["https://site-001.example/", "https://site-002.example/", "https://site-003.example/"],
  "maxConcurrency": 20
}
```

#### Example 3: the sample input

```json
{
  "urls": [
    "https://httpbin.org/response-headers?X-Powered-By=PHP/8.3.30",
    "https://books.toscrape.com/"
  ]
}
```

### Output

One row per input URL (the *Overview* view shows the main columns; *Evidence and notes* shows the rest):

```json
{
  "index": 0,
  "url": "https://httpbin.org/response-headers?X-Powered-By=PHP/8.3.30",
  "finalUrl": null,
  "status": 200,
  "detection": "php-version",
  "phpVersion": "8.3.30",
  "versionSuffix": null,
  "branch": "8.3",
  "patched": "no",
  "eol": false,
  "securitySupportUntil": "2027-12-31",
  "riskTier": "high",
  "fixLine": "upgrade to 8.3.36+",
  "finding": "version indicates an unpatched release (< 8.3.36)",
  "evidence": [{ "signal": "x-powered-by", "value": "PHP/8.3.30" }],
  "notes": [
    "distribution packages (e.g. RHEL) can backport fixes without changing the version string",
    "Headers can be stale, set by a proxy or CDN, or reflect only the front server."
  ],
  "serverHeader": "gunicorn/19.9.0",
  "incident": "php2shell-2026-10",
  "checkedAt": "2026-10-22T13:04:46.054Z",
  "error": null,
  "charged": true
}
```

- `detection`: `php-version`, `php-likely, version hidden` or `no PHP signal`.
- `patched`: `yes` (version indicates the fixed release or later), `no` (a plain version of an unpatched release or of an end-of-life branch) or `unknown` (hidden version, distribution build, pre-release build, a branch the data doesn't list, or no signal). An end-of-life distribution build (`8.1.2-1ubuntu2.14`) is `unknown` with tier `review`, `eol: true`, the end-of-life `fixLine` and the distribution note.
- Pre-release builds (`-dev`, `RC`, `alpha`, `beta`) at or above the fixed number are `unknown` with tier `review`: they may not contain the final release's fix.
- When several versions appear (repeated headers, or `X-Powered-By` and `Server` disagree), the row uses the lowest one in a header and notes the others. Versions with leading zeros (`PHP/08.03.36`) or out-of-range parts (`PHP/8.3.360`) are not read as a patched version.
- `versionSuffix`: text after the version, such as `-1ubuntu2.14`. A distribution build below the fix is `unknown` with the note "distribution build: may carry a backported fix; check your distro's advisory". A distribution build of a release at or after the fix is `yes`.
- `evidence`: the signals seen, as `{signal, value}`: `x-powered-by`, `server`, `cookie` (the name only, for example `PHPSESSID`), `generator`.
- A run also writes `RUN_SUMMARY` with counts by detection, risk tier and error code.

### Error codes

Rows with an `error` are not charged.

| Code | Meaning |
|---|---|
| `blocked-by-robots` | robots.txt disallows the URL for this Actor; it was not requested |
| `robots-unreachable` | robots.txt answered 5xx or 429, or could not be reached (after one retry); the host was not requested |
| `slow-host` | robots.txt asks for a Crawl-delay above 10 s, or one that is not a plain number of seconds (`1e9`, `abc`, `10s`); skipped rather than shortened or guessed |
| `refused` | HTTP 401, 403, 406, 429, 451 or 999; not retried |
| `challenge` | a bot challenge page; not retried |
| `host-halted` | the host refused 3 requests in this run, so it is not contacted again |
| `server-error` | HTTP 5xx; the signals from it are still shown (they may come from a proxy or CDN) |
| `too-many-redirects` | more than 5 redirects |
| `platform` | a social-media, Google or other platform host (refused before any lookup) |
| `blocked` | a private or local address, or a port other than the usual web ports |
| `invalid-url` | not an http(s) URL |
| `duplicate` | the same URL appears earlier in the list |
| `push-failed` | the result could not be saved to the dataset; the run stops with `stoppedBy: "error"` and a `RUN_SUMMARY` |
| `dns`, `timeout`, `connection-reset`, `connection-refused`, `tls`, `network` | the site could not be reached (no retry: one GET per URL) |
| `error` | anything else |

### Pricing

One event, `url-checked`, at **$0.002** per URL whose response was received and analysed, including `no PHP signal` rows and 404 pages. Free: robots.txt blocks, refusals and challenge pages, 5xx answers, network errors and timeouts, invalid, duplicate, platform and private-address URLs. Apify's standard small per-run start fee applies.

| URLs checked | Price |
|---|---|
| 1 | $0.002 |
| 100 | $0.20 |
| 1,000 | $2.00 |
| 5,000 (one full run) | $10.00 |

Set *Max total charge* on the run to cap spend; the Actor stops cleanly before a URL the budget cannot pay for.

### Limits

- 5,000 URLs per run, at most 20 checked in parallel, 1 request per second per host (a host is its registrable domain and its IP address, so subdomains and names for one server share the rate).
- 30 seconds per URL for all redirect hops, not counting politeness waits. A Crawl-delay above 10 s (at 256 MB) skips the host.
- The run stops (`unpaid-work-cap`) once 500 requested URLs gave no charged result and they outnumber the charged ones, or when time spent on uncharged work (robots.txt fetches, refusals, errors) passes 3 s per charged row plus 300 s. The run stops at 240 minutes and keeps its results.
- Only what the site shows over HTTP: a CDN or proxy can hide, replace or add headers, and a version string can't show a backported fix.

### Responsible use and privacy

- **Only check sites you own or manage, or have written permission to check.** You are responsible for having the right to check every URL you list.
- The Actor reads public response headers, cookie names and one meta tag, and outputs no page text. Cookie values are never stored. URLs are saved as you gave them, with values of query parameters named like `key`, `token`, `secret` or session ids (`PHPSESSID`, names ending in `sessid`, `sess` or `_session`) replaced by `[redacted]`.
- It respects robots.txt with its exact product token `web-stack-patch-checker-actor`, never works around a block, and uses no proxies.

### For AI agents (MCP)

**Purpose:** one call returns, for each URL, the PHP version its public headers show and whether that indicates the PHP2Shell fix (CVE-2026-109618, CVE-2026-109619), as `patched` (`yes`, `no`, `unknown`), `riskTier` (`high`, `review`, `low`, `none`) and an exact `fixLine`. One GET per URL plus robots.txt; no exploit traffic. Only for sites the user owns, manages or has written permission to check.

**Minimal input that works:**

```json
{ "urls": ["https://books.toscrape.com/"] }
```

Everything else has a default (10 URLs in parallel, 1 request per second per host).

**One output row per URL** (trimmed): `{"url": "https://www.client.example/", "status": 200, "detection": "php-version", "phpVersion": "8.3.30", "patched": "no", "riskTier": "high", "fixLine": "upgrade to 8.3.36+", "finding": "version indicates an unpatched release (< 8.3.36)", "evidence": [{"signal": "x-powered-by", "value": "PHP/8.3.30"}], "error": null}`. Rows where `error` is not null were not checked (robots.txt, refusal, unreachable) and are free. `patched: "unknown"` means the version is hidden or a distribution build: tell the user to run `php -v` on the server. Never present a row as proof that a site is or isn't exploitable.

**Cost:** $0.002 per URL whose response was analysed (1 URL = $0.002, 100 = $0.20, 1,000 = $2.00), plus Apify's standard small per-run start fee. Free: rows with `error`. Cap spend with Apify's *Max total charge*.

**Limits:** at most 5,000 URLs per run; reads only `X-Powered-By`, `Server`, cookie names and the generator tag. Headers can be stale or set by a proxy or CDN.

**How to call it via the Apify MCP server** (`https://mcp.apify.com`; tool names from the [apify-mcp-server README](https://github.com/apify/apify-mcp-server)):

1. `search-actors` with a query such as `php version patch checker` (free) may list `ataism/web-stack-patch-checker`; search results are ranked, so if it isn't listed, go straight to step 2 with the Actor name.
2. `fetch-actor-details` with actor `ataism/web-stack-patch-checker` returns the input schema, pricing and output schema (free).
3. `call-actor` with `actor` = `ataism/web-stack-patch-checker` and `input` = `{"urls": ["https://www.client-one.example/", "https://shop.client-two.example/"]}` runs it. The response holds run metadata and a dataset ID, not the rows.
4. `get-dataset-items` with that dataset ID returns the rows (follow the `nextStep` in the `call-actor` response).

To expose only this Actor as a tool, connect to `https://mcp.apify.com?tools=ataism/web-stack-patch-checker`.

### Use it via API, n8n or Make

Get an API token in Apify Console → *Settings* → *API & Integrations*, and keep it in an environment variable (`APIFY_TOKEN` below). Never paste it into shared code.

**cURL** (starts a run, waits for it and returns the rows as JSON; this synchronous endpoint waits up to 5 minutes, which fits a few hundred URLs on different hosts; for bigger lists use the clients below):

```bash
curl -X POST "https://api.apify.com/v2/acts/ataism~web-stack-patch-checker/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"urls": ["https://www.client-one.example/", "https://shop.client-two.example/"]}'
```

**JavaScript** (`npm install apify-client`):

```js
import { ApifyClient } from 'apify-client';

const client = new ApifyClient({ token: process.env.APIFY_TOKEN });
const run = await client.actor('ataism/web-stack-patch-checker').call({
    urls: ['https://www.client-one.example/', 'https://shop.client-two.example/'],
});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
for (const row of items) console.log(row.url, row.riskTier, row.phpVersion, row.fixLine);
```

**Python** (`pip install "apify-client>=3"`):

```python
import os
from apify_client import ApifyClient

client = ApifyClient(os.environ["APIFY_TOKEN"])
run = client.actor("ataism/web-stack-patch-checker").call(run_input={
    "urls": ["https://www.client-one.example/", "https://shop.client-two.example/"],
})
for row in client.dataset(run.default_dataset_id).iterate_items():
    print(row["url"], row["riskTier"], row["phpVersion"], row["fixLine"])
```

(With apify-client versions before 3.0, `call()` returns a dict: use `run["defaultDatasetId"]`.)

**n8n** (check a sheet of client sites):

1. Read the URL column with a *Google Sheets* node and combine it into one list (for example with an *Aggregate* node).
2. Add the **Apify** node, operation **Run an Actor and get dataset**, Actor `ataism/web-stack-patch-checker`, input `{"urls": <your list>}`.
3. Filter on `riskTier` = `high` or `review` and send the rows to your ticketing tool or back to the sheet, matching on `url`.

**Make:**

1. Add the **Apify** module **Run an Actor**, choose `ataism/web-stack-patch-checker`, map your URL list into the input JSON and set *Run synchronously* to *Yes*.
2. Add the **Apify** module **Get Dataset Items** and map the run's default dataset ID.
3. Add a filter on `riskTier` and a Google Sheets, email or ticketing module.

### FAQ

**Does it prove a site is patched?** No. It reads what the site says about itself. A hidden or rewritten header, a CDN in front, or a distribution package with a backported fix can all differ from what PHP really runs. Use it to decide where to look first, then run `php -v` on the server.

**Why does a distribution build show `unknown`?** Debian, Ubuntu and RHEL often backport security fixes without changing the upstream version number, so `8.1.2-1ubuntu2.14` can include a fix that `8.1.2` alone would not. Check your distribution's security advisory for the package.

**Why is a site I know runs PHP shown as `no PHP signal`?** Many hosts remove `X-Powered-By` (`expose_php = Off`) and rename the session cookie. That is good practice, and it means only a check on the server can tell the version.

# Actor input Schema

## `urls` (type: `array`):

Pages of sites you own, manage or have written permission to check, one per line (for example https://www.your-site.com/). A URL without http:// or https:// is read as https://. At most 5,000 per run; a URL listed twice is checked once.

## `maxConcurrency` (type: `integer`):

How many URLs are checked at the same time, across different hosts. One host still gets at most 1 request per second. Default 10.

## `mode` (type: `string`):

Internal: "selfTest" runs the maintainer health check and saves no items.

## Actor input object example

```json
{
  "urls": [
    "https://httpbin.org/response-headers?X-Powered-By=PHP/8.3.30",
    "https://books.toscrape.com/"
  ],
  "maxConcurrency": 10,
  "mode": "run"
}
```

# Actor output Schema

## `results` (type: `string`):

One row per URL: detection, PHP version, branch, patched (yes, no, unknown), risk tier, fix line, finding, evidence and notes, or an error (JSON, CSV, Excel or XML).

## `runSummary` (type: `string`):

Counts by detection and risk tier, events charged, failures by code and why the run stopped.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "urls": [
        "https://httpbin.org/response-headers?X-Powered-By=PHP/8.3.30",
        "https://books.toscrape.com/"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("ataism/web-stack-patch-checker").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "urls": [
        "https://httpbin.org/response-headers?X-Powered-By=PHP/8.3.30",
        "https://books.toscrape.com/",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("ataism/web-stack-patch-checker").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "urls": [
    "https://httpbin.org/response-headers?X-Powered-By=PHP/8.3.30",
    "https://books.toscrape.com/"
  ]
}' |
apify call ataism/web-stack-patch-checker --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,ataism/web-stack-patch-checker"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/Khw0tnSDQpWZ1sHsF/builds/99RbwggXSy1W3IQes/openapi.json
