How it works
Four steps: find the files, parse them as PHP would, layer them into effective configurations, then evaluate every rule against each one.
Safety model #
- No execution. The tool never runs
php, a shell or any other process, never callseval, and never opens a network connection.tests/SafetyTest.phpwalks the source tokens and fails if such a call appears. - Read-only file access. Only regular files are opened. FIFOs, devices and sockets are refused so that a planted
php.iniFIFO cannot hang the scan. Files over 4 MiB or containing binary data are skipped with a warning. - Confined roots. With
--root, every symlink is resolved component by component inside the root. An absolute link such asphp.ini → /etc/shadowresolves to<root>/etc/shadow, never to the host file. - Metadata only for paths. Paths referenced by settings are inspected with
stat(); their contents are never read. - No environment expansion.
${VAR}in a value is kept literally and reported (CFG-007). The scanner's own environment is not PHP's.
1. Discovery #
Without arguments, these locations are searched (inside --root when given):
| Platform | php.ini | Scan dir / pools |
|---|---|---|
| Debian, Ubuntu | /etc/php/*/{cli,fpm,apache2,cgi}/php.ini | conf.d/, pool.d/ |
| RHEL, Fedora, Remi | /etc/php.ini, /etc/opt/remi/php*/php.ini | /etc/php.d/, /etc/php-fpm.d/ |
| Alpine, SUSE, Arch | /etc/php*/php.ini, /etc/php*/*/php.ini, /etc/php/php.ini | conf.d/, php-fpm.d/ |
| Official Docker images | /usr/local/etc/php/php.ini | conf.d/, /usr/local/etc/php-fpm.d/ |
| FreeBSD | /usr/local/etc/php.ini | /usr/local/etc/php/ |
| Homebrew | /opt/homebrew/etc/php/*/php.ini, /usr/local/etc/php/*/php.ini | conf.d/, php-fpm.d/ |
| cPanel, Plesk, CloudLinux | /opt/cpanel/ea-php*/root/etc/, /opt/plesk/php/*/etc/, /opt/alt/php*/etc/ | php.d/ |
| Bundles | XAMPP, MAMP, Bitnami, Zend Server, /usr/local/lib/php.ini | sibling conf.d/ |
| Windows | C:\php, XAMPP, WAMP, Laragon, C:\Program Files\PHP\* | — |
The SAPI and PHP version come from the path, e.g. /etc/php/8.2/fpm/ is FPM on 8.2. A PHP directory that has php.ini-production or a conf.d/ but no php.ini is audited as running on built-in defaults (CFG-006). That is the layout of the official Docker images.
2. Parsing like PHP #
;starts a comment, including after an unquoted value.#is not a comment since PHP 7 and is reported.- Unquoted
On/Yes/Truebecome1;Off/No/False/None/Nullbecome empty. That is whysession.cookie_samesite = Noneunquoted actually means "not set". - Double- and single-quoted values, concatenation and multi-line quoted values follow the INI scanner.
error_reportingexpressions such asE_ALL & ~E_DEPRECATEDare evaluated by a small parser with PHP's operator rules, never byeval.- Quantities (
128M,2G,0x10) and booleans are read with the semantics ofzend_ini_parse_quantity()andzend_ini_parse_bool(). - Directive names are case-sensitive;
Display_Errorsis ignored by PHP and reported as a likely typo.
3. Layering #
A base configuration is built in PHP's order, with the last assignment winning:
- PHP's compiled-in defaults
php.ini- scan-directory
*.inifiles in alphabetical order
Each of these is then evaluated as a separate overlay on top of its base:
| Overlay | Can change | Notes |
|---|---|---|
| PHP-FPM pool | anything | php_admin_value[disable_functions] and disable_classes append to php.ini, as in PHP-FPM. Pool settings (listen, user, security.limit_extensions…) feed the FPM rules. |
[PATH=…] / [HOST=…] sections | anything | Applied only to matching CGI/FastCGI requests. |
.user.ini | PHP_INI_PERDIR, PHP_INI_ALL | Attached to FPM/CGI bases. |
.htaccess | PHP_INI_PERDIR, PHP_INI_ALL | Attached to Apache bases; php_admin_* is a configuration error there. |
An overlay that tries to set a PHP_INI_SYSTEM directive, such as disable_functions or allow_url_fopen in .user.ini, does not take effect in PHP. The scanner reports it (CFG-005) rather than counting it as protection. Overlay findings only cover what the overlay changes, so base findings are not repeated.
Built-in defaults matter #
When a directive is not set anywhere, PHP uses its compiled-in default, not the value in php.ini-production. Several of those defaults are insecure:
| Directive | Built-in default | Rule |
|---|---|---|
display_errors | 1 | ERR-001 |
register_argc_argv | 1 | EXEC-011 |
expose_php | 1 | ERR-004 |
enable_dl | 1 | EXEC-008 |
session.use_strict_mode | 0 | SESS-001 |
session.cookie_httponly | 0 | SESS-005 |
cgi.fix_pathinfo | 1 | CGI-002 |
disable_functions, open_basedir | empty | EXEC-005, FS-001 |
Findings caused by a default say at built-in default and have "from_default": true in JSON.
4. Rule evaluation #
- Config rules see one effective configuration. Most security checks are config rules.
- Pool rules see an FPM pool's own settings (
listen,user,env[…]). - File rules run once per file, wherever it is layered: syntax problems, typos, obsolete or deprecated directives, file permissions,
.htaccesshandlers.
Rules declare the SAPIs they apply to. Web-only checks such as session cookies or open_basedir are skipped for cli, and an unknown SAPI is treated as web-facing. The development profile caps disclosure rules at info and skips production-only rules.
Known vulnerabilities and the PHP version #
Some PHP vulnerabilities can only be exploited when a feature is enabled. CVE-2024-4577 needs php-cgi on Windows, CVE-2024-9026 needs catch_workers_output in an FPM pool, and the 2025 HTTP-wrapper bugs need allow_url_fopen. For each tracked CVE the scanner checks two things:
- Version: is the PHP patch release older than the fix for its branch? Branches that reached end of life between fixed branches never got the fix, so they count as affected.
- Configuration: does the effective configuration use the vulnerable feature?
A CVE is reported only when both are true. Fixed-in releases come from the official PHP 8 and PHP 7 changelogs. VULN-001 also reports branches past, or within 180 days of, the end of security support.
Every other publicly disclosed CVE fixed in a later release (parsers, extensions, bundled libraries) is listed in one warning per configuration, VULN-003, with the severity of the worst one. Fixed-in releases come from the changelogs and php-src security advisories, severities from those advisories or NVD.
Where the version comes from
The exact version is read from files, never by running php -v:
| Source | Example | Confidence |
|---|---|---|
--php-version X.Y.Z | value you pass | high |
PHP's php_version.h header | /usr/local/include/php/main/php_version.h (official Docker images) | high |
| Alpine package database | /lib/apk/db/installed | high |
| Homebrew links | /opt/homebrew/opt/php@8.3 → Cellar/php@8.3/8.3.12 | high |
| dpkg database, deb.sury.org builds | 8.3.12-1+ubuntu22.04.1+deb.sury.org+1 | high |
| dpkg database, distribution builds | 8.2.7-1~deb12u1 | low: Debian and Ubuntu backport fixes without changing the upstream version |
| directory name | /etc/php/8.2/ | branch only |
When only the branch is known, the CVEs that apply to the configuration are summarised in one finding (VULN-002) with the release that fixes each, instead of one finding per CVE. Version files are read for host scans and --root scans; for copied configuration files, pass --php-version. RPM databases are binary and are not read.
Path checks #
About 25 directives name paths: extension_dir, session.save_path, upload_tmp_dir, error_log, mail.log, opcache.file_cache, openssl.cafile, curl.cainfo, sendmail_path, absolute extension= paths and others. Each gets a severity for two situations:
- World-writable (FS-005): the path, or the directory where it can be created or replaced, is writable by every local user. For example, a world-writable
extension_diris critical. - Web-served (FS-006): the path is under a
--webroot. Without one, a guess from directory names such aspublic_htmlorhtdocsis used, with low confidence.
Configuration files themselves are checked as well (FS-011).
Limitations #
- A static audit sees files only. It cannot see
php -dflags,PHP_INI_SCAN_DIRorPHP_VALUEset by a web server,ini_set()at runtime, or which extensions are compiled in. php_admin_valuein Apache vhost files is not parsed (.htaccessis).- Only configuration-dependent CVEs are matched to settings; the rest are matched to the version alone and may not be reachable by your code. Distribution backports can make a patched package look vulnerable, which is why those findings carry low confidence.
- The built-in defaults table follows PHP 8. Paths compiled into a particular build (
extension_dir,include_path) may differ.