From 775b73d9e928d1c59f479798c6f0306a16c7d2b7 Mon Sep 17 00:00:00 2001 From: Ian Webster Date: Tue, 20 Oct 2026 22:00:41 -0700 Subject: [PATCH 1/3] Split PHP version checks into server and URL skills --- README.md | 12 +-- SKILL.md | 18 ----- phpcheck.js | 99 +------------------------ phpcheck.test.js | 21 +++++- skills/phpcheck-server/SKILL.md | 15 ++++ skills/phpcheck-url/SKILL.md | 19 +++++ skills/phpcheck-url/scripts/phpcheck.js | 99 +++++++++++++++++++++++++ 7 files changed, 160 insertions(+), 123 deletions(-) delete mode 100644 SKILL.md create mode 100644 skills/phpcheck-server/SKILL.md create mode 100644 skills/phpcheck-url/SKILL.md create mode 100755 skills/phpcheck-url/scripts/phpcheck.js diff --git a/README.md b/README.md index 7f89120..20dce0a 100644 --- a/README.md +++ b/README.md @@ -2,16 +2,18 @@ Check a website or local PHP version without exploit requests. Uses Node.js. No packages to install. -To install, ask Codex: +To install both skills, ask Codex: ```text -$skill-installer install the phpcheck-safe skill from https://github.com/openai/phrenetic-pheasant-checker +$skill-installer install both skills from https://github.com/openai/php2shell-checker ``` -Then ask: +Use [phpcheck-server](skills/phpcheck-server/SKILL.md) when you have server +access, or [phpcheck-url](skills/phpcheck-url/SKILL.md) when you only have a URL: ```text -$phpcheck-safe check https://example.com +$phpcheck-server check the PHP version serving my site +$phpcheck-url check example.com ``` ## Run directly @@ -22,7 +24,7 @@ Check a website: node phpcheck.js "https://example.com" ``` -Check local PHP: +Check local CLI PHP, which may differ from the version serving the site: ```sh node phpcheck.js --local diff --git a/SKILL.md b/SKILL.md deleted file mode 100644 index a7d68a4..0000000 --- a/SKILL.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -name: phpcheck-safe -description: Check a website or local PHP version for the October 2026 PHP vulnerability without exploit requests. ---- - -From this skill's directory, run the bundled checker with the user's URL: - -```sh -node phpcheck.js "https://example.com" --json -``` - -Only check local PHP when the user asks for it: - -```sh -node phpcheck.js --local --json -``` - -Report the verdict and PHP version. Exit codes 1 and 3 mean `vulnerable` and `unknown`, not execution failures. `unknown` does not mean patched. Do not add exploit probes. diff --git a/phpcheck.js b/phpcheck.js index a2b3c62..eb9a41e 100755 --- a/phpcheck.js +++ b/phpcheck.js @@ -1,99 +1,2 @@ #!/usr/bin/env node -const { execFileSync } = require('node:child_process') -const { parseArgs } = require('node:util') - -const VERSION = '1.0.0' -// Vulnerable version cutoffs as of 2026-10-20. A newer stable patch in a -// listed branch is OK by this table; unlisted branches remain unknown. -const VULNERABLE_THROUGH = { - '5.4': 45, '5.5': 38, '5.6': 40, - '7.0': 33, '7.1': 33, '7.2': 34, '7.3': 33, '7.4': 33, - '8.0': 30, '8.1': 34, '8.2': 34, '8.3': 35, '8.4': 26, '8.5': 11, -} -const USAGE = 'Usage: node phpcheck.js URL [--timeout SECONDS] [--json]\n' + - ' node phpcheck.js --local [--php PATH] [--json]\n' + - ' node phpcheck.js --version' - -function patchStatus(version) { - const match = /^(\d+)\.(\d+)\.(\d+)([-+~].*)?$/.exec(version || '') - if (!match) return 'unknown' - const cutoff = VULNERABLE_THROUGH[`${Number(match[1])}.${Number(match[2])}`] - if (cutoff === undefined) return 'unknown' - if (Number(match[3]) <= cutoff) return 'vulnerable' - // A development build of the next patch is not evidence of a stable fix. - if (/(?:^|[-+~.])(?:dev|alpha|beta|rc)/i.test(match[4] || '')) return 'unknown' - return 'ok' -} - -async function urlHeaders(address, timeout) { - if (/[\x00-\x20\x7f]/.test(address)) throw new Error('URL contains spaces or control characters') - const url = new URL(address) - if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Use an http:// or https:// URL') - if (url.username || url.password || url.hash) throw new Error('URL credentials and fragments are not supported') - const headers = [] - for (const method of ['HEAD', 'GET']) { - const response = await fetch(url, { - method, redirect: 'manual', signal: AbortSignal.timeout(Math.ceil(timeout * 1000)), - }) - headers.push(response.headers.get('server') || '', response.headers.get('x-powered-by') || '') - await response.body?.cancel() - if (![405, 501].includes(response.status)) break - } - return headers.join('\n') -} - -async function main() { - let values, address, timeout - try { - const args = parseArgs({ - allowPositionals: true, - options: { - local: { type: 'boolean' }, - php: { type: 'string', default: 'php' }, - timeout: { type: 'string', default: '10' }, - json: { type: 'boolean' }, - version: { type: 'boolean' }, - help: { type: 'boolean', short: 'h' }, - }, - }) - values = args.values - if (values.version) return console.log(VERSION) - if (values.help) return console.log(USAGE) - address = args.positionals[0] - if (args.positionals.length > 1 || (values.local && address) || (!values.local && !address)) { - throw new Error('Provide a URL or --local, not both') - } - timeout = Number(values.timeout) - if (!values.local && !(timeout > 0 && timeout <= 60)) throw new Error('--timeout must be greater than 0 and at most 60 seconds') - } catch (error) { - console.error(error.message + '\n' + USAGE) - process.exitCode = 2 - return - } - - let version = null, error = null, status = 'unknown' - try { - const text = values.local - ? execFileSync(values.php, ['-v'], { encoding: 'utf8', timeout: 10000 }) - : await urlHeaders(address, timeout) - for (const match of text.matchAll(/\bPHP[ /]([^\s,;)]+)/gi)) { - const candidateStatus = patchStatus(match[1]) - // Do not let an OK banner hide a vulnerable or unrecognized PHP version. - if (version === null || candidateStatus === 'vulnerable' || status === 'ok') { - version = match[1] - status = candidateStatus - } - if (status === 'vulnerable') break - } - } catch (exc) { - error = exc.message - } - - const code = { ok: 0, vulnerable: 1, unknown: 3 }[status] - const reason = error || (version ? 'PHP ' + version : 'PHP version not found') - const report = { patch_status: status, php_version: version, reason, exit_code: code } - console.log(values.json ? JSON.stringify(report) : status.toUpperCase() + ': ' + reason) - process.exitCode = code -} - -main() +require('./skills/phpcheck-url/scripts/phpcheck.js') diff --git a/phpcheck.test.js b/phpcheck.test.js index 9622411..f20d9ee 100644 --- a/phpcheck.test.js +++ b/phpcheck.test.js @@ -1,13 +1,15 @@ const assert = require('node:assert/strict') const { spawn } = require('node:child_process') const { once } = require('node:events') +const { cp, mkdtemp, rm } = require('node:fs/promises') const http = require('node:http') +const { tmpdir } = require('node:os') const path = require('node:path') const { test } = require('node:test') -function run(args) { +function run(args, script = path.join(__dirname, 'phpcheck.js')) { return new Promise((resolve, reject) => { - const child = spawn(process.execPath, [path.join(__dirname, 'phpcheck.js'), ...args]) + const child = spawn(process.execPath, [script, ...args]) let stdout = '', stderr = '' child.stdout.on('data', chunk => { stdout += chunk }) child.stderr.on('data', chunk => { stderr += chunk }) @@ -150,3 +152,18 @@ test('plain text output for a newer version is OK with exit code 0', async t => assert.equal(result.code, 0) assert.equal(result.stdout, 'OK: PHP 8.4.27\n') }) + +test('the URL skill runs independently with its bundled checker', async t => { + const directory = await mkdtemp(path.join(tmpdir(), 'phpcheck-url-')) + t.after(() => rm(directory, { recursive: true, force: true })) + await cp(path.join(__dirname, 'skills/phpcheck-url'), directory, { recursive: true }) + const methods = [] + const url = await serve(t, (req, res) => { + methods.push(req.method) + res.setHeader('X-Powered-By', 'PHP/8.4.27') + res.end() + }) + const result = await run([url, '--json'], path.join(directory, 'scripts/phpcheck.js')) + check(result, 'ok', '8.4.27') + assert.deepEqual(methods, ['HEAD']) +}) diff --git a/skills/phpcheck-server/SKILL.md b/skills/phpcheck-server/SKILL.md new file mode 100644 index 0000000..51b972a --- /dev/null +++ b/skills/phpcheck-server/SKILL.md @@ -0,0 +1,15 @@ +--- +name: phpcheck-server +description: Check PHP2Shell patch status with local or server access. +--- + +Check the PHP version serving the site. Start with `php -v`, but verify that +it matches the site's PHP-FPM or Apache installation. + +Compare the version and full package revision with current PHP or vendor +security advisories. Vendor backports may fix an older version. + +The running PHP build and the vendor's package advisory are the useful checks. +Configuration settings alone do not establish that PHP is patched. +Report the version, patch status, and any required update. If you cannot +verify the serving version, report unknown. Do not run exploit probes. diff --git a/skills/phpcheck-url/SKILL.md b/skills/phpcheck-url/SKILL.md new file mode 100644 index 0000000..1c929bd --- /dev/null +++ b/skills/phpcheck-url/SKILL.md @@ -0,0 +1,19 @@ +--- +name: phpcheck-url +description: Check the advertised PHP version from a URL or hostname. +--- + +From this skill's directory, run: + +```sh +node scripts/phpcheck.js "https://example.com" --json +``` + +Use HTTPS for a bare hostname. The checker reads `Server` and `X-Powered-By` +headers. If the version is hidden, report unknown. + +Compare the reported version with current PHP security guidance. Headers +can be changed and vendor backports may fix an older version, so a server-side +version and package check is stronger evidence. Do not run exploit probes. + +Exit codes 1 and 3 mean vulnerable and unknown, not execution failures. diff --git a/skills/phpcheck-url/scripts/phpcheck.js b/skills/phpcheck-url/scripts/phpcheck.js new file mode 100755 index 0000000..a2b3c62 --- /dev/null +++ b/skills/phpcheck-url/scripts/phpcheck.js @@ -0,0 +1,99 @@ +#!/usr/bin/env node +const { execFileSync } = require('node:child_process') +const { parseArgs } = require('node:util') + +const VERSION = '1.0.0' +// Vulnerable version cutoffs as of 2026-10-20. A newer stable patch in a +// listed branch is OK by this table; unlisted branches remain unknown. +const VULNERABLE_THROUGH = { + '5.4': 45, '5.5': 38, '5.6': 40, + '7.0': 33, '7.1': 33, '7.2': 34, '7.3': 33, '7.4': 33, + '8.0': 30, '8.1': 34, '8.2': 34, '8.3': 35, '8.4': 26, '8.5': 11, +} +const USAGE = 'Usage: node phpcheck.js URL [--timeout SECONDS] [--json]\n' + + ' node phpcheck.js --local [--php PATH] [--json]\n' + + ' node phpcheck.js --version' + +function patchStatus(version) { + const match = /^(\d+)\.(\d+)\.(\d+)([-+~].*)?$/.exec(version || '') + if (!match) return 'unknown' + const cutoff = VULNERABLE_THROUGH[`${Number(match[1])}.${Number(match[2])}`] + if (cutoff === undefined) return 'unknown' + if (Number(match[3]) <= cutoff) return 'vulnerable' + // A development build of the next patch is not evidence of a stable fix. + if (/(?:^|[-+~.])(?:dev|alpha|beta|rc)/i.test(match[4] || '')) return 'unknown' + return 'ok' +} + +async function urlHeaders(address, timeout) { + if (/[\x00-\x20\x7f]/.test(address)) throw new Error('URL contains spaces or control characters') + const url = new URL(address) + if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Use an http:// or https:// URL') + if (url.username || url.password || url.hash) throw new Error('URL credentials and fragments are not supported') + const headers = [] + for (const method of ['HEAD', 'GET']) { + const response = await fetch(url, { + method, redirect: 'manual', signal: AbortSignal.timeout(Math.ceil(timeout * 1000)), + }) + headers.push(response.headers.get('server') || '', response.headers.get('x-powered-by') || '') + await response.body?.cancel() + if (![405, 501].includes(response.status)) break + } + return headers.join('\n') +} + +async function main() { + let values, address, timeout + try { + const args = parseArgs({ + allowPositionals: true, + options: { + local: { type: 'boolean' }, + php: { type: 'string', default: 'php' }, + timeout: { type: 'string', default: '10' }, + json: { type: 'boolean' }, + version: { type: 'boolean' }, + help: { type: 'boolean', short: 'h' }, + }, + }) + values = args.values + if (values.version) return console.log(VERSION) + if (values.help) return console.log(USAGE) + address = args.positionals[0] + if (args.positionals.length > 1 || (values.local && address) || (!values.local && !address)) { + throw new Error('Provide a URL or --local, not both') + } + timeout = Number(values.timeout) + if (!values.local && !(timeout > 0 && timeout <= 60)) throw new Error('--timeout must be greater than 0 and at most 60 seconds') + } catch (error) { + console.error(error.message + '\n' + USAGE) + process.exitCode = 2 + return + } + + let version = null, error = null, status = 'unknown' + try { + const text = values.local + ? execFileSync(values.php, ['-v'], { encoding: 'utf8', timeout: 10000 }) + : await urlHeaders(address, timeout) + for (const match of text.matchAll(/\bPHP[ /]([^\s,;)]+)/gi)) { + const candidateStatus = patchStatus(match[1]) + // Do not let an OK banner hide a vulnerable or unrecognized PHP version. + if (version === null || candidateStatus === 'vulnerable' || status === 'ok') { + version = match[1] + status = candidateStatus + } + if (status === 'vulnerable') break + } + } catch (exc) { + error = exc.message + } + + const code = { ok: 0, vulnerable: 1, unknown: 3 }[status] + const reason = error || (version ? 'PHP ' + version : 'PHP version not found') + const report = { patch_status: status, php_version: version, reason, exit_code: code } + console.log(values.json ? JSON.stringify(report) : status.toUpperCase() + ': ' + reason) + process.exitCode = code +} + +main() From 89c921804a2482f44162278f2a3fbf035ae418aa Mon Sep 17 00:00:00 2001 From: Ian Webster Date: Tue, 20 Oct 2026 22:04:37 -0700 Subject: [PATCH 2/3] Include vulnerable version ranges in the server skill --- skills/phpcheck-server/SKILL.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/skills/phpcheck-server/SKILL.md b/skills/phpcheck-server/SKILL.md index 51b972a..f8026ec 100644 --- a/skills/phpcheck-server/SKILL.md +++ b/skills/phpcheck-server/SKILL.md @@ -6,8 +6,28 @@ description: Check PHP2Shell patch status with local or server access. Check the PHP version serving the site. Start with `php -v`, but verify that it matches the site's PHP-FPM or Apache installation. +Vulnerable ranges used by the checker as of October 20, 2026: + +| PHP branch | Vulnerable range (inclusive) | +| --- | --- | +| 5.4 | 5.4.0 to 5.4.45 | +| 5.5 | 5.5.0 to 5.5.38 | +| 5.6 | 5.6.0 to 5.6.40 | +| 7.0 | 7.0.0 to 7.0.33 | +| 7.1 | 7.1.0 to 7.1.33 | +| 7.2 | 7.2.0 to 7.2.34 | +| 7.3 | 7.3.0 to 7.3.33 | +| 7.4 | 7.4.0 to 7.4.33 | +| 8.0 | 8.0.0 to 8.0.30 | +| 8.1 | 8.1.0 to 8.1.34 | +| 8.2 | 8.2.0 to 8.2.34 | +| 8.3 | 8.3.0 to 8.3.35 | +| 8.4 | 8.4.0 to 8.4.26 | +| 8.5 | 8.5.0 to 8.5.11 | + Compare the version and full package revision with current PHP or vendor -security advisories. Vendor backports may fix an older version. +security advisories. Vendor backports may fix an older version. Treat unlisted +branches as unknown until an advisory confirms their status. The running PHP build and the vendor's package advisory are the useful checks. Configuration settings alone do not establish that PHP is patched. From 9371c8f1c388e2e1f185dc25d402181b5a6da6b4 Mon Sep 17 00:00:00 2001 From: Ian Webster Date: Tue, 20 Oct 2026 22:06:05 -0700 Subject: [PATCH 3/3] Simplify local PHP check wording --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 20dce0a..e0a7659 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Check a website: node phpcheck.js "https://example.com" ``` -Check local CLI PHP, which may differ from the version serving the site: +Check the local CLI PHP ```sh node phpcheck.js --local