An extension for PHPStan that detects common security vulnerabilities in PHP code.
Its main feature is taint analysis: following user input ($_GET,
Symfony's and Laravel's Request objects, and Symfony route parameters) through variables and function calls, and
reporting when it reaches a dangerous location such as a SQL query, a shell command, or echo.
Because it analyzes the whole project at once, it also catches input that a controller passes to a service or repository
in another file.
Warning
This extension is experimental and under active development. Error identifiers, configuration and behavior may change between releases. A clean run does not mean your code is free of vulnerabilities.
| Vulnerability | Identifier | Supported sinks |
|---|---|---|
| SQL injection | tainted.sql |
PDO, mysqli, SQLite3, Doctrine DBAL and ORM, Laravel connections, the DB facade, query builders |
| Cross-site scripting (XSS) | tainted.html |
echo, print, printf(), vprintf(), Symfony and Laravel new Response() |
| Command injection | tainted.shell |
exec(), system(), shell_exec(), backticks, Symfony Process::fromShellCommandline(), ... |
| Path traversal, SSRF | tainted.path |
file_get_contents(), fopen(), unlink(), Symfony Filesystem, AbstractController::file(), new BinaryFileResponse(), ... |
| File inclusion | tainted.include |
include, include_once, require, require_once |
| Open redirect | tainted.redirect |
header() with a Location: or Refresh: header, Symfony AbstractController::redirect(), new RedirectResponse() |
| Insecure deserialization | tainted.unserialize |
unserialize() |
| Callable injection | tainted.callable |
call_user_func(), call_user_func_array() |
| Eval injection | tainted.eval |
eval |
| XPath injection | tainted.xpath |
DOMXPath::query(), DOMXPath::evaluate(), SimpleXMLElement::xpath(), Symfony Crawler::filterXPath(), Crawler::evaluate() |
| LDAP injection | tainted.ldap |
ldap_search(), ldap_list(), ldap_read(), Symfony LdapInterface::query() |
User input is detected from:
$_GET,$_POST,$_REQUEST,$_COOKIE- Symfony's
Requestand itsquery,request,cookiesandheadersbags - Laravel's
Requestand its facade (input(),query(),all(),header(), ...), andvalidated()andsafe()on aFormRequest - Controller arguments that match a
{placeholder}in a#[Route]attribute
A value is no longer considered tainted when it:
- Passes through a sanitizer for that kind of sink:
htmlspecialchars(),htmlentities()and Laravel'se()for XSS,escapeshellarg()for command injection,ldap_escape()for LDAP injection, andbasename()for path traversal and file inclusion. The value stays tainted for other sinks, soexec(htmlspecialchars($input))is still reported.escapeshellcmd()isn't a sanitizer, because it allows argument injection. - Has a native type that can't carry an injection: casts like
(int),int,floatandboolparameters and return types, and values checked withis_numeric()orin_array(..., true).
composer require --dev nth-root/phpstan-securityIf you use phpstan/extension-installer, that's all. Otherwise,
include the extension in your phpstan.neon:
includes:
- vendor/nth-root/phpstan-security/extension.neonTo ignore a single finding, use a @phpstan-ignore PHPDoc tag with the identifier:
/** @phpstan-ignore tainted.sql */
$connection->executeQuery($sql);Or ignore a whole category of findings with ignoreErrors in phpstan.neon:
parameters:
ignoreErrors:
- identifier: tainted.sql| Application | Result |
|---|---|
| VulnerableSymfony | SQL injection in /search and /login, path traversal in /legal/content and the avatar upload |
| DVWA | SQL injection, command injection, open redirect and path traversal, and XSS through the security cookie in dvwaHtmlEcho(). Reflected XSS (xss_r) and file inclusion (fi) are missed, the impossible level of exec is a false positive, and the JSONP callback in csp is reported as XSS although it's served as JSON |
| vAPI | SQL injection in API8UsersController, SSRF in SurferController |
| Symfony Demo | No findings |
- Only native types are trusted: a value with a native
inttype or one narrowed byis_numeric()isn't tainted, but a parameter documented as@param intis. - Parameters and return values are shared by all calls to a function: if any caller passes user input to a function that returns its argument, the return value is considered tainted at every call site (#6).
- The analysis is flow-insensitive: once a variable holds user input, it is considered tainted in that function, even if it is overwritten at a later point (#5).
- Responses are checked for XSS even when they aren't HTML (#7), such as
new Response($csv, 200, ['Content-Type' => 'text/csv'])or a LaravelResponsewith an array (which becomes JSON). - Redirects are reported even when the URL starts with a constant path, as in
'/dashboard?tab=' . $tab, although the host can't change (#8).
See also the open issues labelled false positive.
To suppress a false positive, see Ignoring findings.
The analysis only follows tainted data through code it understands.
$_SERVERand$request->attributesare not detected as sources yet (#19, #20).- Built-in functions that aren't configured as a flow function stop taint.
htmlspecialchars()andhtmlentities()count as HTML sanitizers regardless of their$flagsand of where the output ends up, so XSS through an unquoted attribute or ajavascript:URL isn't detected.ldap_escape()counts as an LDAP sanitizer regardless of its$flags, so a value escaped for a DN but used in a filter (or the other way around) isn't detected.basename()counts as a sanitizer for file paths, butbasename('..')is still..:'/uploads/' . basename($name)can name the parent directory, which matters forunlink()orFilesystem::remove(). Without a directory prefix, adata:URL also passes through.- Values assigned to properties (
$this->query = $input) or array elements ($arr['q'] = $input) aren't followed yet (#12, #11). foreachloops aren't followed: inforeach ($_GET['ids'] as $id),$idisn't considered tainted (#10).- Symfony route parameters are only treated as user input for routes defined with
#[Route]attributes. Other routing configuration (YAML, XML, PHP) is not read. - Twig templates aren't analyzed, so XSS through
{{ ... |raw }}isn't detected. - Static calls on Eloquent models (
User::whereRaw()) aren't detected yet (#53). - Laravel's
$request->name, therequest('name')helper, and methods called on$request->string()aren't followed yet (#25, #21). - Laravel route parameters from
routes/*.phparen't treated as user input (#26). - Blade templates aren't analyzed, so XSS through
{!! ... !!}isn't detected.
See also the open issues labelled false negative.