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
33 changes: 30 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
30 changes: 20 additions & 10 deletions phpcheck.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down
152 changes: 152 additions & 0 deletions phpcheck.test.js
Original file line number Diff line number Diff line change
@@ -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')
})