diff --git a/README.md b/README.md index 7f89120..e0a7659 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 the local CLI PHP ```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..f8026ec --- /dev/null +++ b/skills/phpcheck-server/SKILL.md @@ -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. 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()