Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,7 +24,7 @@ Check a website:
node phpcheck.js "https://example.com"
```

Check local PHP:
Check the local CLI PHP

```sh
node phpcheck.js --local
Expand Down
18 changes: 0 additions & 18 deletions SKILL.md

This file was deleted.

99 changes: 1 addition & 98 deletions phpcheck.js
Original file line number Diff line number Diff line change
@@ -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')
21 changes: 19 additions & 2 deletions phpcheck.test.js
Original file line number Diff line number Diff line change
@@ -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 })
Expand Down Expand Up @@ -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'])
})
35 changes: 35 additions & 0 deletions skills/phpcheck-server/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
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.

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. 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.
Report the version, patch status, and any required update. If you cannot
verify the serving version, report unknown. Do not run exploit probes.
19 changes: 19 additions & 0 deletions skills/phpcheck-url/SKILL.md
Original file line number Diff line number Diff line change
@@ -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.
99 changes: 99 additions & 0 deletions skills/phpcheck-url/scripts/phpcheck.js
Original file line number Diff line number Diff line change
@@ -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()