diff --git a/README.md b/README.md index 59a6c13..7f89120 100644 --- a/README.md +++ b/README.md @@ -28,12 +28,39 @@ Check local PHP: node phpcheck.js --local ``` +## Results + +| Result | Exit code | Meaning | +| --- | --- | --- | +| `OK` | `0` | Stable PHP patch version above the embedded cutoff for a listed major/minor branch. | +| `VULNERABLE` | `1` | PHP version at or below the embedded cutoff for that branch. | +| `UNKNOWN` | `3` | No PHP version or response, an unrecognized version/branch, or a newer prerelease build. | + +Use `--json` for machine-readable output (`patch_status` is `ok`, `vulnerable`, +or `unknown`). Invalid arguments exit with code `2`. + +## Tests + +```sh +node --test phpcheck.test.js +``` + +Tests run the CLI against local HTTP servers simulating PHP response headers; +no PHP installation or external website is required. + ## Limitations The checker only evaluates versions exposed in HTTP response headers or by -`php -v`. `UNKNOWN` means it could not confirm a vulnerable version, not that -the target is patched. The embedded version ranges are current as of October -20, 2026. +`php -v`. It reads `X-Powered-By` and `Server`, sends `HEAD`, and falls back to +`GET` only when `HEAD` returns 405 or 501. It does not follow redirects or +inspect response bodies. Missing or hidden PHP headers return `UNKNOWN`. + +`OK` is a version comparison against the embedded October 20, 2026 cutoffs, +assuming subsequent stable patches in each listed branch contain the fix; +it does not independently verify a security fix. Check the cutoffs against +the official PHP security release when its affected and fixed versions are +published. Headers can be hidden or changed, and distribution backports may +not change the upstream version. `UNKNOWN` does not mean the target is patched. ## Contributing diff --git a/phpcheck.js b/phpcheck.js index 81a777e..a2b3c62 100755 --- a/phpcheck.js +++ b/phpcheck.js @@ -3,7 +3,8 @@ const { execFileSync } = require('node:child_process') const { parseArgs } = require('node:util') const VERSION = '1.0.0' -// Known vulnerable releases as of 2026-10-20. Newer releases remain unknown. +// 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, @@ -13,10 +14,15 @@ const USAGE = 'Usage: node phpcheck.js URL [--timeout SECONDS] [--json]\n' + ' node phpcheck.js --local [--php PATH] [--json]\n' + ' node phpcheck.js --version' -function isVulnerable(version) { - const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+~].*)?$/.exec(version || '') - if (!match) return false - return Number(match[3]) <= (VULNERABLE_THROUGH[`${Number(match[1])}.${Number(match[2])}`] ?? -1) +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) { @@ -65,21 +71,25 @@ async function main() { return } - let version = null, error = null + 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)) { - version = match[1] - if (isVulnerable(version)) break + 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 status = isVulnerable(version) ? 'vulnerable' : 'unknown' - const code = status === 'vulnerable' ? 1 : 3 + 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) diff --git a/phpcheck.test.js b/phpcheck.test.js new file mode 100644 index 0000000..9622411 --- /dev/null +++ b/phpcheck.test.js @@ -0,0 +1,152 @@ +const assert = require('node:assert/strict') +const { spawn } = require('node:child_process') +const { once } = require('node:events') +const http = require('node:http') +const path = require('node:path') +const { test } = require('node:test') + +function run(args) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [path.join(__dirname, 'phpcheck.js'), ...args]) + let stdout = '', stderr = '' + child.stdout.on('data', chunk => { stdout += chunk }) + child.stderr.on('data', chunk => { stderr += chunk }) + child.on('error', reject) + child.on('close', code => resolve({ code, stdout, stderr })) + }) +} + +async function serve(t, handler) { + const server = http.createServer(handler) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + t.after(() => new Promise(resolve => { + server.close(resolve) + server.closeAllConnections() + })) + return `http://127.0.0.1:${server.address().port}/` +} + +function check(result, status, version) { + const code = { ok: 0, vulnerable: 1, unknown: 3 }[status] + assert.equal(result.stderr, '') + assert.equal(result.code, code, result.stdout) + const report = JSON.parse(result.stdout) + assert.equal(report.patch_status, status) + assert.equal(report.php_version, version) + assert.equal(report.exit_code, code) + return report +} + +for (const header of ['X-Powered-By', 'x-PoWeReD-bY', 'Server']) { + for (const [version, status] of [['8.4.26', 'vulnerable'], ['8.4.27', 'ok'], ['8.4.100', 'ok']]) { + test(`${header}: PHP/${version} => ${status}`, async t => { + const methods = [] + const url = await serve(t, (req, res) => { + methods.push(req.method) + res.setHeader(header, header === 'Server' ? `Apache/2.4.62 (Unix) PHP/${version}` : `PHP/${version}`) + res.end() + }) + check(await run([url, '--json']), status, version) + assert.deepEqual(methods, ['HEAD']) + }) + } +} + +test('each configured branch has an inclusive vulnerable boundary and a newer OK version', async t => { + // Explicit fixtures: these assert the CLI contract without importing its classifier. + const boundaries = ['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'] + let header + const url = await serve(t, (req, res) => { res.setHeader('X-Powered-By', header); res.end() }) + for (const version of boundaries) { + header = `PHP/${version}` + check(await run([url, '--json']), 'vulnerable', version) + const [major, minor, patch] = version.split('.').map(Number) + const newer = `${major}.${minor}.${patch + 1}` + header = `PHP/${newer}` + check(await run([url, '--json']), 'ok', newer) + } +}) + +for (const version of ['8.6.0', '9.0.0', '8.4', 'not-a-version', '8.4.27RC1', '8.4.27-rc1', '8.4.27~beta1']) { + test(`unrecognized or prerelease PHP/${version} => unknown`, async t => { + const url = await serve(t, (req, res) => { res.setHeader('X-Powered-By', `PHP/${version}`); res.end() }) + check(await run([url, '--json']), 'unknown', version) + }) +} + +for (const [version, status] of [['8.4.26-1ubuntu1', 'vulnerable'], ['8.4.27-1ubuntu1', 'ok']]) { + test(`distribution suffix PHP/${version} => ${status}`, async t => { + const url = await serve(t, (req, res) => { res.setHeader('X-Powered-By', `PHP/${version}`); res.end() }) + check(await run([url, '--json']), status, version) + }) +} + +test('no PHP headers => unknown, even if the body mentions a PHP version', async t => { + const url = await serve(t, (req, res) => { + res.setHeader('Server', 'nginx') + res.setHeader('X-Powered-By', 'Express') + res.end('PHP/8.4.27') + }) + const report = check(await run([url, '--json']), 'unknown', null) + assert.equal(report.reason, 'PHP version not found') + const text = await run([url]) + assert.equal(text.code, 3) + assert.equal(text.stdout, 'UNKNOWN: PHP version not found\n') +}) + +test('empty HTTP response headers => unknown', async t => { + const url = await serve(t, (req, res) => { res.sendDate = false; res.end() }) + check(await run([url, '--json']), 'unknown', null) +}) + +test('connection closes without any HTTP response => unknown', async t => { + const url = await serve(t, req => { req.socket.destroy() }) + const report = check(await run([url, '--json']), 'unknown', null) + assert.ok(report.reason) +}) + +test('a response timeout => unknown', async t => { + const url = await serve(t, () => {}) + const report = check(await run([url, '--timeout', '0.1', '--json']), 'unknown', null) + assert.match(report.reason, /timeout/i) +}) + +for (const statusCode of [405, 501]) { + test(`HEAD ${statusCode} falls back to GET and reads PHP headers`, async t => { + const methods = [] + const url = await serve(t, (req, res) => { + methods.push(req.method) + if (req.method === 'HEAD') res.writeHead(statusCode) + else res.setHeader('X-Powered-By', 'PHP/8.4.27') + res.end() + }) + check(await run([url, '--json']), 'ok', '8.4.27') + assert.deepEqual(methods, ['HEAD', 'GET']) + }) +} + +for (const [server, poweredBy, expectedVersion, status] of [ + ['8.4.26', '8.4.27', '8.4.26', 'vulnerable'], + ['8.4.27', '8.4.26', '8.4.26', 'vulnerable'], + ['9.0.0', '8.4.27', '9.0.0', 'unknown'], + ['8.4.27', '9.0.0', '9.0.0', 'unknown'], +]) { + test(`conflicting headers ${server} / ${poweredBy} => ${status}`, async t => { + const url = await serve(t, (req, res) => { + res.setHeader('Server', `Apache/2.4.62 PHP/${server}`) + res.setHeader('X-Powered-By', `PHP/${poweredBy}`) + res.end() + }) + check(await run([url, '--json']), status, expectedVersion) + }) +} + +test('plain text output for a newer version is OK with exit code 0', async t => { + const url = await serve(t, (req, res) => { res.setHeader('X-Powered-By', 'PHP/8.4.27'); res.end() }) + const result = await run([url]) + assert.equal(result.stderr, '') + assert.equal(result.code, 0) + assert.equal(result.stdout, 'OK: PHP 8.4.27\n') +})