laramint / php-security-scanner
Framework-agnostic static security scanner for PHP. Detects SQLi, XSS, command injection, path traversal, insecure deserialization, weak crypto, hardcoded secrets, and more.
Requires
- php: ^8.0
- ext-json: *
- nikic/php-parser: ^5.0
- symfony/console: ^6.0 || ^7.0
- symfony/finder: ^6.0 || ^7.0
- symfony/yaml: ^6.0 || ^7.0
Requires (Dev)
- laravel/pint: ^1.13
- phpstan/phpstan: ^1.11
- phpunit/phpunit: ^9.6
Suggests
- ext-curl: Faster HTTP for the composer.lock CVE audit; falls back to stream wrappers if absent.
- composer/semver: Strict semantic version matching for advisory constraints; a built-in matcher is used otherwise.
README
A framework-agnostic, static security scanner for PHP.
PHP Security Scanner
A framework-agnostic, static security scanner for PHP. Detects the OWASP-ish core of vulnerabilities — SQL injection, XSS, command injection, path traversal, insecure deserialization, weak crypto, hardcoded secrets, weak randomness, eval, disabled TLS verification, XXE, open redirect, insecure cookies — using AST analysis with kind-aware taint tracking. Also audits composer.lock against the Packagist Security Advisories database so known-CVE dependencies fail the build.
Ships as a CLI binary, a library you can embed, a PHAR you can download, and a GitHub Action that uploads SARIF to Code Scanning.
Install
As a dev dependency
composer require --dev laramint/php-security-scanner ./vendor/bin/php-security-scanner scan src/
As a PHAR (no Composer conflicts)
curl -fsSL https://github.com/laramint/php-security-scanner/releases/latest/download/php-security-scanner.phar \
-o /usr/local/bin/php-security-scanner
chmod +x /usr/local/bin/php-security-scanner
php-security-scanner scan src/
Usage
php-security-scanner scan [paths...] [options] --format=pretty|json|sarif|junit (default: pretty) --severity-threshold=low|medium|high|critical (suppress below) --fail-on=critical|high|medium|low|none (CI gate; default: high) --config=PATH (.yaml, .yml, or .php; auto-discovers php-security-scanner.yaml/.yml/.php) --exclude=GLOB (repeatable) --rule=ID (run only these; repeatable) --skip-rule=ID (skip these; repeatable) --baseline=baseline.json --update-baseline --output=PATH --extension=Fqcn\\To\\Extension (repeatable) --no-progress --no-audit (skip composer.lock CVE audit) --audit-offline (use cached advisories only) --explain (emit INFO notes for sinks the scanner considered but kept silent, naming the sanitizer/cast/method that cleared the taint) php-security-scanner list-rules
Exit codes: 0 clean • 1 finding ≥ --fail-on • 2 config error.
The pretty progress output is PHPUnit-style — one character per file written to stderr so piping JSON/SARIF/JUnit to stdout stays clean:
....⨯.....⨯.............................................. 60 / 134 (44%)
.....................................⨯...... 134 / 134 (100%)
FAIL 134 file(s) scanned in 1.42s, 6 finding(s).
. (gray) = clean file, ⨯ (red bold) = file with at least one finding. Trailing label shows progress (done / total (%)) and the final summary shows the elapsed scan time.
By default, the file walker silently skips vendor/, node_modules/, bower_components/, .git/, storage/, bootstrap/cache/, build/, dist/, out/, coverage/, IDE state (.idea/, .vscode/, .fleet/, .zed/), and tool caches (.phpunit.cache/, .phpstan.cache/, .pint.cache/, .psalm.cache/, .rector.cache/, etc.) — see FileFinder::DEFAULT_SKIP_DIRS. Only add project-specific extras to exclude.
Rules
| ID | Default severity | Detects |
|---|---|---|
eval |
high | eval() use, especially with tainted input |
sql.injection |
critical | Tainted input concatenated into raw SQL |
cmd.injection |
critical | Tainted input passed to exec/shell_exec/system/... |
path.traversal |
high | Tainted input used as file path or include/require target |
xss.echo |
high | Tainted input echoed without htmlspecialchars |
deserialize |
critical | unserialize() on tainted input |
crypto.weak-hash |
medium | md5/sha1 on security-sensitive data |
crypto.deprecated |
high | mcrypt_* functions |
random.insecure |
medium | rand/mt_rand/uniqid used for tokens/secrets/CSRF |
secret.hardcoded |
high | High-entropy literals in $api_key, SECRET, etc. |
ssl.disabled |
critical | CURLOPT_SSL_VERIFYPEER/VERIFYHOST set to false/0 |
xxe |
high | XML parsed without LIBXML_NONET |
redirect.open |
medium | header("Location: …") from tainted input |
cookie.insecure |
medium | setcookie() without secure/httponly/samesite |
assert.use |
high | assert() with a string/tainted argument (eval-like) |
cmd.backticks |
high | Backtick shell-exec operator, especially with tainted input |
eval.mb-ereg |
high | mb_ereg_replace() with the e (eval) modifier |
file.inclusion |
critical | LFI/RFI: tainted input in include/require |
callable.tainted |
critical | Tainted value used as a callable in call_user_func and friends |
object.tainted-new |
high | Tainted value used as class name in new $cls() |
ssrf |
high | Tainted URL passed to curl_init, curl_setopt(CURLOPT_URL), Guzzle, etc. |
cors.permissive |
high | Access-Control-Allow-Origin: * or reflected origin |
net.ftp-cleartext |
medium | ftp_connect() — credentials/data in clear (use ftp_ssl_connect) |
ldap.anonymous-bind |
high | ldap_bind() with empty/null/missing password |
crypto.mcrypt-deprecated |
high | Use of any mcrypt_* function |
crypto.md5-loose-eq |
medium | Loose ==/!= comparison on md5/sha1/hash output |
crypto.md5-password |
high | $password = md5(...) / sha1(...) — use password_hash() |
crypto.cbc-static-iv |
high | openssl_encrypt in CBC mode with a constant/empty IV |
crypto.openssl-decrypt-novalidate |
medium | openssl_decrypt with unauthenticated mode (CBC/ECB) |
crypto.sha224 |
low | hash('sha224', …) — truncated SHA-256, prefer sha256/sha512 |
info.phpinfo |
high | phpinfo() exposes runtime config and environment |
audit.composer.vulnerable-package |
from advisory (default high) | Composer dependency with a known CVE published on Packagist |
audit.composer.network-error |
info | Could not contact the Packagist advisories endpoint while auditing composer.lock |
Run php-security-scanner list-rules to see the live list.
Dependency CVE audit
Whenever the scanner finds a composer.lock at the root of any scanned path, it queries the Packagist Security Advisories API for the exact installed versions and emits one finding per (package, advisory) pair — same Finding shape as code rules, so baselines, severity overrides, --fail-on, and every reporter format work uniformly. CVE id, advisory link, affected version range, and report date are stored under each finding's metadata.
- Auto-discovered — no flag required.
- Results are cached at
sys_get_temp_dir()/php-security-scanner-advisories.jsonfor 6 hours. --no-auditdisables the audit entirely.--audit-offlinereuses the warm cache and never contacts Packagist (useful in CI without egress).- A transient network failure produces a single informational
audit.composer.network-errorfinding (severityinfo) so it doesn't trip--fail-on high. - Constraint matching uses
composer/semverwhen present on the autoloader; otherwise a built-in matcher covers the operators Packagist actually emits (>=,<=,>,<,=,!=, AND with,, OR with|/||, andx.y.*wildcards).
Inline suppression
For code that is intentionally unsafe or already reviewed, add a suppression comment directly in the source file — no external baseline file needed.
| Comment | Effect |
|---|---|
// @php-security-ignore |
Suppress all rules on the next line |
// @php-security-ignore rule.id |
Suppress a specific rule on the next line |
echo $x; // @php-security-ignore |
End-of-line form — suppresses this line |
// @php-security-ignore-start |
Suppress all rules until …-end |
// @php-security-ignore-start rule.id |
Suppress a specific rule until …-end |
// @php-security-ignore-end |
Close the most-recently opened block |
// @php-security-ignore-file |
Suppress all findings in the entire file |
Examples:
// @php-security-ignore echo $userHtml; // this line is suppressed echo $safe; // @php-security-ignore // end-of-line form, same effect // @php-security-ignore-start echo $a; // suppressed echo $b; // suppressed // @php-security-ignore-end echo $c; // NOT suppressed // @php-security-ignore-start xss.echo echo $a; // XSS suppressed; SQL rules still active // @php-security-ignore-end // @php-security-ignore xss.echo // rule-scoped single-line echo $d;
Blocks can be nested; each @php-security-ignore-end closes the innermost open block. Rule-scoped and all-rules blocks may be mixed.
Configuration
Drop a php-security-scanner.yaml (or .yml) in your project root — auto-discovered, no flag needed. See php-security-scanner.dist.yaml:
paths: - src - app # vendor/, node_modules/, .git/, build/, *.cache/ etc. are skipped by default — # only add project-specific extras here. exclude: - tests/fixtures/** - '*.blade.php' rules: secret.hardcoded: severity: critical xss.echo: enabled: false audit.composer.vulnerable-package: severity: critical taint: extra_sources: - { type: method, class: 'App\Http\MyRequest', method: raw } extra_sanitizers: - { type: function, name: my_clean_html, clears: [html] } # Method whose return value is trusted regardless of receiver: - { type: method, method: sanitized, clears: [html, sql, shell, path] } extensions: # - 'LaraMint\PhpSecurityScannerLaravel\LaravelExtension' baseline: .security-baseline.json fail_on: high
Discovery order: --config <path> (any of .yaml/.yml/.php) > php-security-scanner.yaml > php-security-scanner.yml > php-security-scanner.php in each scan-root, then in the current working directory. The legacy PHP form (which still works — see php-security-scanner.dist.php) just needs to return the same shape.
GitHub Action
- uses: laramint/php-security-scanner@v1 id: scan with: path: src fail-on: high - uses: github/codeql-action/upload-sarif@v3 if: always() with: sarif_file: ${{ steps.scan.outputs.sarif-path }}
Findings appear in the repo's Security → Code scanning tab and inline on PR diffs.
Framework extensions
Framework-specific rules live in companion packages. The core stays framework-agnostic; install whichever ones apply.
Status
| Framework | Package | Status |
|---|---|---|
| Laravel | laramint/php-security-scanner-laravel |
✅ Available |
| Yii | laramint/php-security-scanner-yii |
🚧 Planned (not yet released) |
| Symfony | laramint/php-security-scanner-symfony |
🚧 Planned (not yet released) |
| WordPress | laramint/php-security-scanner-wordpress |
🚧 Planned (not yet released) |
Only the Laravel extension is shipped today; Yii, Symfony, and WordPress are on the roadmap. Contributions welcome — the API is small (see Writing your own extension below).
composer require --dev laramint/php-security-scanner-laravel
Installed extensions are auto-loaded via Composer (the package declares its FQCN in extra.php-security-scanner.extension). You can also list them explicitly in your config under extensions, or pass --extension=Fqcn\\To\\Extension for ad-hoc runs.
Writing your own extension
use LaraMint\PhpSecurityScanner\Extension\Extension; use LaraMint\PhpSecurityScanner\Rules\RuleRegistry; use LaraMint\PhpSecurityScanner\Taint\{SourceRegistry, SinkRegistry, SanitizerRegistry}; final class MyExtension implements Extension { public function name(): string { return 'my-framework'; } public function register( RuleRegistry $rules, SourceRegistry $sources, SinkRegistry $sinks, SanitizerRegistry $sanitizers, ): void { $sources->addMethod('App\\Http\\MyRequest', ['raw', 'all']); $sanitizers->addFunction('my_escape', ['html']); $rules->register(new MyCustomRule()); } }
To distribute it as a Composer package, add this to your composer.json:
"extra": { "php-security-scanner": { "extension": "MyVendor\\MyPackage\\MyExtension" } }
It will be auto-discovered the moment a consumer installs your package.
How it works
ScanCommand → Analyzer → for each PHP file:
PhpFileParser (LRU-cached AST + use map)
↓
TaintEngine pre-pass (variable origin map, sanitizer-aware propagation)
↓
FileScanner (one NodeTraverser, rules dispatched by node type)
↓
Reporter (pretty / JSON / SARIF / JUnit)
Taint kinds are HTML / SQL / Shell / Path — htmlspecialchars clears HTML XSS but not SQL injection; escapeshellarg clears shell but not XSS. This avoids the typical static-analyzer false-negative where a single "tainted/clean" flag is fooled by the wrong sanitizer.
Zero-false-positive policy
Rules emit a finding only when a tainted flow from a known source to a known sink can be proven with no sanitizer, cast, or cleansing accessor on the path. When the scanner cannot prove the danger, it stays silent — by design. The trade-off is that some real vulnerabilities behind cross-function flows or unknown sanitizers will be missed; the gain is that what is reported is actionable.
Recognized cleansers (extend via config or extensions):
- Function sanitizers —
htmlspecialchars,htmlentities,strip_tags,e(Laravel helper),escapeshellarg,escapeshellcmd,basename,realpath,pathinfo,intval,floatval,doubleval,abs,intdiv,ctype_digit,filter_var,filter_input,urlencode,rawurlencode. - Type casts —
(int),(float),(bool)clear injection-sensitive taint.(string)and(array)preserve it. - Cleansing methods (kind-agnostic) —
->validate(...),->validated(),->safe(),->integer(...),->boolean(...),->float(...). Useful forrequest()->validate([...]), FormRequest accessors, and similar framework patterns.
--explain mode
To see why the scanner stayed silent on a given sink, run with --explain. INFO-severity findings name the cleanser that neutralized the taint:
$ php-security-scanner scan src/ --explain
[INFO] Considered XSS sink echo: silent — taint cleared by htmlspecialchars() sanitizer.
src/Http/Controllers/UserController.php:42 (xss.echo, confidence: high, CWE-79)
[INFO] Considered SQL sink mysqli_query(): silent — taint cleared by (int) cast.
src/Legacy/Reports.php:114 (sql.injection, confidence: high, CWE-89)
The flag auto-lowers --severity-threshold to info so the notes surface. Currently emitted by the sql.injection and xss.echo rules. Explain notes never replace real findings — HIGH/CRITICAL output is unchanged.
Caveat: the explanation walks the sink argument's AST directly. If the taint was cleared at an earlier assignment (e.g. $id = (int)$_GET['id']; ... $db->query("... " . $id);), the note isn't emitted — at the sink the engine only sees a clean $id variable.
Limitations
Intra-procedural only — no cross-file or cross-function data flow, no class-property taint, no array-key precision. Combined with the zero-FP policy above, this means the scanner will miss any vulnerability whose dangerous flow crosses function/file boundaries or passes through a sanitizer the engine doesn't recognize. The scanner is fast and predictable; for deeper analysis pair it with Psalm or PHPStan with taint plugins.
Development
git clone https://github.com/laramint/php-security-scanner.git cd php-security-scanner composer install composer test # PHPUnit composer test:types # PHPStan
Smoke-scan the bundled fixtures:
./bin/php-security-scanner scan tests/fixtures/vulnerable --fail-on=none
./bin/php-security-scanner scan tests/fixtures/safe # must be clean
License
MIT — see LICENSE.
